实现 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个文件已修改
| | |
| | | ## 模块边界 |
| | | |
| | | - `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 控制器,不承载采集或状态机业务。 |
| | | - 第一阶段打包为一个后端进程,并明确保持单采集实例;最终业务基线不建立适配器租约模型。 |
| | | |
| | |
| | | - 设备协议或调度改动至少覆盖正常多通道、拒绝连接、断线、超时、畸形 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`)。 |
| | |
| | | @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; |
| | | |
| | |
| | | /** 查询采集器列表。 */ |
| | | List<TCollector> selectCollectorList(TCollector collector); |
| | | |
| | | /** 查询全部未删除采集器的设备编码。 */ |
| | | List<String> selectRegisteredCollectorCodes(); |
| | | |
| | | /** 根据设备编码查询未删除采集器。 */ |
| | | TCollector selectCollectorByCode(String collectorCode); |
| | | |
| | |
| | | |
| | | /** 新增采集器。 */ |
| | | int insertCollector(TCollector collector); |
| | | |
| | | /** 同步正式采集器的实时在线事实,不触碰软删除记录。 */ |
| | | int updateRegisteredObservation(@Param("collectorCode") String collectorCode, |
| | | @Param("onlineStatus") String onlineStatus); |
| | | |
| | | /** 编辑采集器基础资料。 */ |
| | | int updateCollector(TCollector collector); |
| | |
| | | package com.battery.business.service; |
| | | |
| | | import java.util.List; |
| | | import java.util.Set; |
| | | import com.battery.business.domain.TCollector; |
| | | |
| | | /** |
| | |
| | | |
| | | /** 启用或禁用采集器。 */ |
| | | int updateCollectorStatus(TCollector collector); |
| | | |
| | | /** 查询全部未删除采集器的设备编码。 */ |
| | | Set<String> selectRegisteredCollectorCodes(); |
| | | |
| | | /** 批量逻辑删除采集器。 */ |
| | | int deleteCollectorByIds(Long[] collectorIds, String updateBy); |
| | |
| | | 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; |
| | |
| | | { |
| | | /** 适配器类型按存储形态划分:这个形态读本项目的落地表(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}, |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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} 里, |
| | |
| | | /** 心跳缺失即来源不可达:取数程序停了、或它自己连不上厂商库。 */ |
| | | 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; |
| | | } |
| | | |
| | | /** 容忍窗口按实例配置的轮询周期算:写库与推送都是这个节奏,连丢三轮即认为离线。 */ |
| | |
| | | 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 |
| | | */ |
| | |
| | | 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( |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | { |
| | | throw new ServiceException("采集器不存在"); |
| | | } |
| | | return applyRealtimeState(collector, candidatesByCode()); |
| | | return collector; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | |
| | | } |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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())) |
| | | { |
| | |
| | | } |
| | | collector.setUpdateTime(new Date()); |
| | | return collectorMapper.updateCollectorStatus(collector); |
| | | } |
| | | |
| | | /** 查询全部未删除采集器的设备编码,供发现候选排除已登记设备。 */ |
| | | @Override |
| | | public Set<String> selectRegisteredCollectorCodes() |
| | | { |
| | | return Set.copyOf(collectorMapper.selectRegisteredCollectorCodes()); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | 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())); |
| | |
| | | { |
| | | throw new ServiceException("采集器名称不能为空"); |
| | | } |
| | | validateIpAddress(collector.getIpAddress()); |
| | | validateOptionalIpAddress(collector.getIpAddress()); |
| | | if (collector.getChannelCount() == null || collector.getChannelCount() < 1 |
| | | || collector.getChannelCount() > 10000) |
| | | { |
| | |
| | | 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) |
| | | { |
| | |
| | | if (isDifferentCollector(sameCode, collector.getCollectorId())) |
| | | { |
| | | throw new ServiceException("设备编码已存在"); |
| | | } |
| | | if (collector.getMacAddress() == null) |
| | | { |
| | | return; |
| | | } |
| | | TCollector sameMac = collectorMapper.selectCollectorByMacAddress(collector.getMacAddress()); |
| | | if (isDifferentCollector(sameMac, collector.getCollectorId())) |
| | |
| | | } |
| | | |
| | | /** |
| | | * 规范化并校验 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()) |
| | |
| | | } |
| | | |
| | | /** |
| | | * 校验 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()) |
| | | { |
| | |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | | |
| | | /** 将设备最新快照转换为测试视角,按测试接收状态决定是否展示采集参数。 */ |
| | |
| | | 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) |
| | |
| | | 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()) |
| | |
| | | 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()); |
| | | } |
| | | |
| | | } |
| | |
| | | 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 |
| | |
| | | and create_time <= #{endCreateTime} |
| | | </if> |
| | | <if test="onlineStatus != null and onlineStatus != ''"> |
| | | and online_status = #{onlineStatus} |
| | | <!-- WHERE 里不能用 SELECT 别名(MySQL 只在 HAVING 认得),所以按同一表达式再过一遍。 --> |
| | | and (<include refid="derivedOnlineStatus" />) = #{onlineStatus} |
| | | </if> |
| | | <if test="testStatus != null and testStatus != ''"> |
| | | and (case when exists ( |
| | |
| | | </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"> |
| | |
| | | |
| | | <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}, |
| | |
| | | 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() |
| | |
| | | assertTrue(adapter.inquire(Set.of(channel())).isEmpty()); |
| | | } |
| | | |
| | | /** 采集器辅助测量按 ADR-0064 逐来源确认;22 所环境量的列语义未确认,因此不交出测量值。 */ |
| | | /** 未声明辅助测量列就不交出测量值:确认之前不按列名推断语义,也不写零占位。 */ |
| | | @Test |
| | | void collectorAndStepCycleObservationsStayEmptyForThisStorageForm() |
| | | void collectorAndStepCycleObservationsStayEmptyWithoutDeclaredAuxiliaryMetrics() |
| | | { |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | ingest(BATCH); |
| | |
| | | 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 |
| | |
| | | |
| | | 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) |
| | |
| | | {"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); |
| | |
| | | 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) |
| | |
| | | /** upsert 本轮检测柜观测,未返回设备保留到逻辑过期。 */ |
| | | void recordCabinets(List<CabinetCandidate> candidates); |
| | | |
| | | /** 替换指定适配器的采集器观测,使已删除模拟采集器立即消失。 */ |
| | | void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates); |
| | | |
| | | /** 删除指定适配器的全部发现候选;仅供人工重置或实机联调清理。 */ |
| | | void removeAdapter(String adapterCode); |
| | | |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | 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) { } |
| | | } |
| | |
| | | public final class InMemoryDeviceDiscoveryCandidateStore implements DeviceDiscoveryCandidateStore |
| | | { |
| | | private final Map<String, CabinetCandidate> cabinets = new LinkedHashMap<>(); |
| | | private final Map<String, CollectorCandidate> collectors = new LinkedHashMap<>(); |
| | | |
| | | /** 按设备编码 upsert 本轮检测柜观测;本轮未返回的候选保留到逻辑过期。 */ |
| | | @Override |
| | |
| | | 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)); |
| | | } |
| | | |
| | | /** 返回指定时刻尚未过期的候选,并清理已过期条目。 */ |
| | |
| | | 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())); |
| | | } |
| | | } |
| | |
| | | /** 工步层的共用读取窗口:足以覆盖一轮接收里工步与循环的连续调用,又不会压住新出现的工步行。 */ |
| | | 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() |
| | |
| | | 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()) |
| | |
| | | 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; |
| | |
| | | |
| | | 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 语法。 */ |
| | |
| | | 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(); |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | 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)); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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;在存储边界逐字段重建,禁止业务层强转。 |
| | |
| | | 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) |
| | | { |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | |
| | | /** |
| | | * 按固定周期触发数据接收,由业务服务按测试接收状态和采样来源保存成功观测。 |
| | | * |
| | | * <p>它不限定 profile:真实来源的接收路径也必须在非开发 profile 下运行。 |
| | | * 开发模拟器专属的调度(模拟器发现轮询、采集器状态同步)仍只在 dev-simulator 下额外启用。 |
| | | * <p>它不限定 profile:真实来源的接收路径必须在任何启动方式下都运行。 |
| | | */ |
| | | @Component |
| | | public class TestDataReceptionScheduler |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | | /** |
| | |
| | | @Autowired |
| | | private ITCollectorService collectorService; |
| | | |
| | | @Autowired |
| | | private ICollectorDiscoveryService collectorDiscoveryService; |
| | | |
| | | /** 分页查询采集器列表。 */ |
| | | @PreAuthorize("@ss.hasPermi('system:collector:list')") |
| | | @GetMapping("/list") |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** 查询采集器详情。 */ |
| | |
| | | # 国际化资源文件路径 |
| | | basename: i18n/messages |
| | | profiles: |
| | | # 默认不连接任何设备;本机演示时显式追加 dev-simulator。 |
| | | # 默认不连接任何设备:没有配置来源时使用无设备实现;真实来源在适配器实例里按行配置。 |
| | | active: druid |
| | | # 文件上传 |
| | | servlet: |
| | |
| | | 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')"); |
| | | } |
| | | } |
| | | |
| File was renamed from ruoyi-admin/src/test/java/com/battery/business/runtime/DevelopmentSimulatorProfileContractTest.java |
| | |
| | | import org.springframework.boot.env.YamlPropertySourceLoader; |
| | | import org.springframework.core.io.ClassPathResource; |
| | | |
| | | class DevelopmentSimulatorProfileContractTest |
| | | /** |
| | | * 默认启动不连接任何设备:没有配置来源时装配无设备实现,真实来源按适配器实例逐行配置。 |
| | | * 这里锁定配置文件里的默认 profile,避免有人在配置里提前打开设备连接。 |
| | | */ |
| | | class DefaultSpringProfileContractTest |
| | | { |
| | | @Test |
| | | void usesNoDeviceProfileByDefault() throws Exception |
| | |
| | | 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", |
| | |
| | | "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)); |
| | |
| | | |
| | | 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 |
| | |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | |
| | | /** |
| | | * 取数入口的装配契约:开发模拟器既是 Spring Bean,又实现了测试数据观测契约, |
| | | * 所以业务侧注入的那个 Bean 必须显式声明为首选,否则上下文里出现两个候选会启动失败。 |
| | | * 取数入口的装配契约:业务侧只注入一个测试数据观测 Bean,各来源实例由适配器注册表在运行时提供, |
| | | * 所以这个入口必须显式声明为首选,否则上下文里出现两个候选会启动失败。 |
| | | * 这里断言的是"哪个 Bean 会被注入"这个结构性事实,而不是 Spring 内部行为。 |
| | | */ |
| | | class RoutingAdapterWiringContractTest |
| | |
| | | "取数入口必须是可注入的 Bean"); |
| | | assertTrue(RoutingTestDataObservationAdapter.class.isAnnotationPresent(Primary.class), |
| | | "来源实例自己也实现该契约时必须显式首选路由入口,否则注入出现两个候选"); |
| | | } |
| | | |
| | | @Test |
| | | void developmentSimulatorAlsoExposesTheContractWhichIsWhyPreferenceIsNeeded() |
| | | { |
| | | assertTrue(TestDataObservationAdapter.class |
| | | .isAssignableFrom(com.battery.device.simulator.BtsDevelopmentSimulator.class)); |
| | | } |
| | | } |
| | |
| | | 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; |
| | | |
| | |
| | | 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 |
| | |
| | | @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"); |
| | |
| | | } |
| | | } |
| | | |
| | | /** 在线事实只有一个来源:所链接来源实例的连接结果;登记表里的存储列不再充当这个事实。 */ |
| | | @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 |
| | |
| | | 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 |
| | |
| | | } |
| | | |
| | | @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); |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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(), |
| | |
| | | @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(); |
| | |
| | | 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")); |
| | |
| | | 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()); |
| | |
| | | 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 |
| | |
| | | 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); |
| | |
| | | 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 |
| | |
| | | "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) { |
| | |
| | | private CurrentLiveService(ITTestService tests, DeviceDiscoveryCandidateStore candidates, |
| | | TestDataObservationAdapter simulator, TTestMapper mapper) |
| | | { |
| | | super(tests, candidates, simulator, mapper); |
| | | super(tests, candidates, simulator, mapper, null); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | -- 开发模拟器产生的开发设备与开发数据 · 定点清理 |
| | | -- ========================================================================== |
| | | -- 背景:开发模拟器(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 字段停止恢复(代码读到时只忽略), |
| | | -- 正式检测柜候选必须保留。 |
| | | -- ========================================================================== |