yeg
6 天以前 e38aca8134570513435c003c25dc05f1ae51c9c9
实现 Ticket04:采集器辅助测量与登记入口,拆除开发模拟器

采集器辅助测量与登记(真实设备下可用)
- 推式来源的辅助测量改由来源实例 source_config.auxiliaryMetrics 声明:
列名、指标编码、单位与采集器通道都写在配置里,适配器只按声明从落地行的
原始载荷取值,交出携带该行自己设备时间的扩展测量。没有声明就返回空值——
22 所环境量列的语义与量纲尚未现场确认,生产配置里默认没有这一项,
未确认前不按列名推断语义(已记入现场清单与缺口台账)。
- 采集器登记不再要求发现候选(唯一生产者是开发模拟器),改为手工登记
设备编码与名称并链接所属来源实例,IP/MAC 可留空;在线状态改由所链接
来源实例的连接结果派生(ADR-0063 的同一口径),登记表的存储列不再是该事实来源。
- 直连来源如实反映「无辅助数据」:来源可达而没有辅助测量时标注
auxiliaryDataAvailable=false,不再把采集器来源恒显示为离线并持续报离线异常。
- 候选机制整体拆除:DeviceDiscoveryCandidateStore 去掉采集器候选、快照与
替换方法,删除 ICollectorDiscoveryService、DiscoveredCollectorVo、候选端点与
两个只服务候选的 Mapper 方法;Redis 存储读到历史 collectors 字段只忽略不回写。
- 补端到端断言:采集器辅助测量落成自己的轮询槽位行,柜端身份与设备时间为空,
不与检测柜记录伪造配对。

拆除开发模拟器
- 删除 battery-device-simulator 模块、dev-simulator profile 的全部装配与调度、
控制入口(控制器、WebConfig、控制页面)、两个 pom 里的 simulator 相关 profile;
默认 profile 说明与调度器注释同步更正,无来源时用无设备实现启动。
- 依赖模拟器的测试删除或改用替身;DevelopmentSimulatorProfileContractTest 因不再
与模拟器相关而改名为 DefaultSpringProfileContractTest。
- 新增 scripts/clear-simulator-development-data.sql:按定点身份先查引用后删设备行,
并给出 Redis 键清单;脚本尚未执行(业务库与 Redis 不在本机),已在缺口台账写明。

跨来源查询纪律核对(当场补齐两条)
- NewareMysqlAdapter 的唯一查询入口给每条语句设 setQueryTimeout,原本只有
JDBC URL 里的 socketTimeout(只管单次读)。
- ReadOnlyVendorDataSource 增加加锁子句拦截:加锁读以 select 开头原本会被当成
查询转发,而它真的会阻塞厂商写入。
- 另开单两条并记入缺口台账:samplesSince 按无索引列排序、新增来源的纪律没有
默认落点(需把只读防线与厂商连接构造器上移到 battery-device-api)。

验证:后端全量 mvn clean test 328 项通过(api 7、business 22、bts 25、neware 27、
ruoyi-admin 247 含 4 跳过),0 失败。
24个文件已删除
1 文件已重命名
1个文件已添加
28个文件已修改
5679 ■■■■■ 已修改文件
AGENTS.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/domain/TCollector.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCollectorVo.java 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/mapper/TCollectorMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/ICollectorDiscoveryService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/ITCollectorService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TCollectorServiceImpl.java 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/resources/mapper/battery/TCollectorMapper.xml 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/test/java/com/battery/business/service/impl/PushIngestReceptionContractTest.java 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/discovery/InMemoryDeviceDiscoveryCandidateStore.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/pom.xml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java 933 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/ScriptedBtsTcpSimulator.java 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/contract/DiscoveryCandidateStoreContractTest.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java 412 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pom.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/pom.xml 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/BtsDevelopmentSimulatorWebConfig.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorDiscoveryService.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/web/TCollectorController.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java 311 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html 660 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/mapper/DeviceRelatedTestMapperTest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/DefaultSpringProfileContractTest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizerTest.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeNoopStartupTest.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/CollectorDiscoveryServiceImplTest.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/TCollectorServiceImplTest.java 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java 111 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java 348 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/clear-simulator-development-data.sql 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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`)。
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;
battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCollectorVo.java
File was deleted
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);
battery-business/src/main/java/com/battery/business/service/ICollectorDiscoveryService.java
File was deleted
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);
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)
    {
        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;
    }
    /** 容忍窗口按实例配置的轮询周期算:写库与推送都是这个节奏,连丢三轮即认为离线。 */
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;
    }
}
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,25 +226,38 @@
        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", "采集器"); value.put("sampleIntervalMs", collector.sampleIntervalMs());
        value.put("source", "采集器");
        if (collector == null)
        {
            // 该来源根本不提供辅助测量(新威实测无辅助数据;推式来源的列语义确认前同样如此)。
            if (sourceOnline) value.put("auxiliaryDataAvailable", false);
            return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
                    sourceOnline ? "ONLINE" : "OFFLINE", value);
        }
        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())
@@ -258,10 +274,18 @@
            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());
        }
        return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(), "OFFLINE", 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());
    }
}
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 &lt;= #{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},
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)
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)
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) { }
}
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()));
    }
}
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())
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();
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);
    }
}
battery-device-simulator/pom.xml
File was deleted
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
File was deleted
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
File was deleted
battery-device-simulator/src/main/java/com/battery/device/simulator/DevelopmentCollectorSimulation.java
File was deleted
battery-device-simulator/src/main/java/com/battery/device/simulator/InMemoryBtsSimulatorStateStore.java
File was deleted
battery-device-simulator/src/main/java/com/battery/device/simulator/ScriptedBtsTcpSimulator.java
File was deleted
battery-device-simulator/src/test/java/com/battery/device/contract/DiscoveryCandidateStoreContractTest.java
File was deleted
battery-device-simulator/src/test/java/com/battery/device/contract/SimulatorPublicContractTest.java
File was deleted
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
File was deleted
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>
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>
ruoyi-admin/src/main/java/com/battery/business/runtime/BtsDevelopmentSimulatorWebConfig.java
File was deleted
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorDiscoveryService.java
File was deleted
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizer.java
File was deleted
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java
File was deleted
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorDiscoveryScheduler.java
File was deleted
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisBtsSimulatorStateStore.java
File was deleted
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);
        }
    }
}
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
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;
    }
    /** 查询采集器详情。 */
ruoyi-admin/src/main/java/com/battery/business/web/device/BtsDevelopmentSimulatorController.java
File was deleted
ruoyi-admin/src/main/resources/application.yml
@@ -61,7 +61,7 @@
    # 国际化资源文件路径
    basename: i18n/messages
  profiles:
    # 默认不连接任何设备;本机演示时显式追加 dev-simulator。
    # 默认不连接任何设备:没有配置来源时使用无设备实现;真实来源在适配器实例里按行配置。
    active: druid
  # 文件上传
  servlet:
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
File was deleted
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')");
        }
    }
ruoyi-admin/src/test/java/com/battery/business/runtime/DefaultSpringProfileContractTest.java
File was renamed from ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentSimulatorProfileContractTest.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
ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentCollectorStatusSynchronizerTest.java
File was deleted
ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java
File was deleted
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",
ruoyi-admin/src/test/java/com/battery/business/runtime/RedisStateStoreTest.java
File was deleted
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));
ruoyi-admin/src/test/java/com/battery/business/service/impl/CollectorDiscoveryServiceImplTest.java
File was deleted
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();
        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());
            simulator.setSoftwareAccessible(false);
            scheduler.discover();
        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(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());
        }
        verify(cabinetMapper).markRegisteredCabinetsOffline("neware76");
    }
    @Test
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));
    }
}
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
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,7 +77,6 @@
    @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();
@@ -91,10 +90,13 @@
            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(), simulator, mapper);
            simulator.configureCollectorChannel(1, "S-01", List.of(
                    new com.battery.device.simulator.BtsSimulatorStateStore.CollectorMetricState("temperature", new BigDecimal("38"), "℃")));
                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"));
@@ -105,29 +107,34 @@
            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"), "℃")));
        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 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(), simulator);
                new com.battery.device.api.discovery.InMemoryDeviceDiscoveryCandidateStore(), adapter, null, null);
            var plan = service.current(10L).testPlan();
            assertEquals(2, plan.currentCycleNo());
            assertEquals(2, plan.currentStepNo());
@@ -136,9 +143,8 @@
            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);
        when(adapter.testPlan(any(ChannelIdentity.class))).thenReturn(null);
            org.junit.jupiter.api.Assertions.assertNull(service.current(10L).testPlan());
        }
    }
    @Test
@@ -212,10 +218,12 @@
        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);
@@ -223,25 +231,60 @@
            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")));
        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"))
            {
                test.setReceptionStatus(status);
                var source = service.current(10L).sources().stream()
            var paused = 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());
            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);
        }
    }
ruoyi-admin/src/test/java/com/battery/business/web/device/BtsDevelopmentSimulatorControllerTest.java
File was deleted
scripts/clear-simulator-development-data.sql
New file
@@ -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 字段停止恢复(代码读到时只忽略),
-- 正式检测柜候选必须保留。
-- ==========================================================================