From 0162f3730ca2e9644322a90450728b63243d40d0 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 29 八月 2026 13:36:20 +0800
Subject: [PATCH] 修正采集器历史与实时快照模型

---
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java              |    3 
 ruoyi-admin/src/main/resources/static/dev/bts-simulator.html                              |    2 
 ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java               |   39 +++++++
 ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java         |    8 +
 ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java |  145 +++++++++++++++++++++++++---
 ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java     |   88 +++++++++++++++++
 6 files changed, 263 insertions(+), 22 deletions(-)

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 40a5707..0b9bb63 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
@@ -153,10 +153,16 @@
         requiredArray(object, "channels").forEach(item -> channels.add(collectorChannelState(item)));
         List<CollectorMeasurementRecord> history = new ArrayList<>();
         requiredArray(object, "history").forEach(item -> history.add(collectorMeasurementRecord(item)));
+        List<CollectorRealtimeMeasurement> latestMeasurements = new ArrayList<>();
+        JSONArray latest = object.getJSONArray("latestMeasurements");
+        if (latest != null)
+            latest.forEach(item -> latestMeasurements.add(collectorRealtimeMeasurement(item)));
+        else
+            latestMeasurements.addAll(latestMeasurementsFromLegacyHistory(history, channels));
         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")));
+                channels, latestMeasurements, history, collectorHistoryFaults(object.get("historyFaults")));
     }
 
     private CollectorChannelState collectorChannelState(Object value)
@@ -187,6 +193,37 @@
                 measurement, object.getString("unit"));
     }
 
+    private CollectorRealtimeMeasurement collectorRealtimeMeasurement(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 CollectorRealtimeMeasurement(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"));
+    }
+
+    /** 兼容 Ticket06 开发期间已写入但尚未拆分快照模型的状态。 */
+    private List<CollectorRealtimeMeasurement> latestMeasurementsFromLegacyHistory(
+            List<CollectorMeasurementRecord> history, List<CollectorChannelState> channels)
+    {
+        java.util.Set<String> configured = new java.util.HashSet<>();
+        channels.forEach(channel -> channel.metrics().forEach(metric ->
+                configured.add(channel.channelCode() + "\u0000" + metric.metricCode())));
+        java.util.Map<String, CollectorRealtimeMeasurement> latest = new java.util.LinkedHashMap<>();
+        for (CollectorMeasurementRecord record : history)
+        {
+            String key = record.channelCode() + "\u0000" + record.metricCode();
+            if (configured.contains(key))
+                latest.put(key, new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(),
+                        record.channelCode(), record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(),
+                        record.serverReceivedAt(), record.metricCode(), record.value(), record.unit()));
+        }
+        return List.copyOf(latest.values());
+    }
+
     private CollectorHistoryFaults collectorHistoryFaults(Object value)
     {
         if (value == null) return CollectorHistoryFaults.none();
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 c2da168..57a89b5 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -439,7 +439,7 @@
     }
 
     async function refresh() {
-      if (document.activeElement && ['INPUT', 'SELECT'].includes(document.activeElement.tagName)) return;
+      if (document.activeElement && ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
       try { render(await request('/state')); } catch (error) { showError(error); }
     }
 
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 ddac794..1663a40 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
@@ -16,6 +16,7 @@
 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.CollectorRealtimeMeasurement;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
@@ -529,7 +530,7 @@
     public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
             String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
             List<CollectorChannelState> channels, CollectorHistoryFaults historyFaults, int historyRecordCount,
-            List<CollectorMeasurementRecord> latestMeasurements)
+            List<CollectorRealtimeMeasurement> latestMeasurements)
     {
         public CollectorSnapshot
         {
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 1902705..7f70ae5 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
@@ -46,11 +46,13 @@
     record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
             String manufacturer, String model, int channelCount, boolean online, int sampleIntervalMs,
             long nextSequence, Instant lastSampledAt, List<CollectorChannelState> channels,
+            List<CollectorRealtimeMeasurement> latestMeasurements,
             List<CollectorMeasurementRecord> history, CollectorHistoryFaults historyFaults)
     {
         public CollectorState
         {
             channels = List.copyOf(channels);
+            latestMeasurements = List.copyOf(latestMeasurements);
             history = List.copyOf(history);
             historyFaults = historyFaults == null ? CollectorHistoryFaults.none() : historyFaults;
         }
@@ -61,7 +63,7 @@
         {
             this(collectorId, deviceCode, ip, mac, name, manufacturer, model, channelCount, online,
                     DevelopmentCollectorSimulation.DEFAULT_SAMPLE_INTERVAL_MS, 1L, null,
-                    DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of(),
+                    DevelopmentCollectorSimulation.defaultChannels(channelCount), List.of(), List.of(),
                     CollectorHistoryFaults.none());
         }
     }
@@ -73,6 +75,10 @@
 
     record CollectorMetricState(String metricCode, BigDecimal value, String unit) { }
 
+    record CollectorRealtimeMeasurement(String adapterCode, String collectorCode, String channelCode,
+            long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
+            String metricCode, BigDecimal value, String unit) { }
+
     record CollectorMeasurementRecord(String adapterCode, String collectorCode, String channelCode,
             long sequenceId, Instant deviceTime, String deviceTimeRaw, Instant serverReceivedAt,
             String metricCode, BigDecimal value, String unit) { }
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
index c1092c5..369172a 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java
@@ -1,10 +1,12 @@
 package com.ruoyi.device.simulator;
 
 import java.math.BigDecimal;
-import java.time.Duration;
 import java.time.Instant;
+import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.LinkedHashMap;
@@ -15,6 +17,7 @@
 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.CollectorRealtimeMeasurement;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
 
 /** 实现第一版开发采集器的独立采样与故障注入合同。 */
@@ -24,7 +27,6 @@
     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 =
@@ -56,8 +58,10 @@
         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;
+        List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements(
+                current.latestMeasurements(), channels);
         return copy(current, ip, channelCount, online, current.sampleIntervalMs(), current.nextSequence(),
-                lastSampledAt, channels, current.history(), current.historyFaults());
+                lastSampledAt, channels, latestMeasurements, current.history(), current.historyFaults());
     }
 
     /** 更新采样频率和历史下载故障;测量原始历史保持只追加。 */
@@ -67,7 +71,8 @@
         validateSampleInterval(sampleIntervalMs);
         validateFaults(historyFaults);
         return copy(current, current.ip(), current.channelCount(), current.online(), sampleIntervalMs,
-                current.nextSequence(), current.lastSampledAt(), current.channels(), current.history(), historyFaults);
+                current.nextSequence(), current.lastSampledAt(), current.channels(), current.latestMeasurements(),
+                current.history(), historyFaults);
     }
 
     /** 替换一个演示通道的指标配置;动态添加项与固定指标共用指标编码合同。 */
@@ -78,8 +83,11 @@
         int channelIndex = parseChannelIndex(channelCode, current.channelCount());
         List<CollectorChannelState> channels = new ArrayList<>(current.channels());
         channels.set(channelIndex, new CollectorChannelState(channelCode(channelIndex + 1), metrics));
+        List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements(
+                current.latestMeasurements(), channels);
         return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
-                current.nextSequence(), current.lastSampledAt(), channels, current.history(), current.historyFaults());
+                current.nextSequence(), current.lastSampledAt(), channels, latestMeasurements,
+                current.history(), current.historyFaults());
     }
 
     /** 按采集器自身频率推进采样;离线期间不生成记录,恢复后从下一序号继续。 */
@@ -92,6 +100,9 @@
         if (sampleAt.isAfter(now)) return current;
 
         List<CollectorMeasurementRecord> history = new ArrayList<>(current.history());
+        Map<String, CollectorRealtimeMeasurement> latest = new LinkedHashMap<>();
+        for (CollectorRealtimeMeasurement measurement : current.latestMeasurements())
+            latest.put(measurementKey(measurement.channelCode(), measurement.metricCode()), measurement);
         long sequence = current.nextSequence();
         int generated = 0;
         Instant lastSampledAt = current.lastSampledAt();
@@ -101,10 +112,16 @@
             {
                 for (CollectorMetricState metric : channel.metrics())
                 {
-                    history.add(new CollectorMeasurementRecord("dev-sim", current.deviceCode(),
+                    CollectorMeasurementRecord record = 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()));
+                            metric.metricCode(), metric.value(), metric.unit());
+                    history.add(record);
+                    latest.put(measurementKey(record.channelCode(), record.metricCode()),
+                            new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(),
+                                    record.channelCode(), record.sequenceId(), record.deviceTime(),
+                                    record.deviceTimeRaw(), record.serverReceivedAt(), record.metricCode(),
+                                    record.value(), record.unit()));
                 }
             }
             lastSampledAt = sampleAt;
@@ -112,20 +129,16 @@
             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());
+                sequence, lastSampledAt, current.channels(), List.copyOf(latest.values()), 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()));
+                current.latestMeasurements());
     }
 
     /** 对只追加原始模拟历史应用可重复的下载故障视图,再按偏移量分页。 */
@@ -155,7 +168,102 @@
                 throw new IllegalStateException("Invalid collector channel identity in Redis");
             validateMetrics(channel.metrics());
         }
+        validateLatestMeasurements(state);
+        validateHistory(state);
         validateFaults(state.historyFaults());
+    }
+
+    private static void validateLatestMeasurements(CollectorState state)
+    {
+        Set<String> configured = configuredMeasurementKeys(state.channels());
+        Set<String> identities = new java.util.HashSet<>();
+        for (CollectorRealtimeMeasurement measurement : state.latestMeasurements())
+        {
+            String key = measurementKey(measurement.channelCode(), measurement.metricCode());
+            if (!validMeasurement(state, measurement.adapterCode(), measurement.collectorCode(),
+                    measurement.channelCode(), measurement.sequenceId(), measurement.deviceTime(),
+                    measurement.deviceTimeRaw(), measurement.serverReceivedAt(), measurement.metricCode(),
+                    measurement.value(), measurement.unit()) || !configured.contains(key) || !identities.add(key)
+                    || measurement.sequenceId() >= state.nextSequence())
+                throw new IllegalStateException("Invalid collector realtime state in Redis");
+        }
+    }
+
+    private static void validateHistory(CollectorState state)
+    {
+        long maximumSequence = 0;
+        long previousSequence = 0;
+        Set<String> identities = new java.util.HashSet<>();
+        for (CollectorMeasurementRecord record : state.history())
+        {
+            String identity = record.sequenceId() + "\u0000" + record.channelCode() + "\u0000" + record.metricCode();
+            if (!validMeasurement(state, record.adapterCode(), record.collectorCode(), record.channelCode(),
+                    record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(),
+                    record.metricCode(), record.value(), record.unit()) || !identities.add(identity)
+                    || record.sequenceId() < previousSequence)
+                throw new IllegalStateException("Invalid collector history state in Redis");
+            maximumSequence = Math.max(maximumSequence, record.sequenceId());
+            previousSequence = record.sequenceId();
+        }
+        if (state.nextSequence() <= maximumSequence)
+            throw new IllegalStateException("Invalid collector history sequence in Redis");
+    }
+
+    private static boolean validMeasurement(CollectorState state, String adapterCode, String collectorCode,
+            String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw,
+            Instant serverReceivedAt, String metricCode, BigDecimal value, String unit)
+    {
+        return "dev-sim".equals(adapterCode) && state.deviceCode().equals(collectorCode)
+                && validHistoricalChannelCode(channelCode)
+                && sequenceId > 0 && deviceTime != null && deviceTimeRaw != null && !deviceTimeRaw.isBlank()
+                && validDeviceTime(deviceTime, deviceTimeRaw) && serverReceivedAt != null
+                && !serverReceivedAt.isBefore(deviceTime) && metricCode != null && !metricCode.isBlank()
+                && metricCode.equals(metricCode.trim()) && value != null && unit != null && !unit.isBlank()
+                && unit.equals(unit.trim());
+    }
+
+    private static boolean validHistoricalChannelCode(String channelCode)
+    {
+        if (channelCode == null || !channelCode.matches("S-\\d{2}")) return false;
+        int index = Integer.parseInt(channelCode.substring(2));
+        return index >= 1 && channelCode(index).equals(channelCode);
+    }
+
+    private static boolean validDeviceTime(Instant deviceTime, String deviceTimeRaw)
+    {
+        try
+        {
+            Instant parsed = LocalDateTime.parse(deviceTimeRaw, DEVICE_TIME_FORMATTER)
+                    .atZone(DEVICE_ZONE).toInstant();
+            return parsed.equals(deviceTime.truncatedTo(ChronoUnit.MILLIS));
+        }
+        catch (DateTimeParseException exception)
+        {
+            return false;
+        }
+    }
+
+    private static List<CollectorRealtimeMeasurement> retainConfiguredMeasurements(
+            List<CollectorRealtimeMeasurement> measurements, List<CollectorChannelState> channels)
+    {
+        Set<String> configured = configuredMeasurementKeys(channels);
+        return measurements.stream()
+                .filter(item -> configured.contains(measurementKey(item.channelCode(), item.metricCode())))
+                .toList();
+    }
+
+    private static Set<String> configuredMeasurementKeys(List<CollectorChannelState> channels)
+    {
+        Set<String> configured = new java.util.HashSet<>();
+        for (CollectorChannelState channel : channels)
+            for (CollectorMetricState metric : channel.metrics())
+                configured.add(measurementKey(channel.channelCode(), metric.metricCode()));
+        return configured;
+    }
+
+    private static String measurementKey(String channelCode, String metricCode)
+    {
+        return channelCode + "\u0000" + metricCode;
     }
 
     private static List<CollectorMeasurementRecord> faultedHistory(CollectorState current)
@@ -183,12 +291,13 @@
 
     private static CollectorState copy(CollectorState current, String ip, int channelCount, boolean online,
             int sampleIntervalMs, long nextSequence, Instant lastSampledAt,
-            List<CollectorChannelState> channels, List<CollectorMeasurementRecord> history,
+            List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements,
+            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);
+                nextSequence, lastSampledAt, channels, latestMeasurements, history, faults);
     }
 
     private static void validateSampleInterval(int sampleIntervalMs)
@@ -236,7 +345,7 @@
     }
 
     public record CollectorRealtimeSnapshot(String collectorCode, boolean online, int sampleIntervalMs,
-            List<CollectorMeasurementRecord> measurements)
+            List<CollectorRealtimeMeasurement> measurements)
     {
         public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); }
     }
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 9e8be45..ba19503 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
@@ -21,8 +21,11 @@
 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.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.SimulatorState;
 import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
@@ -109,6 +112,53 @@
             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 collectorKeepsAppendOnlyHistoryBeyondPreviousDisplayLimit() throws Exception
+    {
+        Instant startedAt = Instant.parse("2026-08-29T03:00:00Z");
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            simulator.configureCollectorSampling(1, 100, CollectorHistoryFaults.none());
+            simulator.advanceCollectors(startedAt);
+            for (int batch = 1; batch <= 5; batch++)
+                simulator.advanceCollectors(startedAt.plusMillis(batch * 100_000L));
+
+            assertEquals(60_012, simulator.collectorHistory(1, 0, 1).total());
+            assertEquals(1, simulator.collectorHistory(1, 0, 1).records().get(0).sequenceId());
+        }
+    }
+
+    @Test
+    void collectorRealtimeDropsMeasurementsRemovedFromCurrentConfiguration() throws Exception
+    {
+        Instant startedAt = Instant.parse("2026-08-29T04:00:00Z");
+        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
+        {
+            simulator.advanceCollectors(startedAt);
+            assertEquals(12, simulator.snapshot().collectors().get(0).latestMeasurements().size());
+
+            simulator.configureCollectorChannel(1, "S-01", List.of(
+                    new CollectorMetricState("temperature", new BigDecimal("27.0"), "℃")));
+            simulator.updateCollector(1, "127.0.1.2", 2, true);
+
+            var snapshot = simulator.snapshot().collectors().get(0);
+            assertEquals(4, snapshot.latestMeasurements().size());
+            assertTrue(snapshot.latestMeasurements().stream()
+                    .noneMatch(item -> item.channelCode().equals("S-03")
+                            || item.channelCode().equals("S-04")
+                            || item.channelCode().equals("S-01")
+                                    && item.metricCode().equals("positive_potential")));
+            assertEquals(12, simulator.collectorHistory(1, 0, 100).total());
+        }
+
+        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
+        {
+            assertEquals(4, restored.snapshot().collectors().get(0).latestMeasurements().size());
+            assertEquals(12, restored.collectorHistory(1, 0, 100).total());
         }
     }
 
@@ -333,4 +383,42 @@
         assertThrows(IllegalStateException.class,
                 () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
     }
+
+    @Test
+    void developmentSimulatorRejectsDamagedCollectorHistoryIdentity()
+    {
+        Instant observedAt = Instant.parse("2026-08-29T05:00:00Z");
+        List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of(
+                new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃"))));
+        CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2",
+                "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true,
+                1000, 2L, observedAt, channels, List.of(), List.of(new CollectorMeasurementRecord(
+                        "dev-sim", "wrong-collector", "S-01", 1L, observedAt,
+                        "2026-08-29 13:00:00.000", observedAt, "temperature",
+                        new BigDecimal("25.0"), "℃")), CollectorHistoryFaults.none());
+        SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
+                List.of(), List.of(collector), 1, 2, List.of());
+
+        assertThrows(IllegalStateException.class,
+                () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
+    }
+
+    @Test
+    void developmentSimulatorRejectsCollectorSequenceBehindPersistedHistory()
+    {
+        Instant observedAt = Instant.parse("2026-08-29T06:00:00Z");
+        List<CollectorChannelState> channels = List.of(new CollectorChannelState("S-01", List.of(
+                new CollectorMetricState("temperature", new BigDecimal("25.0"), "℃"))));
+        CollectorState collector = new CollectorState(1, "dev-sim-collector-1", "127.0.1.2",
+                "02:43:00:00:00:01", "模拟采集器", "开发模拟器", "Virtual Collector", 1, true,
+                1000, 1L, observedAt, channels, List.of(), List.of(new CollectorMeasurementRecord(
+                        "dev-sim", "dev-sim-collector-1", "S-01", 1L, observedAt,
+                        "2026-08-29 14:00:00.000", observedAt, "temperature",
+                        new BigDecimal("25.0"), "℃")), CollectorHistoryFaults.none());
+        SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
+                List.of(), List.of(collector), 1, 2, List.of());
+
+        assertThrows(IllegalStateException.class,
+                () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
+    }
 }

--
Gitblit v1.7.1