From e38aca8134570513435c003c25dc05f1ae51c9c9 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 08 十月 2026 13:56:28 +0800
Subject: [PATCH] 实现 Ticket04:采集器辅助测量与登记入口,拆除开发模拟器
---
battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java | 78 ++
battery-business/src/main/java/com/battery/business/mapper/TCollectorMapper.java | 7
battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java | 84 +-
pom.xml | 14
scripts/clear-simulator-development-data.sql | 114 +++
battery-business/src/test/java/com/battery/business/service/impl/PushIngestReceptionContractTest.java | 73 ++
ruoyi-admin/src/main/java/com/battery/business/web/TCollectorController.java | 34 -
ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java | 3
ruoyi-admin/src/test/java/com/battery/business/runtime/DefaultSpringProfileContractTest.java | 6
battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java | 33 +
ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java | 213 ++++--
ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java | 4
ruoyi-admin/pom.xml | 33 -
ruoyi-admin/src/test/java/com/battery/business/service/impl/TCollectorServiceImplTest.java | 172 ++++-
ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeNoopStartupTest.java | 1
battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java | 16
battery-device-api/src/main/java/com/battery/device/api/discovery/InMemoryDeviceDiscoveryCandidateStore.java | 15
battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java | 29
battery-business/src/main/java/com/battery/business/service/ITCollectorService.java | 4
ruoyi-admin/src/main/resources/application.yml | 2
battery-business/src/main/java/com/battery/business/domain/TCollector.java | 6
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java | 48 -
/dev/null | 348 ------------
battery-business/src/main/resources/mapper/battery/TCollectorMapper.xml | 33
battery-business/src/main/java/com/battery/business/service/impl/TCollectorServiceImpl.java | 118 ++--
ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java | 11
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java | 7
AGENTS.md | 4
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java | 63 +
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java | 113 +++
ruoyi-admin/src/test/java/com/battery/business/mapper/DeviceRelatedTestMapperTest.java | 3
31 files changed, 870 insertions(+), 819 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index fa5a7fe..76e5961 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,7 +5,7 @@
## 模块边界
- `battery-business` 负责测试业务、状态聚合、事务、持久化、修订和异常;它只依赖 `battery-device-api`,不解析 BTS XML 或依赖 Redis/模拟器。
-- `battery-device-api` 提供厂商无关观测模型;`battery-device-bts` 封装协议;`battery-device-simulator` 提供开发实现。协议 XML、TCP 帧和厂商枚举停留在 BTS 模块内部。
+- `battery-device-api` 提供厂商无关观测模型;`battery-device-bts` 封装协议;直连厂商存储的实现放在 `battery-device-neware`,读本项目落地表的推式实现放在 `battery-business`(开发模拟器模块已按 Ticket04 整体拆除)。协议 XML、TCP 帧和厂商枚举停留在 BTS 模块内部。
- `ruoyi-admin` 只负责应用装配及 REST/SSE 控制器,不承载采集或状态机业务。
- 第一阶段打包为一个后端进程,并明确保持单采集实例;最终业务基线不建立适配器租约模型。
@@ -45,4 +45,4 @@
- 设备协议或调度改动至少覆盖正常多通道、拒绝连接、断线、超时、畸形 XML 和实时数据幂等冲突等受影响场景。
- 运行受影响 `battery-*` 模块测试;跨模块契约变更运行全量测试 `mvn test`。
- 验证可执行包:`mvn -pl ruoyi-admin -am package -DskipTests`。启动验证时使用可写的 `LOG_PATH`,完成后停止临时进程。
-- 浏览器主接缝由前端仓库 `npm run test:e2e:ticket04` 编排隔离 MySQL 8、Redis、完整可执行包和模拟器;不得连接共享开发库或复用已有服务端口。
+- 浏览器主接缝由前端仓库 `npm run test:e2e:ticket04` 编排隔离 MySQL 8、Redis、完整可执行包;不得连接共享开发库或复用已有服务端口。**该编排原本还会启动开发模拟器,模拟器已按 Ticket04 整体拆除**,`tests/e2e/` 下依赖模拟器页面与端点的用例在按真实来源重写之前跑不通(见 `docs/KNOWN-GAPS.md`)。
diff --git a/battery-business/src/main/java/com/battery/business/domain/TCollector.java b/battery-business/src/main/java/com/battery/business/domain/TCollector.java
index 4196760..a784699 100644
--- a/battery-business/src/main/java/com/battery/business/domain/TCollector.java
+++ b/battery-business/src/main/java/com/battery/business/domain/TCollector.java
@@ -44,13 +44,11 @@
@Size(max = 64, message = "设备编码长度不能超过64个字符")
private String collectorCode;
- /** IP 地址 */
- @NotBlank(message = "IP地址不能为空")
+ /** IP 地址;辅助测量不是独立硬件,可为空,填了必须合法。 */
@Size(max = 45, message = "IP地址长度不能超过45个字符")
private String ipAddress;
- /** MAC 地址 */
- @NotBlank(message = "MAC地址不能为空")
+ /** MAC 地址;可为空,填了必须合法且在未删除数据中唯一。 */
@Size(max = 17, message = "MAC地址长度不能超过17个字符")
private String macAddress;
diff --git a/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCollectorVo.java b/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCollectorVo.java
deleted file mode 100644
index 0d4a9c9..0000000
--- a/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCollectorVo.java
+++ /dev/null
@@ -1,129 +0,0 @@
-package com.battery.business.domain.vo;
-
-/**
- * 可发现采集器信息。
- */
-public class DiscoveredCollectorVo
-{
- /** 设备编码 */
- private String deviceCode;
-
- /** IP 地址 */
- private String ip;
-
- /** MAC 地址 */
- private String mac;
-
- /** 模拟器提供的采集器名称 */
- private String name;
-
- /** 制造商 */
- private String manufacturer;
-
- /** 型号 */
- private String model;
-
- /** 通道数 */
- private Integer channelCount;
-
- /** 在线状态 */
- private String onlineStatus;
-
- public DiscoveredCollectorVo()
- {
- }
-
- public DiscoveredCollectorVo(String deviceCode, String ip, String mac, String name, String manufacturer,
- String model, Integer channelCount, String onlineStatus)
- {
- this.deviceCode = deviceCode;
- this.ip = ip;
- this.mac = mac;
- this.name = name;
- this.manufacturer = manufacturer;
- this.model = model;
- this.channelCount = channelCount;
- this.onlineStatus = onlineStatus;
- }
-
- public String getDeviceCode()
- {
- return deviceCode;
- }
-
- public void setDeviceCode(String deviceCode)
- {
- this.deviceCode = deviceCode;
- }
-
- public String getIp()
- {
- return ip;
- }
-
- public void setIp(String ip)
- {
- this.ip = ip;
- }
-
- public String getMac()
- {
- return mac;
- }
-
- public void setMac(String mac)
- {
- this.mac = mac;
- }
-
- public String getName()
- {
- return name;
- }
-
- public void setName(String name)
- {
- this.name = name;
- }
-
- public String getManufacturer()
- {
- return manufacturer;
- }
-
- public void setManufacturer(String manufacturer)
- {
- this.manufacturer = manufacturer;
- }
-
- public String getModel()
- {
- return model;
- }
-
- public void setModel(String model)
- {
- this.model = model;
- }
-
- public Integer getChannelCount()
- {
- return channelCount;
- }
-
- public void setChannelCount(Integer channelCount)
- {
- this.channelCount = channelCount;
- }
-
- public String getOnlineStatus()
- {
- return onlineStatus;
- }
-
- public void setOnlineStatus(String onlineStatus)
- {
- this.onlineStatus = onlineStatus;
- }
-
-}
diff --git a/battery-business/src/main/java/com/battery/business/mapper/TCollectorMapper.java b/battery-business/src/main/java/com/battery/business/mapper/TCollectorMapper.java
index f6fdc15..6fd2d95 100644
--- a/battery-business/src/main/java/com/battery/business/mapper/TCollectorMapper.java
+++ b/battery-business/src/main/java/com/battery/business/mapper/TCollectorMapper.java
@@ -17,9 +17,6 @@
/** 查询采集器列表。 */
List<TCollector> selectCollectorList(TCollector collector);
- /** 查询全部未删除采集器的设备编码。 */
- List<String> selectRegisteredCollectorCodes();
-
/** 根据设备编码查询未删除采集器。 */
TCollector selectCollectorByCode(String collectorCode);
@@ -28,10 +25,6 @@
/** 新增采集器。 */
int insertCollector(TCollector collector);
-
- /** 同步正式采集器的实时在线事实,不触碰软删除记录。 */
- int updateRegisteredObservation(@Param("collectorCode") String collectorCode,
- @Param("onlineStatus") String onlineStatus);
/** 编辑采集器基础资料。 */
int updateCollector(TCollector collector);
diff --git a/battery-business/src/main/java/com/battery/business/service/ICollectorDiscoveryService.java b/battery-business/src/main/java/com/battery/business/service/ICollectorDiscoveryService.java
deleted file mode 100644
index bd654c4..0000000
--- a/battery-business/src/main/java/com/battery/business/service/ICollectorDiscoveryService.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.battery.business.service;
-
-import java.util.List;
-import com.battery.business.domain.vo.DiscoveredCollectorVo;
-
-/**
- * 采集器设备发现服务。
- */
-public interface ICollectorDiscoveryService
-{
- /**
- * 查询当前可发现的采集器。
- *
- * @return 可发现采集器列表
- */
- List<DiscoveredCollectorVo> selectDiscoveredCollectorList();
-}
diff --git a/battery-business/src/main/java/com/battery/business/service/ITCollectorService.java b/battery-business/src/main/java/com/battery/business/service/ITCollectorService.java
index 03ed056..040c32e 100644
--- a/battery-business/src/main/java/com/battery/business/service/ITCollectorService.java
+++ b/battery-business/src/main/java/com/battery/business/service/ITCollectorService.java
@@ -1,7 +1,6 @@
package com.battery.business.service;
import java.util.List;
-import java.util.Set;
import com.battery.business.domain.TCollector;
/**
@@ -25,9 +24,6 @@
/** 启用或禁用采集器。 */
int updateCollectorStatus(TCollector collector);
-
- /** 查询全部未删除采集器的设备编码。 */
- Set<String> selectRegisteredCollectorCodes();
/** 批量逻辑删除采集器。 */
int deleteCollectorByIds(Long[] collectorIds, String updateBy);
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java b/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
index f988911..4dfdc59 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
@@ -9,6 +9,10 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
import com.battery.device.api.DeviceAdapter;
import com.battery.device.api.DeviceAdapterException;
import com.battery.device.api.TestDataObservationAdapter;
@@ -46,6 +50,7 @@
{
/** 适配器类型按存储形态划分:这个形态读本项目的落地表(ADR-0065)。 */
public static final String ADAPTER_TYPE = "PUSH_INGEST";
+ private static final Logger log = LoggerFactory.getLogger(PushIngestAdapter.class);
/**
* 身份映射(后端完成):22 所的存储形态没有设备类型与单元维度,一台电脑就是登记意义上的一个柜,
* 通道号就是厂商自己的通道号(表名 {@code Ws<N>_IV_0} 里的 N)。厂商通道号进 {@code chlid},
@@ -155,14 +160,98 @@
}
/**
- * 22 所的 IV 表里有温箱温度、压力与计数器,是现场唯一见到环境量的来源,
- * 但类目语义与量纲尚未确认。按 ADR-0064 逐来源确认可得性,确认之前不交出测量值。
+ * 采集器辅助测量按 ADR-0064 由**同一来源实例**提供,所以这里读的是落地表里该来源自己那些通道的原始行。
+ *
+ * <p>哪些列是辅助测量、各叫什么指标、用什么单位、对应哪个采集器通道,全部只在
+ * 来源配置的 {@code auxiliaryMetrics} 里声明(例如
+ * {@code {"vendorChannel":24,"channelCode":"S-24","column":"Tempbox","metricCode":"TEMPERATURE","unit":"℃"}})。
+ * **没有声明就返回空值**:22 所 IV 表里确实有温箱温度、压力与计数器,是现场唯一见到环境量的来源,
+ * 但列语义与量纲尚未对着现场确认,未确认前不按列名推断语义、也不写出任何值。
+ * 声明之后不必改代码——确认结论落在配置里,不落在推断里。
+ *
+ * <p>来源不可达(心跳缺失或过期)时返回 {@code online=false} 且不带任何测量值:
+ * 离线不伪造测量值,业务侧据此不落任何采集器行。
*/
@Override
public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
{
- return null;
+ List<AuxiliaryMetric> declared = auxiliaryMetrics();
+ if (declared.isEmpty() || !config.adapterCode().equals(adapterCode)) return null;
+ if (!heartbeatFresh())
+ return new CollectorRealtime(collectorCode, false, (int) PERSISTENCE_INTERVAL_MS, List.of(),
+ config.timezoneId());
+ List<CollectorMeasurement> measurements = new ArrayList<>();
+ for (AuxiliaryMetric metric : declared)
+ {
+ TDeviceIngestRecord row = records.selectLatestSample(config.adapterCode(), DEVICE_TYPE, DEVICE_ID,
+ SUB_DEVICE_ID, metric.vendorChannel());
+ if (row == null) continue;
+ BigDecimal value = payloadValue(row.getPayloadJson(), metric.column());
+ // 设备没给这一项就是没给:不写零、不写空值占位,也不阻断其他指标。
+ if (value == null) continue;
+ measurements.add(new CollectorMeasurement(metric.channelCode(),
+ row.getRecordSequence() == null ? 0L : row.getRecordSequence(),
+ row.getDeviceTimeUtc() == null ? null : row.getDeviceTimeUtc().toInstant(),
+ row.getDeviceTimeRaw(),
+ row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant(),
+ metric.metricCode(), value, metric.unit()));
+ }
+ return new CollectorRealtime(collectorCode, true, (int) PERSISTENCE_INTERVAL_MS, measurements,
+ config.timezoneId());
}
+
+ /**
+ * 读取来源配置里声明的辅助测量列。配置格式非法时按"没有声明"处理并留一条警告:
+ * 宁可少交出一项测量,也不按猜测解释厂商列。
+ */
+ private List<AuxiliaryMetric> auxiliaryMetrics()
+ {
+ String declared = config.sourceOption("auxiliaryMetrics");
+ if (declared == null || declared.isBlank()) return List.of();
+ List<AuxiliaryMetric> metrics = new ArrayList<>();
+ try
+ {
+ for (Object item : JSON.parseArray(declared))
+ {
+ if (!(item instanceof JSONObject entry)) continue;
+ Integer vendorChannel = entry.getInteger("vendorChannel");
+ String channelCode = entry.getString("channelCode");
+ String column = entry.getString("column");
+ String metricCode = entry.getString("metricCode");
+ if (vendorChannel == null || channelCode == null || column == null || metricCode == null) continue;
+ metrics.add(new AuxiliaryMetric(vendorChannel, channelCode, column, metricCode,
+ entry.getString("unit")));
+ }
+ }
+ catch (RuntimeException failure)
+ {
+ log.warn("来源 {} 的 auxiliaryMetrics 配置无法解析,按未声明处理", config.adapterCode(), failure);
+ return List.of();
+ }
+ return List.copyOf(metrics);
+ }
+
+ /** 从原始行里按声明取数值:非数值(含 null 与文本)一律当作"设备没给这一项"。 */
+ private static BigDecimal payloadValue(String payloadJson, String column)
+ {
+ if (payloadJson == null || payloadJson.isBlank()) return null;
+ try
+ {
+ Object value = JSON.parseObject(payloadJson).get(column);
+ if (value == null) return null;
+ if (value instanceof BigDecimal decimal) return decimal;
+ if (value instanceof Number number) return new BigDecimal(number.toString());
+ return null;
+ }
+ catch (RuntimeException failure)
+ {
+ return null;
+ }
+ }
+
+ /** 一条声明:来源侧列名到业务指标(含采集器通道与单位)的映射。 */
+ private record AuxiliaryMetric(int vendorChannel, String channelCode, String column, String metricCode,
+ String unit) { }
/**
* 22 所没有独立的工步层:工步与循环号只在采样行的 {@code Step_Index} / {@code Cycle_Index} 里,
@@ -223,16 +312,18 @@
/** 心跳缺失即来源不可达:取数程序停了、或它自己连不上厂商库。 */
private void requireFreshHeartbeat()
{
- java.util.Date receivedAt = records.selectHeartbeatAt(config.adapterCode());
- if (receivedAt == null)
+ if (!heartbeatFresh())
throw new DeviceAdapterException("SOURCE_OFFLINE",
- "尚未收到取数程序的推送:请确认 " + config.host()
+ "尚未收到取数程序的推送或推送已超时:请确认 " + config.host()
+ " 那台机器上的取数程序已启动,并指向本系统的接收端点", null);
- Duration staleAfter = staleAfter();
- Duration silent = Duration.between(receivedAt.toInstant(), clock.instant());
- if (silent.compareTo(staleAfter) > 0)
- throw new DeviceAdapterException("SOURCE_OFFLINE", "取数程序已停止推送:最近一次推送在 "
- + silent.toSeconds() + " 秒前,超过容忍窗口 " + staleAfter.toSeconds() + " 秒", null);
+ }
+
+ /** 来源是否在线:心跳行存在且在容忍窗口内。落地表随时可读,所以"能读到表"不能表示来源可达。 */
+ private boolean heartbeatFresh()
+ {
+ java.util.Date receivedAt = records.selectHeartbeatAt(config.adapterCode());
+ if (receivedAt == null) return false;
+ return Duration.between(receivedAt.toInstant(), clock.instant()).compareTo(staleAfter()) <= 0;
}
/** 容忍窗口按实例配置的轮询周期算:写库与推送都是这个节奏,连丢三轮即认为离线。 */
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TCollectorServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TCollectorServiceImpl.java
index 8cf4d0b..8edfbbe 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TCollectorServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TCollectorServiceImpl.java
@@ -3,22 +3,23 @@
import java.net.InetAddress;
import java.util.Date;
import java.util.List;
-import java.util.Set;
import java.util.regex.Pattern;
-import java.time.Instant;
-import java.util.Map;
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.battery.business.domain.TCollector;
+import com.battery.business.domain.TDeviceAdapter;
import com.battery.business.mapper.TCollectorMapper;
+import com.battery.business.mapper.TDeviceAdapterMapper;
import com.battery.business.service.ITCollectorService;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
/**
* 采集器 Service 实现。
+ *
+ * <p>采集器按 ADR-0064 不建独立设备模型:它的测量是「同一来源的辅助测量」,
+ * 由所链接的来源实例提供。因此这里不再走发现候选(真实来源根本不产出采集器候选),
+ * 而是由操作员手工登记来源身份,在线状态直接取所链接来源实例的连接结果。
*
* @author ruoyi
*/
@@ -28,7 +29,6 @@
private static final String ENABLED = "0";
private static final String DISABLED = "1";
private static final String OFFLINE = "0";
- private static final String ONLINE = "1";
private static final String IDLE = "0";
private static final String TESTING = "1";
private static final Pattern IPV4_PATTERN = Pattern.compile(
@@ -36,12 +36,12 @@
private static final Pattern MAC_PATTERN = Pattern.compile("^[0-9A-F]{2}(:[0-9A-F]{2}){5}$");
private final TCollectorMapper collectorMapper;
- private final DeviceDiscoveryCandidateStore candidateStore;
+ private final TDeviceAdapterMapper adapterMapper;
- public TCollectorServiceImpl(TCollectorMapper collectorMapper, DeviceDiscoveryCandidateStore candidateStore)
+ public TCollectorServiceImpl(TCollectorMapper collectorMapper, TDeviceAdapterMapper adapterMapper)
{
this.collectorMapper = collectorMapper;
- this.candidateStore = candidateStore;
+ this.adapterMapper = adapterMapper;
}
/**
@@ -59,7 +59,7 @@
{
throw new ServiceException("采集器不存在");
}
- return applyRealtimeState(collector, candidatesByCode());
+ return collector;
}
/**
@@ -77,32 +77,26 @@
collector.setEndCreateTime(trimToNull(collector.getEndCreateTime()));
String requestedOnlineStatus = collector.getOnlineStatus();
validateOptionalStatus(requestedOnlineStatus, "在线状态");
+ // 在线状态由 SQL 从所链接来源实例的连接结果派生,这里不再从存储列过滤。
collector.setOnlineStatus(null);
validateOptionalStatus(collector.getTestStatus(), "测试状态");
validateOptionalStatus(collector.getStatus(), "状态");
- Map<String, CollectorCandidate> candidates = candidatesByCode();
- return collectorMapper.selectCollectorList(collector).stream()
- .map(item -> applyRealtimeState(item, candidates))
- .filter(item -> StringUtils.isEmpty(requestedOnlineStatus)
- || requestedOnlineStatus.equals(item.getOnlineStatus()))
- .toList();
+ return collectorMapper.selectCollectorList(collector);
}
/**
* 新增采集器并初始化运行状态。
+ *
+ * <p>前置条件:设备编码与名称非空且设备编码未登记过;IP 与 MAC 可留空(辅助测量不是独立硬件,
+ * 系统不连它),填了则校验格式。在线状态取所链接来源实例的连接结果,未链接来源时保持离线。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int insertCollector(TCollector collector)
{
if (collector == null) throw new ServiceException("采集器信息不能为空");
- collector.setCollectorCode(trimToNull(collector.getCollectorCode()));
- CollectorCandidate candidate = candidateStore.findCollector(collector.getCollectorCode(), Instant.now())
- .orElseThrow(() -> new ServiceException("请先选择有效的发现候选设备"));
- collector.setIpAddress(candidate.ip());
- collector.setMacAddress(candidate.mac());
- collector.setChannelCount(candidate.channelCount());
validateCollector(collector, false);
+ requireLinkedAdapter(collector);
collector.setOnlineStatus(OFFLINE);
// 测试占用由 t_test_collector_channel + t_test 派生,采集器登记表不写入这些字段。
collector.setTestStatus(IDLE);
@@ -118,11 +112,8 @@
}
collector.setDelFlag("0");
collector.setCreateTime(new Date());
- int rows = collectorMapper.insertCollector(collector);
- // 登记只确认来源身份,在线与离线事实都按候选原样同步。
- collectorMapper.updateRegisteredObservation(collector.getCollectorCode(),
- candidate.online() ? "ONLINE" : "OFFLINE");
- return rows;
+ // 登记只确认来源身份:在线事实由所链接来源实例的连接结果表达,登记时不改写它。
+ return collectorMapper.insertCollector(collector);
}
/**
@@ -136,7 +127,10 @@
if (current == null) throw new ServiceException("采集器不存在");
if (collector.getCollectorCode() != null && !collector.getCollectorCode().trim().equals(current.getCollectorCode()))
throw new ServiceException("设备编码不可修改");
+ // 编辑只提交可改字段:未提交的 IP/MAC 不能被当成"清空"。
+ fillMissingFromCurrent(collector, current);
validateCollector(collector, true);
+ requireLinkedAdapter(collector);
validateStatus(collector.getStatus(), "状态");
if (DISABLED.equals(collector.getStatus()))
{
@@ -168,13 +162,6 @@
}
collector.setUpdateTime(new Date());
return collectorMapper.updateCollectorStatus(collector);
- }
-
- /** 查询全部未删除采集器的设备编码,供发现候选排除已登记设备。 */
- @Override
- public Set<String> selectRegisteredCollectorCodes()
- {
- return Set.copyOf(collectorMapper.selectRegisteredCollectorCodes());
}
/**
@@ -215,7 +202,7 @@
}
collector.setCollectorCode(trimToNull(collector.getCollectorCode()));
collector.setIpAddress(trimToNull(collector.getIpAddress()));
- collector.setMacAddress(normalizeMacAddress(collector.getMacAddress()));
+ collector.setMacAddress(normalizeOptionalMacAddress(collector.getMacAddress()));
collector.setCollectorName(trimToNull(collector.getCollectorName()));
collector.setManufacturer(trimToNull(collector.getManufacturer()));
collector.setModel(trimToNull(collector.getModel()));
@@ -229,7 +216,7 @@
{
throw new ServiceException("采集器名称不能为空");
}
- validateIpAddress(collector.getIpAddress());
+ validateOptionalIpAddress(collector.getIpAddress());
if (collector.getChannelCount() == null || collector.getChannelCount() < 1
|| collector.getChannelCount() > 10000)
{
@@ -238,8 +225,34 @@
validateUnique(collector);
}
+ /** 编辑时用既有值补齐未提交的字段,避免把"没传"当成"清空"。 */
+ private void fillMissingFromCurrent(TCollector collector, TCollector current)
+ {
+ if (collector.getCollectorCode() == null) collector.setCollectorCode(current.getCollectorCode());
+ if (collector.getIpAddress() == null) collector.setIpAddress(current.getIpAddress());
+ if (collector.getMacAddress() == null) collector.setMacAddress(current.getMacAddress());
+ if (collector.getCollectorName() == null) collector.setCollectorName(current.getCollectorName());
+ if (collector.getChannelCount() == null) collector.setChannelCount(current.getChannelCount());
+ if (collector.getStatus() == null) collector.setStatus(current.getStatus());
+ if (collector.getAdapterId() == null) collector.setAdapterId(current.getAdapterId());
+ }
+
+ /** 校验所链接的来源实例存在;来源是采集器测量的提供方,链接后可读它的连接结果。 */
+ private void requireLinkedAdapter(TCollector collector)
+ {
+ if (collector.getAdapterId() == null)
+ {
+ return;
+ }
+ TDeviceAdapter adapter = adapterMapper.selectDeviceAdapterById(collector.getAdapterId());
+ if (adapter == null)
+ {
+ throw new ServiceException("所链接的数据来源不存在");
+ }
+ }
+
/**
- * 校验设备编码和 MAC 地址在未删除数据中唯一。
+ * 校验设备编码唯一;MAC 地址只在填写时参与唯一性。
*/
private void validateUnique(TCollector collector)
{
@@ -247,6 +260,10 @@
if (isDifferentCollector(sameCode, collector.getCollectorId()))
{
throw new ServiceException("设备编码已存在");
+ }
+ if (collector.getMacAddress() == null)
+ {
+ return;
}
TCollector sameMac = collectorMapper.selectCollectorByMacAddress(collector.getMacAddress());
if (isDifferentCollector(sameMac, collector.getCollectorId()))
@@ -264,14 +281,14 @@
}
/**
- * 规范化并校验 MAC 地址。
+ * 规范化并校验 MAC 地址;未填写时保持为空。
*/
- private String normalizeMacAddress(String macAddress)
+ private String normalizeOptionalMacAddress(String macAddress)
{
String normalized = trimToNull(macAddress);
if (normalized == null)
{
- throw new ServiceException("MAC地址不能为空");
+ return null;
}
normalized = normalized.replace('-', ':').toUpperCase();
if (!MAC_PATTERN.matcher(normalized).matches())
@@ -282,13 +299,13 @@
}
/**
- * 校验 IPv4 或 IPv6 地址,不接受主机名。
+ * 校验 IPv4 或 IPv6 地址,不接受主机名;未填写时跳过。
*/
- private void validateIpAddress(String ipAddress)
+ private void validateOptionalIpAddress(String ipAddress)
{
if (StringUtils.isEmpty(ipAddress))
{
- throw new ServiceException("IP地址不能为空");
+ return;
}
if (IPV4_PATTERN.matcher(ipAddress).matches())
{
@@ -351,20 +368,5 @@
private String trimToNull(String value)
{
return StringUtils.isEmpty(StringUtils.trim(value)) ? null : StringUtils.trim(value);
- }
-
- private Map<String, CollectorCandidate> candidatesByCode()
- {
- return candidateStore.snapshot(Instant.now()).collectors().stream()
- .collect(java.util.stream.Collectors.toMap(CollectorCandidate::deviceCode, item -> item));
- }
-
- private TCollector applyRealtimeState(TCollector collector, Map<String, CollectorCandidate> candidates)
- {
- CollectorCandidate candidate = candidates.get(collector.getCollectorCode());
- collector.setOnlineStatus(candidate != null && candidate.online() ? ONLINE : OFFLINE);
- // 候选过期只改变当前在线展示,不能抹掉 MySQL 保存的最近观测时间。
- if (candidate != null) collector.setLastSeenAtUtc(candidate.observedAt());
- return collector;
}
}
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
index f9a7270..3ef92c6 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
@@ -18,10 +18,12 @@
import com.battery.device.api.model.Measurement;
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestBattery;
+import com.battery.business.domain.TDeviceAdapter;
import com.battery.business.service.ITestLiveService;
import com.battery.business.service.ITestLiveService.TestPlanRow;
import com.battery.business.service.ITestLiveService.TestPlanSnapshot;
import com.battery.business.service.ITTestService;
+import com.battery.business.mapper.TDeviceAdapterMapper;
import com.battery.business.mapper.TTestMapper;
/** 将设备最新快照转换为测试视角,按测试接收状态决定是否展示采集参数。 */
@@ -32,15 +34,16 @@
private final DeviceDiscoveryCandidateStore candidates;
private final TestDataObservationAdapter adapter;
private final TTestMapper mapper;
+ private final TDeviceAdapterMapper adapters;
@Autowired
public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
- @Nullable TestDataObservationAdapter adapter, TTestMapper mapper)
- { this.tests = tests; this.candidates = candidates; this.adapter = adapter; this.mapper = mapper; }
+ @Nullable TestDataObservationAdapter adapter, TTestMapper mapper, TDeviceAdapterMapper adapters)
+ { this.tests = tests; this.candidates = candidates; this.adapter = adapter; this.mapper = mapper; this.adapters = adapters; }
public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
TestDataObservationAdapter adapter)
- { this(tests, candidates, adapter, null); }
+ { this(tests, candidates, adapter, null, null); }
/** 返回测试当前设备事实;接收中展示测量和实时提醒,历史异常处理状态不参与判定。 */
@Override public LiveState current(Long testId)
@@ -223,45 +226,66 @@
return value;
}
+ /**
+ * 采集器来源:测量按 ADR-0064 由同一来源实例作为辅助测量提供,所以它的在线事实就是该来源的可达性
+ * (ADR-0063:在线只表示来源可达),不再依赖只有开发模拟器会产出的发现候选。
+ *
+ * <p>来源不提供辅助测量、或来源可达但这一刻还没有任何辅助测量值时,明确标注为「无辅助数据」:
+ * 这是正常取值而不是故障,主测量接收不受影响,也不因此生成离线异常。来源不可达时不标这个标记——
+ * 那种情况是来源离线,由既有的离线异常路径表达。
+ */
private LiveSource collectorSource(TTestBattery battery, boolean receiving)
{
if (adapter == null || mapper == null) return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
"OFFLINE", Map.of());
String collectorCode = mapper.selectCollectorCode(battery.getCollectorId());
- // 非接收状态只读设备发现事实,不能先请求测量再隐藏,否则仍会产生新观测。
+ String adapterCode = battery.getBinding() == null ? null : battery.getBinding().getAdapterCode();
+ boolean sourceOnline = sourceOnline(adapterCode);
+ // 非接收状态只读来源可达性,不能先请求测量再隐藏,否则仍会产生新观测。
if (!receiving)
{
- boolean online = candidates.findCollector(collectorCode, Instant.now())
- .map(DeviceDiscoveryCandidateStore.CollectorCandidate::online).orElse(false);
return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
- online ? "ONLINE" : "OFFLINE", Map.of());
+ sourceOnline ? "ONLINE" : "OFFLINE", Map.of());
}
- var collector = adapter.latestCollector(
- battery.getBinding() == null ? null : battery.getBinding().getAdapterCode(), collectorCode);
- if (collector != null)
+ var collector = adapter.latestCollector(adapterCode, collectorCode);
+ Map<String, Object> value = new LinkedHashMap<>();
+ value.put("source", "采集器");
+ if (collector == null)
{
- Map<String, Object> value = new LinkedHashMap<>();
- value.put("source", "采集器"); value.put("sampleIntervalMs", collector.sampleIntervalMs());
- // 在业务响应边界转换毫秒时间,设备时间与服务器接收时间分别保留。
- value.put("measurements", collector.measurements().stream().filter(item -> battery.getCollectorChannels() != null
- && battery.getCollectorChannels().stream().anyMatch(binding -> binding.getChannelCode().equals(item.channelCode())
- && TestWarningExceptionService.matchesCollectorMetric(binding.getMeasurementCode(), item.metricCode()))).map(item -> {
- Map<String, Object> measurement = new LinkedHashMap<>();
- measurement.put("metricCode", item.metricCode());
- measurement.put("channelCode", item.channelCode());
- measurement.put("value", item.value());
- measurement.put("unit", item.unit());
- measurement.put("deviceTime", item.deviceTime().toEpochMilli());
- measurement.put("serverReceivedAt", item.serverReceivedAt().toEpochMilli());
- return measurement;
- }).toList());
- value.put("units", collector.measurements().stream().map(item -> item.unit()).distinct().toList());
- collector.measurements().stream().map(item -> item.serverReceivedAt()).max(Instant::compareTo)
- .ifPresent(observedAt -> value.put("observedAt", observedAt.toEpochMilli()));
+ // 该来源根本不提供辅助测量(新威实测无辅助数据;推式来源的列语义确认前同样如此)。
+ if (sourceOnline) value.put("auxiliaryDataAvailable", false);
return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
- collector.online() ? "ONLINE" : "OFFLINE", collector.online() ? value : Map.of());
+ sourceOnline ? "ONLINE" : "OFFLINE", value);
}
- return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(), "OFFLINE", Map.of());
+ value.put("sampleIntervalMs", collector.sampleIntervalMs());
+ // 在业务响应边界转换毫秒时间,设备时间与服务器接收时间分别保留。
+ value.put("measurements", collector.measurements().stream().filter(item -> battery.getCollectorChannels() != null
+ && battery.getCollectorChannels().stream().anyMatch(binding -> binding.getChannelCode().equals(item.channelCode())
+ && TestWarningExceptionService.matchesCollectorMetric(binding.getMeasurementCode(), item.metricCode()))).map(item -> {
+ Map<String, Object> measurement = new LinkedHashMap<>();
+ measurement.put("metricCode", item.metricCode());
+ measurement.put("channelCode", item.channelCode());
+ measurement.put("value", item.value());
+ measurement.put("unit", item.unit());
+ measurement.put("deviceTime", item.deviceTime().toEpochMilli());
+ measurement.put("serverReceivedAt", item.serverReceivedAt().toEpochMilli());
+ return measurement;
+ }).toList());
+ value.put("units", collector.measurements().stream().map(item -> item.unit()).distinct().toList());
+ collector.measurements().stream().map(item -> item.serverReceivedAt()).max(Instant::compareTo)
+ .ifPresent(observedAt -> value.put("observedAt", observedAt.toEpochMilli()));
+ // 来源可达但这一刻还没有任何辅助测量值:同样是"无辅助数据",按空值展示而不是占位。
+ if (collector.online() && collector.measurements().isEmpty()) value.put("auxiliaryDataAvailable", false);
+ return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
+ collector.online() ? "ONLINE" : "OFFLINE", collector.online() ? value : Map.of());
+ }
+
+ /** 来源实例是否可达;来源编码缺失或查询不到时按不可达处理,不猜也不回退到其他实例。 */
+ private boolean sourceOnline(String adapterCode)
+ {
+ if (adapters == null || adapterCode == null || adapterCode.isBlank()) return false;
+ TDeviceAdapter adapter = adapters.selectDeviceAdapterByCode(adapterCode);
+ return adapter != null && "ONLINE".equalsIgnoreCase(adapter.getConnectionStatus());
}
}
diff --git a/battery-business/src/main/resources/mapper/battery/TCollectorMapper.xml b/battery-business/src/main/resources/mapper/battery/TCollectorMapper.xml
index dce3675..71fde52 100644
--- a/battery-business/src/main/resources/mapper/battery/TCollectorMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TCollectorMapper.xml
@@ -28,10 +28,20 @@
select="com.battery.business.mapper.DeviceRelatedTestMapper.selectCollectorRelatedTests" />
</resultMap>
+ <!-- 在线状态取所链接来源实例的连接结果(ADR-0063:在线只表示来源可达);
+ 采集器测量由该来源提供,所以它不是独立可达的硬件。SELECT 与 WHERE 各用一次,故抽成片段。 -->
+ <sql id="derivedOnlineStatus">
+ case when exists (
+ select 1 from t_device_adapter adapter_fact
+ where adapter_fact.adapter_id = c.adapter_id
+ and upper(adapter_fact.connection_status) = 'ONLINE'
+ ) then '1' else '0' end
+ </sql>
+
<sql id="selectCollectorVo">
select c.collector_id, c.collector_code, c.adapter_id, c.ip_address, c.mac_address, c.collector_name,
c.manufacturer, c.model, c.channel_count,
- case when upper(c.online_status) = 'ONLINE' or c.online_status = '1' then '1' else '0' end as online_status,
+ <include refid="derivedOnlineStatus" /> as online_status,
c.last_seen_at_utc,
case when exists (
select 1 from t_test_collector_channel mapping
@@ -68,7 +78,8 @@
and create_time <= #{endCreateTime}
</if>
<if test="onlineStatus != null and onlineStatus != ''">
- and online_status = #{onlineStatus}
+ <!-- WHERE 里不能用 SELECT 别名(MySQL 只在 HAVING 认得),所以按同一表达式再过一遍。 -->
+ and (<include refid="derivedOnlineStatus" />) = #{onlineStatus}
</if>
<if test="testStatus != null and testStatus != ''">
and (case when exists (
@@ -84,12 +95,6 @@
</if>
</where>
order by create_time desc, collector_id desc
- </select>
-
- <select id="selectRegisteredCollectorCodes" resultType="String">
- select collector_code
- from t_collector
- where del_flag = '0'
</select>
<select id="selectCollectorByCode" parameterType="String" resultMap="TCollectorResult">
@@ -108,27 +113,21 @@
<insert id="insertCollector" parameterType="TCollector" useGeneratedKeys="true" keyProperty="collectorId">
insert into t_collector
- (collector_code, ip_address, mac_address, collector_name, manufacturer, model,
+ (collector_code, adapter_id, ip_address, mac_address, collector_name, manufacturer, model,
channel_count, online_status, last_seen_at_utc,
status, del_flag, create_by, create_time, remark)
values
- (#{collectorCode}, #{ipAddress}, #{macAddress}, #{collectorName}, #{manufacturer}, #{model},
+ (#{collectorCode}, #{adapterId}, #{ipAddress}, #{macAddress}, #{collectorName}, #{manufacturer}, #{model},
#{channelCount}, case when #{onlineStatus} in ('1', 'ONLINE') then 'ONLINE' else 'OFFLINE' end,
#{lastSeenAtUtc},
#{status}, #{delFlag}, #{createBy}, #{createTime}, #{remark})
</insert>
- <update id="updateRegisteredObservation">
- update t_collector
- set online_status = case when #{onlineStatus} in ('1', 'ONLINE') then 'ONLINE' else 'OFFLINE' end,
- last_seen_at_utc = utc_timestamp(3), update_time = sysdate()
- where collector_code = #{collectorCode} and del_flag = 0
- </update>
-
<update id="updateCollector" parameterType="TCollector">
update t_collector
<set>
collector_code = #{collectorCode},
+ adapter_id = #{adapterId},
ip_address = #{ipAddress},
mac_address = #{macAddress},
collector_name = #{collectorName},
diff --git a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
index 25a2792..ec6c1be 100644
--- a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
+++ b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
@@ -50,6 +50,13 @@
private final FakeIngestLanding landing = new FakeIngestLanding();
private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class);
private static final Instant NOW = Instant.parse("2026-09-27T09:00:00Z");
+ /**
+ * 环境量的确认结论落在这里:厂商列名只在配置里出现一次,代码里没有任何厂商列名。
+ * 22 所的列语义与量纲仍未现场确认,所以生产配置里默认没有这一项。
+ */
+ private static final Map<String, String> AUXILIARY_METRICS = Map.of("auxiliaryMetrics",
+ "[{\"vendorChannel\":26,\"channelCode\":\"S-01\",\"column\":\"Tempbox\","
+ + "\"metricCode\":\"TEMPERATURE\",\"unit\":\"℃\"}]");
@Test
void topologyPushMakesChannelsCandidatesWithTheirOwnLatestDataTime()
@@ -163,9 +170,9 @@
assertTrue(adapter.inquire(Set.of(channel())).isEmpty());
}
- /** 采集器辅助测量按 ADR-0064 逐来源确认;22 所环境量的列语义未确认,因此不交出测量值。 */
+ /** 未声明辅助测量列就不交出测量值:确认之前不按列名推断语义,也不写零占位。 */
@Test
- void collectorAndStepCycleObservationsStayEmptyForThisStorageForm()
+ void collectorAndStepCycleObservationsStayEmptyWithoutDeclaredAuxiliaryMetrics()
{
ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
ingest(BATCH);
@@ -178,6 +185,61 @@
assertEquals(5000L, adapter.persistenceIntervalMillis(channel()));
AdapterCapabilities capabilities = adapter.capabilities();
assertTrue(capabilities.discovery() && capabilities.realtimeObservation() && !capabilities.control());
+ }
+
+ /** 环境量与量纲确认后落到配置:列名、指标编码、单位、采集器通道都由声明给出,代码里没有厂商列名。 */
+ @Test
+ void declaredAuxiliaryMetricIsProjectedWithItsOwnSourceIdentityValueUnitAndDeviceTime()
+ {
+ ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
+ ingest("""
+ {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
+ {"record_time":"134349692000000000","Data_Point":420,"Voltage":1.26778,"eCurrent":0.5,"Tempbox":26.5}]}
+ """);
+ PushIngestAdapter adapter = adapter(NOW, AUXILIARY_METRICS);
+
+ var collector = adapter.latestCollector(ADAPTER, "collector-1");
+
+ assertTrue(collector.online());
+ var measurement = collector.measurements().get(0);
+ assertEquals("S-01", measurement.channelCode());
+ assertEquals("TEMPERATURE", measurement.metricCode());
+ assertEquals(0, new java.math.BigDecimal("26.5").compareTo(measurement.value()));
+ assertEquals("℃", measurement.unit());
+ // 设备时间与来源身份来自这一行自己:不与检测柜记录共用时间,也不伪造配对。
+ assertEquals(Instant.parse("2026-09-27T07:53:20Z"), measurement.deviceTime());
+ assertEquals("134349692000000000", measurement.deviceTimeRaw());
+ }
+
+ /** 落地行里没有这一列(设备没给)就不写这一项,也不写零占位。 */
+ @Test
+ void declaredAuxiliaryMetricAbsentFromTheRowIsNotFabricated()
+ {
+ ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
+ ingest(BATCH);
+ PushIngestAdapter adapter = adapter(NOW, AUXILIARY_METRICS);
+
+ var collector = adapter.latestCollector(ADAPTER, "collector-1");
+
+ assertTrue(collector.online());
+ assertTrue(collector.measurements().isEmpty());
+ }
+
+ /** 来源离线(心跳超时)时不交出任何测量值:离线不伪造测量值,业务侧据此也不落采集器行。 */
+ @Test
+ void staleHeartbeatReportsCollectorSourceOfflineWithoutMeasurements()
+ {
+ ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
+ ingest("""
+ {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
+ {"record_time":"134349692000000000","Data_Point":420,"Voltage":1.26778,"eCurrent":0.5,"Tempbox":26.5}]}
+ """);
+ PushIngestAdapter adapter = adapter(NOW.plus(Duration.ofMinutes(10)), AUXILIARY_METRICS);
+
+ var collector = adapter.latestCollector(ADAPTER, "collector-1");
+
+ assertFalse(collector.online());
+ assertTrue(collector.measurements().isEmpty());
}
@Test
@@ -198,14 +260,18 @@
private PushIngestAdapter adapter(Instant now)
{
- return new PushIngestAdapter(config(), landing, Clock.fixed(now, ZoneOffset.UTC));
+ return adapter(now, Map.of("tokenEnv", "BATTERY_INGEST_TOKEN"));
}
- private static AdapterInstanceConfig config()
+ private PushIngestAdapter adapter(Instant now, Map<String, String> sourceOptions)
+ {
+ return new PushIngestAdapter(config(sourceOptions), landing, Clock.fixed(now, ZoneOffset.UTC));
+ }
+
+ private static AdapterInstanceConfig config(Map<String, String> sourceOptions)
{
return new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE, "127.0.0.1", 8080, 60000,
- "Asia/Shanghai", true, AdapterCapabilities.observeOnly(),
- Map.of("tokenEnv", "BATTERY_INGEST_TOKEN"));
+ "Asia/Shanghai", true, AdapterCapabilities.observeOnly(), sourceOptions);
}
private void ingest(String body)
diff --git a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestReceptionContractTest.java b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestReceptionContractTest.java
index 5f83d84..fa11347 100644
--- a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestReceptionContractTest.java
+++ b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestReceptionContractTest.java
@@ -50,6 +50,16 @@
{"record_time":"134349698000000000","Data_Point":422,"Step_Time":600.0,"Step_Index":4,
"Cycle_Index":1,"Voltage":1.2702,"eCurrent":0.5,"ChargeCapacity":0.122,"ChargeEnergy":0.32}]}
""";
+ /** 同一批里带上已声明的环境量列:辅助测量的值取自这一行自己,不做任何缩放。 */
+ private static final String AUXILIARY_BATCH = """
+ {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
+ {"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":4,
+ "Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31,
+ "Tempbox":26.5},
+ {"record_time":"134349704000000000","Data_Point":422,"Step_Time":660.0,"Step_Index":4,
+ "Cycle_Index":1,"Voltage":1.2702,"eCurrent":0.5,"ChargeCapacity":0.122,"ChargeEnergy":0.32,
+ "Tempbox":26.5}]}
+ """;
private final FakeIngestLanding landing = new FakeIngestLanding();
private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class);
@@ -100,19 +110,78 @@
verify(mapper, times(2)).insertRealtimeRecord(any());
}
+ /**
+ * 采集器辅助测量按 ADR-0064 由同一来源提供:它作为扩展测量记录进入实时记录,
+ * **不跟检测柜记录伪造配对**——检测柜走各自的设备数据行(槽位为空),采集器走轮询槽位行(柜端字段为空),
+ * 两边各自保留自己的设备时间,绑定角色决定它进哪个指标。
+ */
+ @Test
+ void collectorAuxiliaryMeasurementBecomesItsOwnRealtimeRowWithoutFakingAPairing()
+ {
+ PushIngestAdapter adapter = ingestPushSource("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\",\"auxiliaryMetrics\":"
+ + "[{\"vendorChannel\":26,\"channelCode\":\"S-01\",\"column\":\"Tempbox\","
+ + "\"metricCode\":\"TEMPERATURE\",\"unit\":\"℃\"}]}");
+ TTestMapper mapper = mock(TTestMapper.class);
+ ITTestService tests = mock(ITTestService.class);
+ TTest test = receivingTest();
+ TTestBattery battery = test.getBatteries().get(0);
+ battery.setCollectorId(7L);
+ com.battery.business.domain.TTestCollectorChannel collectorChannel =
+ new com.battery.business.domain.TTestCollectorChannel();
+ collectorChannel.setChannelCode("S-01");
+ collectorChannel.setMeasurementCode("PRIMARY_TEMPERATURE");
+ battery.setCollectorChannels(List.of(collectorChannel));
+ when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
+ when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
+ when(tests.selectTestById(1L)).thenReturn(test);
+ when(mapper.selectCollectorCode(7L)).thenReturn("j22-collector");
+ TestDataReceptionServiceImpl service = new TestDataReceptionServiceImpl(mapper, tests, adapter, clock);
+
+ landing.insertIngestRecord(boundaryRow("DB_20260927_155209/Ws26_IV_0:420", 420L));
+ service.captureReceivedData();
+ ingest(AUXILIARY_BATCH);
+ clock.advance(Duration.ofSeconds(60));
+ service.captureReceivedData();
+
+ ArgumentCaptor<TTestRealtimeRecord> rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(mapper, times(3)).insertRealtimeRecord(rows.capture());
+ List<TTestRealtimeRecord> cabinetRows = rows.getAllValues().subList(0, 2);
+ TTestRealtimeRecord collectorRow = rows.getAllValues().get(2);
+ // 检测柜事实不占轮询槽位,采集器事实占槽位:两条路径互不覆盖。
+ assertTrue(cabinetRows.stream().allMatch(row -> row.getCaptureSlotUtc() == null));
+ assertTrue(collectorRow.getCaptureSlotUtc() != null);
+ // 采集器行不携带检测柜设备时间与检测柜身份,来源行身份里也没有 cabinet 段。
+ assertEquals(null, collectorRow.getCabinetDeviceTimeUtc());
+ assertEquals(null, collectorRow.getCabinetChlid());
+ assertTrue(collectorRow.getSourceRecordId().indexOf("cabinet:") < 0);
+ // 测量值取自这一行自己的设备时间(该通道落地表里最新一行),指标编码与单位按配置声明,
+ // 固定指标落到自己的列。
+ assertEquals(Instant.parse("2026-09-27T08:13:20Z"), collectorRow.getCollectorDeviceTimeUtc().toInstant());
+ assertEquals("S-01", collectorRow.getCollectorChannelCode());
+ assertEquals(0, new java.math.BigDecimal("26.5").compareTo(collectorRow.getTemperature()));
+ assertEquals(Long.valueOf(7L), collectorRow.getCollectorId());
+ }
+
private PushIngestAdapter ingestPushSource()
+ {
+ return ingestPushSource("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}");
+ }
+
+ private PushIngestAdapter ingestPushSource(String sourceConfig)
{
TDeviceAdapter source = new TDeviceAdapter();
source.setAdapterCode(ADAPTER);
source.setAdapterType(PushIngestAdapter.ADAPTER_TYPE);
source.setStatus("0");
source.setTimezoneId("Asia/Shanghai");
- source.setSourceConfig("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}");
+ source.setSourceConfig(sourceConfig);
when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(source);
ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
return new PushIngestAdapter(new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE,
"127.0.0.1", 8080, 60000, "Asia/Shanghai", true, AdapterCapabilities.observeOnly(),
- java.util.Map.of("tokenEnv", "BATTERY_INGEST_TOKEN")), landing, clock);
+ com.alibaba.fastjson2.JSON.parseObject(sourceConfig)
+ .toJavaObject(new com.alibaba.fastjson2.TypeReference<java.util.Map<String, String>>() { })),
+ landing, clock);
}
private void ingest(String body)
diff --git a/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java b/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java
index 7bb842c..360c126 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java
@@ -13,9 +13,6 @@
/** upsert 本轮检测柜观测,未返回设备保留到逻辑过期。 */
void recordCabinets(List<CabinetCandidate> candidates);
- /** 替换指定适配器的采集器观测,使已删除模拟采集器立即消失。 */
- void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates);
-
/** 删除指定适配器的全部发现候选;仅供人工重置或实机联调清理。 */
void removeAdapter(String adapterCode);
@@ -28,18 +25,11 @@
return snapshot(now).cabinets().stream().filter(item -> item.deviceCode().equals(deviceCode)).findFirst();
}
- /** 按设备编码查询指定时刻未过期的采集器观测;在线和登记状态由业务层校验。 */
- default Optional<CollectorCandidate> findCollector(String deviceCode, Instant now)
- {
- return snapshot(now).collectors().stream().filter(item -> item.deviceCode().equals(deviceCode)).findFirst();
- }
-
- record CandidateSnapshot(List<CabinetCandidate> cabinets, List<CollectorCandidate> collectors)
+ record CandidateSnapshot(List<CabinetCandidate> cabinets)
{
public CandidateSnapshot
{
cabinets = List.copyOf(cabinets);
- collectors = List.copyOf(collectors);
}
}
@@ -93,8 +83,4 @@
this(identity, online, workStatus, observedAt, latestSnapshot, lastDataAt, null);
}
}
-
- record CollectorCandidate(String adapterCode, String deviceCode, String ip, String mac, String name,
- String manufacturer, String model, int channelCount, boolean online,
- Instant observedAt, Instant expiresAt) { }
}
diff --git a/battery-device-api/src/main/java/com/battery/device/api/discovery/InMemoryDeviceDiscoveryCandidateStore.java b/battery-device-api/src/main/java/com/battery/device/api/discovery/InMemoryDeviceDiscoveryCandidateStore.java
index 2426a24..da84c61 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/discovery/InMemoryDeviceDiscoveryCandidateStore.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/discovery/InMemoryDeviceDiscoveryCandidateStore.java
@@ -9,7 +9,6 @@
public final class InMemoryDeviceDiscoveryCandidateStore implements DeviceDiscoveryCandidateStore
{
private final Map<String, CabinetCandidate> cabinets = new LinkedHashMap<>();
- private final Map<String, CollectorCandidate> collectors = new LinkedHashMap<>();
/** 按设备编码 upsert 本轮检测柜观测;本轮未返回的候选保留到逻辑过期。 */
@Override
@@ -18,20 +17,11 @@
candidates.forEach(item -> cabinets.put(item.deviceCode(), item));
}
- /** 整体替换指定适配器的采集器候选,使该适配器下已不存在的采集器立即消失。 */
- @Override
- public synchronized void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates)
- {
- collectors.values().removeIf(item -> item.adapterCode().equals(adapterCode));
- candidates.forEach(item -> collectors.put(item.deviceCode(), item));
- }
-
- /** 删除指定适配器名下的检测柜与采集器候选;仅供人工重置或联调清理。 */
+ /** 删除指定适配器名下的检测柜候选;仅供人工重置或联调清理。 */
@Override
public synchronized void removeAdapter(String adapterCode)
{
cabinets.values().removeIf(item -> item.adapterCode().equals(adapterCode));
- collectors.values().removeIf(item -> item.adapterCode().equals(adapterCode));
}
/** 返回指定时刻尚未过期的候选,并清理已过期条目。 */
@@ -39,7 +29,6 @@
public synchronized CandidateSnapshot snapshot(Instant now)
{
cabinets.values().removeIf(item -> !item.expiresAt().isAfter(now));
- collectors.values().removeIf(item -> !item.expiresAt().isAfter(now));
- return new CandidateSnapshot(List.copyOf(cabinets.values()), List.copyOf(collectors.values()));
+ return new CandidateSnapshot(List.copyOf(cabinets.values()));
}
}
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java b/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
index d122581..b39bd92 100644
--- a/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
@@ -67,6 +67,11 @@
/** 工步层的共用读取窗口:足以覆盖一轮接收里工步与循环的连续调用,又不会压住新出现的工步行。 */
private static final long CAP_STEP_CACHE_MS = 5000L;
private static final int SAMPLE_BATCH_LIMIT = 500;
+ /**
+ * 每条语句的查询截止时间。厂商库的服务端**没有**查询超时,客户端不设就是无限;
+ * JDBC URL 里的 {@code socketTimeout} 只管单次读,慢速滴流的查询仍可能长期占住连接。
+ */
+ private static final int QUERY_TIMEOUT_SECONDS = 10;
private static final Pattern SAFE_TABLE_NAME = Pattern.compile("[A-Za-z0-9_]{1,64}");
/** 设备时间按适配器时区解释;来源列是秒精度 datetime,小数部分按需接受。 */
private static final DateTimeFormatter DEVICE_TIME = new DateTimeFormatterBuilder()
@@ -618,6 +623,8 @@
Connection connection = dataSource.getConnection();
try (PreparedStatement statement = connection.prepareStatement(sql))
{
+ // 读取超时有两层:连接串的 socketTimeout 管单次读,这里给整条语句一个截止时间。
+ statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
for (int index = 0; index < parameters.length; index++)
statement.setObject(index + 1, parameters[index]);
try (ResultSet rows = statement.executeQuery())
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java b/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java
index f682c0d..ea088fe 100644
--- a/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java
@@ -33,6 +33,12 @@
public final class ReadOnlyVendorDataSource implements DataSource
{
private static final List<String> ALLOWED_PREFIXES = List.of("select", "show", "describe", "desc");
+ /**
+ * 加锁读会真的阻塞厂商写入,所以它虽然以 {@code select} 开头也要单独拒绝:
+ * 现场实测厂商库 {@code tx_isolation=REPEATABLE-READ},行锁会挡住上位机的写库。
+ */
+ private static final List<String> LOCKING_CLAUSES = List.of("for update", "for share",
+ "lock in share mode", "updlock", "holdlock", "xlock", "tablock");
private final DataSource delegate;
private final String sourceLabel;
@@ -117,13 +123,17 @@
private void requireQuery(String sql)
{
- if (isQuery(sql))
+ boolean query = isQuery(sql);
+ boolean locking = query && containsLockingClause(sql);
+ if (query && !locking)
{
audit.recordForwarded(sql);
return;
}
audit.recordRejected(sql);
- throw new NonQueryStatementException("厂商数据源只允许查询语句:" + abbreviate(sql));
+ throw new NonQueryStatementException(locking
+ ? "厂商数据源禁止加锁读(会阻塞厂商写入):" + abbreviate(sql)
+ : "厂商数据源只允许查询语句:" + abbreviate(sql));
}
/** 只按语句首关键字判定,避免用正则近似 SQL 语法。 */
@@ -144,6 +154,21 @@
return false;
}
+ /**
+ * 语句里是否带加锁读子句。这是**语法层的拦截**,不是 SQL 解析器:厂商取数的语句由本模块固定给出,
+ * 不会把加锁子句藏在字符串常量里;新增取数语句时由 {@code ReadOnlyVendorDataSourceTest} 一起覆盖。
+ */
+ static boolean containsLockingClause(String sql)
+ {
+ if (sql == null) return false;
+ String normalized = stripLeadingComments(sql).toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
+ for (String clause : LOCKING_CLAUSES)
+ {
+ if (normalized.contains(clause)) return true;
+ }
+ return false;
+ }
+
private static String stripLeadingComments(String sql)
{
String remaining = sql.trim();
diff --git a/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java b/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java
index 9141429..b78ac57 100644
--- a/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java
+++ b/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java
@@ -158,4 +158,37 @@
assertTrue(database.statements().isEmpty());
assertEquals(3, dataSource.audit().rejectedStatements().size());
}
+
+ /** 加锁读以 select 开头却是真的会阻塞厂商写入,所以必须在到达驱动之前被单独拒绝。 */
+ @Test
+ void lockingReadsAreRejectedBeforeTheyReachTheDriver()
+ {
+ FakeVendorDatabase database = new FakeVendorDatabase();
+ ReadOnlyVendorDataSource dataSource = dataSourceOver(database);
+ for (String sql : List.of("select dev_uid from channel for update",
+ "select dev_uid from channel FOR SHARE",
+ "select dev_uid from channel lock in share mode",
+ "select dev_uid from channel with (updlock)",
+ "/* probe */ select dev_uid from channel with (tablock, holdlock)"))
+ {
+ assertTrue(ReadOnlyVendorDataSource.isQuery(sql), "加锁读仍然以查询开头:" + sql);
+ assertTrue(ReadOnlyVendorDataSource.containsLockingClause(sql), "应当识别为加锁读:" + sql);
+ assertThrows(NonQueryStatementException.class,
+ () -> dataSource.getConnection().prepareStatement(sql), sql);
+ }
+ assertTrue(database.statements().isEmpty(), "加锁读不得到达驱动");
+ assertTrue(!ReadOnlyVendorDataSource.containsLockingClause("select dev_uid from channel"));
+ assertTrue(!ReadOnlyVendorDataSource.containsLockingClause(""));
+ }
+
+ /** 连接超时与读取超时都必须由我们显式给出,且不得写入只读事务参数(5.5 不支持)。 */
+ @Test
+ void vendorJdbcUrlCarriesBothTimeoutsAndNoReadOnlySessionVariable()
+ {
+ String url = NewareMysqlAdapterFactory.jdbcUrl("26.1.2.3", 3306, "bts63");
+
+ assertTrue(url.contains("connectTimeout="), url);
+ assertTrue(url.contains("socketTimeout="), url);
+ assertTrue(!url.contains("transaction_read_only"), url);
+ }
}
diff --git a/battery-device-simulator/pom.xml b/battery-device-simulator/pom.xml
deleted file mode 100644
index 0c7b786..0000000
--- a/battery-device-simulator/pom.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
- <modelVersion>4.0.0</modelVersion>
- <parent><groupId>com.ruoyi</groupId><artifactId>ruoyi</artifactId><version>3.9.2</version></parent>
- <groupId>com.battery</groupId><artifactId>battery-device-simulator</artifactId>
- <description>开发设备模拟器实现</description>
- <dependencies>
- <dependency><groupId>com.battery</groupId><artifactId>battery-device-api</artifactId></dependency>
- <!-- 仅测试复用协议客户端验证模拟器边界,生产依赖方向仍保持单向。 -->
- <dependency><groupId>com.battery</groupId><artifactId>battery-device-bts</artifactId><scope>test</scope></dependency>
- <dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><scope>test</scope></dependency>
- <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
- </dependencies>
-</project>
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
deleted file mode 100644
index 1dea589..0000000
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
+++ /dev/null
@@ -1,933 +0,0 @@
-package com.battery.device.simulator;
-
-import java.io.IOException;
-import java.io.StringReader;
-import java.math.BigDecimal;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import javax.xml.stream.XMLInputFactory;
-import javax.xml.stream.XMLStreamConstants;
-import javax.xml.stream.XMLStreamReader;
-import com.battery.device.api.DeviceAdapter;
-import com.battery.device.api.model.DeviceWorkStatus;
-import com.battery.device.api.model.DeviceConnectionStatus;
-import com.battery.device.api.model.DeviceIdentity;
-import com.battery.device.api.model.DeviceObservation;
-import com.battery.device.api.model.DetectedChannel;
-import com.battery.device.api.model.DetectedDeviceMetadata;
-import com.battery.device.api.model.ChannelIdentity;
-import com.battery.device.api.model.AdapterCapabilities;
-import com.battery.device.api.model.RealtimeSnapshot;
-import com.battery.device.api.DeviceObservationAdapter;
-import com.battery.device.api.TestDataObservationAdapter;
-import com.battery.device.simulator.BtsSimulatorStateStore;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
-import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
-import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
-import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
-import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
-import com.battery.device.simulator.InMemoryBtsSimulatorStateStore;
-import com.battery.device.simulator.DevelopmentCollectorSimulation;
-import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
-
-/**
- * 提供由开发后端托管的临时只读 BTS 模拟端点。
- * 同时实现发现与测试数据观测契约:模拟器直接暴露自身状态,因此发现与取数按同一实例解析,
- * 不再需要单独注入一个扩展观测 Bean。TCP 端点仍保留给协议层测试使用。
- */
-public final class BtsDevelopmentSimulator implements DeviceAdapter, TestDataObservationAdapter
-{
- public static final String HOST = "127.0.0.1";
- public static final int DEFAULT_PORT = 15020;
- public static final String ADAPTER_CODE = "dev-sim";
- public static final String ADAPTER_NAME = "dev-sim BTS 模拟器";
- public static final int POLL_INTERVAL_MS = 5000;
- public static final String NEWARE_CT_4008T_5V6A_S1_F = "CT-4008T-5V6A-S1-F";
- public static final String NEWARE_CT_4008TN_5V12A_S1_F = "CT-4008Tn-5V12A-S1-F";
-
- private static final int MAX_CHANNEL_COUNT = 99;
- private static final int MAX_RECENT_REQUESTS = 20;
- private static final int TOMBSTONE_DISCOVERY_COUNT = 3;
-
- private final ScriptedBtsTcpSimulator simulator;
- private final BtsSimulatorStateStore stateStore;
- private SimulatorState state;
- private boolean running = true;
- private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
- private static final DateTimeFormatter DEVICE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
-
- /** 返回固定的适配器实例代码,用于把模拟观测归属到 dev-sim 这一实例。 */
- @Override
- public String adapterCode()
- {
- return ADAPTER_CODE;
- }
-
- /** 返回只观测能力:支持发现与实时观测,不支持历史下载,也不支持向设备下发控制命令。 */
- @Override
- public AdapterCapabilities capabilities()
- {
- return AdapterCapabilities.observeOnly();
- }
-
- /** 直接提供模拟器当前拓扑,供不需要 TCP/XML 的契约测试使用。 */
- @Override
- public synchronized List<DetectedChannel> discoverChannels()
- {
- List<DetectedChannel> result = new ArrayList<>();
- for (DeviceState device : state.devices())
- {
- BtsDeviceProfile profile = profileFor(device);
- for (ChannelState channel : device.channels())
- {
- ChannelIdentity identity = new ChannelIdentity(ADAPTER_CODE, profile.devtype(), device.deviceId(),
- profile.subDeviceId(), channel.channelId());
- result.add(new DetectedChannel(identity, device.ip(), state.softwareAccessible() && device.online() && channel.online(),
- new DetectedDeviceMetadata(profile.name(), profile.manufacturer(), profile.model(),
- simulatorMac(profile.devtype(), device.deviceId()))));
- }
- }
- recordRequest("discover", ADAPTER_CODE, result.toString());
- return List.copyOf(result);
- }
-
- /** 直接提供模拟器观测;离线通道不保留上一次测量或工步事实。 */
- @Override
- public synchronized java.util.Map<ChannelIdentity, DeviceObservation> observe(
- java.util.Set<ChannelIdentity> channels)
- {
- if (channels == null || channels.isEmpty()) return java.util.Map.of();
- java.util.Map<ChannelIdentity, DeviceObservation> result = new java.util.LinkedHashMap<>();
- for (ChannelIdentity requested : channels) result.put(requested, observation(requested));
- recordRequest("observe", channels.toString(), result.toString());
- return java.util.Map.copyOf(result);
- }
-
- /** 直接提供模拟器实时快照;不可观测通道不出现在结果里,与 BTS 协议解析后的语义一致。 */
- @Override
- public synchronized Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
- {
- if (channels == null || channels.isEmpty()) return Map.of();
- Map<ChannelIdentity, RealtimeSnapshot> result = new LinkedHashMap<>();
- for (ChannelIdentity requested : channels)
- {
- if (!canObserve(requested)) continue;
- CabinetRealtime measurement = cabinetMeasurement(requested);
- if (measurement == null) continue;
- DeviceObservation observed = observation(requested);
- result.put(requested, new RealtimeSnapshot(requested, observed.workStatus(), measurement.cycleNo(),
- measurement.stepNo(), measurement.stepType(), null, BigDecimal.valueOf(measurement.currentA()),
- BigDecimal.valueOf(measurement.voltageV()), BigDecimal.valueOf(measurement.capacityAh()),
- BigDecimal.valueOf(measurement.energyWh()), totalTimeSeconds(requested),
- measurement.relativeTimeSeconds(), null, measurement.serverReceivedAt(), Map.of(),
- DeviceConnectionStatus.ONLINE, measurement.deviceTime(), List.of(), null));
- }
- recordRequest("inquire", channels.toString(), result.toString());
- return Map.copyOf(result);
- }
-
- private long totalTimeSeconds(ChannelIdentity identity)
- {
- return requireDevice(identity.devid()).channels().get(identity.chlid() - 1).configuration().totalTimeSeconds();
- }
-
- private DeviceObservation observation(ChannelIdentity requested)
- {
- DeviceState device = state.devices().stream().filter(item -> item.deviceId() == requested.devid())
- .findFirst().orElse(null);
- ChannelState channel = device == null ? null : device.channels().stream()
- .filter(item -> item.channelId() == requested.chlid()).findFirst().orElse(null);
- if (!state.softwareAccessible() || device == null || channel == null
- || !ADAPTER_CODE.equals(requested.adapterCode()) || !device.online() || !channel.online()
- || requested.devtype() != profileFor(device).devtype()
- || requested.subdevid() != profileFor(device).subDeviceId())
- return DeviceObservation.offline(requested, failureReason());
- return new DeviceObservation(new DeviceIdentity(ADAPTER_CODE, requested.devtype(), requested.devid(),
- requested.subdevid()), requested, DeviceConnectionStatus.ONLINE, channel.workStatus(),
- List.of(), null, java.util.Map.of(), null);
- }
-
- /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
- public BtsDevelopmentSimulator(int port) throws IOException
- {
- this(port, new InMemoryBtsSimulatorStateStore());
- }
-
- /** 从长期状态恢复后再启动 TCP 服务,避免服务先暴露默认拓扑。 */
- public BtsDevelopmentSimulator(int port, BtsSimulatorStateStore stateStore) throws IOException
- {
- this.stateStore = stateStore;
- state = stateStore.loadOrInitialize(defaultState());
- validateRestoredState();
- simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
- }
-
- /** 返回模拟器 TCP 端点实际监听的端口。 */
- public int port()
- {
- return simulator.port();
- }
-
- /** 新增一台具有独立虚拟 IP 和通道数的开发设备。 */
- public synchronized int addDevice(String ip, int channelCount)
- {
- validateDeviceConfiguration(ip, channelCount, null);
- int deviceId = state.nextDeviceId();
- List<DeviceState> devices = new ArrayList<>(state.devices());
- devices.add(newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount)));
- persist(new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, devices, state.collectors(),
- deviceId + 1, state.nextCollectorId(), state.tombstones()));
- return deviceId;
- }
-
- /** 修改开发设备的虚拟地址、通道数和在线状态。 */
- public synchronized void updateDevice(int deviceId, String ip, int channelCount, boolean online)
- {
- DeviceState current = requireDevice(deviceId);
- validateDeviceConfiguration(ip, channelCount, deviceId);
- List<ChannelState> channels = new ArrayList<>(channelCount);
- for (int channelId = 1; channelId <= channelCount; channelId++)
- {
- channels.add(channelId <= current.channels().size()
- ? current.channels().get(channelId - 1)
- : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
- }
- List<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels,
- resizeProfile(current.profile(), channelCount)));
- List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
- for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++)
- upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId);
- tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() <= channelCount);
- persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
- state.nextCollectorId(), tombstones));
- }
-
- /** 按预设新威型号元数据新增模拟设备;真实协议和拓扑仍待现场验证。 */
- public synchronized int addNewareDevice(String ip, String model)
- {
- BtsDeviceProfile profile = newareProfile(model);
- validateDeviceConfiguration(ip, profile.channelCount(), null);
- int deviceId = state.nextDeviceId();
- List<DeviceState> devices = new ArrayList<>(state.devices());
- devices.add(newDevice(deviceId, ip, profile));
- persist(new SimulatorState(state.version(), devices, state.collectors(), deviceId + 1,
- state.nextCollectorId(), state.tombstones()));
- return deviceId;
- }
-
- /** 将已有模拟设备切换为新威 profile,并按 profile 通道数调整拓扑。 */
- public synchronized void configureNewareProfile(int deviceId, String model)
- {
- DeviceState current = requireDevice(deviceId);
- BtsDeviceProfile profile = newareProfile(model);
- updateDeviceWithProfile(current, profile);
- }
-
- /** 删除指定开发设备;后续发现响应不再返回它。 */
- public synchronized void deleteDevice(int deviceId)
- {
- DeviceState removed = requireDevice(deviceId);
- List<DeviceState> devices = new ArrayList<>(state.devices());
- devices.removeIf(item -> item.deviceId() == deviceId);
- List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
- for (ChannelState channel : removed.channels())
- {
- upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId());
- }
- persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
- state.nextCollectorId(), tombstones));
- }
-
- /** 修改指定开发设备的单个通道状态。 */
- public synchronized void updateChannel(int deviceId, int channelId, boolean online,
- DeviceWorkStatus workStatus)
- {
- requireControllableStatus(workStatus);
- DeviceState device = requireDevice(deviceId);
- if (channelId < 1 || channelId > device.channels().size())
- throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
- List<ChannelState> channels = new ArrayList<>(device.channels());
- channels.set(channelId - 1, new ChannelState(channelId, online, workStatus,
- channels.get(channelId - 1).configuration()));
- persist(new SimulatorState(state.version(),
- replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())), state.collectors(),
- state.nextDeviceId(), state.nextCollectorId(), state.tombstones()));
- }
-
- /** 保存单通道的当前测量和工步方案,不改写在线或工作状态,不产生观测。 */
- public synchronized void configureChannel(int deviceId, int channelId, ChannelConfiguration configuration)
- {
- if (configuration == null) throw new IllegalArgumentException("通道配置不能为空");
- DeviceState device = requireDevice(deviceId);
- if (channelId < 1 || channelId > device.channels().size())
- throw new IllegalArgumentException("通道不存在");
- List<ChannelState> channels = new ArrayList<>(device.channels());
- ChannelState channel = channels.get(channelId - 1);
- channels.set(channelId - 1, new ChannelState(channelId, channel.online(), channel.workStatus(), configuration));
- persist(new SimulatorState(state.version(),
- replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels, device.profile())),
- state.collectors(), state.nextDeviceId(), state.nextCollectorId(), state.tombstones()));
- }
-
- /** 新增一台与 BTS 检测柜拓扑隔离的开发采集器。 */
- public synchronized int addCollector(String ip, int channelCount)
- {
- validateCollectorConfiguration(ip, channelCount, null);
- int collectorId = state.nextCollectorId();
- List<CollectorState> collectors = new ArrayList<>(state.collectors());
- collectors.add(newCollector(collectorId, ip, channelCount));
- persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
- collectorId + 1, state.tombstones()));
- return collectorId;
- }
-
- /** 修改开发采集器的虚拟地址、通道数和在线状态。 */
- public synchronized void updateCollector(int collectorId, String ip, int channelCount, boolean online)
- {
- validateCollectorConfiguration(ip, channelCount, collectorId);
- CollectorState current = requireCollector(collectorId);
- List<CollectorState> collectors = new ArrayList<>(state.collectors());
- collectors.replaceAll(item -> item.collectorId() == collectorId
- ? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now())
- : item);
- persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
- state.nextCollectorId(), state.tombstones()));
- }
-
- /** 删除指定开发采集器,不影响检测柜 TCP 发现结果。 */
- public synchronized void deleteCollector(int collectorId)
- {
- CollectorState collector = requireCollector(collectorId);
- List<CollectorState> collectors = new ArrayList<>(state.collectors());
- collectors.removeIf(item -> item.collectorId() == collectorId);
- SimulatorState next = new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
- state.nextCollectorId(), state.tombstones());
- persist(next);
- }
-
- /** 保存稳定 S 通道的固定测量值及自定义指标,下一次请求时形成观测。 */
- public synchronized void configureCollectorChannel(int collectorId, String channelCode,
- List<CollectorMetricState> metrics)
- {
- CollectorState current = requireCollector(collectorId);
- replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
- }
-
- /** 修改软件可访问性,不改变设备身份、在线配置或通道工作状态。 */
- public synchronized void setSoftwareAccessible(boolean accessible)
- {
- SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
- state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), accessible, state.observationSequence());
- stateStore.save(next);
- state = next;
- }
-
- /** 批量修改设备在线事实,保留各通道工作状态。 */
- public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
- {
- List<DeviceState> devices = state.devices().stream()
- .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn, device.channels(), device.profile()))
- .toList();
- List<CollectorState> collectors = state.collectors().stream()
- .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(),
- collector.channelCount(), poweredOn, Instant.now())).toList();
- persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(),
- state.nextCollectorId(), state.tombstones()));
- }
-
- /** 修改采集器单个通道工作状态;不代表真实采集器控制能力。 */
- public synchronized void updateCollectorChannelStatus(int collectorId, String channelCode, DeviceWorkStatus status)
- {
- requireControllableStatus(status);
- replaceCollector(DevelopmentCollectorSimulation.configureStatus(requireCollector(collectorId), channelCode, status));
- }
-
- /** 返回通道已配置的开发方案及当前工步位置,不自动推进或生成历史。 */
- @Override
- public synchronized TestPlan testPlan(ChannelIdentity channel)
- {
- if (!canObserve(channel)) return null;
- ChannelState source = requireDevice(channel.devid()).channels().get(channel.chlid() - 1);
- ChannelConfiguration configuration = source.configuration();
- List<PlanRow> rows = new ArrayList<>();
- for (int index = 0; index < configuration.steps().size(); index++) {
- var step = configuration.steps().get(index);
- String status = index + 1 < configuration.stepNo() ? "已完成" : "待执行";
- if (index + 1 == configuration.stepNo()) status = switch (source.workStatus()) {
- case WORKING -> "进行中";
- case PAUSED -> "暂停";
- case FINISHED -> "已完成";
- case PROTECTED -> "异常";
- default -> "待执行";
- };
- // cyclePosition 表示循环内的工步位置,不能使用当前循环号,否则控制台会合并不同工步。
- rows.add(new PlanRow("STEP", index + 1, index + 1, step.name(), step.durationSeconds(),
- step.voltageV(), status, null, null));
- }
- return new TestPlan(configuration.cycleNo(), configuration.stepNo(), configuration.plannedCycleCount(), rows);
- }
-
- /** 请求当前检测柜测量;离线或无法观测时不返回缓存值。 */
- @Override
- public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel)
- {
- CabinetRealtime result = canObserve(channel) ? cabinetMeasurement(channel) : null;
- recordRequest("cabinet-realtime", String.valueOf(channel),
- result == null ? failureReason() : result.toString());
- return result;
- }
-
- private CabinetRealtime cabinetMeasurement(ChannelIdentity identity)
- {
- DeviceState device = requireDevice(identity.devid());
- ChannelState channel = device.channels().get(identity.chlid() - 1);
- if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) return null;
- Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
- long sequence = nextObservationSequence();
- ChannelConfiguration configuration = channel.configuration();
- var step = configuration.steps().get(configuration.stepNo() - 1);
- // 保存值直接成为当前观测;工作状态和设备量程不反向覆盖操作员配置。
- CabinetRealtime result = new CabinetRealtime(sequence, now, configuration.currentA().doubleValue(),
- configuration.voltageV().doubleValue(), configuration.capacityAh().doubleValue(), configuration.energyWh().doubleValue(),
- configuration.cycleNo(), configuration.stepNo(), step.name(), step.stepType(),
- now, DEVICE_TIME.format(now.atZone(DEVICE_ZONE)), DEVICE_ZONE.getId(), configuration.relativeTimeSeconds());
- return result;
- }
-
- /** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */
- @Override
- public synchronized CollectorRealtime latestCollector(String adapterCode, String collectorCode)
- {
- CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode))
- .findFirst().orElse(null);
- if (collector == null) return null;
- var snapshot = collectorRealtime(collector.collectorId());
- return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(),
- snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(),
- item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(),
- item.unit())).toList(), DEVICE_ZONE.getId());
- }
-
- /** 模拟器不提供历史工步统计。 */
- @Override
- public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
-
- /** 模拟器不提供历史循环统计。 */
- @Override
- public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
-
- /** 使用后端正常持久化周期,不提供开发倍速。 */
- @Override
- public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; }
-
- private boolean canObserve(ChannelIdentity channel)
- {
- // 完整来源身份限定数据归属;不可访问和离线只改变观测结果。
- return channel != null && observation(channel).connectionStatus() == DeviceConnectionStatus.ONLINE
- && observation(channel).workStatus() != DeviceWorkStatus.UNOBSERVABLE;
- }
-
- /** 请求采集器当前观测,读取状态页面本身不会触发此请求。 */
- public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
- {
- CollectorState collector = requireCollector(collectorId);
- boolean available = state.softwareAccessible() && collector.online();
- if (available)
- {
- collector = DevelopmentCollectorSimulation.sample(collector, Instant.now(), nextObservationSequence());
- replaceCollector(collector);
- }
- var result = new CollectorRealtimeSnapshot(collector.deviceCode(), available, collector.sampleIntervalMs(),
- available ? collector.latestMeasurements() : List.of());
- recordRequest("collector-realtime", collector.deviceCode(), available ? result.toString() : failureReason());
- return result;
- }
-
- private String failureReason()
- {
- return state.softwareAccessible() ? "UNOBSERVABLE" : "SOFTWARE_UNAVAILABLE";
- }
-
- private long nextObservationSequence()
- {
- long sequence = Math.max(state.observationSequence() + 1, Instant.now().toEpochMilli());
- SimulatorState next = new SimulatorState(state.version(), state.devices(), state.collectors(),
- state.nextDeviceId(), state.nextCollectorId(), state.tombstones(), state.softwareAccessible(), sequence);
- stateStore.save(next);
- state = next;
- return sequence;
- }
-
- /** 返回模拟器配置、当前状态和最近请求,不触发新的设备观测。 */
- public synchronized StateSnapshot snapshot()
- {
- List<DeviceSnapshot> deviceSnapshots = state.devices().stream()
- .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(), device.profile(),
- device.channels().stream()
- .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(),
- channel.workStatus(), channel.configuration()))
- .toList()))
- .toList();
- List<CollectorSnapshot> collectorSnapshots = state.collectors().stream()
- .map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(),
- collector.ip(), collector.mac(), collector.name(), collector.manufacturer(),
- collector.model(), collector.channelCount(), collector.online(), collector.sampleIntervalMs(),
- collector.channels(), state.softwareAccessible() && collector.online() ? collector.latestMeasurements() : List.of()))
- .toList();
- return new StateSnapshot(running, state.softwareAccessible(), HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
- stateStore.loadRecentRequests());
- }
-
- /** 人工恢复默认拓扑;这是正常运行中唯一清空长期模拟状态的入口。 */
- public synchronized void reset()
- {
- SimulatorState defaultState = defaultState();
- stateStore.reset(defaultState);
- state = defaultState;
- }
-
- private SimulatorState defaultState()
- {
- return new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
- List.of(newDevice(1, "127.0.0.2", 4)),
- List.of(newCollector(1, "127.0.1.2", 4)), 2, 2, List.of());
- }
-
- private DeviceState newDevice(int deviceId, String ip, int channelCount)
- {
- return newDevice(deviceId, ip, BtsDeviceProfile.virtual(channelCount));
- }
-
- private DeviceState newDevice(int deviceId, String ip, BtsDeviceProfile profile)
- {
- List<ChannelState> channels = new ArrayList<>(profile.channelCount());
- for (int channelId = 1; channelId <= profile.channelCount(); channelId++)
- channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
- return new DeviceState(deviceId, ip.trim(), true, channels, profile);
- }
-
- private CollectorState newCollector(int collectorId, String ip, int channelCount)
- {
- return new CollectorState(collectorId, "dev-sim-collector-" + collectorId,
- ip.trim(), simulatorCollectorMac(collectorId), "dev-sim 采集器 " + collectorId,
- "开发模拟器", "Virtual Collector", channelCount, true);
- }
-
- private void replaceCollector(CollectorState replacement)
- {
- List<CollectorState> collectors = new ArrayList<>(state.collectors());
- collectors.replaceAll(item -> item.collectorId() == replacement.collectorId() ? replacement : item);
- persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
- state.nextCollectorId(), state.tombstones()));
- }
-
- private synchronized String respond(String request)
- {
- String command = readCommand(request);
- String response = state.softwareAccessible() ? switch (command)
- {
- case "getdevinfo" -> deviceInfoResponse();
- case "inquire" -> observationResponse(request);
- default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
- } : "<bts version=\"1.0\"><cmd>error</cmd></bts>";
- recordRequest(command, request, state.softwareAccessible() ? response : "SOFTWARE_UNAVAILABLE");
- return response;
- }
-
- private void recordRequest(String command, String request, String response)
- {
- List<RecentRequest> recentRequests = new ArrayList<>(stateStore.loadRecentRequests());
- recentRequests.add(0, new RecentRequest(Instant.now(), command, request, response));
- if (recentRequests.size() > MAX_RECENT_REQUESTS)
- recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS));
- stateStore.saveRecentRequests(recentRequests);
- }
-
- private String readCommand(String request)
- {
- try
- {
- XMLInputFactory factory = XMLInputFactory.newFactory();
- factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
- factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
- XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request));
- while (reader.hasNext())
- {
- if (reader.next() == XMLStreamConstants.START_ELEMENT && "cmd".equals(reader.getLocalName()))
- return reader.getElementText().trim();
- }
- }
- catch (Exception exception)
- {
- throw new IllegalArgumentException("Invalid BTS request XML", exception);
- }
- throw new IllegalArgumentException("BTS request does not contain cmd");
- }
-
- private synchronized String deviceInfoResponse()
- {
- int channelCount = state.devices().stream().mapToInt(device -> device.channels().size()).sum()
- + state.tombstones().size();
- StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
- .append("<serverip count=\"0\"/><middle count=\"").append(channelCount).append("\">");
- for (DeviceState device : state.devices())
- {
- for (ChannelState channel : device.channels())
- {
- boolean available = device.online() && channel.online();
- appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available);
- }
- }
- List<ChannelTombstone> remaining = new ArrayList<>();
- for (ChannelTombstone tombstone : state.tombstones())
- {
- appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false);
- if (tombstone.remainingDiscoveries() > 1)
- remaining.add(new ChannelTombstone(tombstone.deviceId(), tombstone.ip(), tombstone.channelId(),
- tombstone.remainingDiscoveries() - 1));
- }
- if (!remaining.equals(state.tombstones()))
- persist(new SimulatorState(state.version(), state.devices(), state.collectors(), state.nextDeviceId(),
- state.nextCollectorId(), remaining));
- return response.append("</middle></bts>").toString();
- }
-
- private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId,
- boolean available)
- {
- // 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。
- DeviceState device = state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst().orElse(null);
- BtsDeviceProfile profile = device == null ? BtsDeviceProfile.virtual(channelId) : profileFor(device);
- response.append("<channel ip=\"").append(ip).append("\" devtype=\"").append(profile.devtype())
- .append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(profile.subDeviceId())
- .append("\" Channelid=\"").append(channelId).append("\" name=\"").append(profile.name())
- .append("\" manufacturer=\"").append(profile.manufacturer()).append("\" model=\"")
- .append(profile.model()).append("\" serialNumber=\"").append(profile.serialNumber())
- .append("\" firmwareVersion=\"").append(profile.firmwareVersion()).append("\" mac=\"")
- .append(simulatorMac(profile.devtype(), deviceId)).append("\">")
- .append(available).append("</channel>");
- }
-
- private synchronized String observationResponse(String request)
- {
- java.util.Set<String> requested = requestedChannels(request);
- List<ObservedChannel> available = new ArrayList<>();
- for (DeviceState device : state.devices())
- for (ChannelState channel : device.channels())
- if (device.online() && channel.online() && channel.workStatus() != DeviceWorkStatus.UNOBSERVABLE
- && requested.contains(profileFor(device).devtype() + ":" + device.deviceId()
- + ":" + profileFor(device).subDeviceId() + ":" + channel.channelId()))
- available.add(new ObservedChannel(device.deviceId(), channel));
-
- StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"")
- .append(available.size()).append("\">");
- for (ObservedChannel observed : available)
- {
- ChannelState channel = observed.channel();
- DeviceState device = requireDevice(observed.deviceId());
- BtsDeviceProfile profile = profileFor(device);
- CabinetRealtime measurement = cabinetMeasurement(new ChannelIdentity(ADAPTER_CODE, profile.devtype(),
- device.deviceId(), profile.subDeviceId(), channel.channelId()));
- double current = measurement.currentA(), voltage = measurement.voltageV();
- double capacity = measurement.capacityAh(), energy = measurement.energyWh();
- int cycleNo = measurement.cycleNo(), stepNo = measurement.stepNo();
- String stepType = measurement.stepType();
- response.append("<inquire dev=\"").append(profile.devtype()).append('-').append(observed.deviceId())
- .append('-').append(profile.subDeviceId()).append('-').append(channel.channelId())
- .append("-0\" cycle_id=\"").append(cycleNo).append("\" step_id=\"").append(stepNo)
- .append("\" step_type=\"").append(stepType).append("\" workstatus=\"")
- .append(toBtsWorkStatus(channel.workStatus()))
- .append("\" current=\"").append(decimal(current)).append("\" voltage=\"")
- .append(decimal(voltage)).append("\" capacity=\"").append(decimal(capacity))
- .append("\" energy=\"").append(decimal(energy)).append("\" totaltime=\"")
- .append(channel.configuration().totalTimeSeconds()).append("\" relativetime=\"")
- .append(channel.configuration().relativeTimeSeconds())
- .append("\" open_or_close=\"0\" log_code=\"0\">")
- // 模拟器返回当前设备方案,供统一观测模型展示;不代表真实 BTS 的完整方案字段。
- .append("<plan><step step_no=\"").append(stepNo).append("\" step_type=\"")
- .append(stepType).append("\" name=\"").append(stepType).append("\"/></plan></inquire>");
- }
- return response.append("</list></bts>").toString();
- }
-
- private java.util.Set<String> requestedChannels(String request)
- {
- java.util.Set<String> channels = new java.util.HashSet<>();
- try
- {
- XMLInputFactory factory = XMLInputFactory.newFactory();
- factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
- factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
- XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request));
- while (reader.hasNext())
- {
- if (reader.next() == XMLStreamConstants.START_ELEMENT && "inquire".equals(reader.getLocalName()))
- {
- String devid = reader.getAttributeValue(null, "devid");
- String chlid = reader.getAttributeValue(null, "chlid");
- String devtype = reader.getAttributeValue(null, "devtype");
- String subdevid = reader.getAttributeValue(null, "subdevid");
- if (devtype != null && devid != null && subdevid != null && chlid != null)
- channels.add(devtype + ":" + devid + ":" + subdevid + ":" + chlid);
- }
- }
- return channels;
- }
- catch (Exception exception)
- {
- throw new IllegalArgumentException("Invalid BTS inquire XML", exception);
- }
- }
-
- private BtsDeviceProfile profileFor(DeviceState device)
- {
- return device.profile() == null ? BtsDeviceProfile.virtual(device.channels().size()) : device.profile();
- }
-
- private BtsDeviceProfile resizeProfile(BtsDeviceProfile profile, int channelCount)
- {
- return new BtsDeviceProfile(profile.devtype(), profile.subDeviceId(), profile.name(), profile.manufacturer(),
- profile.model(), profile.serialNumber(), profile.firmwareVersion(), channelCount,
- profile.currentMinA(), profile.currentMaxA(), profile.voltageMinV(), profile.voltageMaxV(),
- profile.sampleIntervalMs());
- }
-
- private void updateDeviceWithProfile(DeviceState current, BtsDeviceProfile profile)
- {
- int channelCount = profile.channelCount();
- List<ChannelState> channels = new ArrayList<>(channelCount);
- for (int channelId = 1; channelId <= channelCount; channelId++)
- channels.add(channelId <= current.channels().size() ? current.channels().get(channelId - 1)
- : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
- List<DeviceState> devices = replaceDevice(new DeviceState(current.deviceId(), current.ip(), current.online(),
- channels, profile));
- List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
- for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++)
- upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId);
- tombstones.removeIf(item -> item.deviceId() == current.deviceId() && item.channelId() <= channelCount);
- persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
- state.nextCollectorId(), tombstones));
- }
-
- private BtsDeviceProfile newareProfile(String model)
- {
- if (NEWARE_CT_4008T_5V6A_S1_F.equals(model))
- return new BtsDeviceProfile(22, 1, "新威 CT-4008T", "新威", model, "SIM-CT4008T-5V6A",
- "BTS7.5.4", 8, new BigDecimal("-6.000"), new BigDecimal("6.000"), BigDecimal.ZERO,
- new BigDecimal("5.000"), 100);
- if (NEWARE_CT_4008TN_5V12A_S1_F.equals(model))
- return new BtsDeviceProfile(22, 1, "新威 CT-4008Tn", "新威", model, "SIM-CT4008TN-5V12A",
- "BTS8.0", 8, new BigDecimal("-12.000"), new BigDecimal("12.000"), BigDecimal.ZERO,
- new BigDecimal("5.000"), 100);
- throw new IllegalArgumentException("Unsupported Neware simulator model: " + model);
- }
-
- private String decimal(double value)
- {
- return java.math.BigDecimal.valueOf(value).toPlainString();
- }
-
- private String toBtsWorkStatus(DeviceWorkStatus status)
- {
- return switch (status)
- {
- case STOPPED -> "stop";
- case WORKING -> "working";
- case PAUSED -> "pause";
- case FINISHED -> "finish";
- case PROTECTED -> "protect";
- default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status);
- };
- }
-
- private void validateDeviceConfiguration(String ip, int channelCount, Integer currentDeviceId)
- {
- if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address");
- if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
- throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
- boolean duplicate = state.devices().stream()
- .anyMatch(device -> device.ip().equals(ip.trim())
- && device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId));
- duplicate = duplicate || state.collectors().stream().anyMatch(collector -> collector.ip().equals(ip.trim()));
- if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique");
- }
-
- private void validateCollectorConfiguration(String ip, int channelCount, Integer currentCollectorId)
- {
- if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator collector IP must be a valid IPv4 address");
- if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
- throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
- boolean duplicate = state.collectors().stream()
- .anyMatch(collector -> collector.ip().equals(ip.trim())
- && collector.collectorId() != (currentCollectorId == null ? -1 : currentCollectorId));
- duplicate = duplicate || state.devices().stream().anyMatch(device -> device.ip().equals(ip.trim()));
- if (duplicate) throw new IllegalArgumentException("Simulator collector IP must be unique");
- }
-
- private boolean isIpv4(String ip)
- {
- if (ip == null) return false;
- String[] parts = ip.trim().split("\\.", -1);
- if (parts.length != 4) return false;
- for (String part : parts)
- {
- try
- {
- int value = Integer.parseInt(part);
- if (part.isEmpty() || value < 0 || value > 255) return false;
- }
- catch (NumberFormatException exception)
- {
- return false;
- }
- }
- return true;
- }
-
- private DeviceState requireDevice(int deviceId)
- {
- return state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst()
- .orElseThrow(() -> new IllegalArgumentException("Unknown simulator device: " + deviceId));
- }
-
- private CollectorState requireCollector(int collectorId)
- {
- return state.collectors().stream().filter(item -> item.collectorId() == collectorId).findFirst()
- .orElseThrow(() -> new IllegalArgumentException("Unknown simulator collector: " + collectorId));
- }
-
- private List<DeviceState> replaceDevice(DeviceState replacement)
- {
- List<DeviceState> devices = new ArrayList<>(state.devices());
- devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item);
- return devices;
- }
-
- private void upsertTombstone(List<ChannelTombstone> tombstones, int deviceId, String ip, int channelId)
- {
- tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() == channelId);
- tombstones.add(new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT));
- }
-
- private void persist(SimulatorState nextState)
- {
- // 写穿失败时保留当前内存副本,避免模拟器页面显示未持久化状态。
- SimulatorState configured = new SimulatorState(nextState.version(), nextState.devices(), nextState.collectors(),
- nextState.nextDeviceId(), nextState.nextCollectorId(), nextState.tombstones(),
- state.softwareAccessible(), state.observationSequence());
- stateStore.save(configured);
- state = configured;
- }
-
- private String simulatorMac(int devtype, int devid)
- {
- return String.format("02:44:%02X:%02X:%02X:%02X",
- (devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF);
- }
-
- private String simulatorCollectorMac(int collectorId)
- {
- return String.format("02:43:00:00:%02X:%02X", (collectorId >>> 8) & 0xFF, collectorId & 0xFF);
- }
-
- private void requireControllableStatus(DeviceWorkStatus status)
- {
- if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING
- && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED
- && status != DeviceWorkStatus.PROTECTED && status != DeviceWorkStatus.UNOBSERVABLE))
- throw new IllegalArgumentException("Unsupported simulator work status");
- }
-
- private void validateRestoredState()
- {
- java.util.Set<Integer> deviceIds = new java.util.HashSet<>();
- java.util.Set<Integer> collectorIds = new java.util.HashSet<>();
- java.util.Set<String> ips = new java.util.HashSet<>();
- int maximumDeviceId = 0;
- for (DeviceState device : state.devices())
- {
- if (device.deviceId() < 1 || !deviceIds.add(device.deviceId()) || !isIpv4(device.ip())
- || !ips.add(device.ip()) || device.channels().isEmpty()
- || device.channels().size() > MAX_CHANNEL_COUNT)
- throw new IllegalStateException("Invalid BTS simulator device state in Redis");
- if (profileFor(device).channelCount() != device.channels().size())
- throw new IllegalStateException("BTS simulator device profile channel count does not match topology");
- maximumDeviceId = Math.max(maximumDeviceId, device.deviceId());
- for (int index = 0; index < device.channels().size(); index++)
- {
- ChannelState channel = device.channels().get(index);
- if (channel.channelId() != index + 1)
- throw new IllegalStateException("Invalid BTS simulator channel state in Redis");
- requireControllableStatus(channel.workStatus());
- }
- }
- int maximumCollectorId = 0;
- for (CollectorState collector : state.collectors())
- {
- if (collector.collectorId() < 1 || !collectorIds.add(collector.collectorId())
- || !isIpv4(collector.ip()) || !ips.add(collector.ip())
- || collector.channelCount() < 1 || collector.channelCount() > MAX_CHANNEL_COUNT)
- throw new IllegalStateException("Invalid BTS simulator collector state in Redis");
- DevelopmentCollectorSimulation.validate(collector);
- maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
- }
- java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
- boolean invalidTombstone = state.tombstones().stream().anyMatch(item -> item.deviceId() < 1
- || !isIpv4(item.ip()) || item.channelId() < 1 || item.channelId() > MAX_CHANNEL_COUNT
- || item.remainingDiscoveries() < 1 || item.remainingDiscoveries() > TOMBSTONE_DISCOVERY_COUNT
- || !tombstoneIds.add(item.deviceId() + ":" + item.channelId()));
- if (state.nextDeviceId() <= maximumDeviceId || state.nextCollectorId() <= maximumCollectorId
- || invalidTombstone)
- throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis");
- }
-
- /** 停止模拟器:标记为不再运行并释放 TCP 端点;释放失败时以运行时异常报告。 */
- @Override
- public synchronized void close()
- {
- running = false;
- try
- {
- simulator.close();
- }
- catch (Exception exception)
- {
- throw new IllegalStateException("关闭开发模拟器失败", exception);
- }
- }
-
- private record ObservedChannel(int deviceId, ChannelState channel) { }
-
- public record StateSnapshot(boolean running, boolean softwareAccessible, String listenAddress, List<DeviceSnapshot> devices,
- List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { }
-
- public record DeviceSnapshot(int deviceId, String ip, boolean online, BtsDeviceProfile profile,
- List<ChannelSnapshot> channels) { }
-
- public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus,
- ChannelConfiguration configuration) { }
-
- public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
- String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
- List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements)
- {
- public CollectorSnapshot
- {
- channels = List.copyOf(channels);
- latestMeasurements = List.copyOf(latestMeasurements);
- }
- }
-
-}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
deleted file mode 100644
index 9e7e01d..0000000
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
+++ /dev/null
@@ -1,183 +0,0 @@
-package com.battery.device.simulator;
-
-import java.math.BigDecimal;
-import java.time.Instant;
-import java.util.List;
-import com.battery.device.api.model.DeviceWorkStatus;
-
-/** 持久化开发模拟器配置;实现必须在返回前确认写入成功。 */
-public interface BtsSimulatorStateStore
-{
- int CURRENT_VERSION = 1;
- /** 读取已有状态,仅在不存在时保存默认拓扑。 */
- SimulatorState loadOrInitialize(SimulatorState defaultState);
-
- /** 保存设备配置和观测序号;返回前必须完成写入。 */
- void save(SimulatorState state);
-
- /** 读取短期保留的最近请求。 */
- List<RecentRequest> loadRecentRequests();
-
- /** 保存短期保留的最近请求。 */
- void saveRecentRequests(List<RecentRequest> requests);
-
- /** 人工重置主状态和最近请求。 */
- void reset(SimulatorState defaultState);
-
- record SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors,
- int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones,
- Boolean softwareAccessible, long observationSequence)
- {
- public SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors,
- int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones)
- {
- this(version, devices, collectors, nextDeviceId, nextCollectorId, tombstones, true, 0L);
- }
-
- public SimulatorState
- {
- if (version != CURRENT_VERSION)
- throw new IllegalStateException("Unsupported BTS simulator state version: " + version);
- softwareAccessible = softwareAccessible == null ? true : softwareAccessible;
- devices = List.copyOf(devices);
- collectors = List.copyOf(collectors);
- tombstones = List.copyOf(tombstones);
- }
- }
-
- record DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels,
- BtsDeviceProfile profile)
- {
- public DeviceState
- {
- channels = List.copyOf(channels);
- profile = profile == null ? BtsDeviceProfile.virtual(channels.size()) : profile;
- }
-
- /** 兼容旧版只保存拓扑和通道状态的模拟设备。 */
- public DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels)
- {
- this(deviceId, ip, online, channels, BtsDeviceProfile.virtual(channels.size()));
- }
- }
-
- record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
- String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
- List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements)
- {
- public CollectorState
- {
- channels = List.copyOf(channels);
- latestMeasurements = List.copyOf(latestMeasurements);
- }
-
- public CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
- String manufacturer, String model, int channelCount, boolean online)
- {
- this(collectorId, deviceCode, ip, mac, name, manufacturer, model, channelCount, online,
- DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS,
- DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of());
- }
- }
-
- record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics, DeviceWorkStatus workStatus)
- {
- public CollectorChannelState
- {
- metrics = List.copyOf(metrics);
- workStatus = workStatus == null ? DeviceWorkStatus.WORKING : workStatus;
- }
-
- public CollectorChannelState(String channelCode, List<CollectorMetricState> metrics)
- {
- this(channelCode, metrics, DeviceWorkStatus.WORKING);
- }
- }
-
- record CollectorMetricState(String metricCode, BigDecimal value, String unit) { }
-
- record CollectorRealtimeMeasurement(String adapterCode, String collectorCode, String channelCode,
- long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
- String metricCode, BigDecimal value, String unit) { }
-
- record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus,
- ChannelConfiguration configuration)
- {
- public ChannelState
- {
- configuration = configuration == null ? ChannelConfiguration.defaults() : configuration;
- }
-
- public ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus)
- { this(channelId, online, workStatus, null); }
- }
-
- /** 通道当前观测配置与方案一起保存,避免当前工步引用不存在的方案行。 */
- record ChannelConfiguration(BigDecimal currentA, BigDecimal voltageV, BigDecimal capacityAh,
- BigDecimal energyWh, int cycleNo, int stepNo, int plannedCycleCount,
- long totalTimeSeconds, long relativeTimeSeconds, List<SimulatorPlanStep> steps)
- {
- public ChannelConfiguration
- {
- for (BigDecimal value : new BigDecimal[] { currentA, voltageV, capacityAh, energyWh })
- if (value == null || !Double.isFinite(value.doubleValue()))
- throw new IllegalArgumentException("通道测量值必须是有效数值");
- if (steps == null || steps.isEmpty() || steps.stream().anyMatch(java.util.Objects::isNull))
- throw new IllegalArgumentException("工步方案至少包含一个有效工步");
- steps = List.copyOf(steps);
- if (cycleNo < 1 || plannedCycleCount < cycleNo || stepNo < 1 || stepNo > steps.size()
- || totalTimeSeconds < 0 || relativeTimeSeconds < 0 || relativeTimeSeconds > totalTimeSeconds)
- throw new IllegalArgumentException("循环、当前工步或时间配置无效");
- }
-
- /** 为新增通道和既有配置补齐固定观测值,不产生成功接收记录。 */
- public static ChannelConfiguration defaults()
- {
- return new ChannelConfiguration(BigDecimal.ZERO, new BigDecimal("3.7"), new BigDecimal("1.2"),
- new BigDecimal("4.44"), 1, 1, 1, 0, 0,
- List.of(new SimulatorPlanStep("恒流充电", "cc", 60d, 3.7d)));
- }
- }
-
- record SimulatorPlanStep(String name, String stepType, double durationSeconds, double voltageV)
- {
- public SimulatorPlanStep
- {
- if (name == null || name.isBlank() || stepType == null
- || !List.of("cc", "dc", "cv", "rest", "end", "cccv").contains(stepType)
- || !Double.isFinite(durationSeconds) || durationSeconds < 0 || !Double.isFinite(voltageV))
- throw new IllegalArgumentException("工步名称、类型、时间或电压无效");
- name = name.trim();
- }
- }
-
- /** BTS 检测柜的可配置厂商 profile,用于设备发现元数据展示。 */
- record BtsDeviceProfile(int devtype, int subDeviceId, String name, String manufacturer, String model,
- String serialNumber, String firmwareVersion, int channelCount, BigDecimal currentMinA,
- BigDecimal currentMaxA, BigDecimal voltageMinV, BigDecimal voltageMaxV, int sampleIntervalMs)
- {
- public BtsDeviceProfile
- {
- if (devtype < 0 || subDeviceId < 0 || channelCount < 1 || sampleIntervalMs < 1)
- throw new IllegalArgumentException("Invalid BTS device profile limits");
- if (name == null || name.isBlank() || manufacturer == null || manufacturer.isBlank()
- || model == null || model.isBlank() || serialNumber == null || serialNumber.isBlank()
- || firmwareVersion == null || firmwareVersion.isBlank())
- throw new IllegalArgumentException("BTS device profile metadata must not be blank");
- if (currentMinA == null || currentMaxA == null || voltageMinV == null || voltageMaxV == null
- || currentMinA.compareTo(currentMaxA) > 0 || voltageMinV.compareTo(voltageMaxV) > 0)
- throw new IllegalArgumentException("Invalid BTS device profile ranges");
- }
-
- public static BtsDeviceProfile virtual(int channelCount)
- {
- return new BtsDeviceProfile(22, 1, "dev-sim 设备", "开发模拟器", "BTS Virtual Cabinet",
- "SIM-VIRTUAL", "dev-sim", channelCount, new BigDecimal("-6.000"),
- new BigDecimal("6.000"), BigDecimal.ZERO, new BigDecimal("5.000"), 100);
- }
- }
-
- record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { }
-
- record RecentRequest(Instant receivedAt, String command, String request, String response) { }
-}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java
deleted file mode 100644
index b0975f8..0000000
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.battery.device.simulator;
-
-import java.math.BigDecimal;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.List;
-import com.battery.device.api.model.DeviceWorkStatus;
-import com.battery.device.simulator.BtsSimulatorStateStore.*;
-
-/** 提供请求时的采集器当前值;S 通道仍是待现场验证的开发合同。 */
-public final class DevelopmentCollectorSimulation
-{
- public static final int DEFAULT_SAMPLE_INTERVAL_MS = 1000;
- private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
- private static final DateTimeFormatter DEVICE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
-
- private DevelopmentCollectorSimulation() { }
-
- /** 创建带稳定 S 通道和三项固定演示指标的采集器状态。 */
- public static List<CollectorChannelState> defaultChannels(int channelCount)
- {
- List<CollectorChannelState> channels = new ArrayList<>(channelCount);
- for (int index = 1; index <= channelCount; index++)
- {
- channels.add(new CollectorChannelState(channelCode(index), List.of(
- new CollectorMetricState("temperature", new BigDecimal("25.0")
- .add(BigDecimal.valueOf(index, 1)), "℃"),
- new CollectorMetricState("positive_potential", new BigDecimal("3.700"), "V"),
- new CollectorMetricState("negative_potential", new BigDecimal("-0.100"), "V"))));
- }
- return List.copyOf(channels);
- }
-
-
- /** 调整设备拓扑,保留已有通道的配置。 */
- public static CollectorState resize(CollectorState current, String ip, int channelCount, boolean online, Instant changedAt)
- {
- var defaults = defaultChannels(channelCount);
- List<CollectorChannelState> channels = new ArrayList<>();
- for (int index = 0; index < channelCount; index++)
- channels.add(index < current.channels().size() ? current.channels().get(index) : defaults.get(index));
- return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(),
- current.name(), current.manufacturer(), current.model(), channelCount, online,
- current.sampleIntervalMs(), channels, List.of());
- }
-
- /** 保存当前测量配置,等待下一次实时请求。 */
- public static CollectorState configureChannel(CollectorState current, String channelCode, List<CollectorMetricState> metrics)
- {
- validateMetrics(metrics);
- int index = parseChannelIndex(channelCode, current.channelCount());
- List<CollectorChannelState> channels = new ArrayList<>(current.channels());
- channels.set(index, new CollectorChannelState(channelCode, metrics, channels.get(index).workStatus()));
- return copy(current, channels, List.of());
- }
-
- /** 保存通道工作状态,等待下一次实时请求。 */
- public static CollectorState configureStatus(CollectorState current, String channelCode, DeviceWorkStatus status)
- {
- int index = parseChannelIndex(channelCode, current.channelCount());
- List<CollectorChannelState> channels = new ArrayList<>(current.channels());
- channels.set(index, new CollectorChannelState(channelCode, channels.get(index).metrics(), status));
- return copy(current, channels, List.of());
- }
-
- /** 仅生成本次请求的当前值,不补算上一次请求与本次之间的空档。 */
- public static CollectorState sample(CollectorState current, Instant now, long sequence)
- {
- Instant observedAt = now.truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
- List<CollectorRealtimeMeasurement> measurements = new ArrayList<>();
- for (CollectorChannelState channel : current.channels())
- {
- if (channel.workStatus() == DeviceWorkStatus.UNOBSERVABLE) continue;
- for (CollectorMetricState metric : channel.metrics())
- measurements.add(new CollectorRealtimeMeasurement("dev-sim", current.deviceCode(), channel.channelCode(),
- sequence, observedAt, DEVICE_TIME_FORMATTER.format(observedAt.atZone(DEVICE_ZONE)), observedAt,
- metric.metricCode(), metric.value(), metric.unit()));
- }
- return copy(current, current.channels(), measurements);
- }
-
- /** 校验恢复后的稳定通道和测量配置。 */
- public static void validate(CollectorState state)
- {
- if (state.channels().size() != state.channelCount()) throw new IllegalStateException("Invalid collector topology");
- for (int index = 0; index < state.channels().size(); index++)
- {
- var channel = state.channels().get(index);
- if (!channelCode(index + 1).equals(channel.channelCode())) throw new IllegalStateException("Invalid collector channel identity");
- validateMetrics(channel.metrics());
- }
- }
-
- private static CollectorState copy(CollectorState current, List<CollectorChannelState> channels,
- List<CollectorRealtimeMeasurement> measurements)
- {
- return new CollectorState(current.collectorId(), current.deviceCode(), current.ip(), current.mac(),
- current.name(), current.manufacturer(), current.model(), current.channelCount(), current.online(),
- current.sampleIntervalMs(), channels, measurements);
- }
-
- private static void validateMetrics(List<CollectorMetricState> metrics)
- {
- if (metrics == null || metrics.isEmpty())
- throw new IllegalArgumentException("Collector channel must contain at least one metric");
- java.util.HashSet<String> codes = new java.util.HashSet<>();
- for (CollectorMetricState metric : metrics)
- {
- if (metric.metricCode() == null || metric.metricCode().isBlank() || metric.value() == null
- || !metric.metricCode().equals(metric.metricCode().trim())
- || metric.unit() == null || metric.unit().isBlank()
- || !metric.unit().equals(metric.unit().trim()) || !codes.add(metric.metricCode()))
- throw new IllegalArgumentException("Collector metric code, value and unit must be valid and unique");
- }
- }
-
- private static int parseChannelIndex(String value, int channelCount)
- {
- if (value == null || !value.matches("S-\\d{2}"))
- throw new IllegalArgumentException("Collector channel must use S-01...S-N identity");
- int index = Integer.parseInt(value.substring(2)) - 1;
- if (index < 0 || index >= channelCount || !channelCode(index + 1).equals(value))
- throw new IllegalArgumentException("Unknown collector channel: " + value);
- return index;
- }
-
- private static String channelCode(int index)
- {
- return String.format("S-%02d", index);
- }
-
- public record CollectorRealtimeSnapshot(String collectorCode, boolean online, int sampleIntervalMs,
- List<CollectorRealtimeMeasurement> measurements)
- {
- public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); }
- }
-
-}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java
deleted file mode 100644
index f7903f7..0000000
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.battery.device.simulator;
-
-import java.util.List;
-
-/** 测试和独立协议合同使用的内存状态存储。 */
-public final class InMemoryBtsSimulatorStateStore implements BtsSimulatorStateStore
-{
- private SimulatorState state;
- private List<RecentRequest> recentRequests = List.of();
-
- public InMemoryBtsSimulatorStateStore() { }
- public InMemoryBtsSimulatorStateStore(SimulatorState state) { this.state = state; }
-
- /** 返回已有状态;尚未保存过任何状态时以 defaults 初始化并复用同一实例。 */
- @Override
- public synchronized SimulatorState loadOrInitialize(SimulatorState defaults)
- {
- if (state == null) state = defaults;
- return state;
- }
-
- /** 保存状态引用,不做拷贝、校验或版本迁移。 */
- @Override
- public synchronized void save(SimulatorState state) { this.state = state; }
- /** 返回最近请求列表;尚未保存过时为空列表。 */
- @Override
- public synchronized List<RecentRequest> loadRecentRequests() { return recentRequests; }
- /** 保存最近请求的不可变副本,调用方后续修改原列表不影响这里。 */
- @Override
- public synchronized void saveRecentRequests(List<RecentRequest> requests) { recentRequests = List.copyOf(requests); }
- /** 用 defaults 覆盖主状态并清空最近请求。 */
- @Override
- public synchronized void reset(SimulatorState defaults)
- {
- state = defaults;
- recentRequests = List.of();
- }
-}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/ScriptedBtsTcpSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/ScriptedBtsTcpSimulator.java
deleted file mode 100644
index 762ff2c..0000000
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/ScriptedBtsTcpSimulator.java
+++ /dev/null
@@ -1,203 +0,0 @@
-package com.battery.device.simulator;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.ServerSocket;
-import java.net.Socket;
-import java.net.SocketException;
-import java.nio.charset.StandardCharsets;
-import java.time.Duration;
-import java.util.List;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Function;
-
-/**
- * 提供可复用的 BTS TCP 脚本设备边界,用于闭环与恢复测试。
- */
-public final class ScriptedBtsTcpSimulator implements AutoCloseable
-{
- private static final byte[] FRAME_END = "#\r\n".getBytes(StandardCharsets.UTF_8);
-
- private final ServerSocket serverSocket;
- private final int port;
- /** 连接服务线程显式创建并命名。该池只提交一个常驻服务任务,队列容量 1 只是声明不再接受更多任务。 */
- private final ExecutorService executor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
- new LinkedBlockingQueue<>(1), simulatorThreadFactory());
- private final BlockingQueue<ScriptedAction> actions = new LinkedBlockingQueue<>();
- private final List<String> requests = new CopyOnWriteArrayList<>();
- private final Function<String, String> responseProvider;
-
- public ScriptedBtsTcpSimulator() throws IOException
- {
- this(0, true, null);
- }
-
- private ScriptedBtsTcpSimulator(int port, boolean acceptConnections,
- Function<String, String> responseProvider) throws IOException
- {
- if (port < 0 || port > 65535) throw new IllegalArgumentException("port must be between 0 and 65535");
- this.responseProvider = responseProvider;
- serverSocket = new ServerSocket();
- serverSocket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port));
- this.port = serverSocket.getLocalPort();
- if (acceptConnections)
- executor.submit(this::serve);
- else
- serverSocket.close();
- }
-
- /** 创建一启动就关闭监听、任何连接都会被拒绝的模拟器。 */
- public static ScriptedBtsTcpSimulator refusingConnections() throws IOException
- {
- return new ScriptedBtsTcpSimulator(0, false, null);
- }
-
- /** 创建仅监听本机、可按请求持续生成响应的模拟器。 */
- public static ScriptedBtsTcpSimulator responding(int port, Function<String, String> responseProvider)
- throws IOException
- {
- if (responseProvider == null) throw new IllegalArgumentException("responseProvider must not be null");
- return new ScriptedBtsTcpSimulator(port, true, responseProvider);
- }
-
- /** 返回该模拟器绑定的本机端口;构造时传入 0 时返回的是系统分配端口而非 0。 */
- public int port()
- {
- return port;
- }
-
- /** 向脚本队列追加一条零延迟应答,按 UTF-8 编码,由下一次请求按顺序取用。 */
- public void enqueue(String response)
- {
- actions.add(new ScriptedAction(Duration.ZERO, response.getBytes(StandardCharsets.UTF_8), false));
- }
-
- /** 追加一条延迟应答:连接服务线程收到请求后先等待 delay 再写出;delay 不得为负。 */
- public void enqueueDelayed(Duration delay, String response)
- {
- if (delay.isNegative()) throw new IllegalArgumentException("delay must not be negative");
- actions.add(new ScriptedAction(delay, response.getBytes(StandardCharsets.UTF_8), false));
- }
-
- /** 追加一条零延迟的原始字节应答:字节原样写出后追加帧结束符,不做字符编码转换。 */
- public void enqueueRaw(byte[] response)
- {
- actions.add(new ScriptedAction(Duration.ZERO, response.clone(), false));
- }
-
- /** 追加一条断开动作:处理到该条时结束当前连接,且不写出任何应答。 */
- public void enqueueDisconnect()
- {
- actions.add(new ScriptedAction(Duration.ZERO, null, true));
- }
-
- /** 返回已接收请求帧的只读快照;只有脚本模式(未配置 responseProvider)才记录。 */
- public List<String> requests()
- {
- return List.copyOf(requests);
- }
-
- private void serve()
- {
- while (!serverSocket.isClosed())
- {
- try (Socket socket = serverSocket.accept())
- {
- serveConnection(socket);
- }
- catch (SocketException ex)
- {
- if (!serverSocket.isClosed()) throw new IllegalStateException(ex);
- }
- catch (Exception ignored)
- {
- }
- }
- }
-
- private void serveConnection(Socket socket) throws Exception
- {
- while (!socket.isClosed())
- {
- String request = readFrame(socket.getInputStream());
- if (responseProvider == null) requests.add(request);
- ScriptedAction action = responseProvider == null
- ? actions.poll(5, TimeUnit.SECONDS)
- : new ScriptedAction(Duration.ZERO,
- responseProvider.apply(request).getBytes(StandardCharsets.UTF_8), false);
- if (action == null) throw new IOException("No scripted BTS action available");
- TimeUnit.MILLISECONDS.sleep(action.delay().toMillis());
- if (action.disconnect()) return;
- socket.getOutputStream().write(action.payload());
- socket.getOutputStream().write(FRAME_END);
- socket.getOutputStream().flush();
- }
- }
-
- private String readFrame(InputStream input) throws IOException
- {
- return new String(readFrameBytes(input), StandardCharsets.UTF_8);
- }
-
- private byte[] readFrameBytes(InputStream input) throws IOException
- {
- java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
- byte[] delimiter = FRAME_END;
- int matched = 0;
- while (true)
- {
- int current = input.read();
- if (current < 0) throw new java.io.EOFException("BTS frame ended before delimiter");
- if ((byte) current == delimiter[matched])
- {
- matched++;
- if (matched == delimiter.length) return bytes.toByteArray();
- continue;
- }
- if (matched > 0)
- {
- for (int i = 0; i < matched; i++) bytes.write(delimiter[i]);
- matched = 0;
- if ((byte) current == delimiter[0])
- {
- matched = 1;
- continue;
- }
- }
- bytes.write(current);
- }
- }
-
- /** 关闭监听并终止连接服务线程,最多等待 2 秒,避免测试退出时残留线程。 */
- @Override
- public void close() throws Exception
- {
- serverSocket.close();
- executor.shutdownNow();
- executor.awaitTermination(2, TimeUnit.SECONDS);
- }
-
- /** 模拟器连接服务线程统一命名并设为守护线程,避免测试退出时残留。 */
- private static ThreadFactory simulatorThreadFactory()
- {
- AtomicInteger sequence = new AtomicInteger();
- return runnable -> {
- Thread thread = new Thread(runnable, "bts-simulator-pool-" + sequence.incrementAndGet());
- thread.setDaemon(true);
- return thread;
- };
- }
-
- private record ScriptedAction(Duration delay, byte[] payload, boolean disconnect)
- {
- }
-}
diff --git a/battery-device-simulator/src/test/java/com/battery/device/contract/DiscoveryCandidateStoreContractTest.java b/battery-device-simulator/src/test/java/com/battery/device/contract/DiscoveryCandidateStoreContractTest.java
deleted file mode 100644
index 5a6af6a..0000000
--- a/battery-device-simulator/src/test/java/com/battery/device/contract/DiscoveryCandidateStoreContractTest.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.battery.device.contract;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import java.time.Instant;
-import java.util.List;
-import org.junit.jupiter.api.Test;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
-import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore;
-import com.battery.device.api.model.ChannelIdentity;
-import com.battery.device.api.model.DeviceWorkStatus;
-
-class DiscoveryCandidateStoreContractTest
-{
- @Test
- void returnsOnlyLogicallyUnexpiredCandidates()
- {
- Instant now = Instant.parse("2026-08-27T00:00:00Z");
- InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
- store.recordCabinets(List.of(candidate("expired", now.minusSeconds(1)),
- candidate("current", now.plusSeconds(1))));
-
- assertEquals(List.of("current"), store.snapshot(now).cabinets().stream()
- .map(CabinetCandidate::deviceCode).toList());
- }
-
- @Test
- void removesAllCandidateTypesForOneAdapter()
- {
- Instant now = Instant.parse("2026-08-27T00:00:00Z");
- InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
- store.recordCabinets(List.of(candidate("dev-sim-cabinet", now.plusSeconds(30))));
- store.replaceCollectors("dev-sim", List.of(new CollectorCandidate("dev-sim", "dev-sim-collector-1",
- "127.0.1.2", null, "采集器", null, null, 4, true, now, now.plusSeconds(30))));
-
- store.removeAdapter("dev-sim");
-
- assertEquals(List.of(), store.snapshot(now).cabinets());
- assertEquals(List.of(), store.snapshot(now).collectors());
- }
-
- private CabinetCandidate candidate(String code, Instant expiresAt)
- {
- Instant observedAt = Instant.parse("2026-08-26T23:59:55Z");
- ChannelIdentity identity = new ChannelIdentity("dev-sim", 22, 1, 1, 1);
- return new CabinetCandidate("dev-sim", null, code, 22, 1, "127.0.0.2", null,
- code, null, null, List.of(new ChannelCandidate(identity, true,
- DeviceWorkStatus.STOPPED, observedAt)), observedAt, expiresAt);
- }
-}
diff --git a/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java b/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
deleted file mode 100644
index ad4b56d..0000000
--- a/battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
+++ /dev/null
@@ -1,412 +0,0 @@
-package com.battery.device.contract;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import java.math.BigDecimal;
-import java.time.Duration;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.util.List;
-import java.util.Set;
-import org.junit.jupiter.api.Test;
-import com.alibaba.fastjson2.JSON;
-import com.battery.device.api.DeviceAdapter;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.bts.BtsDeviceAdapter;
-import com.battery.device.bts.BtsXmlCodec;
-import com.battery.device.bts.BtsTcpTransport;
-import com.battery.device.simulator.ScriptedBtsTcpSimulator;
-import com.battery.device.api.model.DetectedChannel;
-import com.battery.device.api.model.DeviceWorkStatus;
-import com.battery.device.simulator.BtsSimulatorStateStore;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
-import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
-import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
-import com.battery.device.simulator.BtsSimulatorStateStore.RecentRequest;
-import com.battery.device.simulator.InMemoryBtsSimulatorStateStore;
-
-class SimulatorPublicContractTest
-{
- @Test
- void collectorRealtimeDropsMeasurementsRemovedFromCurrentConfiguration() throws Exception
- {
- Instant startedAt = Instant.parse("2026-08-29T04:00:00Z");
- InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
- {
- simulator.collectorRealtime(1);
- assertEquals(12, simulator.snapshot().collectors().get(0).latestMeasurements().size());
-
- simulator.configureCollectorChannel(1, "S-01", List.of(
- new CollectorMetricState("temperature", new BigDecimal("27.0"), "℃")));
- simulator.updateCollector(1, "127.0.1.2", 2, true);
-
- simulator.collectorRealtime(1);
- var snapshot = simulator.snapshot().collectors().get(0);
- assertEquals(4, snapshot.latestMeasurements().size());
- assertTrue(snapshot.latestMeasurements().stream()
- .noneMatch(item -> item.channelCode().equals("S-03")
- || item.channelCode().equals("S-04")
- || item.channelCode().equals("S-01")
- && item.metricCode().equals("positive_potential")));
- }
-
- try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
- {
- assertEquals(4, restored.collectorRealtime(1).measurements().size());
- }
- }
-
- @Test
- void collectorRejectsMetricWhitespaceThatWouldBreakRestore() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- assertThrows(IllegalArgumentException.class, () -> simulator.configureCollectorChannel(1, "S-01",
- List.of(new CollectorMetricState("temperature ", new BigDecimal("25.0"), "℃"))));
- assertThrows(IllegalArgumentException.class, () -> simulator.configureCollectorChannel(1, "S-01",
- List.of(new CollectorMetricState("temperature", new BigDecimal("25.0"), " ℃"))));
- }
- }
-
- @Test
- void simulatorIsReusableOutsideTheBtsPackage() throws Exception
- {
- try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1)))
- {
- simulator.enqueue("<bts><cmd>getdevinfo_resp</cmd><middle count=\"0\"/></bts>");
-
- assertEquals("<bts><cmd>getdevinfo_resp</cmd><middle count=\"0\"/></bts>",
- transport.exchange("<bts><cmd>getdevinfo</cmd></bts>"));
- assertEquals(1, simulator.requests().size());
- }
- }
-
- @Test
- void simulatorPreservesRequestEndingWithDelimiterPrefix() throws Exception
- {
- try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1)))
- {
- simulator.enqueue("ok");
-
- assertEquals("ok", transport.exchange("payload#"));
- assertEquals("payload#", simulator.requests().get(0));
- }
- }
-
- @Test
- void developmentSimulatorSupportsRepeatedDiscoveryAndObservation() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("development-bts", transport,
- new BtsXmlCodec("development-bts", ZoneId.of("Asia/Hong_Kong"))))
- {
- var firstDiscovery = adapter.discoverChannels();
- var secondDiscovery = adapter.discoverChannels();
-
- assertEquals(4, firstDiscovery.size());
- assertEquals(firstDiscovery, secondDiscovery);
- assertEquals("开发模拟器", firstDiscovery.get(0).deviceMetadata().manufacturer());
- assertEquals("BTS Virtual Cabinet", firstDiscovery.get(0).deviceMetadata().model());
- assertEquals(1, firstDiscovery.stream().map(channel -> channel.identity().devid()).distinct().count());
- assertEquals(4, firstDiscovery.stream().filter(channel -> channel.available()).count());
-
- Set<com.battery.device.api.model.ChannelIdentity> channels = firstDiscovery.stream()
- .map(channel -> channel.identity())
- .collect(java.util.stream.Collectors.toSet());
- var firstObservation = adapter.inquire(channels);
- var secondObservation = adapter.inquire(channels);
-
- assertEquals(4, firstObservation.size());
- assertEquals(DeviceWorkStatus.STOPPED, firstObservation.values().iterator().next().workStatus());
- assertEquals(firstObservation.keySet(), secondObservation.keySet());
- }
- }
-
- @Test
- void developmentSimulatorSupportsNewareProfilesAndFixedObservation() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- int tDevice = simulator.addNewareDevice("127.0.0.8", "CT-4008T-5V6A-S1-F");
- int tnDevice = simulator.addNewareDevice("127.0.0.9", "CT-4008Tn-5V12A-S1-F");
- simulator.updateChannel(tDevice, 1, true, DeviceWorkStatus.WORKING);
-
- var discovered = adapter.discoverChannels();
- var tChannels = discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.8")).toList();
- var tnChannels = discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.9")).toList();
-
- assertEquals(4, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.2")).count());
- assertEquals(8, tChannels.size());
- assertEquals(8, tnChannels.size());
- assertEquals("新威", tChannels.get(0).deviceMetadata().manufacturer());
- assertEquals("CT-4008T-5V6A-S1-F", tChannels.get(0).deviceMetadata().model());
- assertEquals("CT-4008Tn-5V12A-S1-F", tnChannels.get(0).deviceMetadata().model());
- assertEquals(8, simulator.snapshot().devices().stream()
- .filter(device -> device.deviceId() == tDevice || device.deviceId() == tnDevice)
- .mapToInt(device -> device.channels().size()).sum() / 2);
-
- Set<com.battery.device.api.model.ChannelIdentity> channel = tChannels.stream()
- .map(DetectedChannel::identity).filter(identity -> identity.chlid() == 1)
- .collect(java.util.stream.Collectors.toSet());
- var first = adapter.inquire(channel).values().iterator().next();
- var second = adapter.inquire(channel).values().iterator().next();
- assertEquals(DeviceWorkStatus.WORKING, first.workStatus());
- assertEquals(first.currentA(), second.currentA());
- assertTrue(first.voltageV().doubleValue() >= 0.0 && first.voltageV().doubleValue() <= 5.0);
- }
- }
-
- @Test
- void configuredChannelValuesReachBtsInquireWithoutClampingOrRounding() throws Exception
- {
- try (var simulator = new BtsDevelopmentSimulator(0);
- var transport = new BtsTcpTransport("127.0.0.1", simulator.port(), Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong")))) {
- simulator.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
- new java.math.BigDecimal("-2.123456"), new java.math.BigDecimal("8.125"),
- new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30,
- List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
- new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0))));
- var channel = adapter.discoverChannels().stream().filter(item -> item.identity().chlid() == 1)
- .findFirst().orElseThrow().identity();
- var observation = adapter.inquire(Set.of(channel)).get(channel);
- assertEquals(-2.123456, observation.currentA().doubleValue());
- assertEquals(8.125, observation.voltageV().doubleValue());
- assertEquals(7.2, observation.capacityAh().doubleValue());
- assertEquals(29.7, observation.energyWh().doubleValue());
- assertEquals(2, observation.cycleNo());
- assertEquals(2, observation.stepNo());
- assertEquals("dc", observation.stepType());
- assertEquals(120L, observation.totalTimeSeconds());
- assertEquals(30L, observation.relativeTimeSeconds());
- assertEquals(30L, simulator.latestCabinet(channel).relativeTimeSeconds());
- }
- }
-
- @Test
- void developmentSimulatorPersistsNewareProfileMetadata() throws Exception
- {
- InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
- {
- simulator.addNewareDevice("127.0.0.8", "CT-4008Tn-5V12A-S1-F");
- }
-
- try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", restored.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- var profileChannels = adapter.discoverChannels().stream()
- .filter(channel -> channel.serverIp().equals("127.0.0.8")).toList();
- assertEquals(8, profileChannels.size());
- assertEquals("CT-4008Tn-5V12A-S1-F", profileChannels.get(0).deviceMetadata().model());
- assertEquals("新威", profileChannels.get(0).deviceMetadata().manufacturer());
- }
- }
-
- @Test
- void developmentSimulatorPublishesOperatorControlledChannelFacts() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
- simulator.updateChannel(1, 2, false, DeviceWorkStatus.PAUSED);
-
- var discovered = adapter.discoverChannels();
- Set<com.battery.device.api.model.ChannelIdentity> available = discovered.stream()
- .filter(channel -> channel.available())
- .map(channel -> channel.identity())
- .collect(java.util.stream.Collectors.toSet());
- var observed = adapter.inquire(available);
-
- assertEquals(4, discovered.size());
- assertEquals(3, available.size());
- assertEquals(DeviceWorkStatus.WORKING, observed.entrySet().stream()
- .filter(entry -> entry.getKey().chlid() == 1)
- .findFirst().orElseThrow().getValue().workStatus());
-
- simulator.updateDevice(1, "127.0.0.2", 4, false);
- assertEquals(0, adapter.discoverChannels().stream().filter(channel -> channel.available()).count());
- assertEquals(4, simulator.snapshot().devices().get(0).channels().size());
- assertEquals(3, simulator.snapshot().recentRequests().size());
- }
- }
-
- @Test
- void developmentSimulatorSupportsMultipleConfigurableDevices() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- int secondDeviceId = simulator.addDevice("127.0.0.3", 2);
- simulator.updateDevice(1, "127.0.0.2", 4, true);
-
- var discovered = adapter.discoverChannels();
-
- assertEquals(6, discovered.size());
- assertEquals(2, discovered.stream().map(channel -> channel.identity().devid()).distinct().count());
- assertEquals(4, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.2")).count());
- assertEquals(2, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.3")).count());
-
- simulator.deleteDevice(secondDeviceId);
- for (int index = 0; index < 3; index++)
- {
- var withTombstone = adapter.discoverChannels();
- assertEquals(6, withTombstone.size());
- assertEquals(4, withTombstone.stream().filter(DetectedChannel::available).count());
- }
- assertEquals(4, adapter.discoverChannels().size());
- }
- }
-
- @Test
- void developmentCollectorsNeverEnterBtsCabinetDiscovery() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- assertEquals(4, adapter.discoverChannels().size());
-
- simulator.addCollector("127.0.1.8", 12);
-
- assertEquals(4, adapter.discoverChannels().size());
- assertEquals(2, simulator.snapshot().collectors().size());
- }
- }
-
- @Test
- void developmentSimulatorRestoresConfigurationNumberingAndStatus() throws Exception
- {
- InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
- try (BtsDevelopmentSimulator first = new BtsDevelopmentSimulator(0, store))
- {
- first.updateDevice(1, "127.0.0.9", 2, false);
- first.addDevice("127.0.0.10", 3);
- first.updateCollector(1, "127.0.1.9", 7, false);
- }
-
- try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
- {
- assertEquals(2, restored.snapshot().devices().size());
- assertEquals("127.0.0.9", restored.snapshot().devices().get(0).ip());
- assertEquals(false, restored.snapshot().devices().get(0).online());
- assertEquals(7, restored.snapshot().collectors().get(0).channelCount());
- assertEquals(3, restored.addDevice("127.0.0.11", 1));
- }
- }
-
- @Test
- void developmentSimulatorDoesNotExposeWriteThroughFailureInMemory() throws Exception
- {
- InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore();
- BtsSimulatorStateStore failing = new BtsSimulatorStateStore()
- {
- @Override public SimulatorState loadOrInitialize(SimulatorState value)
- { return delegate.loadOrInitialize(value); }
- @Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
- @Override public java.util.List<RecentRequest> loadRecentRequests()
- { return delegate.loadRecentRequests(); }
- @Override public void saveRecentRequests(java.util.List<RecentRequest> requests)
- { delegate.saveRecentRequests(requests); }
- @Override public void reset(SimulatorState value) { delegate.reset(value); }
- };
-
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, failing))
- {
- assertThrows(IllegalStateException.class, () -> simulator.addDevice("127.0.0.3", 2));
- assertEquals(1, simulator.snapshot().devices().size());
- }
- }
-
- @Test
- void collectorDeletionDoesNotReplaceMemoryWhenAtomicPersistenceFails() throws Exception
- {
- InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore();
- BtsSimulatorStateStore failing = new BtsSimulatorStateStore()
- {
- @Override public SimulatorState loadOrInitialize(SimulatorState value)
- { return delegate.loadOrInitialize(value); }
- @Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
- @Override public java.util.List<RecentRequest> loadRecentRequests()
- { return delegate.loadRecentRequests(); }
- @Override public void saveRecentRequests(java.util.List<RecentRequest> requests)
- { delegate.saveRecentRequests(requests); }
- @Override public void reset(SimulatorState value) { delegate.reset(value); }
- };
-
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, failing))
- {
- assertThrows(IllegalStateException.class, () -> simulator.deleteCollector(1));
- assertEquals(1, simulator.snapshot().collectors().size());
- }
- }
-
- @Test
- void developmentSimulatorRestoresUnfinishedTombstones() throws Exception
- {
- InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
- try (BtsDevelopmentSimulator first = new BtsDevelopmentSimulator(0, store);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", first.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- first.deleteDevice(1);
- assertEquals(4, adapter.discoverChannels().size());
- }
-
- try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store);
- BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", restored.port(),
- Duration.ofSeconds(1), Duration.ofSeconds(1));
- DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
- new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
- {
- assertEquals(4, adapter.discoverChannels().size());
- assertEquals(4, adapter.discoverChannels().size());
- assertEquals(0, adapter.discoverChannels().size());
- }
- }
-
- @Test
- void developmentSimulatorRejectsNonContiguousRestoredChannelTopology()
- {
- SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
- java.util.List.of(new DeviceState(1, "127.0.0.2", true,
- java.util.List.of(new ChannelState(2, true, DeviceWorkStatus.STOPPED)))),
- java.util.List.of(), 2, 1, java.util.List.of());
-
- assertThrows(IllegalStateException.class,
- () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
- }
-
-}
diff --git a/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java b/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
deleted file mode 100644
index a35fb53..0000000
--- a/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
+++ /dev/null
@@ -1,123 +0,0 @@
-package com.battery.device.simulator;
-
-import static org.junit.jupiter.api.Assertions.*;
-import org.junit.jupiter.api.Test;
-import com.battery.device.api.model.ChannelIdentity;
-import com.battery.device.api.model.DeviceConnectionStatus;
-import com.battery.device.api.model.DeviceWorkStatus;
-
-class SimulatorObservationAdapterContractTest
-{
- @Test void configuredChannelFactsSurviveSoftwareFailureAndRestart() throws Exception
- {
- var store = new InMemoryBtsSimulatorStateStore();
- long firstSequence;
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
- {
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.PROTECTED);
- simulator.updateCollectorChannelStatus(1, "S-01", DeviceWorkStatus.UNOBSERVABLE);
- var collector = simulator.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1");
- assertTrue(collector.measurements().stream().noneMatch(item -> item.channelCode().equals("S-01")));
- firstSequence = collector.measurements().get(0).sequenceId();
- simulator.setSoftwareAccessible(false);
- simulator.updateDevice(1, "127.0.0.8", 4, false);
- assertFalse(simulator.snapshot().softwareAccessible());
- }
- try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
- {
- assertFalse(restored.snapshot().softwareAccessible());
- assertEquals("127.0.0.8", restored.snapshot().devices().get(0).ip());
- assertEquals(DeviceWorkStatus.PROTECTED, restored.snapshot().devices().get(0).channels().get(0).workStatus());
- assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().isEmpty());
- restored.setSoftwareAccessible(true);
- assertTrue(restored.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1").measurements().get(0).sequenceId() > firstSequence);
- assertEquals(DeviceWorkStatus.UNOBSERVABLE, restored.snapshot().collectors().get(0).channels().get(0).workStatus());
- }
- }
-
- @Test void offlineCollectorDoesNotReturnLastSuccessAndStateReadsDoNotSample() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- assertTrue(simulator.snapshot().collectors().get(0).latestMeasurements().isEmpty());
- var first = simulator.collectorRealtime(1);
- var recent = simulator.snapshot().recentRequests();
- assertEquals(recent, simulator.snapshot().recentRequests());
- simulator.updateCollector(1, "127.0.1.2", 4, false);
- assertFalse(simulator.collectorRealtime(1).online());
- assertTrue(simulator.collectorRealtime(1).measurements().isEmpty());
- simulator.updateCollector(1, "127.0.1.2", 4, true);
- assertTrue(simulator.collectorRealtime(1).measurements().get(0).sequenceId() > first.measurements().get(0).sequenceId());
- var channel = simulator.discoverChannels().get(0).identity();
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.UNOBSERVABLE);
- assertNull(simulator.latestCabinet(channel));
- assertEquals(DeviceWorkStatus.UNOBSERVABLE, simulator.observe(java.util.Set.of(channel)).get(channel).workStatus());
- }
- }
-
- @Test void softwareFailurePreservesIdentityAndDoesNotReplayMeasurements() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- var channel = simulator.discoverChannels().get(0).identity();
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
- var first = simulator.latestCabinet(channel);
- assertNotNull(first);
- assertFalse(simulator.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1").measurements().isEmpty());
- simulator.setSoftwareAccessible(false);
- assertEquals(channel, simulator.discoverChannels().get(0).identity());
- assertTrue(simulator.snapshot().devices().get(0).online());
- assertEquals(DeviceWorkStatus.UNOBSERVABLE,
- simulator.observe(java.util.Set.of(channel)).get(channel).workStatus());
- assertNull(simulator.latestCabinet(channel));
- assertTrue(simulator.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1").measurements().isEmpty());
- simulator.setSoftwareAccessible(true);
- var next = simulator.latestCabinet(channel);
- assertTrue(next.sequenceId() > first.sequenceId());
- assertFalse(next.deviceTime().isBefore(first.deviceTime()));
- assertTrue(simulator.stepRecords(channel).isEmpty());
- assertTrue(simulator.cycleRecords(channel).isEmpty());
- assertEquals(60000L, simulator.persistenceIntervalMillis(channel));
- }
- }
-
- @Test void persistenceRejectsForeignIdentitiesAndOfflineCachedFacts() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- ChannelIdentity channel = simulator.discoverChannels().get(0).identity();
- simulator.updateChannel(channel.devid(), channel.chlid(), true, DeviceWorkStatus.WORKING);
- assertNotNull(simulator.latestCabinet(channel));
- for (ChannelIdentity foreign : java.util.List.of(
- new ChannelIdentity("another-adapter", channel.devtype(), channel.devid(), channel.subdevid(), channel.chlid()),
- new ChannelIdentity(channel.adapterCode(), 999, channel.devid(), channel.subdevid(), channel.chlid()),
- new ChannelIdentity(channel.adapterCode(), channel.devtype(), channel.devid(), 999, channel.chlid())))
- {
- assertNull(simulator.latestCabinet(foreign));
- assertNull(simulator.testPlan(foreign));
- assertTrue(simulator.stepRecords(foreign).isEmpty());
- assertTrue(simulator.cycleRecords(foreign).isEmpty());
- }
- simulator.updateChannel(channel.devid(), channel.chlid(), false, DeviceWorkStatus.WORKING);
- assertNull(simulator.latestCabinet(channel));
- assertTrue(simulator.stepRecords(channel).isEmpty());
- assertTrue(simulator.cycleRecords(channel).isEmpty());
- }
- }
-
- @Test
- void simulatorCanBeUsedDirectlyThroughTheVendorNeutralContract() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- ChannelIdentity channel = simulator.discoverChannels().get(0).identity();
- simulator.updateChannel(channel.devid(), channel.chlid(), true, DeviceWorkStatus.WORKING);
-
- var observation = simulator.observe(java.util.Set.of(channel)).get(channel);
-
- assertEquals("dev-sim", simulator.adapterCode());
- assertEquals(DeviceConnectionStatus.ONLINE, observation.connectionStatus());
- assertEquals(DeviceWorkStatus.WORKING, observation.workStatus());
- }
- }
-}
diff --git a/pom.xml b/pom.xml
index 1913403..72c77b3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -184,12 +184,6 @@
<artifactId>battery-device-neware</artifactId>
<version>${ruoyi.version}</version>
</dependency>
- <dependency>
- <groupId>com.battery</groupId>
- <artifactId>battery-device-simulator</artifactId>
- <version>${ruoyi.version}</version>
- </dependency>
-
<!-- 通用工具-->
<dependency>
<groupId>com.ruoyi</groupId>
@@ -213,14 +207,6 @@
<module>ruoyi-common</module>
</modules>
<packaging>pom</packaging>
- <profiles>
- <profile>
- <id>simulator</id>
- <activation><activeByDefault>true</activeByDefault></activation>
- <modules><module>battery-device-simulator</module></modules>
- </profile>
- <profile><id>without-simulator</id></profile>
- </profiles>
<build>
<plugins>
diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml
index 6973c13..b47d636 100644
--- a/ruoyi-admin/pom.xml
+++ b/ruoyi-admin/pom.xml
@@ -117,39 +117,6 @@
</dependency>
</dependencies>
- <profiles>
- <profile>
- <id>simulator</id>
- <activation><activeByDefault>true</activeByDefault></activation>
- <dependencies><dependency><groupId>com.battery</groupId><artifactId>battery-device-simulator</artifactId></dependency></dependencies>
- </profile>
- <profile>
- <id>without-simulator</id>
- <build><plugins><plugin>
- <groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
- <configuration>
- <excludes>
- <exclude>**/DevelopmentSimulatorConfig.java</exclude>
- <exclude>**/DevelopmentSimulatorDiscoveryScheduler.java</exclude>
- <exclude>**/DevelopmentCollectorSamplingScheduler.java</exclude>
- <exclude>**/DevelopmentCollectorStatusSynchronizer.java</exclude>
- <exclude>**/RedisBtsSimulatorStateStore.java</exclude>
- <exclude>**/BtsDevelopmentSimulatorController.java</exclude>
- <exclude>**/BtsDevelopmentSimulatorWebConfig.java</exclude>
- </excludes>
- <testExcludes>
- <testExclude>**/BtsDevelopmentSimulatorControllerTest.java</testExclude>
- <testExclude>**/DevelopmentSimulatorProfileContractTest.java</testExclude>
- <testExclude>**/DevelopmentCollectorStatusSynchronizerTest.java</testExclude>
- <testExclude>**/RedisStateStoreTest.java</testExclude>
- <testExclude>**/DeviceAdapterRuntimeConfigTest.java</testExclude>
- <testExclude>**/CollectorDiscoveryServiceImplTest.java</testExclude>
- </testExcludes>
- </configuration>
- </plugin></plugins></build>
- </profile>
- </profiles>
-
<build>
<plugins>
<plugin>
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/BtsDevelopmentSimulatorWebConfig.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/BtsDevelopmentSimulatorWebConfig.java
deleted file mode 100644
index 092b525..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/BtsDevelopmentSimulatorWebConfig.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.battery.business.runtime;
-
-import java.net.InetAddress;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.web.servlet.HandlerInterceptor;
-import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-/** 将临时 BTS 模拟器页面和控制 API 限制在本机。 */
-@Configuration
-public class BtsDevelopmentSimulatorWebConfig implements WebMvcConfigurer
-{
- /** 只为模拟器页面和控制 API 挂本机拦截器,其余路径不受影响。 */
- @Override
- public void addInterceptors(InterceptorRegistry registry)
- {
- registry.addInterceptor(new LocalOnlyInterceptor())
- .addPathPatterns("/dev/bts-simulator.html", "/dev/bts-simulator/**");
- }
-
- public static final class LocalOnlyInterceptor implements HandlerInterceptor
- {
- @Override
- public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
- throws Exception
- {
- if (isLoopback(request.getRemoteAddr())) return true;
- response.sendError(HttpServletResponse.SC_FORBIDDEN, "BTS simulator controls are local-only");
- return false;
- }
-
- private boolean isLoopback(String remoteAddress)
- {
- try
- {
- return remoteAddress != null && InetAddress.getByName(remoteAddress).isLoopbackAddress();
- }
- catch (Exception ignored)
- {
- return false;
- }
- }
- }
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorDiscoveryService.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorDiscoveryService.java
deleted file mode 100644
index de8fe36..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorDiscoveryService.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.battery.business.runtime;
-
-import java.util.List;
-import java.util.Set;
-import java.time.Instant;
-import org.springframework.stereotype.Service;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
-import com.battery.business.domain.vo.DiscoveredCollectorVo;
-import com.battery.business.service.ICollectorDiscoveryService;
-import com.battery.business.service.ITCollectorService;
-
-/** 在应用装配边界将临时模拟采集器暴露给现有采集器页面。 */
-@Service
-public class DevelopmentCollectorDiscoveryService implements ICollectorDiscoveryService
-{
- private final DeviceDiscoveryCandidateStore candidateStore;
- private final ITCollectorService collectorService;
-
- public DevelopmentCollectorDiscoveryService(DeviceDiscoveryCandidateStore candidateStore,
- ITCollectorService collectorService)
- {
- this.candidateStore = candidateStore;
- this.collectorService = collectorService;
- }
-
- /** 返回未过期且未登记的采集器,离线事实不影响发现展示或登记资格。 */
- @Override
- public List<DiscoveredCollectorVo> selectDiscoveredCollectorList()
- {
- Set<String> registeredCodes = collectorService.selectRegisteredCollectorCodes();
- return candidateStore.snapshot(Instant.now()).collectors().stream()
- .filter(collector -> !registeredCodes.contains(collector.deviceCode()))
- .map(collector -> new DiscoveredCollectorVo(collector.deviceCode(), collector.ip(), collector.mac(),
- collector.name(), collector.manufacturer(), collector.model(), collector.channelCount(),
- collector.online() ? "在线" : "离线"))
- .toList();
- }
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java
deleted file mode 100644
index e5de182..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.battery.business.runtime;
-
-import java.time.Duration;
-import java.time.Instant;
-import java.util.List;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.annotation.Profile;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
-import com.battery.business.mapper.TCollectorMapper;
-
-/** 将模拟采集器的发现写入 Redis,并同步已登记采集器的实时在线事实。 */
-@Component
-@Profile("dev-simulator")
-public class DevelopmentCollectorStatusSynchronizer
-{
- private final BtsDevelopmentSimulator simulator;
- private final DeviceDiscoveryCandidateStore candidateStore;
- private final TCollectorMapper collectorMapper;
-
- @Autowired
- public DevelopmentCollectorStatusSynchronizer(BtsDevelopmentSimulator simulator,
- DeviceDiscoveryCandidateStore candidateStore, TCollectorMapper collectorMapper)
- {
- this.simulator = simulator;
- this.candidateStore = candidateStore;
- this.collectorMapper = collectorMapper;
- }
-
- /** 保留单元测试及独立模拟器装配使用的旧构造入口。 */
-
- /** 完整替换 dev-sim 采集器快照,删除配置后候选立即消失。 */
- @Scheduled(fixedDelay = BtsDevelopmentSimulator.POLL_INTERVAL_MS)
- public synchronized void synchronizeOnlineStatus()
- {
- Instant observedAt = Instant.now();
- var simulatorState = simulator.snapshot();
- List<CollectorCandidate> candidates = simulatorState.collectors().stream()
- .map(item -> new CollectorCandidate(BtsDevelopmentSimulator.ADAPTER_CODE, item.deviceCode(),
- item.ip(), item.mac(), item.name(),
- item.manufacturer(), item.model(), item.channelCount(), simulatorState.softwareAccessible() && item.online(), observedAt,
- observedAt.plus(Duration.ofSeconds(30))))
- .toList();
- candidateStore.replaceCollectors(BtsDevelopmentSimulator.ADAPTER_CODE, candidates);
- candidates.forEach(candidate -> collectorMapper.updateRegisteredObservation(candidate.deviceCode(),
- candidate.online() ? "ONLINE" : "OFFLINE"));
- }
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java
deleted file mode 100644
index d1aa1f0..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.battery.business.runtime;
-import java.io.IOException;
-import org.springframework.boot.ApplicationRunner;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.context.annotation.*;
-import com.battery.device.api.registry.*;
-import com.battery.device.api.model.AdapterCapabilities;
-import com.battery.device.simulator.*;
-/** 开发模拟器装配可整体移除,业务和真实 BTS 不引用此配置。 */
-@Configuration
-@Profile("dev-simulator")
-public class DevelopmentSimulatorConfig {
- /** 按配置的监听端口装配开发模拟器;仅 dev-simulator 生效,容器关闭时释放其端口。 */
- @Bean(destroyMethod = "close")
- @Profile("dev-simulator")
- public BtsDevelopmentSimulator btsDevelopmentSimulator(BtsSimulatorStateStore stateStore,
- @Value("${battery.dev-simulator.port:15020}") int simulatorPort) throws IOException
- {
- return new BtsDevelopmentSimulator(simulatorPort, stateStore);
- }
-
- /**
- * 将固定开发适配器按代码装配注册进运行时实例表,不写 t_device_adapter。
- *
- * <p>其运行时能力只有发现与实时观测,控制能力为不支持,不向设备发送控制命令。</p>
- */
- @Bean
- @Profile("dev-simulator")
- public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry,
- BtsDevelopmentSimulator simulator,
- @Value("${battery.dev-simulator.port:15020}") int simulatorPort)
- {
- // 固定开发装配不进入 t_device_adapter,直接注册运行时实例。
- // 注册模拟器对象本身,使发现与取数落在同一实例上,不再单独注入扩展观测 Bean。
- return arguments -> registry.register(developmentSimulatorRuntimeConfig(simulatorPort), simulator);
- }
-
- private AdapterInstanceConfig developmentSimulatorRuntimeConfig(int simulatorPort)
- {
- return new AdapterInstanceConfig(BtsDevelopmentSimulator.ADAPTER_CODE, "BTS", BtsDevelopmentSimulator.HOST,
- simulatorPort, BtsDevelopmentSimulator.POLL_INTERVAL_MS,
- "Asia/Hong_Kong", true,
- new AdapterCapabilities(true, true, false, false));
- }
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java
deleted file mode 100644
index bbfe583..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.battery.business.runtime;
-
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-import org.springframework.context.annotation.Profile;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.api.registry.DeviceAdapterRegistry;
-import com.battery.business.service.impl.DeviceDiscoveryPersistenceService;
-import com.battery.business.service.impl.DeviceDiscoveryPersistenceService.DiscoverySource;
-
-/** 轮询代码固定装配的 dev-sim;该运行时不依赖数据库适配器记录。 */
-@Component
-@Profile("dev-simulator")
-public class DevelopmentSimulatorDiscoveryScheduler
-{
- private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(DevelopmentSimulatorDiscoveryScheduler.class);
- private final DeviceAdapterRegistry registry;
- private final DeviceDiscoveryPersistenceService persistence;
- public DevelopmentSimulatorDiscoveryScheduler(DeviceAdapterRegistry registry,
- DeviceDiscoveryPersistenceService persistence)
- {
- this.registry = registry;
- this.persistence = persistence;
- }
-
- /** 注册表已包含固定开发适配器时,将一轮检测柜观测写入 Redis 候选。 */
- @Scheduled(fixedDelay = BtsDevelopmentSimulator.POLL_INTERVAL_MS)
- public void discover()
- {
- if (!registry.contains(BtsDevelopmentSimulator.ADAPTER_CODE)) return;
- try
- {
- persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null,
- BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS));
- }
- catch (RuntimeException exception)
- {
- persistence.markUnavailable(BtsDevelopmentSimulator.ADAPTER_CODE);
- log.warn("模拟设备观测失败,已清除过期实时值:{}", exception.getMessage());
- }
- }
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
deleted file mode 100644
index 61dba9d..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
+++ /dev/null
@@ -1,233 +0,0 @@
-package com.battery.business.runtime;
-
-import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.stereotype.Component;
-import org.springframework.context.annotation.Profile;
-import com.alibaba.fastjson2.JSON;
-import com.alibaba.fastjson2.JSONArray;
-import com.alibaba.fastjson2.JSONObject;
-import com.battery.device.simulator.BtsSimulatorStateStore;
-import com.battery.device.simulator.BtsSimulatorStateStore.BtsDeviceProfile;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration;
-import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep;
-import com.battery.device.api.model.DeviceWorkStatus;
-
-/** 将开发模拟器完整状态写入 Redis,主状态不设置普通 TTL。 */
-@Component
-@Profile("dev-simulator")
-public class RedisBtsSimulatorStateStore implements BtsSimulatorStateStore
-{
- static final String STATE_KEY = "dev:bts-simulator:state:v1";
- static final String RECENT_REQUESTS_KEY = "dev:bts-simulator:recent-requests:v1";
- private static final long RECENT_REQUESTS_TTL_HOURS = 1;
-
- private final RedisTemplate<Object, Object> redisTemplate;
-
- public RedisBtsSimulatorStateStore(RedisTemplate<Object, Object> redisTemplate)
- {
- this.redisTemplate = redisTemplate;
- }
-
- /** 读取模拟器主状态,不存在时原子写入默认拓扑;并发初始化时以 Redis 中先写入的那份为准。 */
- @Override
- public SimulatorState loadOrInitialize(SimulatorState defaultState)
- {
- Object stored = redisTemplate.opsForValue().get(STATE_KEY);
- if (stored != null) return restoreState(stored);
- String initial = JSON.toJSONString(defaultState);
- if (Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(STATE_KEY, initial))) return defaultState;
- stored = redisTemplate.opsForValue().get(STATE_KEY);
- if (stored == null) throw new IllegalStateException("BTS simulator state initialization lost in Redis");
- return restoreState(stored);
- }
-
- /** 整体覆盖模拟器主状态;该键不设普通 TTL。 */
- @Override
- public void save(SimulatorState state)
- {
- redisTemplate.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
- }
-
- /** 读取短期保留的最近请求;键不存在时返回空列表。 */
- @Override
- public List<RecentRequest> loadRecentRequests()
- {
- Object stored = redisTemplate.opsForValue().get(RECENT_REQUESTS_KEY);
- if (stored == null) return List.of();
- return List.copyOf(JSON.parseArray(stored.toString(), RecentRequest.class));
- }
-
- /** 覆盖保存最近请求,整键按小时级 TTL 自动过期。 */
- @Override
- public void saveRecentRequests(List<RecentRequest> requests)
- {
- redisTemplate.opsForValue().set(RECENT_REQUESTS_KEY, JSON.toJSONString(requests),
- RECENT_REQUESTS_TTL_HOURS, TimeUnit.HOURS);
- }
-
- /** 人工重置:删除最近请求并写回默认主状态。 */
- @Override
- public void reset(SimulatorState defaultState)
- {
- redisTemplate.delete(RECENT_REQUESTS_KEY);
- save(defaultState);
- }
-
- private SimulatorState restoreState(Object stored)
- {
- try
- {
- // 通用 RedisTemplate 在可执行包中可能将泛型数组恢复为 JSONObject,必须在存储边界逐层重建。
- JSONObject root = jsonObject(stored);
- Integer version = root.getInteger("version");
- if (version == null || version != CURRENT_VERSION)
- throw new IllegalStateException("Unsupported BTS simulator state version: " + version);
- List<DeviceState> devices = new ArrayList<>();
- requiredArray(root, "devices").forEach(item -> devices.add(deviceState(item)));
- List<CollectorState> collectors = new ArrayList<>();
- requiredArray(root, "collectors").forEach(item -> collectors.add(collectorState(jsonObject(item))));
- List<ChannelTombstone> tombstones = new ArrayList<>();
- requiredArray(root, "tombstones").forEach(item -> tombstones.add(channelTombstone(item)));
- SimulatorState state = new SimulatorState(version, devices, collectors,
- requiredInteger(root, "nextDeviceId"), requiredInteger(root, "nextCollectorId"), tombstones,
- root.getBoolean("softwareAccessible"), root.getLongValue("observationSequence"));
- return state;
- }
- catch (IllegalStateException exception)
- {
- throw exception;
- }
- catch (Exception exception)
- {
- throw new IllegalStateException("Invalid BTS simulator state in Redis", exception);
- }
- }
-
- private JSONObject jsonObject(Object value)
- {
- if (value instanceof JSONObject object) return object;
- if (value instanceof String text)
- {
- Object parsed = JSON.parse(text);
- if (parsed instanceof String nested) parsed = JSON.parse(nested);
- if (parsed instanceof JSONObject object) return object;
- return JSON.parseObject(JSON.toJSONString(parsed));
- }
- return JSON.parseObject(JSON.toJSONString(value));
- }
-
- private JSONArray requiredArray(JSONObject object, String field)
- {
- JSONArray array = object.getJSONArray(field);
- if (array == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
- return array;
- }
-
- private int requiredInteger(JSONObject object, String field)
- {
- Integer value = object.getInteger(field);
- if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
- return value;
- }
-
- private DeviceState deviceState(Object value)
- {
- JSONObject object = jsonObject(value);
- List<ChannelState> channels = new ArrayList<>();
- requiredArray(object, "channels").forEach(item -> channels.add(channelState(item)));
- BtsDeviceProfile profile = object.containsKey("profile") ? deviceProfile(object.getJSONObject("profile")) : null;
- return new DeviceState(requiredInteger(object, "deviceId"), object.getString("ip"),
- object.getBooleanValue("online"), channels, profile);
- }
-
- private BtsDeviceProfile deviceProfile(JSONObject object)
- {
- if (object == null) throw new IllegalStateException("Invalid BTS simulator device profile");
- return new BtsDeviceProfile(requiredInteger(object, "devtype"), requiredInteger(object, "subDeviceId"),
- object.getString("name"), object.getString("manufacturer"), object.getString("model"),
- object.getString("serialNumber"), object.getString("firmwareVersion"),
- requiredInteger(object, "channelCount"), requiredDecimal(object, "currentMinA"),
- requiredDecimal(object, "currentMaxA"), requiredDecimal(object, "voltageMinV"),
- requiredDecimal(object, "voltageMaxV"), requiredInteger(object, "sampleIntervalMs"));
- }
-
- private CollectorState collectorState(JSONObject object)
- {
- int count = requiredInteger(object, "channelCount");
- List<CollectorChannelState> channels = new ArrayList<>();
- JSONArray configured = object.getJSONArray("channels");
- if (configured == null) channels.addAll(com.battery.device.simulator.DevelopmentCollectorSimulation.defaultChannels(count));
- else configured.forEach(item -> channels.add(collectorChannelState(item)));
- // 重启只恢复配置,不把上次响应或已废止的历史作为新观测。
- return new CollectorState(requiredInteger(object, "collectorId"), object.getString("deviceCode"),
- object.getString("ip"), object.getString("mac"), object.getString("name"),
- object.getString("manufacturer"), object.getString("model"), count, object.getBooleanValue("online"),
- com.battery.device.simulator.DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, channels, List.of());
- }
-
- private CollectorChannelState collectorChannelState(Object value)
- {
- JSONObject object = jsonObject(value);
- List<CollectorMetricState> metrics = new ArrayList<>();
- requiredArray(object, "metrics").forEach(item -> metrics.add(collectorMetricState(item)));
- return new CollectorChannelState(object.getString("channelCode"), metrics,
- object.getString("workStatus") == null ? DeviceWorkStatus.WORKING
- : DeviceWorkStatus.valueOf(object.getString("workStatus")));
- }
-
- private CollectorMetricState collectorMetricState(Object value)
- {
- JSONObject object = jsonObject(value);
- BigDecimal measurement = object.getBigDecimal("value");
- if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value");
- return new CollectorMetricState(object.getString("metricCode"), measurement, object.getString("unit"));
- }
-
- private ChannelState channelState(Object value)
- {
- JSONObject object = jsonObject(value);
- String workStatus = object.getString("workStatus");
- if (workStatus == null) throw new IllegalStateException("Missing BTS simulator state field: workStatus");
- JSONObject configuration = object.getJSONObject("configuration");
- return new ChannelState(requiredInteger(object, "channelId"), object.getBooleanValue("online"),
- DeviceWorkStatus.valueOf(workStatus), channelConfiguration(configuration));
- }
-
- /** Redis 通用反序列化会把泛型 steps 恢复为 JSONObject,不能让 record 构造器直接强转。 */
- private ChannelConfiguration channelConfiguration(JSONObject object)
- {
- if (object == null) return null;
- JSONArray configuredSteps = requiredArray(object, "steps");
- List<SimulatorPlanStep> steps = new ArrayList<>();
- configuredSteps.forEach(item -> {
- JSONObject step = jsonObject(item);
- steps.add(new SimulatorPlanStep(step.getString("name"), step.getString("stepType"),
- step.getDoubleValue("durationSeconds"), step.getDoubleValue("voltageV")));
- });
- return new ChannelConfiguration(requiredDecimal(object, "currentA"), requiredDecimal(object, "voltageV"),
- requiredDecimal(object, "capacityAh"), requiredDecimal(object, "energyWh"),
- requiredInteger(object, "cycleNo"), requiredInteger(object, "stepNo"),
- requiredInteger(object, "plannedCycleCount"), object.getLongValue("totalTimeSeconds"),
- object.getLongValue("relativeTimeSeconds"), steps);
- }
-
- private ChannelTombstone channelTombstone(Object value)
- {
- JSONObject object = jsonObject(value);
- return new ChannelTombstone(requiredInteger(object, "deviceId"), object.getString("ip"),
- requiredInteger(object, "channelId"), requiredInteger(object, "remainingDiscoveries"));
- }
-
- private BigDecimal requiredDecimal(JSONObject object, String field)
- {
- BigDecimal value = object.getBigDecimal(field);
- if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
- return value;
- }
-
-
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
index ac32c87..f5f6dac 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
@@ -36,31 +36,17 @@
Map<String, CabinetCandidate> merged = new LinkedHashMap<>();
current.cabinets().forEach(item -> merged.put(item.deviceCode(), item));
candidates.forEach(item -> merged.put(item.deviceCode(), item));
- save(new StoredState(CURRENT_VERSION, List.copyOf(merged.values()), current.collectors()));
+ save(new StoredState(CURRENT_VERSION, List.copyOf(merged.values())));
}
- /** 替换指定设备适配器实例的全部采集器候选,其它实例的候选保持不变。 */
- @Override
- public synchronized void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates)
- {
- StoredState current = load();
- List<CollectorCandidate> retained = current.collectors().stream()
- .filter(item -> !item.adapterCode().equals(adapterCode)).toList();
- java.util.ArrayList<CollectorCandidate> merged = new java.util.ArrayList<>(retained);
- merged.addAll(candidates);
- save(new StoredState(CURRENT_VERSION, current.cabinets(), merged));
- }
-
- /** 删除指定设备适配器实例的检测柜与采集器候选;仅供人工重置或实机联调清理。 */
+ /** 删除指定设备适配器实例的检测柜候选;仅供人工重置或实机联调清理。 */
@Override
public synchronized void removeAdapter(String adapterCode)
{
StoredState current = load();
List<CabinetCandidate> cabinets = current.cabinets().stream()
.filter(item -> !item.adapterCode().equals(adapterCode)).toList();
- List<CollectorCandidate> collectors = current.collectors().stream()
- .filter(item -> !item.adapterCode().equals(adapterCode)).toList();
- save(new StoredState(CURRENT_VERSION, cabinets, collectors));
+ save(new StoredState(CURRENT_VERSION, cabinets));
}
/**
@@ -74,17 +60,14 @@
StoredState current = load();
List<CabinetCandidate> cabinets = current.cabinets().stream()
.filter(item -> item.expiresAt().isAfter(now)).toList();
- List<CollectorCandidate> collectors = current.collectors().stream()
- .filter(item -> item.expiresAt().isAfter(now)).toList();
- if (cabinets.size() != current.cabinets().size() || collectors.size() != current.collectors().size())
- save(new StoredState(CURRENT_VERSION, cabinets, collectors));
- return new CandidateSnapshot(cabinets, collectors);
+ if (cabinets.size() != current.cabinets().size()) save(new StoredState(CURRENT_VERSION, cabinets));
+ return new CandidateSnapshot(cabinets);
}
private StoredState load()
{
Object stored = redisTemplate.opsForValue().get(STATE_KEY);
- if (stored == null) return new StoredState(CURRENT_VERSION, List.of(), List.of());
+ if (stored == null) return new StoredState(CURRENT_VERSION, List.of());
try
{
// 通用 RedisTemplate 可能把泛型数组恢复为 JSONObject;在存储边界逐字段重建,禁止业务层强转。
@@ -93,10 +76,9 @@
if (version == null || version != CURRENT_VERSION)
throw new IllegalStateException("Unsupported device discovery state version: " + version);
List<CabinetCandidate> cabinets = new ArrayList<>();
+ // collectors 是模拟器时代写进同一 key 的历史字段:读到就忽略,不再恢复也不回写。
requiredArray(root, "cabinets").forEach(item -> cabinets.add(cabinetCandidate(item)));
- List<CollectorCandidate> collectors = new ArrayList<>();
- requiredArray(root, "collectors").forEach(item -> collectors.add(collectorCandidate(item)));
- return new StoredState(CURRENT_VERSION, cabinets, collectors);
+ return new StoredState(CURRENT_VERSION, cabinets);
}
catch (IllegalStateException exception)
{
@@ -155,16 +137,6 @@
latestSnapshot, optionalInstant(object, "lastDataAt"), object.getString("sourceDisplayNo"));
}
- private CollectorCandidate collectorCandidate(Object value)
- {
- JSONObject object = jsonObject(value);
- return new CollectorCandidate(object.getString("adapterCode"), object.getString("deviceCode"),
- object.getString("ip"), object.getString("mac"), object.getString("name"),
- object.getString("manufacturer"), object.getString("model"), object.getIntValue("channelCount"),
- object.getBooleanValue("online"), requiredInstant(object, "observedAt"),
- requiredInstant(object, "expiresAt"));
- }
-
private Instant requiredInstant(JSONObject object, String field)
{
Instant value = object.getObject(field, Instant.class);
@@ -183,13 +155,11 @@
redisTemplate.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
}
- private record StoredState(int version, List<CabinetCandidate> cabinets,
- List<CollectorCandidate> collectors)
+ private record StoredState(int version, List<CabinetCandidate> cabinets)
{
private StoredState
{
cabinets = List.copyOf(cabinets);
- collectors = List.copyOf(collectors);
}
}
}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java
index 3f7e7d5..0aa0b07 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java
@@ -7,8 +7,7 @@
/**
* 按固定周期触发数据接收,由业务服务按测试接收状态和采样来源保存成功观测。
*
- * <p>它不限定 profile:真实来源的接收路径也必须在非开发 profile 下运行。
- * 开发模拟器专属的调度(模拟器发现轮询、采集器状态同步)仍只在 dev-simulator 下额外启用。
+ * <p>它不限定 profile:真实来源的接收路径必须在任何启动方式下都运行。
*/
@Component
public class TestDataReceptionScheduler
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/TCollectorController.java b/ruoyi-admin/src/main/java/com/battery/business/web/TCollectorController.java
index 0655948..1e9ea28 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/web/TCollectorController.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/web/TCollectorController.java
@@ -1,6 +1,5 @@
package com.battery.business.web;
-import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -15,15 +14,10 @@
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
-import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
-import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.enums.BusinessType;
import com.battery.business.domain.TCollector;
-import com.battery.business.domain.vo.DiscoveredCollectorVo;
-import com.battery.business.service.ICollectorDiscoveryService;
import com.battery.business.service.ITCollectorService;
/**
@@ -38,9 +32,6 @@
@Autowired
private ITCollectorService collectorService;
- @Autowired
- private ICollectorDiscoveryService collectorDiscoveryService;
-
/** 分页查询采集器列表。 */
@PreAuthorize("@ss.hasPermi('system:collector:list')")
@GetMapping("/list")
@@ -49,31 +40,6 @@
startPage();
List<TCollector> list = collectorService.selectCollectorList(collector);
return getDataTable(list);
- }
-
- /**
- * 查询当前可发现的采集器列表。
- *
- * @return 可发现采集器分页数据
- */
- @PreAuthorize("@ss.hasPermi('system:collector:list')")
- @GetMapping("/discovered/list")
- public TableDataInfo discoveredList()
- {
- List<DiscoveredCollectorVo> devices = collectorDiscoveryService.selectDiscoveredCollectorList();
- PageDomain pageDomain = TableSupport.buildPageRequest();
- int pageNum = pageDomain.getPageNum() == null || pageDomain.getPageNum() < 1 ? 1 : pageDomain.getPageNum();
- int pageSize = pageDomain.getPageSize() == null || pageDomain.getPageSize() < 1 ? 10 : pageDomain.getPageSize();
- long offset = (long) (pageNum - 1) * pageSize;
- int fromIndex = (int) Math.min(offset, devices.size());
- int toIndex = (int) Math.min((long) fromIndex + pageSize, devices.size());
- List<DiscoveredCollectorVo> rows = fromIndex == toIndex
- ? Collections.emptyList()
- : devices.subList(fromIndex, toIndex);
- TableDataInfo result = new TableDataInfo(rows, devices.size());
- result.setCode(HttpStatus.SUCCESS);
- result.setMsg("查询成功");
- return result;
}
/** 查询采集器详情。 */
diff --git a/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java b/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
deleted file mode 100644
index 1aa60ae..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
+++ /dev/null
@@ -1,311 +0,0 @@
-package com.battery.business.web.device;
-
-import java.math.BigDecimal;
-import java.util.List;
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.core.io.Resource;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.PutMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.server.ResponseStatusException;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.annotation.Profile;
-import com.ruoyi.common.annotation.Anonymous;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.simulator.BtsDevelopmentSimulator.StateSnapshot;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
-import com.battery.device.api.model.DeviceWorkStatus;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
-import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
-
-/** 本机开发模拟器的配置与观测 API,不向设备发送控制命令。 */
-@Anonymous
-@RestController
-@Profile("dev-simulator")
-public class BtsDevelopmentSimulatorController
-{
- private final BtsDevelopmentSimulator simulator;
- private final DeviceDiscoveryCandidateStore candidateStore;
- private final com.battery.business.runtime.DevelopmentCollectorStatusSynchronizer statusSynchronizer;
-
- @Autowired
- public BtsDevelopmentSimulatorController(BtsDevelopmentSimulator simulator,
- DeviceDiscoveryCandidateStore candidateStore,
- com.battery.business.runtime.DevelopmentCollectorStatusSynchronizer statusSynchronizer)
- {
- this.simulator = simulator;
- this.candidateStore = candidateStore;
- this.statusSynchronizer = statusSynchronizer;
- }
-
- /** 保留独立模拟器测试和工具调用的构造入口,不触发数据库同步。 */
- public BtsDevelopmentSimulatorController(BtsDevelopmentSimulator simulator,
- DeviceDiscoveryCandidateStore candidateStore)
- {
- this(simulator, candidateStore, null);
- }
-
- /** 返回模拟器配置页;匿名放行后仍由本机拦截器限制来源。 */
- @GetMapping(value = "/dev/bts-simulator.html", produces = MediaType.TEXT_HTML_VALUE)
- public Resource page()
- {
- return new ClassPathResource("static/dev/bts-simulator.html");
- }
-
- /** 返回模拟器、虚拟设备、通道和最近请求的当前快照。 */
- @GetMapping("/dev/bts-simulator/state")
- public StateSnapshot state()
- {
- return simulator.snapshot();
- }
-
- /** 人工清空模拟配置并恢复默认检测柜和采集器。 */
- @PostMapping("/dev/bts-simulator/reset")
- public StateSnapshot reset()
- {
- simulator.reset();
- candidateStore.removeAdapter(BtsDevelopmentSimulator.ADAPTER_CODE);
- return simulator.snapshot();
- }
-
- /** 模拟现场人工统一开关全部检测柜和采集器,不发送任何设备控制命令。 */
- @PostMapping("/dev/bts-simulator/power")
- public StateSnapshot setPower(@RequestBody SimulatorPowerCommand command)
- {
- if (command == null || command.poweredOn() == null)
- throw badRequest(new IllegalArgumentException("poweredOn must be true or false"));
- simulator.setAllDevicesPoweredOn(command.poweredOn());
- synchronizeOnlineStatusIfConfigured();
- return simulator.snapshot();
- }
-
- /** 添加一台虚拟设备。 */
- @PostMapping("/dev/bts-simulator/devices")
- public StateSnapshot createDevice(@RequestBody CreateDeviceCommand command)
- {
- try
- {
- if (command.model() == null || command.model().isBlank())
- simulator.addDevice(command.ip(), command.channelCount());
- else
- simulator.addNewareDevice(command.ip(), command.model());
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 修改指定虚拟设备的地址、通道数和在线状态。 */
- @PutMapping("/dev/bts-simulator/devices/{deviceId}")
- public StateSnapshot updateDevice(@PathVariable int deviceId, @RequestBody DeviceStateCommand command)
- {
- try
- {
- simulator.updateDevice(deviceId, command.ip(), command.channelCount(), command.online());
- if (command.model() != null && !command.model().isBlank())
- simulator.configureNewareProfile(deviceId, command.model());
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 删除指定虚拟设备。 */
- @DeleteMapping("/dev/bts-simulator/devices/{deviceId}")
- public StateSnapshot deleteDevice(@PathVariable int deviceId)
- {
- try
- {
- simulator.deleteDevice(deviceId);
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 添加一台与检测柜分开配置的虚拟采集器。 */
- @PostMapping("/dev/bts-simulator/collectors")
- public StateSnapshot createCollector(@RequestBody CreateCollectorCommand command)
- {
- try
- {
- simulator.addCollector(command.ip(), command.channelCount());
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 修改虚拟采集器的地址、通道数和在线状态。 */
- @PutMapping("/dev/bts-simulator/collectors/{collectorId}")
- public StateSnapshot updateCollector(@PathVariable int collectorId,
- @RequestBody CollectorStateCommand command)
- {
- try
- {
- simulator.updateCollector(collectorId, command.ip(), command.channelCount(), command.online());
- synchronizeOnlineStatusIfConfigured();
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 保存稳定 S 通道的固定测量值及自定义指标,不产生新的成功观测。 */
- @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}")
- public StateSnapshot configureCollectorChannel(@PathVariable int collectorId,
- @PathVariable String channelCode, @RequestBody CollectorChannelCommand command)
- {
- try
- {
- List<CollectorMetricState> metrics = command.metrics().stream()
- .map(metric -> new CollectorMetricState(metric.metricCode(), metric.value(), metric.unit()))
- .toList();
- simulator.configureCollectorChannel(collectorId, channelCode, metrics);
- return simulator.snapshot();
- }
- catch (IllegalArgumentException | NullPointerException exception)
- {
- throw badRequest(new IllegalArgumentException("Invalid collector channel metrics", exception));
- }
- }
-
- /** 请求采集器当前实时快照;不可访问或离线时返回空测量。 */
- @GetMapping("/dev/bts-simulator/collectors/{collectorId}/realtime")
- public CollectorRealtimeSnapshot collectorRealtime(@PathVariable int collectorId)
- {
- try
- {
- return simulator.collectorRealtime(collectorId);
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 删除指定虚拟采集器。 */
- @DeleteMapping("/dev/bts-simulator/collectors/{collectorId}")
- public StateSnapshot deleteCollector(@PathVariable int collectorId)
- {
- try
- {
- simulator.deleteCollector(collectorId);
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw badRequest(exception);
- }
- }
-
- /** 修改指定虚拟设备通道的在线和工作状态。 */
- @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}")
- public StateSnapshot updateChannel(@PathVariable int deviceId, @PathVariable int channelId,
- @RequestBody ChannelStateCommand command)
- {
- try
- {
- simulator.updateChannel(deviceId, channelId, command.online(), command.workStatus());
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception)
- {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
- }
- }
-
- /** 保存单通道的当前观测参数和工步方案,不改变设备在线或工作状态。 */
- @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}/configuration")
- public StateSnapshot configureChannel(@PathVariable int deviceId, @PathVariable int channelId,
- @RequestBody com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration configuration)
- {
- try {
- simulator.configureChannel(deviceId, channelId, configuration);
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception) { throw badRequest(exception); }
- }
-
- /** 修改软件可访问性,设备配置继续保留。 */
- @PutMapping("/dev/bts-simulator/software")
- public StateSnapshot software(@RequestBody SoftwareCommand command)
- {
- if (command == null || command.accessible() == null)
- throw badRequest(new IllegalArgumentException("accessible must be true or false"));
- simulator.setSoftwareAccessible(command.accessible());
- synchronizeOnlineStatusIfConfigured();
- return simulator.snapshot();
- }
-
- /** 修改采集器通道当前工作状态。 */
- @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}/status")
- public StateSnapshot collectorChannelStatus(@PathVariable int collectorId, @PathVariable String channelCode,
- @RequestBody CollectorChannelStatusCommand command)
- {
- try
- {
- simulator.updateCollectorChannelStatus(collectorId, channelCode, command.workStatus());
- return simulator.snapshot();
- }
- catch (IllegalArgumentException exception) { throw badRequest(exception); }
- }
-
- private ResponseStatusException badRequest(IllegalArgumentException exception)
- {
- return new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
- }
-
- private void synchronizeOnlineStatusIfConfigured()
- {
- if (statusSynchronizer != null) statusSynchronizer.synchronizeOnlineStatus();
- }
-
- public record CreateDeviceCommand(String ip, int channelCount, String model)
- {
- public CreateDeviceCommand(String ip, int channelCount)
- {
- this(ip, channelCount, null);
- }
- }
-
- public record SimulatorPowerCommand(Boolean poweredOn) { }
-
- public record DeviceStateCommand(String ip, int channelCount, boolean online, String model)
- {
- public DeviceStateCommand(String ip, int channelCount, boolean online)
- {
- this(ip, channelCount, online, null);
- }
- }
-
- public record ChannelStateCommand(boolean online, DeviceWorkStatus workStatus) { }
-
- public record CreateCollectorCommand(String ip, int channelCount) { }
-
- public record CollectorStateCommand(String ip, int channelCount, boolean online) { }
-
- public record CollectorChannelCommand(List<CollectorMetricCommand> metrics) { }
-
- public record CollectorMetricCommand(String metricCode, BigDecimal value, String unit) { }
-
- public record SoftwareCommand(Boolean accessible) { }
-
- public record CollectorChannelStatusCommand(DeviceWorkStatus workStatus) { }
-}
diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml
index dd250f8..c99b10a 100644
--- a/ruoyi-admin/src/main/resources/application.yml
+++ b/ruoyi-admin/src/main/resources/application.yml
@@ -61,7 +61,7 @@
# 国际化资源文件路径
basename: i18n/messages
profiles:
- # 默认不连接任何设备;本机演示时显式追加 dev-simulator。
+ # 默认不连接任何设备:没有配置来源时使用无设备实现;真实来源在适配器实例里按行配置。
active: druid
# 文件上传
servlet:
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
deleted file mode 100644
index a0ae882..0000000
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ /dev/null
@@ -1,660 +0,0 @@
-<!doctype html>
-<html lang="zh-CN">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>开发设备模拟器</title>
- <style>
- :root { color-scheme: light; font-family: "Microsoft YaHei", Arial, sans-serif; color: #26323d; background: #f3f6f8; }
- * { box-sizing: border-box; }
- body { margin: 0; min-width: 320px; }
- header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 62px; padding: 0 28px; color: #fff; background: #213547; border-bottom: 3px solid #26a269; }
- h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: 0; }
- header span { font: 12px Consolas, monospace; color: #c9d5de; }
- main { width: min(1120px, calc(100% - 32px)); margin: 22px auto 40px; }
- section { margin-bottom: 20px; background: #fff; border: 1px solid #d5dde3; }
- .section-title, .device-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 0 16px; background: #edf2f5; border-bottom: 1px solid #d5dde3; }
- h2, h3 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0; }
- .summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
- .summary > div { min-height: 76px; padding: 15px 16px; border-right: 1px solid #e2e8ec; }
- .summary > div:last-child { border-right: 0; }
- .label { display: block; margin-bottom: 7px; color: #6b7780; font-size: 12px; }
- .value { font-size: 14px; font-weight: 600; }
- .status { display: inline-flex; align-items: center; gap: 7px; }
- .status::before { width: 8px; height: 8px; content: ""; border-radius: 50%; background: #c43d4b; }
- .status.online::before { background: #218a5b; }
- .add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; }
- .device-settings { grid-template-columns: minmax(130px, 1fr) 90px minmax(150px, 1fr) 100px auto auto; }
- .collector-settings { display: grid; grid-template-columns: minmax(130px, 1fr) 90px 110px auto auto; gap: 12px; align-items: end; padding: 16px; }
-
- label { color: #5e6a73; font-size: 12px; }
- label > span { display: block; margin-bottom: 6px; }
- input[type="text"], input[type="number"], select, textarea { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
- textarea { min-height: 64px; padding: 7px 9px; resize: vertical; font: 12px Consolas, monospace; }
- input[type="checkbox"] { width: 17px; height: 17px; accent-color: #218a5b; cursor: pointer; }
- .switch-label { display: inline-flex; align-items: center; gap: 8px; height: 34px; }
- .switch-label > span { margin: 0; }
- button { height: 34px; padding: 0 13px; color: #fff; background: #2877a9; border: 1px solid #2877a9; border-radius: 3px; cursor: pointer; }
- button:hover { background: #1f638e; }
- button:disabled { cursor: not-allowed; opacity: .5; }
- button.danger { color: #a03946; background: #fff; border-color: #d8a6ad; }
- button.danger:hover { background: #fff3f4; }
- .power-actions { display: flex; flex-wrap: wrap; gap: 8px; }
- .device { margin: 16px; border: 1px solid #d5dde3; }
- .device-title { background: #f7f9fa; }
- table { width: 100%; border-collapse: collapse; table-layout: fixed; }
- th, td { height: 48px; padding: 8px 14px; text-align: left; border-bottom: 1px solid #e3e9ed; font-size: 13px; }
- th { height: 40px; color: #5e6a73; background: #f8fafb; font-size: 12px; font-weight: 600; }
- tbody tr:last-child td { border-bottom: 0; }
- td select { width: 150px; }
- .request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; }
- .request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; }
- .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: pre-wrap; overflow-wrap: anywhere; }
- .collector-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e3e9ed; }
- .collector-output { min-height: 46px; margin: 0 16px 16px; padding: 10px; overflow: auto; color: #34434d; background: #f7f9fa; border: 1px solid #d5dde3; font: 11px Consolas, monospace; white-space: pre-wrap; }
- .empty { height: 96px; color: #7d8992; text-align: center; }
- .error { display: none; margin-bottom: 16px; padding: 11px 14px; color: #8b2635; background: #fff0f2; border: 1px solid #e4a8b0; font-size: 13px; }
- .error.visible { display: block; }
- dialog { width: min(940px, calc(100% - 24px)); max-height: calc(100% - 32px); padding: 0; border: 1px solid #bdc8d0; border-radius: 4px; color: #26323d; }
- dialog::backdrop { background: rgb(0 0 0 / 35%); }
- .editor-body { padding: 16px; }
- .editor-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
- .editor-scroll { overflow-x: auto; margin-top: 12px; }
- .editor-scroll table { min-width: 700px; }
- .editor-scroll td { padding: 6px; }
- .editor-scroll th { padding: 8px 6px; white-space: nowrap; }
- .editor-scroll select { width: 100%; }
- .editor-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 16px; border-top: 1px solid #d5dde3; }
- .icon-button { width: 30px; padding: 0; color: #52616c; background: #fff; border-color: #bdc8d0; }
- .row-actions { display: flex; gap: 4px; }
- .editor-error { margin: 0 0 12px; }
- @media (max-width: 760px) {
- header { align-items: flex-start; flex-direction: column; padding: 14px 16px; }
- main { width: calc(100% - 20px); margin-top: 12px; }
- .summary { grid-template-columns: 1fr; }
- .summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; }
- .add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; }
- .device-settings button, .collector-settings button { width: 100%; }
- .device { margin: 10px; overflow-x: auto; }
- .device table { min-width: 620px; }
- .section-title, .device-title { flex-wrap: wrap; padding: 10px; }
- .device-title .label { overflow-wrap: anywhere; }
- th, td { padding: 8px; }
- .request-time { width: 130px; }
- .request-command { width: 90px; }
- .editor-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- }
- </style>
-</head>
-<body>
- <header><h1>开发设备模拟器</h1><span>dev-sim / 仅限本机</span></header>
- <main>
- <div id="error" class="error"></div>
- <section>
- <div class="section-title">
- <h2>软件连接</h2>
- <div class="power-actions">
- <label class="switch-label"><input id="software-accessible" type="checkbox"><span>新威软件可访问</span></label>
- <button id="power-on" type="button">全部设备上线</button>
- <button id="power-off" class="danger" type="button">全部设备离线</button>
- <button id="reset-simulator" class="danger" type="button">重置模拟器</button>
- </div>
- </div>
- <div class="summary">
- <div><span class="label">模拟器服务</span><span id="runtime" class="value status">未知</span></div>
- <div><span class="label">现场设备</span><span id="device-power" class="value status">未知</span></div>
- <div><span class="label">监听地址</span><span id="address" class="value">-</span></div>
- <div><span class="label">检测柜</span><span id="device-count" class="value">0 台</span></div>
- <div><span class="label">采集器</span><span id="collector-count" class="value">0 台</span></div>
- </div>
- </section>
- <section>
- <div class="section-title"><h2>添加检测柜</h2></div>
- <form id="add-device" class="add-form">
- <label><span>虚拟 IP</span><input id="new-ip" type="text" value="127.0.0.3" required></label>
- <label><span>通道数</span><input id="new-channel-count" type="number" min="1" max="99" value="4" required></label>
- <label><span>设备 profile</span><select id="new-device-model"><option value="">开发虚拟柜</option><option value="CT-4008T-5V6A-S1-F">新威 CT-4008T-5V6A</option><option value="CT-4008Tn-5V12A-S1-F">新威 CT-4008Tn-5V12A</option></select></label>
- <button type="submit">添加检测柜</button>
- </form>
- </section>
- <section>
- <div class="section-title"><h2>检测柜与通道</h2><span class="label">约在下一轮设备发现后同步至业务页面</span></div>
- <div id="devices"></div>
- </section>
- <section>
- <div class="section-title"><h2>添加采集器</h2><span class="label">与检测柜分开配置</span></div>
- <form id="add-collector" class="add-form">
- <label><span>虚拟 IP</span><input id="new-collector-ip" type="text" value="127.0.1.3" required></label>
- <label><span>通道数</span><input id="new-collector-channel-count" type="number" min="1" max="99" value="4" required></label>
- <button type="submit">添加采集器</button>
- </form>
- </section>
- <section>
- <div class="section-title"><h2>采集器</h2><span class="label">在采集器页面“选择设备”中直接显示</span></div>
- <div id="collectors"></div>
- </section>
- <section>
- <div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div>
- <table>
- <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 / 响应</th></tr></thead>
- <tbody id="requests"></tbody>
- </table>
- </section>
- </main>
- <dialog id="channel-editor" aria-labelledby="editor-title">
- <form id="editor-form">
- <div class="section-title"><h3 id="editor-title"></h3><button class="icon-button" type="button" id="editor-close" aria-label="关闭配置" title="关闭配置">×</button></div>
- <div class="editor-body"><div id="editor-error" class="error editor-error" role="alert"></div><div id="editor-content"></div></div>
- <div class="editor-actions"><button type="button" id="editor-cancel" class="danger">取消</button><button type="submit" id="editor-save">保存通道配置</button></div>
- </form>
- </dialog>
- <script>
- const api = '/dev/bts-simulator';
- const labels = { STOPPED: '空闲', WORKING: '运行', PAUSED: '暂停', FINISHED: '结束', PROTECTED: '异常', UNOBSERVABLE: '无法观测' };
- const errorBox = document.querySelector('#error');
- const editor = document.querySelector('#channel-editor');
- const editorForm = document.querySelector('#editor-form');
- const editorContent = document.querySelector('#editor-content');
- const stepTypes = { cc: '恒流充电', dc: '恒流放电', cv: '恒压充电', rest: '搁置', end: '结束', cccv: '恒流恒压充电' };
- const metricLabels = { temperature: '温度', positive_potential: '正电位', negative_potential: '负电位' };
- let saveEditor = null;
- document.querySelector('#editor-close').onclick = () => editor.close();
- document.querySelector('#editor-cancel').onclick = () => editor.close();
- editorForm.addEventListener('submit', async event => {
- event.preventDefault();
- const save = document.querySelector('#editor-save');
- save.disabled = true;
- try { const state = await saveEditor(); editor.close(); render(state); }
- catch (error) { showError(error); }
- finally { save.disabled = false; }
- });
-
- async function request(path, options) {
- const response = await fetch(api + path, { ...options, headers: { 'Content-Type': 'application/json' } });
- if (!response.ok) {
- let message = `请求失败 (${response.status})`;
- try { const error = await response.json(); message = error?.msg || error?.message || message; } catch (_) { }
- throw new Error(message);
- }
- const payload = await response.json();
- if (payload && payload.data && typeof payload.data === 'object'
- && !Array.isArray(payload.devices) && payload.data.devices) return payload.data;
- return payload;
- }
-
- function showError(error) {
- errorBox.textContent = error instanceof Error ? error.message : '控制页请求失败';
- errorBox.classList.add('visible');
- if (editor.open) {
- const message = document.querySelector('#editor-error');
- message.textContent = errorBox.textContent;
- message.classList.add('visible');
- }
- }
-
- function render(state) {
- errorBox.classList.remove('visible');
- if (!state || typeof state !== 'object') throw new Error('模拟器响应缺少状态数据');
- if (state.data && typeof state.data === 'object' && !Array.isArray(state.devices)) state = state.data;
- state = {
- ...state,
- devices: Array.isArray(state.devices) ? state.devices : [],
- collectors: Array.isArray(state.collectors) ? state.collectors : [],
- recentRequests: Array.isArray(state.recentRequests) ? state.recentRequests : [],
- };
- document.querySelector('#software-accessible').checked = state.softwareAccessible;
- const runtime = document.querySelector('#runtime');
- runtime.textContent = state.running ? '运行中' : '已停止';
- runtime.classList.toggle('online', state.running);
- const devicesPoweredOn = state.devices.length + state.collectors.length > 0
- && state.devices.every(device => device.online)
- && state.collectors.every(collector => collector.online);
- const devicesPoweredOff = state.devices.every(device => !device.online)
- && state.collectors.every(collector => !collector.online);
- const devicePower = document.querySelector('#device-power');
- devicePower.textContent = devicesPoweredOn ? '全部在线' : devicesPoweredOff ? '全部离线' : '部分在线';
- devicePower.classList.toggle('online', devicesPoweredOn);
- document.querySelector('#power-on').disabled = devicesPoweredOn;
- document.querySelector('#power-off').disabled = devicesPoweredOff;
- document.querySelector('#address').textContent = state.listenAddress;
- document.querySelector('#device-count').textContent = `${state.devices.length} 台`;
- document.querySelector('#collector-count').textContent = `${state.collectors.length} 台`;
- renderDevices(state.devices);
- renderCollectors(state.collectors);
- renderRequests(state.recentRequests);
- }
-
- function renderDevices(devices) {
- const container = document.querySelector('#devices');
- if (!devices.length) {
- const empty = document.createElement('div');
- empty.className = 'empty';
- empty.textContent = '暂无虚拟设备';
- container.replaceChildren(empty);
- return;
- }
- container.replaceChildren(...devices.map(device => {
- const panel = document.createElement('article');
- panel.className = 'device';
- const title = document.createElement('div');
- title.className = 'device-title';
- const heading = document.createElement('h3');
- heading.textContent = `检测柜 ${device.deviceId}`;
- const identity = document.createElement('span');
- identity.className = 'label';
- identity.textContent = `devtype ${device.profile.devtype} / devid ${device.deviceId} / subdevid ${device.profile.subDeviceId} / ${device.profile.model}`;
- title.append(heading, identity);
-
- const settings = document.createElement('div');
- settings.className = 'device-settings';
- const ip = field('虚拟 IP', 'text', device.ip);
- const channelCount = field('通道数', 'number', device.channels.length);
- channelCount.input.min = '1';
- channelCount.input.max = '99';
- const model = document.createElement('label');
- model.innerHTML = '<span>设备 profile</span>';
- const modelInput = document.createElement('select');
- modelInput.innerHTML = '<option value="">开发虚拟柜</option><option value="CT-4008T-5V6A-S1-F">新威 CT-4008T-5V6A</option><option value="CT-4008Tn-5V12A-S1-F">新威 CT-4008Tn-5V12A</option>';
- modelInput.value = device.profile.model.startsWith('CT-4008T') ? device.profile.model : '';
- model.appendChild(modelInput);
- const onlineLabel = document.createElement('label');
- onlineLabel.className = 'switch-label';
- const online = document.createElement('input');
- online.type = 'checkbox';
- online.checked = device.online;
- const onlineText = document.createElement('span');
- onlineText.textContent = device.online ? '整机在线' : '整机离线';
- online.addEventListener('change', () => { onlineText.textContent = online.checked ? '整机在线' : '整机离线'; });
- onlineLabel.append(online, onlineText);
- const save = document.createElement('button');
- save.type = 'button';
- save.textContent = '保存配置';
- save.addEventListener('click', async () => {
- try { render(await request(`/devices/${device.deviceId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked, model: modelInput.value || null }) })); }
- catch (error) { showError(error); }
- });
- const remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'danger';
- remove.textContent = '删除检测柜';
- remove.addEventListener('click', async () => {
- try { render(await request(`/devices/${device.deviceId}`, { method: 'DELETE' })); }
- catch (error) { showError(error); }
- });
- settings.append(ip.label, channelCount.label, model, onlineLabel, save, remove);
-
- const table = document.createElement('table');
- table.innerHTML = '<thead><tr><th>通道</th><th>在线状态</th><th>工作状态</th><th>观测配置</th></tr></thead>';
- const body = document.createElement('tbody');
- body.append(...device.channels.map(channel => channelRow(device.deviceId, channel)));
- table.append(body);
- panel.append(title, settings, table);
- return panel;
- }));
- }
-
- function renderCollectors(collectors) {
- const container = document.querySelector('#collectors');
- if (!collectors.length) {
- const empty = document.createElement('div');
- empty.className = 'empty';
- empty.textContent = '暂无虚拟采集器';
- container.replaceChildren(empty);
- return;
- }
- container.replaceChildren(...collectors.map(collector => {
- const panel = document.createElement('article');
- panel.className = 'device';
- const title = document.createElement('div');
- title.className = 'device-title';
- const heading = document.createElement('h3');
- heading.textContent = collector.name;
- const identity = document.createElement('span');
- identity.className = 'label';
- identity.textContent = `${collector.deviceCode} / ${collector.mac}`;
- title.append(heading, identity);
-
- const settings = document.createElement('div');
- settings.className = 'collector-settings';
- const ip = field('虚拟 IP', 'text', collector.ip);
- const channelCount = field('通道数', 'number', collector.channelCount);
- channelCount.input.min = '1';
- channelCount.input.max = '99';
- const onlineLabel = document.createElement('label');
- onlineLabel.className = 'switch-label';
- const online = document.createElement('input');
- online.type = 'checkbox';
- online.checked = collector.online;
- const onlineText = document.createElement('span');
- onlineText.textContent = collector.online ? '整机在线' : '整机离线';
- online.addEventListener('change', () => { onlineText.textContent = online.checked ? '整机在线' : '整机离线'; });
- onlineLabel.append(online, onlineText);
- const save = document.createElement('button');
- save.type = 'button';
- save.textContent = '保存配置';
- save.addEventListener('click', async () => {
- try {
- render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) }));
- }
- catch (error) { showError(error); }
- });
- const remove = document.createElement('button');
- remove.type = 'button';
- remove.className = 'danger';
- remove.textContent = '删除采集器';
- remove.addEventListener('click', async () => {
- try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); }
- catch (error) { showError(error); }
- });
- settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
-
- const table = document.createElement('table');
- table.innerHTML = '<thead><tr><th style="width:100px">稳定通道</th><th style="width:170px">工作状态</th><th>测量项</th><th style="width:130px">操作</th></tr></thead>';
- const channelBody = document.createElement('tbody');
- channelBody.append(...collector.channels.map(channel => collectorChannelRow(collector.collectorId, channel)));
- table.append(channelBody);
-
- const actions = document.createElement('div');
- actions.className = 'collector-actions';
- const realtimeButton = document.createElement('button');
- realtimeButton.type = 'button';
- realtimeButton.textContent = '查看实时';
- const output = document.createElement('pre');
- output.className = 'collector-output';
- output.textContent = collector.latestMeasurements.length
- ? measurementLines(collector.latestMeasurements)
- : '暂无成功观测';
- realtimeButton.addEventListener('click', async () => {
- try { output.textContent = measurementLines((await request(`/collectors/${collector.collectorId}/realtime`)).measurements); }
- catch (error) { showError(error); }
- });
- actions.append(realtimeButton);
- panel.append(title, settings, table, actions, output);
- return panel;
- }));
- }
-
- function collectorChannelRow(collectorId, channel) {
- const row = document.createElement('tr');
- const identity = document.createElement('td');
- identity.textContent = channel.channelCode;
- const metricsCell = document.createElement('td');
- metricsCell.textContent = channel.metrics.map(item => `${metricLabels[item.metricCode] || item.metricCode} ${item.value} ${item.unit}`).join(' / ');
- metricsCell.style.overflowWrap = 'anywhere';
- const action = document.createElement('td');
- const save = document.createElement('button');
- save.type = 'button';
- save.textContent = '编辑测量值';
- save.addEventListener('click', () => editCollectorChannel(collectorId, channel));
- action.append(save);
- const statusCell = document.createElement('td');
- const status = document.createElement('select');
- status.setAttribute('aria-label', channel.channelCode + ' 工作状态');
- Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
- status.value = channel.workStatus;
- status.addEventListener('change', async () => {
- try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}/status`, { method: 'PUT', body: JSON.stringify({ workStatus: status.value }) })); }
- catch (error) { showError(error); }
- });
- statusCell.append(status);
- row.append(identity, statusCell, metricsCell, action);
- return row;
- }
-
- function measurementLines(items) {
- if (!items.length) return '暂无测量';
- return items.map(item => `${item.collectorCode} #${item.sequenceId} ${item.channelCode} ${item.metricCode}=${item.value} ${item.unit} 设备 ${item.deviceTimeRaw} / 接收 ${item.serverReceivedAt}`).join('\n');
- }
-
- function field(labelText, type, value) {
- const label = document.createElement('label');
- const text = document.createElement('span');
- text.textContent = labelText;
- const input = document.createElement('input');
- input.type = type;
- input.value = value;
- label.append(text, input);
- return { label, input };
- }
-
- function openEditor(title) {
- editorContent.replaceChildren();
- document.querySelector('#editor-title').textContent = title;
- document.querySelector('#editor-error').classList.remove('visible');
- editor.showModal();
- }
-
- function numericField(label, value, integer = false) {
- const result = field(label, 'number', value);
- result.input.required = true;
- result.input.step = integer ? '1' : 'any';
- return result;
- }
-
- function iconButton(symbol, label, action) {
- const button = document.createElement('button');
- button.type = 'button'; button.className = 'icon-button'; button.textContent = symbol;
- button.title = label; button.setAttribute('aria-label', label); button.onclick = action;
- return button;
- }
-
- function editCabinetChannel(deviceId, channel) {
- openEditor(`检测柜 ${deviceId} · 通道 ${channel.channelId} 参数与方案`);
- const config = channel.configuration;
- const fields = document.createElement('div'); fields.className = 'editor-fields';
- const inputs = {};
- for (const [key, label, integer] of [
- ['voltageV', '电压 (V)'], ['currentA', '电流 (A)'], ['capacityAh', '容量 (Ah)'], ['energyWh', '能量 (Wh)'],
- ['cycleNo', '当前循环', true], ['stepNo', '当前工步', true], ['plannedCycleCount', '计划循环数', true],
- ['totalTimeSeconds', '总时间 (s)', true], ['relativeTimeSeconds', '工步时间 (s)', true],
- ]) {
- const item = numericField(label, config[key], integer);
- if (integer) item.input.min = key.endsWith('Seconds') ? '0' : '1';
- inputs[key] = item.input; fields.append(item.label);
- }
- const heading = document.createElement('div'); heading.className = 'section-title';
- const title = document.createElement('h3'); title.textContent = '工步方案';
- const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加工步';
- heading.append(title, add);
- const scroll = document.createElement('div'); scroll.className = 'editor-scroll';
- const table = document.createElement('table');
- table.innerHTML = '<thead><tr><th style="width:50px">序号</th><th>工步名称</th><th style="width:140px">工步类型</th><th style="width:110px">时长 (s)</th><th style="width:100px">电压 (V)</th><th style="width:112px">操作</th></tr></thead>';
- const body = document.createElement('tbody'); table.append(body); scroll.append(table);
- const steps = [];
- function syncRows() {
- body.replaceChildren(...steps.map((step, index) => {
- step.number.textContent = index + 1;
- step.up.disabled = index === 0; step.down.disabled = index === steps.length - 1;
- step.remove.disabled = steps.length === 1;
- return step.row;
- }));
- inputs.stepNo.max = String(steps.length);
- if (Number(inputs.stepNo.value) > steps.length) inputs.stepNo.value = steps.length;
- }
- function addStep(value) {
- const row = document.createElement('tr'); const number = document.createElement('td'); row.append(number);
- const name = field('工步名称', 'text', value.name).input; name.required = true; name.setAttribute('aria-label', '工步名称');
- const type = document.createElement('select'); type.setAttribute('aria-label', '工步类型');
- Object.entries(stepTypes).forEach(([code, label]) => type.add(new Option(label, code))); type.value = value.stepType;
- const duration = numericField('时长 (s)', value.durationSeconds).input; duration.min = '0'; duration.setAttribute('aria-label', '时长 (s)');
- const voltage = numericField('电压 (V)', value.voltageV).input; voltage.setAttribute('aria-label', '工步电压 (V)');
- for (const input of [name, type, duration, voltage]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); }
- const step = { row, number, name, type, duration, voltage };
- const move = delta => { const index = steps.indexOf(step); steps.splice(index, 1); steps.splice(index + delta, 0, step); syncRows(); };
- step.up = iconButton('↑', '上移工步', () => move(-1));
- step.down = iconButton('↓', '下移工步', () => move(1));
- step.remove = iconButton('×', '删除工步', () => { steps.splice(steps.indexOf(step), 1); syncRows(); });
- const actions = document.createElement('td'); const buttons = document.createElement('div'); buttons.className = 'row-actions';
- buttons.append(step.up, step.down, step.remove); actions.append(buttons); row.append(actions);
- steps.push(step); syncRows();
- }
- config.steps.forEach(addStep);
- // 初次填充方案行时保留保存过的当前工步位置。
- inputs.stepNo.value = config.stepNo;
- add.onclick = () => addStep({ name: '新工步', stepType: 'rest', durationSeconds: 60, voltageV: 0 });
- editorContent.append(fields, heading, scroll);
- saveEditor = () => request(`/devices/${deviceId}/channels/${channel.channelId}/configuration`, {
- method: 'PUT', body: JSON.stringify({
- ...Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, Number(input.value)])),
- steps: steps.map(step => ({ name: step.name.value, stepType: step.type.value,
- durationSeconds: Number(step.duration.value), voltageV: Number(step.voltage.value) })),
- }),
- });
- }
-
- function editCollectorChannel(collectorId, channel) {
- openEditor(`采集器 ${collectorId} · ${channel.channelCode} 测量值`);
- const add = document.createElement('button'); add.type = 'button'; add.textContent = '添加测量项';
- const scroll = document.createElement('div'); scroll.className = 'editor-scroll';
- const table = document.createElement('table'); table.style.minWidth = '560px';
- table.innerHTML = '<thead><tr><th>测量项</th><th>数值</th><th style="width:100px">单位</th><th style="width:50px">操作</th></tr></thead>';
- const body = document.createElement('tbody'); table.append(body); scroll.append(table);
- const metrics = [];
- function addMetric(item) {
- const row = document.createElement('tr');
- const kind = document.createElement('select'); kind.setAttribute('aria-label', '测量项');
- Object.entries(metricLabels).forEach(([code, label]) => kind.add(new Option(label, code)));
- kind.add(new Option('自定义', 'custom')); kind.value = metricLabels[item.metricCode] ? item.metricCode : 'custom';
- const code = field('自定义名称', 'text', item.metricCode).input; code.setAttribute('aria-label', '自定义名称');
- const value = numericField('数值', item.value).input; value.setAttribute('aria-label', '数值');
- const unit = field('单位', 'text', item.unit).input; unit.required = true; unit.setAttribute('aria-label', '单位');
- const updateKind = () => {
- code.hidden = kind.value !== 'custom'; code.required = !code.hidden;
- unit.readOnly = kind.value !== 'custom';
- if (kind.value !== 'custom') unit.value = kind.value === 'temperature' ? '℃' : 'V';
- };
- code.hidden = kind.value !== 'custom'; code.required = !code.hidden;
- kind.onchange = updateKind;
- const identity = document.createElement('td'); identity.append(kind, code); row.append(identity);
- for (const input of [value, unit]) { const cell = document.createElement('td'); cell.append(input); row.append(cell); }
- const metric = { row, kind, code, value, unit };
- const action = document.createElement('td');
- metric.remove = iconButton('×', '删除测量项', () => {
- metrics.splice(metrics.indexOf(metric), 1); row.remove(); syncButtons();
- });
- action.append(metric.remove); row.append(action); metrics.push(metric); body.append(row); syncButtons();
- }
- function syncButtons() { metrics.forEach(item => { item.remove.disabled = metrics.length === 1; }); }
- channel.metrics.forEach(addMetric);
- add.onclick = () => addMetric({ metricCode: '', value: 0, unit: 'V' });
- editorContent.append(add, scroll);
- saveEditor = () => {
- const values = metrics.map(item => ({ metricCode: item.kind.value === 'custom' ? item.code.value.trim() : item.kind.value,
- value: Number(item.value.value), unit: item.unit.value.trim() }));
- if (new Set(values.map(item => item.metricCode)).size !== values.length) throw new Error('测量项不能重复');
- return request(`/collectors/${collectorId}/channels/${channel.channelCode}`, {
- method: 'PUT', body: JSON.stringify({ metrics: values }),
- });
- };
- }
-
- function channelRow(deviceId, channel) {
- const row = document.createElement('tr');
- const identity = document.createElement('td');
- identity.textContent = `通道 ${channel.channelId}`;
- const onlineCell = document.createElement('td');
- const onlineLabel = document.createElement('label');
- onlineLabel.className = 'switch-label';
- const online = document.createElement('input');
- online.type = 'checkbox';
- online.checked = channel.online;
- const onlineText = document.createElement('span');
- onlineText.textContent = channel.online ? '在线' : '离线';
- onlineLabel.append(online, onlineText);
- onlineCell.append(onlineLabel);
- const statusCell = document.createElement('td');
- const status = document.createElement('select');
- Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
- status.value = channel.workStatus;
- status.setAttribute('aria-label', `检测柜 ${deviceId} 通道 ${channel.channelId} 工作状态`);
- const update = async () => {
- try { render(await request(`/devices/${deviceId}/channels/${channel.channelId}`, { method: 'PUT', body: JSON.stringify({ online: online.checked, workStatus: status.value }) })); }
- catch (error) { showError(error); await refresh(); }
- };
- online.addEventListener('change', update);
- status.addEventListener('change', update);
- statusCell.append(status);
- const configCell = document.createElement('td');
- const configure = document.createElement('button'); configure.type = 'button'; configure.textContent = '参数与方案';
- configure.onclick = () => editCabinetChannel(deviceId, channel);
- configCell.append(configure);
- row.append(identity, onlineCell, statusCell, configCell);
- return row;
- }
-
- function renderRequests(items) {
- const requests = document.querySelector('#requests');
- document.querySelector('#request-count').textContent = `${items.length} 条`;
- if (!items.length) {
- const row = document.createElement('tr');
- const cell = document.createElement('td');
- cell.colSpan = 3;
- cell.className = 'empty';
- cell.textContent = '暂无请求';
- row.append(cell);
- requests.replaceChildren(row);
- return;
- }
- requests.replaceChildren(...items.map(item => {
- const row = document.createElement('tr');
- const time = document.createElement('td');
- time.className = 'request-time';
- time.textContent = new Date(item.receivedAt).toLocaleString('zh-CN', { hour12: false });
- const command = document.createElement('td');
- command.className = 'request-command';
- command.textContent = item.command;
- const xml = document.createElement('td');
- xml.className = 'request-xml';
- xml.textContent = `${item.request}\n${item.response || '暂无响应'}`;
- xml.title = xml.textContent;
- row.append(time, command, xml);
- return row;
- }));
- }
-
- async function refresh() {
- if (editor.open) return;
- if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
- try { render(await request('/state')); } catch (error) { showError(error); }
- }
-
- document.getElementById('reset-simulator').addEventListener('click', async () => {
- if (!confirm('确认清空全部模拟配置和状态并恢复默认设备?')) return;
- try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); }
- });
-
- document.getElementById('software-accessible').addEventListener('change', async event => {
- try { render(await request('/software', { method: 'PUT', body: JSON.stringify({ accessible: event.target.checked }) })); }
- catch (error) { showError(error); }
- });
-
- document.getElementById('power-on').addEventListener('click', async () => {
- try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: true }) })); }
- catch (error) { showError(error); }
- });
-
- document.getElementById('power-off').addEventListener('click', async () => {
- if (!confirm('确认将全部模拟检测柜和采集器设为离线?')) return;
- try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); }
- catch (error) { showError(error); }
- });
-
- document.querySelector('#add-device').addEventListener('submit', async event => {
- event.preventDefault();
- try {
- render(await request('/devices', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-ip').value, channelCount: Number(document.querySelector('#new-channel-count').value), model: document.querySelector('#new-device-model').value || null }) }));
- } catch (error) { showError(error); }
- });
-
- document.querySelector('#add-collector').addEventListener('submit', async event => {
- event.preventDefault();
- try {
- render(await request('/collectors', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-collector-ip').value, channelCount: Number(document.querySelector('#new-collector-channel-count').value) }) }));
- } catch (error) { showError(error); }
- });
-
- refresh();
- setInterval(refresh, 2000);
- </script>
-</body>
-</html>
diff --git a/ruoyi-admin/src/test/java/com/battery/business/mapper/DeviceRelatedTestMapperTest.java b/ruoyi-admin/src/test/java/com/battery/business/mapper/DeviceRelatedTestMapperTest.java
index a6188d6..1f87f3d 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/mapper/DeviceRelatedTestMapperTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/mapper/DeviceRelatedTestMapperTest.java
@@ -61,6 +61,9 @@
sql.execute("insert into t_detection_cabinet(cabinet_id,cabinet_name,online_status) values (1,'Cabinet','OFFLINE'),(3,'Unused','ONLINE')");
sql.execute("create table t_collector(collector_id bigint primary key, collector_code varchar(64), collector_name varchar(100), last_seen_at_utc timestamp, " + common + ")");
sql.execute("insert into t_collector(collector_id,collector_name,online_status) values (1,'Collector','ONLINE'),(3,'Unused','OFFLINE')");
+ // 采集器在线状态由所链接来源实例的连接结果派生(ADR-0063/0064),所以这个表必须在场。
+ sql.execute("create table t_device_adapter(adapter_id bigint primary key, connection_status varchar(16))");
+ sql.execute("insert into t_device_adapter values (1,'ONLINE')");
}
}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentSimulatorProfileContractTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/DefaultSpringProfileContractTest.java
similarity index 69%
rename from ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentSimulatorProfileContractTest.java
rename to ruoyi-admin/src/test/java/com/battery/business/runtime/DefaultSpringProfileContractTest.java
index 0033d51..221c1d8 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentSimulatorProfileContractTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/DefaultSpringProfileContractTest.java
@@ -7,7 +7,11 @@
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.io.ClassPathResource;
-class DevelopmentSimulatorProfileContractTest
+/**
+ * 默认启动不连接任何设备:没有配置来源时装配无设备实现,真实来源按适配器实例逐行配置。
+ * 这里锁定配置文件里的默认 profile,避免有人在配置里提前打开设备连接。
+ */
+class DefaultSpringProfileContractTest
{
@Test
void usesNoDeviceProfileByDefault() throws Exception
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizerTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizerTest.java
deleted file mode 100644
index a68c9f9..0000000
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizerTest.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.battery.business.runtime;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.junit.jupiter.api.Test;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore;
-import com.battery.business.mapper.TCollectorMapper;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-
-class DevelopmentCollectorStatusSynchronizerTest
-{
- @Test
- void productionConstructorIsExplicitlyAutowired()
- {
- var constructors = DevelopmentCollectorStatusSynchronizer.class.getDeclaredConstructors();
- assertEquals(1, constructors.length);
- var production = java.util.Arrays.stream(constructors)
- .filter(item -> item.isAnnotationPresent(Autowired.class)).findFirst().orElseThrow();
- assertEquals(3, production.getParameterCount());
- }
- @Test
- void synchronizesOnlyConfiguredCollectorsWithoutRegisteringThem() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
- TCollectorMapper collectorMapper = mock(TCollectorMapper.class);
- DevelopmentCollectorStatusSynchronizer synchronizer =
- new DevelopmentCollectorStatusSynchronizer(simulator, store, collectorMapper);
- simulator.updateCollector(1, "127.0.1.2", 4, false);
-
- synchronizer.synchronizeOnlineStatus();
-
- assertEquals(false, store.snapshot(java.time.Instant.now()).collectors().get(0).online());
- verify(collectorMapper).updateRegisteredObservation("dev-sim-collector-1", "OFFLINE");
-
- simulator.deleteCollector(1);
- synchronizer.synchronizeOnlineStatus();
-
- assertEquals(0, store.snapshot(java.time.Instant.now()).collectors().size());
- }
- }
-}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java
deleted file mode 100644
index 99f5714..0000000
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.battery.business.runtime;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import org.junit.jupiter.api.Test;
-import org.springframework.boot.DefaultApplicationArguments;
-import org.mockito.ArgumentCaptor;
-import com.battery.device.api.registry.AdapterInstanceConfig;
-import com.battery.device.api.registry.DeviceAdapterRegistry;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-
-class DeviceAdapterRuntimeConfigTest
-{
- @Test
- void registersDevelopmentSimulatorInstanceBeforeApplicationReady() throws Exception
- {
- DeviceAdapterRegistry registry = mock(DeviceAdapterRegistry.class);
- BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
- var runner = new DevelopmentSimulatorConfig().registerDevelopmentSimulatorAdapter(registry, simulator, 15021);
-
- runner.run(new DefaultApplicationArguments(new String[0]));
-
- // 注册模拟器对象本身:发现与取数落在同一实例上,不再单独注入扩展观测 Bean。
- ArgumentCaptor<AdapterInstanceConfig> adapter = ArgumentCaptor.forClass(AdapterInstanceConfig.class);
- verify(registry).register(adapter.capture(), org.mockito.ArgumentMatchers.same(simulator));
- assertEquals("dev-sim", adapter.getValue().adapterCode());
- assertEquals("127.0.0.1", adapter.getValue().host());
- assertEquals(15021, adapter.getValue().port());
- assertEquals(5000, adapter.getValue().pollIntervalMs());
- }
-}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeNoopStartupTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeNoopStartupTest.java
index 91d338c..f3c364c 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeNoopStartupTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeNoopStartupTest.java
@@ -22,6 +22,7 @@
context.registerBean(TDeviceIngestRecordMapper.class, () -> mock(TDeviceIngestRecordMapper.class));
context.refresh();
DeviceAdapterRegistry registry = context.getBean(DeviceAdapterRegistry.class);
+ // 模拟器已整体拆除:它的运行时编码不再出现在注册表里。
assertFalse(registry.contains("dev-sim"));
registry.synchronize(new com.battery.device.api.registry.AdapterInstanceConfig(
"noop-check", "NOOP", "127.0.0.1", 1, 1000, "Asia/Hong_Kong",
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
deleted file mode 100644
index 8023b0d..0000000
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
+++ /dev/null
@@ -1,298 +0,0 @@
-package com.battery.business.runtime;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.clearInvocations;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicReference;
-import org.junit.jupiter.api.Test;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.data.redis.core.ListOperations;
-import org.springframework.data.redis.core.SessionCallback;
-import org.springframework.data.redis.core.SetOperations;
-import org.springframework.data.redis.core.ValueOperations;
-import com.alibaba.fastjson2.JSON;
-import com.alibaba.fastjson2.JSONObject;
-import com.battery.device.api.model.DeviceWorkStatus;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
-import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
-import com.battery.device.simulator.BtsSimulatorStateStore.CollectorState;
-import com.battery.device.simulator.BtsSimulatorStateStore.DeviceState;
-import com.battery.device.simulator.BtsSimulatorStateStore.SimulatorState;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
-import com.battery.device.api.model.ChannelIdentity;
-import com.ruoyi.framework.config.FastJson2JsonRedisSerializer;
-
-class RedisStateStoreTest
-{
- @Test
- void channelParametersAndPlanSurviveRedisRestartAndOnlineChanges() throws Exception
- {
- var stored = new AtomicReference<Object>();
- var configuration = new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
- new java.math.BigDecimal("-3.123456"), new java.math.BigDecimal("8.125"),
- new java.math.BigDecimal("7.2"), new java.math.BigDecimal("29.7"), 2, 2, 5, 120, 30,
- List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
- new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电 <验证>", "dc", 90, 3.0)));
- try (var simulator = new BtsDevelopmentSimulator(0, store(stored))) {
- simulator.configureChannel(1, 1, configuration);
- simulator.updateChannel(1, 1, false, DeviceWorkStatus.PAUSED);
- simulator.setAllDevicesPoweredOn(false);
- simulator.setSoftwareAccessible(false);
- }
- try (var simulator = new BtsDevelopmentSimulator(0, store(stored))) {
- assertEquals(configuration, simulator.snapshot().devices().get(0).channels().get(0).configuration());
- simulator.setSoftwareAccessible(true);
- simulator.setAllDevicesPoweredOn(true);
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
- var identity = simulator.discoverChannels().get(0).identity();
- assertEquals(-3.123456, simulator.latestCabinet(identity).currentA());
- assertEquals(8.125, simulator.latestCabinet(identity).voltageV());
- assertEquals("放电 <验证>", simulator.testPlan(identity).rows().get(1).stepName());
- assertEquals("进行中", simulator.testPlan(identity).rows().get(1).executionStatus());
- }
- }
-
- @Test
- void oldChannelStateReceivesDefaultConfigurationWithoutResettingIdentity()
- {
- var object = JSON.parseObject(JSON.toJSONString(state(7, "127.0.0.9")));
- object.getJSONArray("devices").getJSONObject(0).getJSONArray("channels").getJSONObject(0).remove("configuration");
- var restored = store(new AtomicReference<>(object.toJSONString())).loadOrInitialize(state(1, "127.0.0.2"));
- assertEquals(7, restored.devices().get(0).deviceId());
- assertEquals("127.0.0.9", restored.devices().get(0).ip());
- assertEquals(1, restored.devices().get(0).channels().get(0).configuration().steps().size());
- }
-
- @Test
- void initializesOnlyWhenStateIsAbsentAndRestoresSavedState()
- {
- AtomicReference<Object> stored = new AtomicReference<>();
- RedisBtsSimulatorStateStore store = store(stored);
- SimulatorState defaults = state(1, "127.0.0.2");
-
- assertEquals(defaults, store.loadOrInitialize(defaults));
- SimulatorState changed = state(7, "127.0.0.9");
- store.save(changed);
-
- assertEquals(changed, store.loadOrInitialize(defaults));
- }
-
- @Test
- void restoresNestedSimulatorStateFromRedisSerializer()
- {
- SimulatorState expected = new SimulatorState(1,
- List.of(new DeviceState(2, "127.0.0.3", false,
- List.of(new ChannelState(1, true, DeviceWorkStatus.WORKING)))),
- List.of(new CollectorState(3, "dev-sim-collector-3", "127.0.1.3",
- "02:43:00:00:00:03", "模拟采集器", "开发模拟器",
- "Virtual Collector", 3, true)),
- 3, 4, List.of(new ChannelTombstone(1, "127.0.0.2", 4, 2)), false, 900L);
- FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
- AtomicReference<Object> stored = new AtomicReference<>(
- serializer.deserialize(serializer.serialize(JSON.toJSONString(expected))));
-
- SimulatorState restored = store(stored).loadOrInitialize(state(1, "127.0.0.2"));
-
- assertEquals(expected, restored);
- assertEquals(DeviceWorkStatus.WORKING, restored.devices().get(0).channels().get(0).workStatus());
- }
-
- @Test
- void restoresChannelPlanWhenRedisReturnsNestedJsonObjects()
- {
- JSONObject state = JSON.parseObject(JSON.toJSONString(state(1, "127.0.0.2")));
- JSONObject configuration = state.getJSONArray("devices").getJSONObject(0)
- .getJSONArray("channels").getJSONObject(0).getJSONObject("configuration");
- configuration.getJSONArray("steps").forEach(item -> ((JSONObject) item).put("@type", JSONObject.class.getName()));
- FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
- Object restoredValue = serializer.deserialize(serializer.serialize(state));
-
- SimulatorState restored = store(new AtomicReference<>(restoredValue))
- .loadOrInitialize(state(9, "127.0.0.9"));
-
- assertEquals("恒流充电", restored.devices().get(0).channels().get(0)
- .configuration().steps().get(0).name());
- }
-
- @Test
- void upgradesLegacyCollectorDiscoveryStateWithSamplingDefaults()
- {
- AtomicReference<Object> stored = new AtomicReference<>("{\"version\":1,\"devices\":[],"
- + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\","
- + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\","
- + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\","
- + "\"model\":\"Virtual Collector\",\"channelCount\":2,\"online\":true}],"
- + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}");
-
- SimulatorState restored = store(stored).loadOrInitialize(state(1, "127.0.0.2"));
-
- CollectorState collector = restored.collectors().get(0);
- assertEquals(1000, collector.sampleIntervalMs());
- assertEquals(List.of("S-01", "S-02"),
- collector.channels().stream().map(channel -> channel.channelCode()).toList());
- }
-
- @Test
- void rejectsIncompatibleVersionWithoutOverwritingIt()
- {
- AtomicReference<Object> stored = new AtomicReference<>("{\"version\":2,\"devices\":[],"
- + "\"collectors\":[],\"nextDeviceId\":1,\"nextCollectorId\":1,\"tombstones\":[]}");
- RedisBtsSimulatorStateStore store = store(stored);
-
- IllegalStateException exception = assertThrows(IllegalStateException.class,
- () -> store.loadOrInitialize(state(1, "127.0.0.2")));
-
- assertEquals("Unsupported BTS simulator state version: 2", exception.getMessage());
- assertEquals(true, stored.get().toString().contains("\"version\":2"));
- }
-
- @Test
- void restoresNestedDiscoveryCandidatesFromJson()
- {
- AtomicReference<Object> stored = new AtomicReference<>();
- RedisDeviceDiscoveryCandidateStore first = new RedisDeviceDiscoveryCandidateStore(redis(stored));
- java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z");
- CabinetCandidate candidate = new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1,
- "127.0.0.2", null, "模拟柜", "开发模拟器", "BTS Virtual Cabinet",
- List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true,
- DeviceWorkStatus.WORKING, observedAt)), observedAt, observedAt.plusSeconds(30));
- first.recordCabinets(List.of(candidate));
-
- RedisDeviceDiscoveryCandidateStore restored = new RedisDeviceDiscoveryCandidateStore(redis(stored));
- CabinetCandidate actual = restored.snapshot(observedAt.plusSeconds(1)).cabinets().get(0);
-
- assertEquals("dev-sim-22-1", actual.deviceCode());
- assertEquals(DeviceWorkStatus.WORKING, actual.channels().get(0).workStatus());
- assertEquals(observedAt, actual.channels().get(0).observedAt());
- }
-
- @Test
- void removesDevelopmentCandidatesFromRedisState()
- {
- AtomicReference<Object> stored = new AtomicReference<>();
- RedisDeviceDiscoveryCandidateStore store = new RedisDeviceDiscoveryCandidateStore(redis(stored));
- java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z");
- store.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1,
- "127.0.0.2", null, "模拟柜", null, null,
- List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true,
- DeviceWorkStatus.STOPPED, observedAt)), observedAt, observedAt.plusSeconds(30))));
-
- store.removeAdapter("dev-sim");
-
- assertEquals(List.of(), store.snapshot(observedAt).cabinets());
- }
-
- @Test
- void restoresDiscoveryCandidatesFromLegacyAutoTypeJson()
- {
- AtomicReference<Object> stored = new AtomicReference<>();
- RedisDeviceDiscoveryCandidateStore first = new RedisDeviceDiscoveryCandidateStore(redis(stored));
- java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z");
- first.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1,
- "127.0.0.2", null, "模拟柜", null, null,
- List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true,
- DeviceWorkStatus.STOPPED, observedAt)), observedAt, observedAt.plusSeconds(30))));
- first.replaceCollectors("dev-sim", List.of(new CollectorCandidate("dev-sim", "dev-sim-collector-1",
- "127.0.1.2", null, "模拟采集器", null, null, 8, true,
- observedAt, observedAt.plusSeconds(30))));
-
- JSONObject legacy = JSON.parseObject(stored.get().toString());
- legacy.put("@type", "com.battery.business.runtime.RedisDeviceDiscoveryCandidateStore$StoredState");
- JSONObject cabinet = legacy.getJSONArray("cabinets").getJSONObject(0);
- cabinet.put("@type", JSONObject.class.getName());
- cabinet.getJSONArray("channels").getJSONObject(0)
- .put("@type", JSONObject.class.getName());
- legacy.getJSONArray("collectors").getJSONObject(0)
- .put("@type", JSONObject.class.getName());
- FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
- stored.set(serializer.deserialize(serializer.serialize(legacy)));
-
- var snapshot = new RedisDeviceDiscoveryCandidateStore(redis(stored)).snapshot(observedAt.plusSeconds(1));
- CabinetCandidate restored = snapshot.cabinets().get(0);
-
- assertEquals("dev-sim-22-1", restored.deviceCode());
- assertEquals(DeviceWorkStatus.STOPPED, restored.channels().get(0).workStatus());
- assertEquals("dev-sim-collector-1", snapshot.collectors().get(0).deviceCode());
- }
-
- @SuppressWarnings({ "unchecked", "rawtypes" })
- private RedisBtsSimulatorStateStore store(AtomicReference<Object> stored)
- {
- return new RedisBtsSimulatorStateStore(redis(stored));
- }
-
- @SuppressWarnings({ "unchecked", "rawtypes" })
- private RedisTemplate<Object, Object> redis(AtomicReference<Object> stored)
- {
- RedisTemplate<Object, Object> redis = mock(RedisTemplate.class);
- ValueOperations<Object, Object> values = mock(ValueOperations.class);
- ListOperations<Object, Object> lists = mock(ListOperations.class);
- SetOperations<Object, Object> sets = mock(SetOperations.class);
- Map<Object, List<Object>> listValues = new LinkedHashMap<>();
- when(redis.opsForValue()).thenReturn(values);
- when(redis.opsForList()).thenReturn(lists);
- when(redis.opsForSet()).thenReturn(sets);
- when(sets.add(any(), any())).thenReturn(1L);
- when(sets.members(any())).thenReturn(Set.of());
- when(redis.exec()).thenReturn(List.of(Boolean.TRUE));
- when(redis.execute(any(SessionCallback.class))).thenAnswer(invocation -> {
- SessionCallback<?> callback = invocation.getArgument(0);
- return callback.execute(redis);
- });
- when(lists.rightPushAll(any(), org.mockito.ArgumentMatchers.<Object>anyCollection()))
- .thenAnswer(invocation -> {
- Object key = invocation.getArgument(0);
- List<Object> records = new ArrayList<>(listValues.getOrDefault(key, List.of()));
- FastJson2JsonRedisSerializer<Object> serializer =
- new FastJson2JsonRedisSerializer<>(Object.class);
- List<Object> appended = invocation.getArgument(1);
- appended.forEach(item -> records.add(
- serializer.deserialize(serializer.serialize(item))));
- listValues.put(key, records);
- return (long) records.size();
- });
- when(lists.range(any(), anyLong(), anyLong())).thenAnswer(invocation -> {
- List<Object> records = listValues.getOrDefault(invocation.getArgument(0), List.of());
- long requestedStart = invocation.getArgument(1);
- long requestedEnd = invocation.getArgument(2);
- int start = (int) Math.min(requestedStart, records.size());
- int end = (int) Math.min(requestedEnd + 1, records.size());
- return start >= end ? List.of() : List.copyOf(records.subList(start, end));
- });
- when(lists.size(any())).thenAnswer(invocation ->
- (long) listValues.getOrDefault(invocation.getArgument(0), List.of()).size());
- when(redis.delete(org.mockito.ArgumentMatchers.<Object>any()))
- .thenAnswer(invocation -> listValues.remove(invocation.getArgument(0)) != null);
- when(values.get(any())).thenAnswer(invocation -> stored.get());
- when(values.setIfAbsent(any(), any())).thenAnswer(invocation -> {
- if (stored.get() != null) return false;
- stored.set(invocation.getArgument(1));
- return true;
- });
- doAnswer(invocation -> { stored.set(invocation.getArgument(1)); return null; })
- .when(values).set(any(), any());
- return redis;
- }
-
- private SimulatorState state(int deviceId, String ip)
- {
- return new SimulatorState(1,
- List.of(new DeviceState(deviceId, ip, true,
- List.of(new ChannelState(1, true, DeviceWorkStatus.STOPPED)))),
- List.of(), deviceId + 1, 1, List.of());
- }
-}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java
index 643bb07..ce0ed9b 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java
@@ -52,8 +52,8 @@
"neware76", "{\"control\":false}", "{\"database\":\"bts63\",\"user\":\"visitor\"}");
assertEquals(1, jdbc.queryForObject(
"select count(*) from t_device_adapter where source_config is not null", Integer.class));
- // 代码装配实例(例如开发模拟器)保持为空。
- jdbc.update("insert into t_device_adapter(adapter_code, capabilities) values(?,?)", "dev-sim",
+ // 没有来源形态配置的行(列可空)仍允许存在。
+ jdbc.update("insert into t_device_adapter(adapter_code, capabilities) values(?,?)", "legacy-row",
"{\"control\":false}");
assertEquals(1, jdbc.queryForObject(
"select count(*) from t_device_adapter where source_config is null", Integer.class));
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/CollectorDiscoveryServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/CollectorDiscoveryServiceImplTest.java
deleted file mode 100644
index c42ac66..0000000
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/CollectorDiscoveryServiceImplTest.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.battery.business.service.impl;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-import java.util.List;
-import java.util.Set;
-import org.junit.jupiter.api.Test;
-import com.battery.business.runtime.DevelopmentCollectorDiscoveryService;
-import com.battery.business.runtime.DevelopmentCollectorStatusSynchronizer;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore;
-import com.battery.business.domain.vo.DiscoveredCollectorVo;
-import com.battery.business.mapper.TCollectorMapper;
-import com.battery.business.service.ITCollectorService;
-
-/**
- * 采集器设备发现服务测试。
- */
-public class CollectorDiscoveryServiceImplTest
-{
- @Test
- public void shouldReturnCollectorsWithoutMixingCabinets() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- ITCollectorService collectorService = mock(ITCollectorService.class);
- when(collectorService.selectRegisteredCollectorCodes()).thenReturn(Set.of());
- InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
- DevelopmentCollectorDiscoveryService service =
- new DevelopmentCollectorDiscoveryService(store, collectorService);
- TCollectorMapper collectorMapper = mock(TCollectorMapper.class);
- simulator.addDevice("127.0.0.8", 2);
- simulator.addCollector("127.0.1.3", 6);
- new DevelopmentCollectorStatusSynchronizer(simulator, store, collectorMapper).synchronizeOnlineStatus();
-
- List<DiscoveredCollectorVo> result = service.selectDiscoveredCollectorList();
-
- assertEquals(2, result.size());
- DiscoveredCollectorVo first = result.get(0);
- assertEquals("dev-sim-collector-1", first.getDeviceCode());
- assertEquals("127.0.1.2", first.getIp());
- assertEquals("02:43:00:00:00:01", first.getMac());
- assertEquals("dev-sim 采集器 1", first.getName());
- assertEquals("开发模拟器", first.getManufacturer());
- assertEquals("Virtual Collector", first.getModel());
- assertEquals(4, first.getChannelCount());
- assertEquals("在线", first.getOnlineStatus());
- }
- }
-
- @Test
- public void shouldReturnOfflineUnregisteredCollectors() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- ITCollectorService collectorService = mock(ITCollectorService.class);
- when(collectorService.selectRegisteredCollectorCodes()).thenReturn(Set.of());
- InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
- DevelopmentCollectorDiscoveryService service =
- new DevelopmentCollectorDiscoveryService(store, collectorService);
- TCollectorMapper collectorMapper = mock(TCollectorMapper.class);
- simulator.updateCollector(1, "127.0.1.9", 8, false);
- new DevelopmentCollectorStatusSynchronizer(simulator, store, collectorMapper).synchronizeOnlineStatus();
-
- List<DiscoveredCollectorVo> result = service.selectDiscoveredCollectorList();
-
- assertEquals(1, result.size());
- assertEquals("dev-sim-collector-1", result.get(0).getDeviceCode());
- assertEquals("离线", result.get(0).getOnlineStatus());
- }
- }
-
- @Test
- public void shouldExcludeRegisteredCollectorAndReturnItAfterDeletion() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- ITCollectorService collectorService = mock(ITCollectorService.class);
- InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
- DevelopmentCollectorDiscoveryService service =
- new DevelopmentCollectorDiscoveryService(store, collectorService);
- TCollectorMapper collectorMapper = mock(TCollectorMapper.class);
- simulator.updateCollector(1, "127.0.1.2", 4, false);
- new DevelopmentCollectorStatusSynchronizer(simulator, store, collectorMapper).synchronizeOnlineStatus();
- when(collectorService.selectRegisteredCollectorCodes())
- .thenReturn(Set.of("dev-sim-collector-1"))
- .thenReturn(Set.of());
-
- assertEquals(0, service.selectDiscoveredCollectorList().size());
- assertEquals(1, service.selectDiscoveredCollectorList().size());
- }
- }
-}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
index a68bc03..09e335e 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
@@ -19,34 +19,45 @@
class DeviceDiscoveryPersistenceServiceTest
{
+ /**
+ * 来源不可达时,既有候选保留来源身份但旧快照被撤下、在线转为离线,登记设备同步离线;
+ * 来源恢复后下一次成功发现重新带回实时事实。
+ */
@Test
- void failedDiscoveryInvalidatesSuccessfulSnapshotUntilSoftwareReturns() throws Exception
+ void failedDiscoveryInvalidatesSuccessfulSnapshotUntilSourceReturns()
{
- try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0);
- var registry = new DeviceAdapterRegistry(List.of(new com.battery.device.bts.BtsDeviceAdapterFactory())))
- {
- registry.synchronize(new com.battery.device.api.registry.AdapterInstanceConfig("dev-sim", "BTS",
- "127.0.0.1", simulator.port(), 5000, "Asia/Hong_Kong", true,
- com.battery.device.api.model.AdapterCapabilities.observeOnly()));
- var store = new InMemoryDeviceDiscoveryCandidateStore();
- var mapper = Mockito.mock(TDetectionCabinetMapper.class);
- var persistence = new DeviceDiscoveryPersistenceService(store, mapper);
- var scheduler = new com.battery.business.runtime.DevelopmentSimulatorDiscoveryScheduler(registry, persistence);
- simulator.updateChannel(1, 1, true, com.battery.device.api.model.DeviceWorkStatus.WORKING);
- scheduler.discover();
- var before = store.snapshot(java.time.Instant.now()).cabinets().get(0);
- org.junit.jupiter.api.Assertions.assertNotNull(before.channels().get(0).latestSnapshot());
- simulator.setSoftwareAccessible(false);
- scheduler.discover();
- var failed = store.snapshot(java.time.Instant.now()).cabinets().get(0);
- assertEquals(before.deviceCode(), failed.deviceCode());
- org.junit.jupiter.api.Assertions.assertFalse(failed.online());
- org.junit.jupiter.api.Assertions.assertNull(failed.channels().get(0).latestSnapshot());
- verify(mapper).markRegisteredCabinetsOffline("dev-sim");
- simulator.setSoftwareAccessible(true);
- scheduler.discover();
- org.junit.jupiter.api.Assertions.assertTrue(store.snapshot(java.time.Instant.now()).cabinets().get(0).online());
- }
+ InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+ TDetectionCabinetMapper cabinetMapper = Mockito.mock(TDetectionCabinetMapper.class);
+ DeviceAdapterRegistry registry = Mockito.mock(DeviceAdapterRegistry.class);
+ DeviceAdapter adapter = Mockito.mock(DeviceAdapter.class);
+ ChannelIdentity identity = new ChannelIdentity("neware76", 21, 36, 1, 2);
+ java.time.Instant observedAt = java.time.Instant.parse("2026-09-28T01:54:56Z");
+ com.battery.device.api.model.RealtimeSnapshot snapshot = new com.battery.device.api.model.RealtimeSnapshot(
+ identity, com.battery.device.api.model.DeviceWorkStatus.WORKING, 3, 7, "1", null,
+ new java.math.BigDecimal("1.2"), new java.math.BigDecimal("3.3"),
+ new java.math.BigDecimal("0.5"), new java.math.BigDecimal("1.1"), 60L, 30L, null, observedAt, java.util.Map.of());
+ when(adapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "26.1.2.3", true)));
+ when(adapter.inquire(any())).thenReturn(java.util.Map.of(identity, snapshot));
+ when(registry.execute(eq("neware76"), any())).thenAnswer(invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<DeviceAdapter, Object> operation = (Function<DeviceAdapter, Object>) invocation.getArgument(1);
+ return operation.apply(adapter);
+ });
+ DeviceDiscoveryPersistenceService service = new DeviceDiscoveryPersistenceService(store, cabinetMapper);
+ service.discoverForReception(registry, new DeviceDiscoveryPersistenceService.DiscoverySource(
+ "neware76", 8L, "新威 7.6 那台", 60000, true));
+
+ var before = store.snapshot(java.time.Instant.now()).cabinets().get(0);
+ org.junit.jupiter.api.Assertions.assertNotNull(before.channels().get(0).latestSnapshot());
+ org.junit.jupiter.api.Assertions.assertTrue(before.online());
+
+ service.markUnavailable("neware76");
+
+ var failed = store.snapshot(java.time.Instant.now()).cabinets().get(0);
+ assertEquals(before.deviceCode(), failed.deviceCode());
+ org.junit.jupiter.api.Assertions.assertFalse(failed.online());
+ org.junit.jupiter.api.Assertions.assertNull(failed.channels().get(0).latestSnapshot());
+ verify(cabinetMapper).markRegisteredCabinetsOffline("neware76");
}
@Test
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java
index 3b36194..5bee422 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java
@@ -8,8 +8,8 @@
import com.battery.device.api.TestDataObservationAdapter;
/**
- * 取数入口的装配契约:开发模拟器既是 Spring Bean,又实现了测试数据观测契约,
- * 所以业务侧注入的那个 Bean 必须显式声明为首选,否则上下文里出现两个候选会启动失败。
+ * 取数入口的装配契约:业务侧只注入一个测试数据观测 Bean,各来源实例由适配器注册表在运行时提供,
+ * 所以这个入口必须显式声明为首选,否则上下文里出现两个候选会启动失败。
* 这里断言的是"哪个 Bean 会被注入"这个结构性事实,而不是 Spring 内部行为。
*/
class RoutingAdapterWiringContractTest
@@ -22,12 +22,5 @@
"取数入口必须是可注入的 Bean");
assertTrue(RoutingTestDataObservationAdapter.class.isAnnotationPresent(Primary.class),
"来源实例自己也实现该契约时必须显式首选路由入口,否则注入出现两个候选");
- }
-
- @Test
- void developmentSimulatorAlsoExposesTheContractWhichIsWhyPreferenceIsNeeded()
- {
- assertTrue(TestDataObservationAdapter.class
- .isAssignableFrom(com.battery.device.simulator.BtsDevelopmentSimulator.class));
}
}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TCollectorServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TCollectorServiceImplTest.java
index 3aef6e8..f132f1f 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TCollectorServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TCollectorServiceImplTest.java
@@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -24,12 +25,15 @@
import org.mockito.junit.jupiter.MockitoExtension;
import com.ruoyi.common.exception.ServiceException;
import com.battery.business.domain.TCollector;
+import com.battery.business.domain.TDeviceAdapter;
import com.battery.business.mapper.TCollectorMapper;
-import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
+import com.battery.business.mapper.TDeviceAdapterMapper;
/**
* 采集器 Service 单元测试。
+ *
+ * <p>采集器按 ADR-0064 不建独立设备模型:登记不再依赖发现候选(真实来源不产出采集器候选),
+ * 由操作员手工登记来源身份,在线状态取所链接来源实例的连接结果。
*/
@ExtendWith(MockitoExtension.class)
class TCollectorServiceImplTest
@@ -37,20 +41,17 @@
@Mock
private TCollectorMapper collectorMapper;
+ @Mock
+ private TDeviceAdapterMapper adapterMapper;
+
private TCollectorServiceImpl collectorService;
- private InMemoryDeviceDiscoveryCandidateStore candidateStore;
private TCollector collector;
@BeforeEach
void setUp()
{
- candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
- collectorService = new TCollectorServiceImpl(collectorMapper, candidateStore);
- java.time.Instant now = java.time.Instant.now();
- candidateStore.replaceCollectors("dev-sim", java.util.List.of(new CollectorCandidate(
- "dev-sim", "COL-001", "192.168.1.10", "AA:BB:CC:DD:EE:FF", "一号采集器",
- "开发模拟器", "Virtual Collector", 8, true, now, now.plusSeconds(60))));
+ collectorService = new TCollectorServiceImpl(collectorMapper, adapterMapper);
collector = new TCollector();
collector.setCollectorId(1L);
collector.setCollectorCode("COL-001");
@@ -80,21 +81,52 @@
}
}
+ /** 在线事实只有一个来源:所链接来源实例的连接结果;登记表里的存储列不再充当这个事实。 */
@Test
- void registeredCollectorCodeSqlShouldIgnoreDeletedRows() throws IOException
+ void onlineStatusSqlShouldDeriveFromLinkedAdapter() throws IOException
{
try (InputStream input = getClass().getResourceAsStream(
"/mapper/battery/TCollectorMapper.xml"))
{
assertNotNull(input);
String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
- int selectStart = mapperXml.indexOf("<select id=\"selectRegisteredCollectorCodes\"");
- assertTrue(selectStart >= 0);
- int selectEnd = mapperXml.indexOf("</select>", selectStart);
- String selectSql = mapperXml.substring(selectStart, selectEnd);
-
- assertTrue(selectSql.contains("where del_flag = '0'"));
+ int fragmentStart = mapperXml.indexOf("<sql id=\"derivedOnlineStatus\"");
+ assertTrue(fragmentStart >= 0, "在线状态的派生表达式应当是一个共用片段");
+ String fragment = mapperXml.substring(fragmentStart, mapperXml.indexOf("</sql>", fragmentStart));
+ assertTrue(fragment.contains("from t_device_adapter adapter_fact"), fragment);
+ // 存储列不再参与:只有派生的那个表达式。
+ assertFalse(mapperXml.contains("upper(c.online_status)"));
+ // SELECT 与 WHERE 各用一次(WHERE 里不能用别名,所以走同一个片段)。
+ assertEquals(2, mapperXml.split("<include refid=\"derivedOnlineStatus\" />", -1).length - 1);
}
+ }
+
+ /** 登记不再要求发现候选:手工填写的来源身份即可落库。 */
+ @Test
+ void insertCollectorShouldNotRequireADiscoveryCandidate()
+ {
+ collector.setCollectorId(null);
+ when(collectorMapper.insertCollector(any(TCollector.class))).thenReturn(1);
+
+ assertEquals(1, collectorService.insertCollector(collector));
+
+ verify(collectorMapper).insertCollector(any(TCollector.class));
+ }
+
+ @Test
+ void insertCollectorShouldAcceptCollectorWithoutIpAndMac()
+ {
+ collector.setCollectorId(null);
+ collector.setIpAddress(null);
+ collector.setMacAddress(null);
+ when(collectorMapper.insertCollector(any(TCollector.class))).thenReturn(1);
+
+ assertEquals(1, collectorService.insertCollector(collector));
+
+ ArgumentCaptor<TCollector> captor = ArgumentCaptor.forClass(TCollector.class);
+ verify(collectorMapper).insertCollector(captor.capture());
+ assertNull(captor.getValue().getIpAddress());
+ assertNull(captor.getValue().getMacAddress());
}
@Test
@@ -117,6 +149,55 @@
assertEquals("0", saved.getTestStatus());
assertEquals("0", saved.getStatus());
assertEquals("0", saved.getDelFlag());
+ }
+
+ @Test
+ void insertCollectorShouldRejectMalformedIpAndMacInsteadOfGuessing()
+ {
+ collector.setCollectorId(null);
+ collector.setIpAddress("invalid-host");
+ collector.setMacAddress("AA:BB:CC:DD:EE:FF");
+
+ ServiceException ip = assertThrows(ServiceException.class,
+ () -> collectorService.insertCollector(collector));
+ assertEquals("IP地址格式不正确", ip.getMessage());
+
+ collector.setIpAddress("192.168.1.10");
+ collector.setMacAddress("invalid-mac");
+ ServiceException mac = assertThrows(ServiceException.class,
+ () -> collectorService.insertCollector(collector));
+ assertEquals("MAC地址格式不正确", mac.getMessage());
+ verify(collectorMapper, never()).insertCollector(any(TCollector.class));
+ }
+
+ @Test
+ void insertCollectorShouldRejectUnknownLinkedSource()
+ {
+ collector.setCollectorId(null);
+ collector.setAdapterId(9L);
+ when(adapterMapper.selectDeviceAdapterById(9L)).thenReturn(null);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> collectorService.insertCollector(collector));
+
+ assertEquals("所链接的数据来源不存在", exception.getMessage());
+ verify(collectorMapper, never()).insertCollector(any(TCollector.class));
+ }
+
+ /** 链接到来源实例后,登记保存该链接:采集器的辅助测量由它提供。 */
+ @Test
+ void insertCollectorShouldPersistLinkedSource()
+ {
+ collector.setCollectorId(null);
+ collector.setAdapterId(3L);
+ when(adapterMapper.selectDeviceAdapterById(3L)).thenReturn(new TDeviceAdapter());
+ when(collectorMapper.insertCollector(any(TCollector.class))).thenReturn(1);
+
+ assertEquals(1, collectorService.insertCollector(collector));
+
+ ArgumentCaptor<TCollector> captor = ArgumentCaptor.forClass(TCollector.class);
+ verify(collectorMapper).insertCollector(captor.capture());
+ assertEquals(Long.valueOf(3L), captor.getValue().getAdapterId());
}
@Test
@@ -148,36 +229,6 @@
}
@Test
- void insertCollectorShouldValidateAuthoritativeCandidateTopology()
- {
- collector.setCollectorId(null);
- collector.setIpAddress("invalid-host");
- collector.setMacAddress("invalid-mac");
- collector.setChannelCount(99);
- when(collectorMapper.insertCollector(any(TCollector.class))).thenReturn(1);
-
- assertEquals(1, collectorService.insertCollector(collector));
-
- ArgumentCaptor<TCollector> captor = ArgumentCaptor.forClass(TCollector.class);
- verify(collectorMapper).insertCollector(captor.capture());
- assertEquals("192.168.1.10", captor.getValue().getIpAddress());
- assertEquals("AA:BB:CC:DD:EE:FF", captor.getValue().getMacAddress());
- assertEquals(8, captor.getValue().getChannelCount());
- }
-
- @Test
- void offlineCollectorCanBeRegisteredWithoutChangingOnlineFact()
- {
- java.time.Instant now = java.time.Instant.now();
- candidateStore.replaceCollectors("dev-sim", java.util.List.of(new CollectorCandidate(
- "dev-sim", "COL-001", "192.168.1.10", "AA:BB:CC:DD:EE:FF", "一号采集器",
- "开发模拟器", "Virtual Collector", 8, false, now, now.plusSeconds(60))));
- when(collectorMapper.insertCollector(any(TCollector.class))).thenReturn(1);
- assertEquals(1, collectorService.insertCollector(collector));
- verify(collectorMapper).updateRegisteredObservation("COL-001", "OFFLINE");
- }
-
- @Test
void insertCollectorShouldRejectDuplicateCode()
{
collector.setCollectorId(null);
@@ -203,6 +254,27 @@
assertEquals(1, collectorService.updateCollector(collector));
verify(collectorMapper).updateCollector(collector);
+ }
+
+ /** 编辑只提交可改字段:未提交的 IP/MAC 不能被当成"清空"。 */
+ @Test
+ void updateCollectorShouldKeepUnsubmittedIpAndMac()
+ {
+ when(collectorMapper.selectCollectorById(1L)).thenReturn(collector);
+ when(collectorMapper.selectCollectorByCode("COL-001")).thenReturn(collector);
+ when(collectorMapper.selectCollectorByMacAddress("AA:BB:CC:DD:EE:FF")).thenReturn(collector);
+ when(collectorMapper.updateCollector(any(TCollector.class))).thenReturn(1);
+ TCollector edit = new TCollector();
+ edit.setCollectorId(1L);
+ edit.setCollectorName("改名后");
+
+ assertEquals(1, collectorService.updateCollector(edit));
+
+ ArgumentCaptor<TCollector> captor = ArgumentCaptor.forClass(TCollector.class);
+ verify(collectorMapper).updateCollector(captor.capture());
+ assertEquals("192.168.1.10", captor.getValue().getIpAddress());
+ assertEquals("AA:BB:CC:DD:EE:FF", captor.getValue().getMacAddress());
+ assertEquals("改名后", captor.getValue().getCollectorName());
}
@Test
@@ -256,14 +328,6 @@
assertEquals(1, collectorService.updateCollectorStatus(command));
verify(collectorMapper, never()).updateCollectorStatus(any(TCollector.class));
- }
-
- @Test
- void selectRegisteredCollectorCodesShouldReturnUndeletedCodes()
- {
- when(collectorMapper.selectRegisteredCollectorCodes()).thenReturn(java.util.List.of("COL-001"));
-
- assertEquals(java.util.Set.of("COL-001"), collectorService.selectRegisteredCollectorCodes());
}
@Test
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
index c216676..3178319 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
@@ -61,7 +61,7 @@
var mapper = mock(TTestMapper.class); when(mapper.selectCollectorCode(7L)).thenReturn("collector-7");
var adapter = mock(TestDataObservationAdapter.class);
var service = new TTestLiveServiceImpl(tests,
- new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, mapper);
+ new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, mapper, null);
test.setReceptionBoundaryAtUtc(java.util.Date.from(now));
for (Instant deviceTime : List.of(now.minusSeconds(1), now)) {
var measurement = new TestDataObservationAdapter.CollectorMeasurement("S-01", 1L, deviceTime, deviceTime.toString(),
@@ -77,68 +77,74 @@
@Test
void currentTemperatureAlarmIsIndependentOfHistoricalTodoHandling() throws Exception
{
- try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0)) {
- TTest test = test("RECEIVING");
- var battery = test.getBatteries().get(0); battery.setCollectorId(7L);
- var primary = new com.battery.business.domain.TTestCollectorChannel();
- primary.setChannelCode("S-01"); primary.setMeasurementCode("PRIMARY_TEMPERATURE");
- var secondary = new com.battery.business.domain.TTestCollectorChannel();
- secondary.setChannelCode("S-03"); secondary.setMeasurementCode("SECONDARY_TEMPERATURE_2");
- battery.setCollectorChannels(List.of(primary, secondary));
- var rule = new com.battery.business.domain.TTestWarningThreshold();
- rule.setThresholdId(11L); rule.setMetricCode("TEMPERATURE");
- rule.setMinValue(new BigDecimal("8")); rule.setMaxValue(new BigDecimal("33"));
- test.setThresholds(List.of(rule));
- var tests = mock(ITTestService.class); when(tests.selectTestById(10L)).thenReturn(test);
- var mapper = mock(TTestMapper.class); when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1");
- var service = new TTestLiveServiceImpl(tests,
- new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), simulator, mapper);
- simulator.configureCollectorChannel(1, "S-01", List.of(
- new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState("temperature", new BigDecimal("38"), "℃")));
- var alarms = service.current(10L).exceptions().stream().filter(item -> "THRESHOLD".equals(item.get("exceptionType"))).toList();
- assertEquals(1, alarms.size());
- assertEquals(new BigDecimal("38"), alarms.get(0).get("value"));
- assertEquals("7:S-01", alarms.get(0).get("sourceIdentity"));
- assertFalse(alarms.get(0).containsKey("exceptionId"));
+ TTest test = test("RECEIVING");
+ var battery = test.getBatteries().get(0); battery.setCollectorId(7L);
+ var primary = new com.battery.business.domain.TTestCollectorChannel();
+ primary.setChannelCode("S-01"); primary.setMeasurementCode("PRIMARY_TEMPERATURE");
+ var secondary = new com.battery.business.domain.TTestCollectorChannel();
+ secondary.setChannelCode("S-03"); secondary.setMeasurementCode("SECONDARY_TEMPERATURE_2");
+ battery.setCollectorChannels(List.of(primary, secondary));
+ var rule = new com.battery.business.domain.TTestWarningThreshold();
+ rule.setThresholdId(11L); rule.setMetricCode("TEMPERATURE");
+ rule.setMinValue(new BigDecimal("8")); rule.setMaxValue(new BigDecimal("33"));
+ test.setThresholds(List.of(rule));
+ var tests = mock(ITTestService.class); when(tests.selectTestById(10L)).thenReturn(test);
+ var mapper = mock(TTestMapper.class); when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1");
+ var adapter = mock(TestDataObservationAdapter.class);
+ Instant now = Instant.now();
+ // 采集器来源交出主、辅助两路测量:只有绑定了的 S-01 主温度进预警。
+ when(adapter.latestCollector(org.mockito.ArgumentMatchers.nullable(String.class),
+ org.mockito.ArgumentMatchers.eq("dev-sim-collector-1"))).thenReturn(collectorRealtime(now, "38"));
+ var service = new TTestLiveServiceImpl(tests,
+ new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, mapper, null);
+ var alarms = service.current(10L).exceptions().stream().filter(item -> "THRESHOLD".equals(item.get("exceptionType"))).toList();
+ assertEquals(1, alarms.size());
+ assertEquals(new BigDecimal("38"), alarms.get(0).get("value"));
+ assertEquals("7:S-01", alarms.get(0).get("sourceIdentity"));
+ assertFalse(alarms.get(0).containsKey("exceptionId"));
- var history = new TTestWarningException(); history.setExceptionStatus("OPEN");
- history.setExceptionType("THRESHOLD"); history.setMetricCode("temperature");
- history.setAbnormalValue(new BigDecimal("38"));
- when(mapper.selectWarningExceptions(10L, "OPEN")).thenReturn(List.of(history));
- simulator.configureCollectorChannel(1, "S-01", List.of(
- new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState("temperature", new BigDecimal("25"), "℃")));
- assertTrue(service.current(10L).exceptions().stream().noneMatch(item -> "THRESHOLD".equals(item.get("exceptionType"))));
- assertEquals("OPEN", history.getExceptionStatus());
- }
+ var history = new TTestWarningException(); history.setExceptionStatus("OPEN");
+ history.setExceptionType("THRESHOLD"); history.setMetricCode("temperature");
+ history.setAbnormalValue(new BigDecimal("38"));
+ when(mapper.selectWarningExceptions(10L, "OPEN")).thenReturn(List.of(history));
+ when(adapter.latestCollector(org.mockito.ArgumentMatchers.nullable(String.class),
+ org.mockito.ArgumentMatchers.eq("dev-sim-collector-1"))).thenReturn(collectorRealtime(now, "25"));
+ assertTrue(service.current(10L).exceptions().stream().noneMatch(item -> "THRESHOLD".equals(item.get("exceptionType"))));
+ assertEquals("OPEN", history.getExceptionStatus());
+ }
+
+ private static TestDataObservationAdapter.CollectorRealtime collectorRealtime(Instant now, String temperature)
+ {
+ return new TestDataObservationAdapter.CollectorRealtime("dev-sim-collector-1", true, 1000,
+ List.of(new TestDataObservationAdapter.CollectorMeasurement("S-01", 1L, now, now.toString(), now,
+ "TEMPERATURE", new BigDecimal(temperature), "℃")), null);
}
@Test
- void consoleReadsConfiguredSimulatorPlanAtCurrentChannelPosition() throws Exception
+ void consoleReadsConfiguredPlanAtCurrentChannelPosition() throws Exception
{
- try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0)) {
- var test = test("RECEIVING");
- var binding = test.getBatteries().get(0).getBinding();
- binding.setDevtype(22); binding.setSubdevid(1);
- simulator.configureChannel(1, 1, new com.battery.device.simulator.BtsSimulatorStateStore.ChannelConfiguration(
- BigDecimal.ONE, new BigDecimal("4.125"), BigDecimal.ONE, BigDecimal.ONE, 2, 2, 3, 120, 30,
- List.of(new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("充电", "cc", 60, 4.2),
- new com.battery.device.simulator.BtsSimulatorStateStore.SimulatorPlanStep("放电", "dc", 90, 3.0))));
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
- var tests = mock(ITTestService.class);
- when(tests.selectTestById(10L)).thenReturn(test);
- var service = new TTestLiveServiceImpl(tests,
- new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), simulator);
- var plan = service.current(10L).testPlan();
- assertEquals(2, plan.currentCycleNo());
- assertEquals(2, plan.currentStepNo());
- assertEquals(3, plan.plannedCycleCount());
- assertEquals(List.of(1, 2), plan.rows().stream().map(row -> row.cyclePosition()).toList());
- assertEquals("放电", plan.rows().get(1).stepName());
- assertEquals(90d, plan.rows().get(1).durationSeconds());
- assertEquals("进行中", plan.rows().get(1).executionStatus());
- simulator.updateChannel(1, 1, false, DeviceWorkStatus.WORKING);
- org.junit.jupiter.api.Assertions.assertNull(service.current(10L).testPlan());
- }
+ var test = test("RECEIVING");
+ var binding = test.getBatteries().get(0).getBinding();
+ binding.setDevtype(22); binding.setSubdevid(1);
+ var tests = mock(ITTestService.class);
+ when(tests.selectTestById(10L)).thenReturn(test);
+ var adapter = mock(TestDataObservationAdapter.class);
+ // 来源交出它自己的方案:当前循环 2、当前工步 2、计划 3 个循环,工步行按循环位置排列。
+ when(adapter.testPlan(any(ChannelIdentity.class))).thenReturn(new TestDataObservationAdapter.TestPlan(2, 2, 3,
+ List.of(new TestDataObservationAdapter.PlanRow("STEP", 1, 1, "充电", 60, 4.2, "已完成", null, null),
+ new TestDataObservationAdapter.PlanRow("STEP", 2, 2, "放电", 90, 3.0, "进行中", null, null))));
+ var service = new TTestLiveServiceImpl(tests,
+ new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, null, null);
+ var plan = service.current(10L).testPlan();
+ assertEquals(2, plan.currentCycleNo());
+ assertEquals(2, plan.currentStepNo());
+ assertEquals(3, plan.plannedCycleCount());
+ assertEquals(List.of(1, 2), plan.rows().stream().map(row -> row.cyclePosition()).toList());
+ assertEquals("放电", plan.rows().get(1).stepName());
+ assertEquals(90d, plan.rows().get(1).durationSeconds());
+ assertEquals("进行中", plan.rows().get(1).executionStatus());
+ when(adapter.testPlan(any(ChannelIdentity.class))).thenReturn(null);
+ org.junit.jupiter.api.Assertions.assertNull(service.current(10L).testPlan());
}
@Test
@@ -212,36 +218,73 @@
assertTrue(alarms.stream().anyMatch(item -> "CABINET_CHANNEL".equals(item.get("sourceType"))));
}
+ /**
+ * 非接收状态只读来源可达性:状态取自所链接来源实例的连接结果,且**不请求**任何测量,
+ * 否则暂停期间仍会产生新观测。
+ */
@Test
void nonReceivingConsoleReadsCollectorStatusWithoutRequestingMeasurements() throws Exception
{
- try (var simulator = new com.battery.device.simulator.BtsDevelopmentSimulator(0))
+ TTest test = test("RECEIVING");
+ test.getBatteries().get(0).setCollectorId(7L);
+ var tests = mock(ITTestService.class);
+ when(tests.selectTestById(10L)).thenReturn(test);
+ var mapper = mock(TTestMapper.class);
+ when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1");
+ var adapter = mock(TestDataObservationAdapter.class);
+ var adapters = mock(com.battery.business.mapper.TDeviceAdapterMapper.class);
+ var source = new com.battery.business.domain.TDeviceAdapter();
+ source.setConnectionStatus("ONLINE");
+ when(adapters.selectDeviceAdapterByCode("dev-sim")).thenReturn(source);
+ var service = new TTestLiveServiceImpl(tests,
+ new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, mapper, adapters);
+ test.setReceptionStatus("RECEIVING");
+ var receiving = service.current(10L).sources().stream()
+ .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow();
+ assertEquals("ONLINE", receiving.deviceStatus());
+ verify(adapter).latestCollector(org.mockito.ArgumentMatchers.eq("dev-sim"),
+ org.mockito.ArgumentMatchers.eq("dev-sim-collector-1"));
+ org.mockito.Mockito.clearInvocations(adapter);
+ for (String status : List.of("PAUSED", "ENDED", "NOT_STARTED"))
{
- TTest test = test("RECEIVING");
- test.getBatteries().get(0).setCollectorId(7L);
- var tests = mock(ITTestService.class);
- when(tests.selectTestById(10L)).thenReturn(test);
- var mapper = mock(TTestMapper.class);
- when(mapper.selectCollectorCode(7L)).thenReturn("dev-sim-collector-1");
- var candidates = new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore();
- Instant now = Instant.now();
- candidates.replaceCollectors("dev-sim", List.of(new DeviceDiscoveryCandidateStore.CollectorCandidate(
- "dev-sim", "dev-sim-collector-1", "127.0.1.2", null, "模拟采集器", null, null,
- 4, true, now, now.plusSeconds(30))));
- var service = new TTestLiveServiceImpl(tests, candidates, simulator, mapper);
- service.current(10L);
- var received = simulator.snapshot().recentRequests();
- assertTrue(received.stream().anyMatch(item -> item.command().equals("collector-realtime")));
- for (String status : List.of("PAUSED", "ENDED", "NOT_STARTED"))
- {
- test.setReceptionStatus(status);
- var source = service.current(10L).sources().stream()
- .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow();
- assertEquals("ONLINE", source.deviceStatus());
- assertTrue(source.latestSnapshot().isEmpty());
- assertEquals(received, simulator.snapshot().recentRequests());
- }
+ test.setReceptionStatus(status);
+ var paused = service.current(10L).sources().stream()
+ .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow();
+ assertEquals("ONLINE", paused.deviceStatus());
+ assertTrue(paused.latestSnapshot().isEmpty());
}
+ verify(adapter, never()).latestCollector(org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString());
+ }
+
+ /** 来源不提供辅助测量(新威实测无辅助数据)时如实标注,并且不因此生成离线异常。 */
+ @Test
+ void collectorWithoutAuxiliaryMeasurementsStaysOnlineAndIsMarkedUnavailable() throws Exception
+ {
+ TTest test = test("RECEIVING");
+ test.getBatteries().get(0).setCollectorId(7L);
+ var tests = mock(ITTestService.class);
+ when(tests.selectTestById(10L)).thenReturn(test);
+ var mapper = mock(TTestMapper.class);
+ when(mapper.selectCollectorCode(7L)).thenReturn("neware76-collector");
+ var adapter = mock(TestDataObservationAdapter.class);
+ when(adapter.latestCollector(org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString())).thenReturn(null);
+ var adapters = mock(com.battery.business.mapper.TDeviceAdapterMapper.class);
+ var source = new com.battery.business.domain.TDeviceAdapter();
+ source.setConnectionStatus("ONLINE");
+ when(adapters.selectDeviceAdapterByCode("dev-sim")).thenReturn(source);
+ var service = new TTestLiveServiceImpl(tests,
+ new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, mapper, adapters);
+
+ var collector = service.current(10L).sources().stream()
+ .filter(item -> item.sourceType().equals("COLLECTOR")).findFirst().orElseThrow();
+
+ assertEquals("ONLINE", collector.deviceStatus());
+ assertEquals(Boolean.FALSE, collector.latestSnapshot().get("auxiliaryDataAvailable"));
+ assertFalse(collector.latestSnapshot().containsKey("measurements"));
+ assertTrue(service.current(10L).exceptions().stream()
+ .noneMatch(item -> "COLLECTOR".equals(item.get("sourceType"))));
}
@Test
@@ -386,7 +429,7 @@
"dev-sim", 1L, "dev-sim", 1, 1, "127.0.0.2", "mac", "cabinet", "vendor", "model",
List.of(channel), now, now.plusSeconds(60));
when(candidates.snapshot(any(Instant.class))).thenReturn(
- new DeviceDiscoveryCandidateStore.CandidateSnapshot(List.of(cabinet), List.of()));
+ new DeviceDiscoveryCandidateStore.CandidateSnapshot(List.of(cabinet)));
var plan = new TestDataObservationAdapter.TestPlan(1, 1, 1, List.of());
when(simulator.testPlan(any(ChannelIdentity.class))).thenReturn(plan);
if (mapper != null && test.getBatteries().get(0).getCollectorId() != null) {
@@ -464,7 +507,7 @@
private CurrentLiveService(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
TestDataObservationAdapter simulator, TTestMapper mapper)
{
- super(tests, candidates, simulator, mapper);
+ super(tests, candidates, simulator, mapper, null);
}
}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java b/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
deleted file mode 100644
index aa11c90..0000000
--- a/ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
+++ /dev/null
@@ -1,348 +0,0 @@
-package com.battery.business.web.device;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.junit.jupiter.api.Assertions.assertArrayEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import java.lang.reflect.Method;
-import java.math.BigDecimal;
-import java.net.InetAddress;
-import java.time.Instant;
-import java.util.List;
-import org.junit.jupiter.api.Test;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.server.ResponseStatusException;
-import com.ruoyi.common.annotation.Anonymous;
-import com.battery.device.simulator.BtsDevelopmentSimulator;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
-import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
-import com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore;
-import com.battery.device.api.model.ChannelIdentity;
-import com.battery.device.api.model.DeviceWorkStatus;
-import com.battery.business.runtime.BtsDevelopmentSimulatorWebConfig;
-import com.battery.business.runtime.DevelopmentCollectorStatusSynchronizer;
-
-class BtsDevelopmentSimulatorControllerTest
-{
- @Test
- void invalidChannelConfigurationDoesNotReplaceSavedValues() throws Exception
- {
- try (var simulator = new BtsDevelopmentSimulator(0)) {
- var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build();
- var before = simulator.snapshot().devices().get(0).channels().get(0).configuration();
- String valid = com.alibaba.fastjson2.JSON.toJSONString(before);
- for (String property : List.of("stepNo", "cycleNo", "currentA", "steps")) {
- var invalid = com.alibaba.fastjson2.JSON.parseObject(valid);
- invalid.put(property, switch (property) {
- case "stepNo" -> 2;
- case "cycleNo" -> 0;
- case "steps" -> List.of();
- default -> null;
- });
- mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put(
- "/dev/bts-simulator/devices/1/channels/1/configuration")
- .contentType("application/json").content(invalid.toJSONString()))
- .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest());
- assertEquals(before, simulator.snapshot().devices().get(0).channels().get(0).configuration());
- }
- }
- }
-
- @Test
- void channelConfigurationChangesMeasurementsAndPlanOnlyForSelectedChannel() throws Exception
- {
- try (var simulator = new BtsDevelopmentSimulator(0)) {
- var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build();
- mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put(
- "/dev/bts-simulator/devices/1/channels/1/configuration")
- .contentType("application/json").content("""
- {"currentA":-2.5,"voltageV":4.125,"capacityAh":8.2,"energyWh":33.8,
- "cycleNo":2,"stepNo":2,"plannedCycleCount":3,"totalTimeSeconds":120,"relativeTimeSeconds":30,
- "steps":[{"name":"充电","stepType":"cc","durationSeconds":60,"voltageV":4.2},
- {"name":"放电","stepType":"dc","durationSeconds":90,"voltageV":3.0}]}
- """))
- .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
- var identity = simulator.discoverChannels().get(0).identity();
- var actual = simulator.latestCabinet(identity);
- assertEquals(-2.5, actual.currentA());
- assertEquals(4.125, actual.voltageV());
- assertEquals(8.2, actual.capacityAh());
- assertEquals(33.8, actual.energyWh());
- assertEquals(2, actual.cycleNo());
- assertEquals(2, actual.stepNo());
- assertEquals("dc", actual.stepType());
- assertEquals("放电", actual.stepName());
- assertEquals(2, simulator.testPlan(identity).rows().size());
- assertEquals(3, simulator.testPlan(identity).plannedCycleCount());
- var other = simulator.discoverChannels().get(1).identity();
- assertEquals(1, simulator.testPlan(other).rows().size());
- assertEquals(1, simulator.latestCabinet(other).stepNo());
- }
- }
-
- @Test
- void restChangesSoftwareAndCollectorChannelFactsAndRetiresPlayback() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller(simulator)).build();
- mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/dev/bts-simulator/software")
- .contentType("application/json").content("{\"accessible\":false}"))
- .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
- assertFalse(simulator.snapshot().softwareAccessible());
- mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put(
- "/dev/bts-simulator/collectors/1/channels/S-01/status")
- .contentType("application/json").content("{\"workStatus\":\"PROTECTED\"}"))
- .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
- assertEquals(DeviceWorkStatus.PROTECTED, simulator.snapshot().collectors().get(0).channels().get(0).workStatus());
- for (String path : List.of("/devices/1/channels/1/playback/play", "/devices/1/channels/1/playback/next-step"))
- mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/dev/bts-simulator" + path))
- .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound());
- for (String path : List.of("/collectors/1/history", "/devices/1/channels/1/records"))
- mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/dev/bts-simulator" + path))
- .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isNotFound());
- }
- }
-
- @Test
- void controllerUpdatesDeviceAndChannelState() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
-
- controller.updateDevice(1,
- new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.2", 4, false));
- controller.updateChannel(1, 3,
- new BtsDevelopmentSimulatorController.ChannelStateCommand(true, DeviceWorkStatus.PAUSED));
-
- assertFalse(controller.state().devices().get(0).online());
- assertEquals(DeviceWorkStatus.PAUSED,
- controller.state().devices().get(0).channels().get(2).workStatus());
- }
- }
-
- @Test
- void controllerAddsUpdatesAndDeletesVirtualDevices() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
-
- var created = controller.createDevice(
- new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.0.8", 6));
- int deviceId = created.devices().stream()
- .filter(device -> device.ip().equals("127.0.0.8"))
- .findFirst().orElseThrow().deviceId();
- controller.updateDevice(deviceId,
- new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.9", 3, false));
-
- assertEquals(2, controller.state().devices().size());
- assertEquals(3, controller.state().devices().stream()
- .filter(device -> device.deviceId() == deviceId)
- .findFirst().orElseThrow().channels().size());
-
- controller.deleteDevice(deviceId);
- assertEquals(1, controller.state().devices().size());
- }
- }
-
- @Test
- void controllerCreatesNewareDeviceFromExplicitModelProfile() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
-
- var created = controller.createDevice(new BtsDevelopmentSimulatorController.CreateDeviceCommand(
- "127.0.0.8", 0, "CT-4008T-5V6A-S1-F"));
- var device = created.devices().stream().filter(item -> item.ip().equals("127.0.0.8"))
- .findFirst().orElseThrow();
-
- assertEquals(8, device.channels().size());
- assertEquals("新威", device.profile().manufacturer());
- assertEquals("CT-4008T-5V6A-S1-F", device.profile().model());
- assertEquals(100, device.profile().sampleIntervalMs());
- }
- }
-
- @Test
- void controllerConfiguresCollectorsSeparatelyFromCabinets() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
- var created = controller.createCollector(
- new BtsDevelopmentSimulatorController.CreateCollectorCommand("127.0.1.8", 6));
- int collectorId = created.collectors().stream()
- .filter(collector -> collector.ip().equals("127.0.1.8"))
- .findFirst().orElseThrow().collectorId();
-
- controller.updateCollector(collectorId,
- new BtsDevelopmentSimulatorController.CollectorStateCommand("127.0.1.9", 3, false));
-
- assertEquals(1, controller.state().devices().size());
- assertEquals(2, controller.state().collectors().size());
- assertFalse(controller.state().collectors().stream()
- .filter(collector -> collector.collectorId() == collectorId)
- .findFirst().orElseThrow().online());
-
- controller.deleteCollector(collectorId);
- assertEquals(1, controller.state().collectors().size());
- assertEquals(1, controller.state().devices().size());
- }
- }
-
- @Test
- void controllerExposesOnlyCurrentCollectorMeasurements() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
- controller.configureCollectorChannel(1, "S-01",
- new BtsDevelopmentSimulatorController.CollectorChannelCommand(List.of(
- new BtsDevelopmentSimulatorController.CollectorMetricCommand(
- "temperature", new BigDecimal("27.2"), "℃"),
- new BtsDevelopmentSimulatorController.CollectorMetricCommand(
- "custom_voltage", new BigDecimal("1.234"), "V"))));
-
- var realtime = controller.collectorRealtime(1);
-
- assertTrue(realtime.online());
- assertTrue(realtime.measurements().stream().anyMatch(item -> item.metricCode().equals("custom_voltage")));
-
- }
- }
-
- @Test
- void controllerChangesOnlineFactsWithoutResettingChannelStatus() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
- simulator.updateChannel(1, 1, true, DeviceWorkStatus.PAUSED);
-
- var poweredOff = controller.setPower(
- new BtsDevelopmentSimulatorController.SimulatorPowerCommand(false));
-
- assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online()));
- assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online()));
- assertEquals(DeviceWorkStatus.PAUSED, poweredOff.devices().get(0).channels().get(0).workStatus());
-
- var poweredOn = controller.setPower(
- new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true));
- assertTrue(poweredOn.devices().stream().allMatch(device -> device.online()
- && device.channels().stream().allMatch(channel -> channel.online()
-)));
- assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online()));
- }
- }
-
- @Test
- void powerChangeImmediatelyReconcilesCollectorStatus() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- DevelopmentCollectorStatusSynchronizer synchronizer = mock(DevelopmentCollectorStatusSynchronizer.class);
- BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator,
- new InMemoryDeviceDiscoveryCandidateStore(), synchronizer);
-
- controller.setPower(new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true));
-
- verify(synchronizer).synchronizeOnlineStatus();
- }
- }
-
- @Test
- void simulatorPowerCommandRequiresExplicitState() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
-
- ResponseStatusException exception = assertThrows(ResponseStatusException.class,
- () -> controller.setPower(new BtsDevelopmentSimulatorController.SimulatorPowerCommand(null)));
-
- assertEquals(400, exception.getStatusCode().value());
- assertTrue(controller.state().collectors().get(0).online());
- }
- }
-
- @Test
- void virtualIpMustBeUniqueAcrossCabinetsAndCollectors() throws Exception
- {
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller = controller(simulator);
-
- assertThrows(org.springframework.web.server.ResponseStatusException.class,
- () -> controller.createCollector(
- new BtsDevelopmentSimulatorController.CreateCollectorCommand("127.0.0.2", 4)));
- assertThrows(org.springframework.web.server.ResponseStatusException.class,
- () -> controller.createDevice(
- new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.1.2", 4)));
- }
- }
-
- @Test
- void developmentControlBoundaryOnlyAllowsLoopbackClients() throws Exception
- {
- var interceptor = new BtsDevelopmentSimulatorWebConfig.LocalOnlyInterceptor();
- MockHttpServletResponse localResponse = new MockHttpServletResponse();
- MockHttpServletRequest localRequest = requestFrom(InetAddress.getLoopbackAddress().getHostAddress());
- MockHttpServletResponse remoteResponse = new MockHttpServletResponse();
- MockHttpServletRequest remoteRequest = requestFrom("192.168.1.20");
-
- assertTrue(interceptor.preHandle(localRequest, localResponse, new Object()));
- assertFalse(interceptor.preHandle(remoteRequest, remoteResponse, new Object()));
- assertEquals(403, remoteResponse.getStatus());
- assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class));
- }
-
- @Test
- void exposesStaticControlPageThroughAnonymousHandler() throws Exception
- {
- Method page = BtsDevelopmentSimulatorController.class.getMethod("page");
-
- assertArrayEquals(new String[] { "/dev/bts-simulator.html" },
- page.getAnnotation(GetMapping.class).value());
- assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class));
- }
-
- @Test
- void resettingSimulatorRemovesExistingDevelopmentCandidates() throws Exception
- {
- Instant now = Instant.parse("2026-08-27T00:00:00Z");
- InMemoryDeviceDiscoveryCandidateStore candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
- candidateStore.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-8", 22, 8,
- "127.0.0.8", null, "旧模拟柜", null, null,
- List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 8, 1, 1), true,
- DeviceWorkStatus.STOPPED, now)), now, now.plusSeconds(30))));
- try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
- {
- BtsDevelopmentSimulatorController controller =
- new BtsDevelopmentSimulatorController(simulator, candidateStore);
-
- controller.reset();
-
- assertEquals(List.of(), candidateStore.snapshot(now).cabinets());
- }
- }
-
- private BtsDevelopmentSimulatorController controller(BtsDevelopmentSimulator simulator)
- {
- return new BtsDevelopmentSimulatorController(simulator, new InMemoryDeviceDiscoveryCandidateStore());
- }
-
- private MockHttpServletRequest requestFrom(String remoteAddress)
- {
- MockHttpServletRequest request = new MockHttpServletRequest("GET", "/dev/bts-simulator/state");
- request.setRemoteAddr(remoteAddress);
- return request;
- }
-}
diff --git a/scripts/clear-simulator-development-data.sql b/scripts/clear-simulator-development-data.sql
new file mode 100644
index 0000000..f7a8e6c
--- /dev/null
+++ b/scripts/clear-simulator-development-data.sql
@@ -0,0 +1,114 @@
+-- 开发模拟器产生的开发设备与开发数据 · 定点清理
+-- ==========================================================================
+-- 背景:开发模拟器(dev-sim)已整体拆除,不再产生任何新数据;本脚本清理它此前产生的开发设备。
+--
+-- ⚠️ 脚本故意不给一条「整段执行」的路径。**先跑「一、先查」,按结果决定后面能做什么。**
+--
+-- 关键事实:当前业务基线(`db/migration-final/V2__final_business_baseline.sql`)**没有外键约束**
+-- (全表 0 个 FOREIGN KEY,旧的 db/migration 已不作为 Flyway 位置)。所以删除设备行**不会被数据库拦下**,
+-- 悬空引用(例如实时记录还指着已删掉的通道)也不会报错——先查段是唯一防线。
+--
+-- 纪律(与 Ticket04「不影响已登记的正式设备、业务事实、审计记录与历史数据」一致):
+-- 1. **不整表 TRUNCATE、不按时间范围删除、不删审计/修订记录**,只按模拟器的固定身份处理:
+-- 适配器编码 `dev-sim`、采集器编码 `dev-sim-collector-N`。
+-- 2. **能在产品页面上做的删除,先在页面上做**:检测柜与采集器的删除动作会走产品的引用校验与逻辑删除口径
+-- (`del_flag`),本脚本不另造一条删除路径。本脚本的 SQL 删除只用于界面上已经没有入口的行
+-- (例如代码装配时代留下的模拟器适配器行)。
+-- 3. **绑定了模拟器通道的测试用「测试整体删除」清理**:ADR-0047 的明确无痕例外(一个事务清掉测试全部子数据、
+-- 绑定、阈值与数据修订,不写日志与修订)。不要在这里直接 DELETE 测试行,那会留下指向测试的孤儿数据。
+-- 4. 删除段只在**先查段的引用计数全为 0** 时执行,分步确认影响行数。有引用却先删了设备行,
+-- 那些引用就会变成悬空数据,而且没有任何报错提醒你。
+-- 5. 执行前先停止 Spring Boot 后端(避免运行期把它重新写回)。
+--
+-- 交付说明:这些行是否存在取决于此前是否用模拟器登记过设备。先查段计数全为 0 就说明没有模拟器开发设备,
+-- 删除段即为空操作。
+-- ==========================================================================
+
+-- 一、先查:确认要删什么、以及必须先经产品路径清掉的关联测试
+-- --------------------------------------------------------------------------
+
+-- 1.1 模拟器适配器行(ADR-0018:dev-sim 是代码装配、不写 t_device_adapter,所以这里通常为 0)
+SELECT COUNT(*) AS dev_sim_adapter_rows
+FROM t_device_adapter
+WHERE adapter_code = 'dev-sim';
+
+-- 1.2 模拟器登记过的检测柜与通道
+SELECT c.cabinet_id, c.cabinet_code, c.cabinet_name, c.adapter_id, c.del_flag
+FROM t_detection_cabinet c
+JOIN t_device_adapter a ON a.adapter_id = c.adapter_id
+WHERE a.adapter_code = 'dev-sim';
+
+SELECT ch.channel_id, ch.cabinet_id, ch.devtype, ch.devid, ch.subdevid, ch.chlid, ch.del_flag
+FROM t_detection_channel ch
+JOIN t_detection_cabinet c ON c.cabinet_id = ch.cabinet_id
+JOIN t_device_adapter a ON a.adapter_id = c.adapter_id
+WHERE a.adapter_code = 'dev-sim';
+
+-- 1.3 模拟器采集器
+SELECT collector_id, collector_code, collector_name, adapter_id, del_flag
+FROM t_collector
+WHERE collector_code LIKE 'dev-sim-collector-%';
+
+-- 1.4 **必须先清掉的关联测试**:下面四个计数都必须为 0,否则先按纪律第 2、3 条在界面上删除这些测试
+-- (删除测试会一并清掉它的绑定、实时/工步/循环事实与采集器映射,也就解开了对设备行的引用)
+
+-- 1.4.1 绑定了模拟器通道的测试
+SELECT DISTINCT t.test_id, t.test_name, t.test_status, t.reception_status
+FROM t_test t
+JOIN t_test_channel_binding b ON b.test_id = t.test_id
+JOIN t_detection_channel ch ON ch.channel_id = b.channel_id
+JOIN t_detection_cabinet c ON c.cabinet_id = ch.cabinet_id
+JOIN t_device_adapter a ON a.adapter_id = c.adapter_id
+WHERE a.adapter_code = 'dev-sim';
+
+-- 1.4.2 仍指向模拟器通道的测试通道绑定
+SELECT COUNT(*) AS binding_rows
+FROM t_test_channel_binding b
+JOIN t_detection_channel ch ON ch.channel_id = b.channel_id
+JOIN t_detection_cabinet c ON c.cabinet_id = ch.cabinet_id
+JOIN t_device_adapter a ON a.adapter_id = c.adapter_id
+WHERE a.adapter_code = 'dev-sim';
+
+-- 1.4.3 仍指向模拟器设备或采集器的实时记录
+SELECT COUNT(*) AS realtime_snapshot_rows
+FROM t_test_realtime_record r
+WHERE r.collector_id IN (SELECT collector_id FROM t_collector WHERE collector_code LIKE 'dev-sim-collector-%')
+ OR (r.adapter_code = 'dev-sim');
+
+-- 1.4.4 仍指向模拟器采集器的测试-采集器映射
+SELECT COUNT(*) AS collector_mapping_rows
+FROM t_test_collector_channel m
+JOIN t_collector col ON col.collector_id = m.collector_id
+WHERE col.collector_code LIKE 'dev-sim-collector-%';
+
+-- 二、删除:**仅当 1.4 的引用计数全为 0** 才执行;分步执行并确认影响行数
+-- --------------------------------------------------------------------------
+
+-- 2.1 模拟器通道(先删子表)
+DELETE ch FROM t_detection_channel ch
+JOIN t_detection_cabinet c ON c.cabinet_id = ch.cabinet_id
+JOIN t_device_adapter a ON a.adapter_id = c.adapter_id
+WHERE a.adapter_code = 'dev-sim';
+
+-- 2.2 模拟器检测柜
+DELETE c FROM t_detection_cabinet c
+JOIN t_device_adapter a ON a.adapter_id = c.adapter_id
+WHERE a.adapter_code = 'dev-sim';
+
+-- 2.3 模拟器采集器(含已被页面逻辑删除的行)
+DELETE FROM t_collector WHERE collector_code LIKE 'dev-sim-collector-%';
+
+-- 2.4 模拟器适配器行(若 1.1 计为 0,这一句是空操作)
+DELETE FROM t_device_adapter WHERE adapter_code = 'dev-sim';
+
+-- ==========================================================================
+-- 三、Redis 里的模拟器状态(在 Redis 上执行,不在业务库里)
+-- --------------------------------------------------------------------------
+-- 这些键是模拟器的运行状态,拆除后不再有人写它们:
+-- redis-cli DEL dev:bts-simulator:state:v1
+-- redis-cli DEL dev:bts-simulator:recent-requests:v1
+-- redis-cli DEL dev:bts-simulator:collector-history:v1
+-- **不要删** dev:device-discovery:candidates:v1 —— 它是正式发现候选(RedisDeviceDiscoveryCandidateStore)
+-- 用的同一个键;模拟器时代写进去的采集器候选已随 collectors 字段停止恢复(代码读到时只忽略),
+-- 正式检测柜候选必须保留。
+-- ==========================================================================
--
Gitblit v1.7.1