From 7ab0e426264707fb85ee38d0a9a73caf41899635 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 29 八月 2026 13:11:40 +0800
Subject: [PATCH] 实现采集器独立实时与历史采样模拟
---
ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java | 15
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java | 73 ++++
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java | 25 +
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html | 118 +++++++
ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java | 87 +++++
ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java | 4
ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java | 29 +
ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java | 48 +++
ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java | 20 +
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java | 86 +++++
ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java | 249 ++++++++++++++++
ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java | 90 ++++++
12 files changed, 821 insertions(+), 23 deletions(-)
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java
new file mode 100644
index 0000000..56f3215
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java
@@ -0,0 +1,25 @@
+package com.ruoyi.config;
+
+import java.time.Instant;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+
+/** 按各采集器配置频率推进开发模拟测量,不参与正式测试数据持久化。 */
+@Component
+public class DevelopmentCollectorSamplingScheduler
+{
+ private final BtsDevelopmentSimulator simulator;
+
+ public DevelopmentCollectorSamplingScheduler(BtsDevelopmentSimulator simulator)
+ {
+ this.simulator = simulator;
+ }
+
+ /** 高频检查只在采样到期时写穿 Redis,采集器之间不要求同频。 */
+ @Scheduled(fixedDelay = 100)
+ public void sample()
+ {
+ simulator.advanceCollectors(Instant.now());
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
index 621bda0..19af6bf 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
@@ -3,6 +3,7 @@
import java.io.IOException;
import java.util.List;
import org.springframework.boot.ApplicationRunner;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -25,22 +26,24 @@
}
@Bean(destroyMethod = "close")
- public BtsDevelopmentSimulator btsDevelopmentSimulator(BtsSimulatorStateStore stateStore) throws IOException
+ public BtsDevelopmentSimulator btsDevelopmentSimulator(BtsSimulatorStateStore stateStore,
+ @Value("${battery.dev-simulator.port:15020}") int simulatorPort) throws IOException
{
- return new BtsDevelopmentSimulator(BtsDevelopmentSimulator.DEFAULT_PORT, stateStore);
+ return new BtsDevelopmentSimulator(simulatorPort, stateStore);
}
@Bean
- public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry)
+ public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry,
+ @Value("${battery.dev-simulator.port:15020}") int simulatorPort)
{
// 固定开发装配不进入 t_device_adapter,直接注册运行时实例。
- return arguments -> registry.synchronize(developmentSimulatorRuntimeConfig());
+ return arguments -> registry.synchronize(developmentSimulatorRuntimeConfig(simulatorPort));
}
- private AdapterInstanceConfig developmentSimulatorRuntimeConfig()
+ private AdapterInstanceConfig developmentSimulatorRuntimeConfig(int simulatorPort)
{
return new AdapterInstanceConfig(BtsDevelopmentSimulator.ADAPTER_CODE, "BTS", BtsDevelopmentSimulator.HOST,
- BtsDevelopmentSimulator.DEFAULT_PORT, BtsDevelopmentSimulator.POLL_INTERVAL_MS,
+ simulatorPort, BtsDevelopmentSimulator.POLL_INTERVAL_MS,
"Asia/Hong_Kong", true,
new AdapterCapabilities(true, true, false, false));
}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
index f5fa5ae..40a5707 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
@@ -1,5 +1,7 @@
package com.ruoyi.config;
+import java.math.BigDecimal;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -133,10 +135,67 @@
private CollectorState collectorState(Object value)
{
JSONObject object = jsonObject(value);
- return new CollectorState(requiredInteger(object, "collectorId"), object.getString("deviceCode"),
- object.getString("ip"), object.getString("mac"), object.getString("name"),
- object.getString("manufacturer"), object.getString("model"),
- requiredInteger(object, "channelCount"), object.getBooleanValue("online"));
+ int collectorId = requiredInteger(object, "collectorId");
+ String deviceCode = object.getString("deviceCode");
+ String ip = object.getString("ip");
+ String mac = object.getString("mac");
+ String name = object.getString("name");
+ String manufacturer = object.getString("manufacturer");
+ String model = object.getString("model");
+ int channelCount = requiredInteger(object, "channelCount");
+ boolean online = object.getBooleanValue("online");
+ // Ticket04 状态只包含发现配置;首次恢复时以演示默认值补齐采样状态。
+ if (!object.containsKey("sampleIntervalMs"))
+ return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model,
+ channelCount, online);
+
+ List<CollectorChannelState> channels = new ArrayList<>();
+ requiredArray(object, "channels").forEach(item -> channels.add(collectorChannelState(item)));
+ List<CollectorMeasurementRecord> history = new ArrayList<>();
+ requiredArray(object, "history").forEach(item -> history.add(collectorMeasurementRecord(item)));
+ return new CollectorState(collectorId, deviceCode, ip, mac, name, manufacturer, model,
+ channelCount, online, requiredInteger(object, "sampleIntervalMs"),
+ requiredLong(object, "nextSequence"), optionalInstant(object, "lastSampledAt"),
+ channels, history, collectorHistoryFaults(object.get("historyFaults")));
+ }
+
+ private CollectorChannelState collectorChannelState(Object value)
+ {
+ JSONObject object = jsonObject(value);
+ List<CollectorMetricState> metrics = new ArrayList<>();
+ requiredArray(object, "metrics").forEach(item -> metrics.add(collectorMetricState(item)));
+ return new CollectorChannelState(object.getString("channelCode"), metrics);
+ }
+
+ private CollectorMetricState collectorMetricState(Object value)
+ {
+ JSONObject object = jsonObject(value);
+ BigDecimal measurement = object.getBigDecimal("value");
+ if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value");
+ return new CollectorMetricState(object.getString("metricCode"), measurement, object.getString("unit"));
+ }
+
+ private CollectorMeasurementRecord collectorMeasurementRecord(Object value)
+ {
+ JSONObject object = jsonObject(value);
+ BigDecimal measurement = object.getBigDecimal("value");
+ if (measurement == null) throw new IllegalStateException("Missing BTS simulator state field: value");
+ return new CollectorMeasurementRecord(object.getString("adapterCode"), object.getString("collectorCode"),
+ object.getString("channelCode"), requiredLong(object, "sequenceId"),
+ requiredInstant(object, "deviceTime"), object.getString("deviceTimeRaw"),
+ requiredInstant(object, "serverReceivedAt"), object.getString("metricCode"),
+ measurement, object.getString("unit"));
+ }
+
+ private CollectorHistoryFaults collectorHistoryFaults(Object value)
+ {
+ if (value == null) return CollectorHistoryFaults.none();
+ JSONObject object = jsonObject(value);
+ List<Long> missing = new ArrayList<>();
+ JSONArray values = object.getJSONArray("missingSequences");
+ if (values != null) values.forEach(item -> missing.add(Long.valueOf(item.toString())));
+ return new CollectorHistoryFaults(missing, object.getLong("duplicateSequence"),
+ object.getLong("conflictingSequence"), object.getBooleanValue("outOfOrder"));
}
private ChannelState channelState(Object value)
@@ -154,4 +213,24 @@
return new ChannelTombstone(requiredInteger(object, "deviceId"), object.getString("ip"),
requiredInteger(object, "channelId"), requiredInteger(object, "remainingDiscoveries"));
}
+
+ private long requiredLong(JSONObject object, String field)
+ {
+ Long value = object.getLong(field);
+ if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
+ return value;
+ }
+
+ private Instant requiredInstant(JSONObject object, String field)
+ {
+ Instant value = optionalInstant(object, field);
+ if (value == null) throw new IllegalStateException("Missing BTS simulator state field: " + field);
+ return value;
+ }
+
+ private Instant optionalInstant(JSONObject object, String field)
+ {
+ String value = object.getString(field);
+ return value == null ? null : Instant.parse(value);
+ }
}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
index 30fc2a7..7563d63 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
@@ -1,5 +1,7 @@
package com.ruoyi.web.controller.device;
+import java.math.BigDecimal;
+import java.util.List;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -10,6 +12,7 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.ruoyi.common.annotation.Anonymous;
@@ -17,6 +20,10 @@
import com.ruoyi.device.bts.BtsDevelopmentSimulator.StateSnapshot;
import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
import com.ruoyi.device.model.DeviceWorkStatus;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
+import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
+import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 本机临时 BTS 模拟器控制 API。 */
@Anonymous
@@ -132,6 +139,72 @@
}
}
+ /** 配置采集器采样频率和可重复的历史下载故障场景。 */
+ @PutMapping("/dev/bts-simulator/collectors/{collectorId}/sampling")
+ public StateSnapshot configureCollectorSampling(@PathVariable int collectorId,
+ @RequestBody CollectorSamplingCommand command)
+ {
+ try
+ {
+ simulator.configureCollectorSampling(collectorId, command.sampleIntervalMs(),
+ new CollectorHistoryFaults(command.missingSequences(), command.duplicateSequence(),
+ command.conflictingSequence(), command.outOfOrder()));
+ return simulator.snapshot();
+ }
+ catch (IllegalArgumentException exception)
+ {
+ throw badRequest(exception);
+ }
+ }
+
+ /** 配置稳定 S 通道的固定和动态测量项。 */
+ @PutMapping("/dev/bts-simulator/collectors/{collectorId}/channels/{channelCode}")
+ public StateSnapshot configureCollectorChannel(@PathVariable int collectorId,
+ @PathVariable String channelCode, @RequestBody CollectorChannelCommand command)
+ {
+ try
+ {
+ List<CollectorMetricState> metrics = command.metrics().stream()
+ .map(metric -> new CollectorMetricState(metric.metricCode(), metric.value(), metric.unit()))
+ .toList();
+ simulator.configureCollectorChannel(collectorId, channelCode, metrics);
+ return simulator.snapshot();
+ }
+ catch (IllegalArgumentException | NullPointerException exception)
+ {
+ throw badRequest(new IllegalArgumentException("Invalid collector channel metrics", exception));
+ }
+ }
+
+ /** 查询采集器最新实时快照;离线时保留最后观测并明确返回离线事实。 */
+ @GetMapping("/dev/bts-simulator/collectors/{collectorId}/realtime")
+ public CollectorRealtimeSnapshot collectorRealtime(@PathVariable int collectorId)
+ {
+ try
+ {
+ return simulator.collectorRealtime(collectorId);
+ }
+ catch (IllegalArgumentException exception)
+ {
+ throw badRequest(exception);
+ }
+ }
+
+ /** 按偏移量分页下载采集器模拟历史,每页最多 1000 条。 */
+ @GetMapping("/dev/bts-simulator/collectors/{collectorId}/history")
+ public CollectorHistoryPage collectorHistory(@PathVariable int collectorId,
+ @RequestParam(defaultValue = "0") long offset, @RequestParam(defaultValue = "100") int limit)
+ {
+ try
+ {
+ return simulator.collectorHistory(collectorId, offset, limit);
+ }
+ catch (IllegalArgumentException exception)
+ {
+ throw badRequest(exception);
+ }
+ }
+
/** 删除指定虚拟采集器。 */
@DeleteMapping("/dev/bts-simulator/collectors/{collectorId}")
public StateSnapshot deleteCollector(@PathVariable int collectorId)
@@ -177,4 +250,17 @@
public record CreateCollectorCommand(String ip, int channelCount) { }
public record CollectorStateCommand(String ip, int channelCount, boolean online) { }
+
+ public record CollectorSamplingCommand(int sampleIntervalMs, List<Long> missingSequences,
+ Long duplicateSequence, Long conflictingSequence, boolean outOfOrder)
+ {
+ public CollectorSamplingCommand
+ {
+ missingSequences = missingSequences == null ? List.of() : List.copyOf(missingSequences);
+ }
+ }
+
+ public record CollectorChannelCommand(List<CollectorMetricCommand> metrics) { }
+
+ public record CollectorMetricCommand(String metricCode, BigDecimal value, String unit) { }
}
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
index 270d4c8..c2da168 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -25,10 +25,12 @@
.status.online::before { background: #218a5b; }
.add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; }
.device-settings { grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; }
- .collector-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; }
+ .collector-settings { display: grid; grid-template-columns: minmax(160px, 1fr) 110px 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; }
+ .sampling-settings { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 12px; align-items: end; padding: 0 16px 16px; }
label { color: #5e6a73; font-size: 12px; }
label > span { display: block; margin-bottom: 6px; }
- input[type="text"], input[type="number"], select { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
+ input[type="text"], input[type="number"], select, textarea { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
+ textarea { min-height: 64px; padding: 7px 9px; resize: vertical; font: 12px Consolas, monospace; }
input[type="checkbox"] { width: 17px; height: 17px; accent-color: #218a5b; cursor: pointer; }
.switch-label { display: inline-flex; align-items: center; gap: 8px; height: 34px; }
.switch-label > span { margin: 0; }
@@ -46,6 +48,8 @@
.request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; }
.request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; }
.request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
+ .collector-actions { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid #e3e9ed; }
+ .collector-output { min-height: 46px; margin: 0 16px 16px; padding: 10px; overflow: auto; color: #34434d; background: #f7f9fa; border: 1px solid #d5dde3; font: 11px Consolas, monospace; white-space: pre-wrap; }
.empty { height: 96px; color: #7d8992; text-align: center; }
.error { display: none; margin-bottom: 16px; padding: 11px 14px; color: #8b2635; background: #fff0f2; border: 1px solid #e4a8b0; font-size: 13px; }
.error.visible { display: block; }
@@ -54,7 +58,7 @@
main { width: calc(100% - 20px); margin-top: 12px; }
.summary { grid-template-columns: 1fr; }
.summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; }
- .add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; }
+ .add-form, .device-settings, .collector-settings, .sampling-settings { grid-template-columns: 1fr 1fr; }
.device-settings button, .collector-settings button { width: 100%; }
.device { margin: 10px; overflow-x: auto; }
.device table { min-width: 520px; }
@@ -232,6 +236,9 @@
const channelCount = field('通道数', 'number', collector.channelCount);
channelCount.input.min = '1';
channelCount.input.max = '99';
+ const sampleInterval = field('采样间隔(毫秒)', 'number', collector.sampleIntervalMs);
+ sampleInterval.input.min = '100';
+ sampleInterval.input.max = '60000';
const onlineLabel = document.createElement('label');
onlineLabel.className = 'switch-label';
const online = document.createElement('input');
@@ -245,7 +252,10 @@
save.type = 'button';
save.textContent = '保存配置';
save.addEventListener('click', async () => {
- try { render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); }
+ try {
+ await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) });
+ render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), ...collector.historyFaults }) }));
+ }
catch (error) { showError(error); }
});
const remove = document.createElement('button');
@@ -256,12 +266,108 @@
try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); }
catch (error) { showError(error); }
});
- settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
- panel.append(title, settings);
+ settings.append(ip.label, channelCount.label, sampleInterval.label, onlineLabel, save, remove);
+
+ const faults = document.createElement('div');
+ faults.className = 'sampling-settings';
+ const missing = field('缺失序号(逗号分隔)', 'text', collector.historyFaults.missingSequences.join(','));
+ const duplicate = field('重复序号', 'number', collector.historyFaults.duplicateSequence || '');
+ const conflict = field('冲突序号', 'number', collector.historyFaults.conflictingSequence || '');
+ const outOfOrderLabel = document.createElement('label');
+ outOfOrderLabel.className = 'switch-label';
+ const outOfOrder = document.createElement('input');
+ outOfOrder.type = 'checkbox';
+ outOfOrder.checked = collector.historyFaults.outOfOrder;
+ const outOfOrderText = document.createElement('span');
+ outOfOrderText.textContent = '历史乱序';
+ outOfOrderLabel.append(outOfOrder, outOfOrderText);
+ const saveFaults = document.createElement('button');
+ saveFaults.type = 'button';
+ saveFaults.textContent = '保存历史场景';
+ saveFaults.addEventListener('click', async () => {
+ const missingSequences = missing.input.value.split(',').map(value => value.trim()).filter(Boolean).map(Number);
+ try { render(await request(`/collectors/${collector.collectorId}/sampling`, { method: 'PUT', body: JSON.stringify({ sampleIntervalMs: Number(sampleInterval.input.value), missingSequences, duplicateSequence: numberOrNull(duplicate.input.value), conflictingSequence: numberOrNull(conflict.input.value), outOfOrder: outOfOrder.checked }) })); }
+ catch (error) { showError(error); }
+ });
+ faults.append(missing.label, duplicate.label, conflict.label, outOfOrderLabel, saveFaults);
+
+ const table = document.createElement('table');
+ table.innerHTML = '<thead><tr><th style="width:110px">稳定通道</th><th>测量项(每行:指标编码=值 单位)</th><th style="width:130px">操作</th></tr></thead>';
+ const channelBody = document.createElement('tbody');
+ channelBody.append(...collector.channels.map(channel => collectorChannelRow(collector.collectorId, channel)));
+ table.append(channelBody);
+
+ const actions = document.createElement('div');
+ actions.className = 'collector-actions';
+ const realtimeButton = document.createElement('button');
+ realtimeButton.type = 'button';
+ realtimeButton.textContent = '查看实时';
+ const historyButton = document.createElement('button');
+ historyButton.type = 'button';
+ historyButton.textContent = '下载历史';
+ const historyCount = document.createElement('span');
+ historyCount.className = 'label';
+ historyCount.textContent = `原始历史 ${collector.historyRecordCount} 条`;
+ const output = document.createElement('pre');
+ output.className = 'collector-output';
+ output.textContent = collector.latestMeasurements.length
+ ? measurementLines(collector.latestMeasurements)
+ : '等待第一条采样';
+ realtimeButton.addEventListener('click', async () => {
+ try { output.textContent = measurementLines((await request(`/collectors/${collector.collectorId}/realtime`)).measurements); }
+ catch (error) { showError(error); }
+ });
+ historyButton.addEventListener('click', async () => {
+ try {
+ const page = await request(`/collectors/${collector.collectorId}/history?offset=0&limit=20`);
+ output.textContent = `历史 ${page.total} 条 / 本页 ${page.records.length} 条\n${measurementLines(page.records)}`;
+ } catch (error) { showError(error); }
+ });
+ actions.append(realtimeButton, historyButton, historyCount);
+ panel.append(title, settings, faults, table, actions, output);
return panel;
}));
}
+ function collectorChannelRow(collectorId, channel) {
+ const row = document.createElement('tr');
+ const identity = document.createElement('td');
+ identity.textContent = channel.channelCode;
+ const metricsCell = document.createElement('td');
+ const metrics = document.createElement('textarea');
+ metrics.setAttribute('aria-label', `${channel.channelCode} 测量项`);
+ metrics.value = channel.metrics.map(item => `${item.metricCode}=${item.value} ${item.unit}`).join('\n');
+ metricsCell.append(metrics);
+ const action = document.createElement('td');
+ const save = document.createElement('button');
+ save.type = 'button';
+ save.textContent = '保存测量值';
+ save.addEventListener('click', async () => {
+ try { render(await request(`/collectors/${collectorId}/channels/${channel.channelCode}`, { method: 'PUT', body: JSON.stringify({ metrics: parseMetrics(metrics.value) }) })); }
+ catch (error) { showError(error); }
+ });
+ action.append(save);
+ row.append(identity, metricsCell, action);
+ return row;
+ }
+
+ function parseMetrics(text) {
+ return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
+ const match = line.match(/^([^=\s]+)=(-?\d+(?:\.\d+)?)\s+(.+)$/);
+ if (!match) throw new Error(`测量项格式错误:${line}`);
+ return { metricCode: match[1], value: Number(match[2]), unit: match[3] };
+ });
+ }
+
+ function measurementLines(items) {
+ if (!items.length) return '暂无测量';
+ return items.map(item => `${item.collectorCode} #${item.sequenceId} ${item.channelCode} ${item.metricCode}=${item.value} ${item.unit} 设备 ${item.deviceTimeRaw} / 接收 ${item.serverReceivedAt}`).join('\n');
+ }
+
+ function numberOrNull(value) {
+ return value.trim() ? Number(value) : null;
+ }
+
function field(labelText, type, value) {
const label = document.createElement('label');
const text = document.createElement('span');
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
index cbceb70..f16a9b2 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
@@ -15,7 +15,7 @@
void registersDevelopmentSimulatorBeforeApplicationReady() throws Exception
{
DeviceAdapterRegistry registry = mock(DeviceAdapterRegistry.class);
- var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(registry);
+ var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(registry, 15021);
runner.run(new DefaultApplicationArguments(new String[0]));
@@ -23,7 +23,7 @@
verify(registry).synchronize(adapter.capture());
assertEquals("dev-sim", adapter.getValue().adapterCode());
assertEquals("127.0.0.1", adapter.getValue().host());
- assertEquals(15020, adapter.getValue().port());
+ assertEquals(15021, adapter.getValue().port());
assertEquals(5000, adapter.getValue().pollIntervalMs());
}
}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
index 94f9d19..6ccc48d 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
@@ -62,6 +62,26 @@
}
@Test
+ void upgradesLegacyCollectorDiscoveryStateWithSamplingDefaults()
+ {
+ AtomicReference<Object> stored = new AtomicReference<>("{\"version\":1,\"devices\":[],"
+ + "\"collectors\":[{\"collectorId\":1,\"deviceCode\":\"dev-sim-collector-1\","
+ + "\"ip\":\"127.0.1.2\",\"mac\":\"02:43:00:00:00:01\","
+ + "\"name\":\"模拟采集器\",\"manufacturer\":\"开发模拟器\","
+ + "\"model\":\"Virtual Collector\",\"channelCount\":2,\"online\":true}],"
+ + "\"nextDeviceId\":1,\"nextCollectorId\":2,\"tombstones\":[]}");
+
+ SimulatorState restored = store(stored).loadOrInitialize(state(1, "127.0.0.2"));
+
+ CollectorState collector = restored.collectors().get(0);
+ assertEquals(1000, collector.sampleIntervalMs());
+ assertEquals(1L, collector.nextSequence());
+ assertEquals(List.of("S-01", "S-02"),
+ collector.channels().stream().map(channel -> channel.channelCode()).toList());
+ assertEquals(List.of(), collector.history());
+ }
+
+ @Test
void rejectsIncompatibleVersionWithoutOverwritingIt()
{
AtomicReference<Object> stored = new AtomicReference<>("{\"version\":2,\"devices\":[],"
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
index 518fba3..720b47c 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
@@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Method;
+import java.math.BigDecimal;
import java.net.InetAddress;
import java.time.Instant;
import java.util.List;
@@ -95,6 +96,34 @@
}
@Test
+ void controllerExposesCollectorRealtimeAndPagedHistory() throws Exception
+ {
+ try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+ {
+ BtsDevelopmentSimulatorController controller = controller(simulator);
+ controller.configureCollectorSampling(1,
+ new BtsDevelopmentSimulatorController.CollectorSamplingCommand(
+ 100, List.of(), null, null, false));
+ controller.configureCollectorChannel(1, "S-01",
+ new BtsDevelopmentSimulatorController.CollectorChannelCommand(List.of(
+ new BtsDevelopmentSimulatorController.CollectorMetricCommand(
+ "temperature", new BigDecimal("27.2"), "℃"),
+ new BtsDevelopmentSimulatorController.CollectorMetricCommand(
+ "custom_voltage", new BigDecimal("1.234"), "V"))));
+ simulator.advanceCollectors(Instant.parse("2026-08-29T01:00:00Z"));
+
+ var realtime = controller.collectorRealtime(1);
+ var firstPage = controller.collectorHistory(1, 0, 2);
+
+ assertTrue(realtime.online());
+ assertTrue(realtime.measurements().stream().anyMatch(item -> item.metricCode().equals("custom_voltage")));
+ assertEquals(2, firstPage.records().size());
+ assertTrue(firstPage.hasMore());
+ assertEquals(2, firstPage.nextOffset());
+ }
+ }
+
+ @Test
void virtualIpMustBeUniqueAcrossCabinetsAndCollectors() throws Exception
{
try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
index c299502..ddac794 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -12,11 +12,18 @@
import com.ruoyi.device.simulator.BtsSimulatorStateStore;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
+import com.ruoyi.device.simulator.DevelopmentCollectorSimulation;
+import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorHistoryPage;
+import com.ruoyi.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
public final class BtsDevelopmentSimulator implements AutoCloseable
@@ -138,8 +145,7 @@
CollectorState current = requireCollector(collectorId);
List<CollectorState> collectors = new ArrayList<>(state.collectors());
collectors.replaceAll(item -> item.collectorId() == collectorId
- ? new CollectorState(collectorId, current.deviceCode(), ip.trim(), current.mac(), current.name(),
- current.manufacturer(), current.model(), channelCount, online)
+ ? DevelopmentCollectorSimulation.resize(current, ip, channelCount, online, Instant.now())
: item);
persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
state.nextCollectorId(), state.tombstones()));
@@ -155,6 +161,45 @@
state.nextCollectorId(), state.tombstones()));
}
+ /** 配置采集器独立采样频率和历史下载故障场景。 */
+ public synchronized void configureCollectorSampling(int collectorId, int sampleIntervalMs,
+ CollectorHistoryFaults historyFaults)
+ {
+ CollectorState current = requireCollector(collectorId);
+ replaceCollector(DevelopmentCollectorSimulation.configureSampling(current, sampleIntervalMs, historyFaults));
+ }
+
+ /** 配置一个稳定 S 通道的固定或动态演示指标。 */
+ public synchronized void configureCollectorChannel(int collectorId, String channelCode,
+ List<CollectorMetricState> metrics)
+ {
+ CollectorState current = requireCollector(collectorId);
+ replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
+ }
+
+ /** 按每台采集器自己的频率推进实时和历史采样。 */
+ public synchronized void advanceCollectors(Instant now)
+ {
+ List<CollectorState> collectors = state.collectors().stream()
+ .map(collector -> DevelopmentCollectorSimulation.advance(collector, now))
+ .toList();
+ if (!collectors.equals(state.collectors()))
+ persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
+ state.nextCollectorId(), state.tombstones()));
+ }
+
+ /** 返回采集器最新实时快照;快照与分页历史保持不同读取路径。 */
+ public synchronized CollectorRealtimeSnapshot collectorRealtime(int collectorId)
+ {
+ return DevelopmentCollectorSimulation.realtime(requireCollector(collectorId));
+ }
+
+ /** 分页下载采集器模拟历史,并应用已配置的重复、冲突、乱序或缺失场景。 */
+ public synchronized CollectorHistoryPage collectorHistory(int collectorId, long offset, int limit)
+ {
+ return DevelopmentCollectorSimulation.history(requireCollector(collectorId), offset, limit);
+ }
+
/** 返回控制页需要的运行状态快照。 */
public synchronized StateSnapshot snapshot()
{
@@ -168,7 +213,9 @@
List<CollectorSnapshot> collectorSnapshots = state.collectors().stream()
.map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(),
collector.ip(), collector.mac(), collector.name(), collector.manufacturer(),
- collector.model(), collector.channelCount(), collector.online()))
+ collector.model(), collector.channelCount(), collector.online(), collector.sampleIntervalMs(),
+ collector.channels(), collector.historyFaults(), collector.history().size(),
+ DevelopmentCollectorSimulation.realtime(collector).measurements()))
.toList();
return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
stateStore.loadRecentRequests());
@@ -202,6 +249,14 @@
return new CollectorState(collectorId, "dev-sim-collector-" + collectorId,
ip.trim(), simulatorCollectorMac(collectorId), "dev-sim 采集器 " + collectorId,
"开发模拟器", "Virtual Collector", channelCount, true);
+ }
+
+ private void replaceCollector(CollectorState replacement)
+ {
+ List<CollectorState> collectors = new ArrayList<>(state.collectors());
+ collectors.replaceAll(item -> item.collectorId() == replacement.collectorId() ? replacement : item);
+ persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
+ state.nextCollectorId(), state.tombstones()));
}
private String respond(String request)
@@ -442,6 +497,7 @@
|| !isIpv4(collector.ip()) || !ips.add(collector.ip())
|| collector.channelCount() < 1 || collector.channelCount() > MAX_CHANNEL_COUNT)
throw new IllegalStateException("Invalid BTS simulator collector state in Redis");
+ DevelopmentCollectorSimulation.validate(collector);
maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
}
java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
@@ -471,6 +527,15 @@
public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { }
public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
- String manufacturer, String model, int channelCount, boolean online) { }
+ String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
+ List<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, int historyRecordCount,
+ List<CollectorMeasurementRecord> latestMeasurements)
+ {
+ public CollectorSnapshot
+ {
+ channels = List.copyOf(channels);
+ latestMeasurements = List.copyOf(latestMeasurements);
+ }
+ }
}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
index c1525db..1902705 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
@@ -1,5 +1,6 @@
package com.ruoyi.device.simulator;
+import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.ruoyi.device.model.DeviceWorkStatus;
@@ -43,7 +44,52 @@
}
record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
- String manufacturer, String model, int channelCount, boolean online) { }
+ String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
+ long nextSequence, Instant lastSampledAt, List<CollectorChannelState> channels,
+ List<CollectorMeasurementRecord> history, CollectorHistoryFaults historyFaults)
+ {
+ public CollectorState
+ {
+ channels = List.copyOf(channels);
+ history = List.copyOf(history);
+ historyFaults = historyFaults == null ? CollectorHistoryFaults.none() : historyFaults;
+ }
+
+ /** 兼容只包含发现配置的旧模拟状态;Redis 读取边界会补齐采样合同。 */
+ public CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
+ String manufacturer, String model, int channelCount, boolean online)
+ {
+ this(collectorId, deviceCode, ip, mac, name, manufacturer, model, channelCount, online,
+ DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, 1L, null,
+ DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of(),
+ CollectorHistoryFaults.none());
+ }
+ }
+
+ record CollectorChannelState(String channelCode, List<CollectorMetricState> metrics)
+ {
+ public CollectorChannelState { metrics = List.copyOf(metrics); }
+ }
+
+ record CollectorMetricState(String metricCode, BigDecimal value, String unit) { }
+
+ record CollectorMeasurementRecord(String adapterCode, String collectorCode, String channelCode,
+ long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
+ String metricCode, BigDecimal value, String unit) { }
+
+ record CollectorHistoryFaults(List<Long> missingSequences, Long duplicateSequence,
+ Long conflictingSequence, boolean outOfOrder)
+ {
+ public CollectorHistoryFaults
+ {
+ missingSequences = missingSequences == null ? List.of() : List.copyOf(missingSequences);
+ }
+
+ public static CollectorHistoryFaults none()
+ {
+ return new CollectorHistoryFaults(List.of(), null, null, false);
+ }
+ }
record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java
new file mode 100644
index 0000000..c1092c5
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java
@@ -0,0 +1,249 @@
+package com.ruoyi.device.simulator;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorChannelState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
+
+/** 实现第一版开发采集器的独立采样与故障注入合同。 */
+public final class DevelopmentCollectorSimulation
+{
+ public static final int DEFAULT_SAMPLE_INTERVAL_MS = 1000;
+ public static final int MIN_SAMPLE_INTERVAL_MS = 100;
+ public static final int MAX_SAMPLE_INTERVAL_MS = 60000;
+ public static final int MAX_HISTORY_PAGE_SIZE = 1000;
+ private static final int MAX_HISTORY_RECORDS = 50000;
+ private static final int MAX_SAMPLES_PER_ADVANCE = 1000;
+ private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
+ private static final DateTimeFormatter DEVICE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
+
+ private DevelopmentCollectorSimulation() { }
+
+ /** 创建带稳定 S 通道和三项固定演示指标的采集器状态。 */
+ public static List<CollectorChannelState> defaultChannels(int channelCount)
+ {
+ List<CollectorChannelState> channels = new ArrayList<>(channelCount);
+ for (int index = 1; index <= channelCount; index++)
+ {
+ channels.add(new CollectorChannelState(channelCode(index), List.of(
+ new CollectorMetricState("temperature", new BigDecimal("25.0")
+ .add(BigDecimal.valueOf(index, 1)), "℃"),
+ new CollectorMetricState("positive_potential", new BigDecimal("3.700"), "V"),
+ new CollectorMetricState("negative_potential", new BigDecimal("-0.100"), "V"))));
+ }
+ return List.copyOf(channels);
+ }
+
+ /** 调整通道数时保留已有通道配置,并为新增通道生成稳定默认配置。 */
+ public static CollectorState resize(CollectorState current, String ip, int channelCount, boolean online,
+ Instant changedAt)
+ {
+ List<CollectorChannelState> defaults = defaultChannels(channelCount);
+ List<CollectorChannelState> channels = new ArrayList<>(channelCount);
+ for (int index = 0; index < channelCount; index++)
+ channels.add(index < current.channels().size() ? current.channels().get(index) : defaults.get(index));
+ Instant lastSampledAt = online == current.online() ? current.lastSampledAt() : changedAt;
+ return copy(current, ip, channelCount, online, current.sampleIntervalMs(), current.nextSequence(),
+ lastSampledAt, channels, current.history(), current.historyFaults());
+ }
+
+ /** 更新采样频率和历史下载故障;测量原始历史保持只追加。 */
+ public static CollectorState configureSampling(CollectorState current, int sampleIntervalMs,
+ CollectorHistoryFaults historyFaults)
+ {
+ validateSampleInterval(sampleIntervalMs);
+ validateFaults(historyFaults);
+ return copy(current, current.ip(), current.channelCount(), current.online(), sampleIntervalMs,
+ current.nextSequence(), current.lastSampledAt(), current.channels(), current.history(), historyFaults);
+ }
+
+ /** 替换一个演示通道的指标配置;动态添加项与固定指标共用指标编码合同。 */
+ public static CollectorState configureChannel(CollectorState current, String channelCode,
+ List<CollectorMetricState> metrics)
+ {
+ validateMetrics(metrics);
+ int channelIndex = parseChannelIndex(channelCode, current.channelCount());
+ List<CollectorChannelState> channels = new ArrayList<>(current.channels());
+ channels.set(channelIndex, new CollectorChannelState(channelCode(channelIndex + 1), metrics));
+ return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
+ current.nextSequence(), current.lastSampledAt(), channels, current.history(), current.historyFaults());
+ }
+
+ /** 按采集器自身频率推进采样;离线期间不生成记录,恢复后从下一序号继续。 */
+ public static CollectorState advance(CollectorState current, Instant now)
+ {
+ if (!current.online()) return current;
+ Instant sampleAt = current.lastSampledAt() == null
+ ? now
+ : current.lastSampledAt().plusMillis(current.sampleIntervalMs());
+ if (sampleAt.isAfter(now)) return current;
+
+ List<CollectorMeasurementRecord> history = new ArrayList<>(current.history());
+ long sequence = current.nextSequence();
+ int generated = 0;
+ Instant lastSampledAt = current.lastSampledAt();
+ while (!sampleAt.isAfter(now) && generated < MAX_SAMPLES_PER_ADVANCE)
+ {
+ for (CollectorChannelState channel : current.channels())
+ {
+ for (CollectorMetricState metric : channel.metrics())
+ {
+ history.add(new CollectorMeasurementRecord("dev-sim", current.deviceCode(),
+ channel.channelCode(), sequence, sampleAt,
+ DEVICE_TIME_FORMATTER.format(sampleAt.atZone(DEVICE_ZONE)), now,
+ metric.metricCode(), metric.value(), metric.unit()));
+ }
+ }
+ lastSampledAt = sampleAt;
+ sequence++;
+ generated++;
+ sampleAt = sampleAt.plusMillis(current.sampleIntervalMs());
+ }
+ if (history.size() > MAX_HISTORY_RECORDS)
+ history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_RECORDS, history.size()));
+ return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
+ sequence, lastSampledAt, current.channels(), history, current.historyFaults());
+ }
+
+ /** 返回每个通道和指标的最新实时快照,不将快照写入另一条历史路径。 */
+ public static CollectorRealtimeSnapshot realtime(CollectorState current)
+ {
+ Map<String, CollectorMeasurementRecord> latest = new LinkedHashMap<>();
+ for (CollectorMeasurementRecord record : current.history())
+ latest.put(record.channelCode() + "\u0000" + record.metricCode(), record);
+ return new CollectorRealtimeSnapshot(current.deviceCode(), current.online(), current.sampleIntervalMs(),
+ List.copyOf(latest.values()));
+ }
+
+ /** 对只追加原始模拟历史应用可重复的下载故障视图,再按偏移量分页。 */
+ public static CollectorHistoryPage history(CollectorState current, long offset, int limit)
+ {
+ if (offset < 0) throw new IllegalArgumentException("offset must not be negative");
+ if (limit < 1 || limit > MAX_HISTORY_PAGE_SIZE)
+ throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_PAGE_SIZE);
+
+ List<CollectorMeasurementRecord> download = faultedHistory(current);
+ int from = (int) Math.min(offset, download.size());
+ int to = Math.min(from + limit, download.size());
+ return new CollectorHistoryPage(current.deviceCode(), offset, to, to < download.size(),
+ download.size(), download.subList(from, to));
+ }
+
+ /** 校验 Redis 恢复的采集器采样状态,拒绝损坏状态被静默替换。 */
+ public static void validate(CollectorState state)
+ {
+ validateSampleInterval(state.sampleIntervalMs());
+ if (state.nextSequence() < 1 || state.channels().size() != state.channelCount())
+ throw new IllegalStateException("Invalid collector sampling state in Redis");
+ for (int index = 0; index < state.channels().size(); index++)
+ {
+ CollectorChannelState channel = state.channels().get(index);
+ if (!channelCode(index + 1).equals(channel.channelCode()))
+ throw new IllegalStateException("Invalid collector channel identity in Redis");
+ validateMetrics(channel.metrics());
+ }
+ validateFaults(state.historyFaults());
+ }
+
+ private static List<CollectorMeasurementRecord> faultedHistory(CollectorState current)
+ {
+ CollectorHistoryFaults faults = current.historyFaults();
+ Set<Long> missing = Set.copyOf(faults.missingSequences());
+ List<CollectorMeasurementRecord> records = new ArrayList<>();
+ for (CollectorMeasurementRecord record : current.history())
+ {
+ if (missing.contains(record.sequenceId())) continue;
+ records.add(record);
+ if (Long.valueOf(record.sequenceId()).equals(faults.duplicateSequence())) records.add(record);
+ if (Long.valueOf(record.sequenceId()).equals(faults.conflictingSequence()))
+ records.add(new CollectorMeasurementRecord(record.adapterCode(), record.collectorCode(),
+ record.channelCode(), record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(),
+ record.serverReceivedAt(), record.metricCode(),
+ record.value().add(new BigDecimal("0.001")), record.unit()));
+ }
+ if (faults.outOfOrder())
+ records.sort(Comparator.comparingLong(CollectorMeasurementRecord::sequenceId).reversed()
+ .thenComparing(CollectorMeasurementRecord::channelCode)
+ .thenComparing(CollectorMeasurementRecord::metricCode));
+ return records;
+ }
+
+ private static CollectorState copy(CollectorState current, String ip, int channelCount, boolean online,
+ int sampleIntervalMs, long nextSequence, Instant lastSampledAt,
+ List<CollectorChannelState> channels, List<CollectorMeasurementRecord> history,
+ CollectorHistoryFaults faults)
+ {
+ return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(),
+ current.name(), current.manufacturer(), current.model(), channelCount, online, sampleIntervalMs,
+ nextSequence, lastSampledAt, channels, history, faults);
+ }
+
+ private static void validateSampleInterval(int sampleIntervalMs)
+ {
+ if (sampleIntervalMs < MIN_SAMPLE_INTERVAL_MS || sampleIntervalMs > MAX_SAMPLE_INTERVAL_MS)
+ throw new IllegalArgumentException("sampleIntervalMs must be between " + MIN_SAMPLE_INTERVAL_MS
+ + " and " + MAX_SAMPLE_INTERVAL_MS);
+ }
+
+ private static void validateMetrics(List<CollectorMetricState> metrics)
+ {
+ if (metrics == null || metrics.isEmpty())
+ throw new IllegalArgumentException("Collector channel must contain at least one metric");
+ java.util.HashSet<String> codes = new java.util.HashSet<>();
+ for (CollectorMetricState metric : metrics)
+ {
+ if (metric.metricCode() == null || metric.metricCode().isBlank() || metric.value() == null
+ || metric.unit() == null || metric.unit().isBlank() || !codes.add(metric.metricCode().trim()))
+ throw new IllegalArgumentException("Collector metric code, value and unit must be valid and unique");
+ }
+ }
+
+ private static void validateFaults(CollectorHistoryFaults faults)
+ {
+ if (faults == null) throw new IllegalArgumentException("historyFaults must not be null");
+ if (faults.missingSequences().stream().anyMatch(sequence -> sequence == null || sequence < 1)
+ || faults.duplicateSequence() != null && faults.duplicateSequence() < 1
+ || faults.conflictingSequence() != null && faults.conflictingSequence() < 1)
+ throw new IllegalArgumentException("History fault sequences must be positive");
+ }
+
+ private static int parseChannelIndex(String value, int channelCount)
+ {
+ if (value == null || !value.matches("S-\\d{2}"))
+ throw new IllegalArgumentException("Collector channel must use S-01...S-N identity");
+ int index = Integer.parseInt(value.substring(2)) - 1;
+ if (index < 0 || index >= channelCount || !channelCode(index + 1).equals(value))
+ throw new IllegalArgumentException("Unknown collector channel: " + value);
+ return index;
+ }
+
+ private static String channelCode(int index)
+ {
+ return String.format("S-%02d", index);
+ }
+
+ public record CollectorRealtimeSnapshot(String collectorCode, boolean online, int sampleIntervalMs,
+ List<CollectorMeasurementRecord> measurements)
+ {
+ public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); }
+ }
+
+ public record CollectorHistoryPage(String collectorCode, long offset, long nextOffset, boolean hasMore,
+ long total, List<CollectorMeasurementRecord> records)
+ {
+ public CollectorHistoryPage { records = List.copyOf(records); }
+ }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
index de1a699..9e8be45 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
@@ -1,9 +1,14 @@
package com.ruoyi.device.contract;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.math.BigDecimal;
import java.time.Duration;
+import java.time.Instant;
import java.time.ZoneId;
+import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
import com.ruoyi.device.api.DeviceAdapter;
@@ -16,6 +21,8 @@
import com.ruoyi.device.model.DeviceWorkStatus;
import com.ruoyi.device.simulator.BtsSimulatorStateStore;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorHistoryFaults;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMetricState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
@@ -23,6 +30,89 @@
class SimulatorPublicContractTest
{
@Test
+ void collectorGeneratesIndependentRealtimeAndHistoricalMeasurements() throws Exception
+ {
+ Instant startedAt = Instant.parse("2026-08-29T01:00:00Z");
+ try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+ {
+ simulator.configureCollectorSampling(1, 200, CollectorHistoryFaults.none());
+ simulator.configureCollectorChannel(1, "S-01", List.of(
+ new CollectorMetricState("temperature", new BigDecimal("26.5"), "℃"),
+ new CollectorMetricState("positive_potential", new BigDecimal("3.801"), "V"),
+ new CollectorMetricState("negative_potential", new BigDecimal("-0.120"), "V"),
+ new CollectorMetricState("pressure", new BigDecimal("101.2"), "kPa")));
+
+ simulator.advanceCollectors(startedAt);
+ simulator.advanceCollectors(startedAt.plusMillis(400));
+
+ var realtime = simulator.snapshot().collectors().get(0).latestMeasurements();
+ var history = simulator.collectorHistory(1, 0, 1000);
+ assertEquals(13, realtime.size());
+ assertTrue(realtime.stream().allMatch(item -> item.sequenceId() == 3));
+ assertTrue(realtime.stream().anyMatch(item -> item.channelCode().equals("S-01")
+ && item.metricCode().equals("pressure") && item.unit().equals("kPa")));
+ assertEquals(39, history.total());
+ assertEquals("dev-sim", history.records().get(0).adapterCode());
+ assertEquals("dev-sim-collector-1", history.records().get(0).collectorCode());
+ assertEquals(startedAt, history.records().get(0).deviceTime());
+ assertTrue(history.records().get(0).deviceTimeRaw().startsWith("2026-08-29 09:00:00"));
+ assertEquals(startedAt, history.records().get(0).serverReceivedAt());
+ }
+ }
+
+ @Test
+ void collectorHistorySupportsPagingAndRepeatableFaultScenarios() throws Exception
+ {
+ Instant startedAt = Instant.parse("2026-08-29T02:00:00Z");
+ try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+ {
+ simulator.configureCollectorSampling(1, 100,
+ new CollectorHistoryFaults(List.of(2L), 1L, 3L, true));
+ simulator.advanceCollectors(startedAt);
+ simulator.advanceCollectors(startedAt.plusMillis(200));
+
+ var first = simulator.collectorHistory(1, 0, 17);
+ var second = simulator.collectorHistory(1, first.nextOffset(), 1000);
+ List<com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord> records =
+ new java.util.ArrayList<>(first.records());
+ records.addAll(second.records());
+
+ assertEquals(48, first.total());
+ assertTrue(first.hasMore());
+ assertFalse(second.hasMore());
+ assertTrue(records.stream().noneMatch(item -> item.sequenceId() == 2));
+ assertEquals(24, records.stream().filter(item -> item.sequenceId() == 1).count());
+ assertEquals(2, records.stream().filter(item -> item.sequenceId() == 3
+ && item.channelCode().equals("S-01") && item.metricCode().equals("temperature")).count());
+ assertTrue(records.get(0).sequenceId() >= records.get(records.size() - 1).sequenceId());
+ }
+ }
+
+ @Test
+ void collectorStopsWhileOfflineAndResumesFromPersistedSequence() throws Exception
+ {
+ InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
+ Instant now = Instant.now();
+ try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
+ {
+ simulator.advanceCollectors(now);
+ simulator.updateCollector(1, "127.0.1.2", 4, false);
+ simulator.advanceCollectors(now.plusSeconds(10));
+ assertEquals(1, simulator.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId());
+ }
+
+ try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
+ {
+ restored.updateCollector(1, "127.0.1.2", 4, true);
+ restored.advanceCollectors(Instant.now().plusSeconds(2));
+ assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().get(0).sequenceId() >= 2);
+ assertEquals(List.of("S-01", "S-02", "S-03", "S-04"),
+ restored.snapshot().collectors().get(0).channels().stream()
+ .map(channel -> channel.channelCode()).toList());
+ }
+ }
+
+ @Test
void simulatorIsReusableOutsideTheBtsPackage() throws Exception
{
try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
--
Gitblit v1.7.1