From c2dd019c1fddaeae412e2b6f44fb86ff0dc635d4 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 02 九月 2026 22:08:16 +0800
Subject: [PATCH] 实现主动轮询完整实时快照

---
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java               |    9 +
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java      |    2 
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentRealtimeSnapshotScheduler.java       |   19 +++
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java     |   12 -
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java         |    3 
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                              |   41 ++++++++
 ruoyi-admin/src/main/resources/db/migration/V31__unified_realtime_snapshots.sql            |   36 +++++++
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                        |   14 ++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java     |   15 ++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java   |   11 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java |   87 +++++++++++++++++
 11 files changed, 234 insertions(+), 15 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
index 56f3215..ded4bad 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java
@@ -3,10 +3,12 @@
 import java.time.Instant;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.context.annotation.Profile;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
 
 /** 按各采集器配置频率推进开发模拟测量,不参与正式测试数据持久化。 */
 @Component
+@Profile("dev-simulator")
 public class DevelopmentCollectorSamplingScheduler
 {
     private final BtsDevelopmentSimulator simulator;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentRealtimeSnapshotScheduler.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentRealtimeSnapshotScheduler.java
new file mode 100644
index 0000000..305ba48
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentRealtimeSnapshotScheduler.java
@@ -0,0 +1,19 @@
+package com.ruoyi.config;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.context.annotation.Profile;
+import com.ruoyi.system.service.ITestDataReceptionService;
+
+/** 开发环境按模拟器槽位主动形成完整实时采集快照;生产轮询周期由真实设备调度器决定。 */
+@Component
+@Profile("dev-simulator")
+public class DevelopmentRealtimeSnapshotScheduler
+{
+    private final ITestDataReceptionService reception;
+
+    public DevelopmentRealtimeSnapshotScheduler(ITestDataReceptionService reception) { this.reception = reception; }
+
+    @Scheduled(fixedDelay = 100)
+    public void capture() { reception.captureDevelopmentRealtimeSnapshots(); }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
index ad7c8ad..d0ab590 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
@@ -2,26 +2,24 @@
 
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.context.annotation.Profile;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
 import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService;
 import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService.DiscoverySource;
-import com.ruoyi.system.service.ITestDataReceptionService;
 
 /** 轮询代码固定装配的 dev-sim;该运行时不依赖数据库适配器记录。 */
 @Component
+@Profile("dev-simulator")
 public class DevelopmentSimulatorDiscoveryScheduler
 {
     private final DeviceAdapterRegistry registry;
     private final DeviceDiscoveryPersistenceService persistence;
-    private final ITestDataReceptionService reception;
-
     public DevelopmentSimulatorDiscoveryScheduler(DeviceAdapterRegistry registry,
-            DeviceDiscoveryPersistenceService persistence, ITestDataReceptionService reception)
+            DeviceDiscoveryPersistenceService persistence)
     {
         this.registry = registry;
         this.persistence = persistence;
-        this.reception = reception;
     }
 
     /** 注册表已包含固定开发适配器时,将一轮检测柜观测写入 Redis 候选。 */
@@ -31,9 +29,5 @@
         if (!registry.contains(BtsDevelopmentSimulator.ADAPTER_CODE)) return;
         persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null,
                 BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS));
-        // 发现轮询成功后立即把接收窗口内的模拟 record 写入测试事实;页面不再读取模拟器历史。
-        reception.captureDevelopmentCabinetRecords();
-        // 采集器拥有独立采样频率;只接收本轮实时快照,不读取历史下载接口。
-        reception.captureDevelopmentCollectorMeasurements();
     }
 }
diff --git a/ruoyi-admin/src/main/resources/db/migration/V31__unified_realtime_snapshots.sql b/ruoyi-admin/src/main/resources/db/migration/V31__unified_realtime_snapshots.sql
new file mode 100644
index 0000000..0e5f683
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V31__unified_realtime_snapshots.sql
@@ -0,0 +1,36 @@
+-- 一次主动轮询形成的完整实时采集快照;原始 record/collector_sample 继续保留用于追溯。
+CREATE TABLE t_test_realtime_snapshot (
+    snapshot_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    binding_id BIGINT NOT NULL,
+    channel_id BIGINT NOT NULL,
+    collector_id BIGINT NOT NULL,
+    capture_slot_utc DATETIME(3) NOT NULL,
+    cabinet_sequence_id BIGINT NULL,
+    cabinet_device_time_utc DATETIME(3) NULL,
+    cabinet_device_time_raw VARCHAR(64) NULL,
+    collector_sequence_id BIGINT NULL,
+    collector_device_time_utc DATETIME(3) NULL,
+    collector_device_time_raw VARCHAR(64) NULL,
+    server_received_at_utc DATETIME(3) NOT NULL,
+    cycle_no INT NULL,
+    step_no INT NULL,
+    step_type VARCHAR(64) NULL,
+    step_name VARCHAR(191) NULL,
+    voltage_v DECIMAL(30,12) NULL,
+    current_a DECIMAL(30,12) NULL,
+    capacity_ah DECIMAL(30,12) NULL,
+    energy_wh DECIMAL(30,12) NULL,
+    temperature DECIMAL(30,12) NULL,
+    positive_potential DECIMAL(30,12) NULL,
+    negative_potential DECIMAL(30,12) NULL,
+    extension_measurements_json JSON NULL,
+    content_sha256 BINARY(32) NOT NULL,
+    PRIMARY KEY (snapshot_id),
+    UNIQUE KEY uk_test_snapshot_slot (test_id, binding_id, capture_slot_utc),
+    KEY idx_snapshot_test_time (test_id, capture_slot_utc),
+    CONSTRAINT fk_snapshot_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_snapshot_binding FOREIGN KEY (binding_id) REFERENCES t_test_channel_binding (binding_id),
+    CONSTRAINT fk_snapshot_channel FOREIGN KEY (channel_id) REFERENCES t_detection_channel (channel_id),
+    CONSTRAINT fk_snapshot_collector FOREIGN KEY (collector_id) REFERENCES t_collector (collector_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主动轮询形成的完整实时采集快照';
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 cf28b98..281a531 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
@@ -413,6 +413,15 @@
         return List.copyOf(btsHistoryFor(btsKey(device, channelId)));
     }
 
+    /** 返回开发模拟器当前通道的持久化槽位间隔;仅供开发调度层使用。 */
+    public synchronized long persistenceIntervalMillis(int deviceId, int channelId)
+    {
+        DeviceState device = requireDevice(deviceId);
+        requireChannel(device, channelId);
+        BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
+        return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier()));
+    }
+
     /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
     public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
     {
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
index ff75420..6374fd2 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -53,6 +53,20 @@
     List<TTestWarningThreshold> selectTestThresholds(Long testId);
     /** 查询当前正在接收的测试,供接收调度绑定测试事实。 */
     List<Long> selectReceivingTestIds();
+    int insertRealtimeSnapshot(@Param("testId") Long testId, @Param("bindingId") Long bindingId,
+            @Param("channelId") Long channelId, @Param("collectorId") Long collectorId,
+            @Param("captureSlotUtc") Date captureSlotUtc, @Param("cabinetSequenceId") Long cabinetSequenceId,
+            @Param("cabinetDeviceTimeUtc") Date cabinetDeviceTimeUtc, @Param("cabinetDeviceTimeRaw") String cabinetDeviceTimeRaw,
+            @Param("collectorSequenceId") Long collectorSequenceId, @Param("collectorDeviceTimeUtc") Date collectorDeviceTimeUtc,
+            @Param("collectorDeviceTimeRaw") String collectorDeviceTimeRaw, @Param("serverReceivedAtUtc") Date serverReceivedAtUtc,
+            @Param("cycleNo") Integer cycleNo, @Param("stepNo") Integer stepNo, @Param("stepType") String stepType,
+            @Param("stepName") String stepName, @Param("voltageV") java.math.BigDecimal voltageV,
+            @Param("currentA") java.math.BigDecimal currentA, @Param("capacityAh") java.math.BigDecimal capacityAh,
+            @Param("energyWh") java.math.BigDecimal energyWh, @Param("temperature") java.math.BigDecimal temperature,
+            @Param("positivePotential") java.math.BigDecimal positivePotential,
+            @Param("negativePotential") java.math.BigDecimal negativePotential,
+            @Param("extensionMeasurementsJson") String extensionMeasurementsJson, @Param("contentSha256") byte[] contentSha256);
+    List<java.util.Map<String, Object>> selectPersistedRealtimeSnapshots(@Param("testId") Long testId);
     List<java.util.Map<String, Object>> selectPersistedRecords(@org.apache.ibatis.annotations.Param("testId") Long testId);
     List<java.util.Map<String, Object>> selectPersistedCollectorRecords(@org.apache.ibatis.annotations.Param("testId") Long testId);
     List<java.util.Map<String, Object>> selectPersistedSteps(@org.apache.ibatis.annotations.Param("testId") Long testId);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java
index f88f924..350cce4 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java
@@ -3,6 +3,9 @@
 /** 将设备已接收测量追加到当前测试事实。 */
 public interface ITestDataReceptionService
 {
+    /** 按开发模拟器持久化槽位主动查询并保存完整实时采集快照。 */
+    void captureDevelopmentRealtimeSnapshots();
+
     /** 扫描当前接收中的开发检测柜,并把接收窗口内的新记录持久化。 */
     void captureDevelopmentCabinetRecords();
 
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
index 4d8a52c..78ee125 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
@@ -134,7 +134,8 @@
             List<Map<String, Object>> changes = (List<Map<String, Object>>) (List<?>) JSON.parseArray(action.getChangesSummary(), Map.class);
             Set<String> editable = switch (action.getDataType())
             {
-                case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy", "metricValue");
+                case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy", "metricValue",
+                        "temperature", "positivePotential", "negativePotential");
                 case "STEP" -> Set.of("stepDuration", "capacity", "energy", "startVoltage", "endVoltage",
                         "chargePeakVoltage", "dischargeValleyVoltage");
                 case "CYCLE" -> Set.of("chargeCapacity", "dischargeCapacity", "efficiency", "chargeEnergy",
@@ -215,7 +216,8 @@
     {
         return switch (dataType)
         {
-            case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy", "metricValue");
+            case "REALTIME" -> Set.of("voltage", "current", "capacity", "energy", "metricValue",
+                    "temperature", "positivePotential", "negativePotential");
             case "STEP" -> Set.of("stepDuration", "capacity", "energy", "startVoltage", "endVoltage",
                     "chargePeakVoltage", "dischargeValleyVoltage");
             case "CYCLE" -> Set.of("chargeCapacity", "dischargeCapacity", "efficiency", "chargeEnergy",
@@ -233,6 +235,11 @@
             try { return new Long[] { Long.valueOf(parts[1]), Long.valueOf(parts[3]) }; }
             catch (NumberFormatException exception) { return null; }
         }
+        if ("REALTIME".equals(dataType) && parts.length == 2 && "snapshot".equals(parts[0]))
+        {
+            try { return new Long[] { Long.valueOf(parts[1]) }; }
+            catch (NumberFormatException exception) { return null; }
+        }
         int expectedLength = "STEP".equals(dataType) ? 8 : "CYCLE".equals(dataType) ? 7 : 6;
         int legacyLength = "STEP".equals(dataType) ? 5 : "CYCLE".equals(dataType) ? 4 : 3;
         if (parts.length != expectedLength && parts.length != legacyLength) return null;
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
index 910777b..06ee3c8 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
@@ -37,10 +37,15 @@
     {
         requireTest(testId);
         List<Map<String, Object>> source = new java.util.ArrayList<>();
-        List<Map<String, Object>> cabinet = mapper.selectPersistedRecords(testId);
-        List<Map<String, Object>> collector = mapper.selectPersistedCollectorRecords(testId);
-        if (cabinet != null) source.addAll(cabinet);
-        if (collector != null) source.addAll(collector);
+        List<Map<String, Object>> snapshots = mapper.selectPersistedRealtimeSnapshots(testId);
+        if (snapshots != null && !snapshots.isEmpty()) source.addAll(snapshots);
+        else {
+            // 迁移完成前兼容旧数据;新采集只从统一快照读取。
+            List<Map<String, Object>> cabinet = mapper.selectPersistedRecords(testId);
+            List<Map<String, Object>> collector = mapper.selectPersistedCollectorRecords(testId);
+            if (cabinet != null) source.addAll(cabinet);
+            if (collector != null) source.addAll(collector);
+        }
         List<Map<String, Object>> effective = applyEffectiveData(testId, "REALTIME", source);
         attachCollectorStepContext(effective);
         return effective;
@@ -111,6 +116,8 @@
 
     private String stableIdentity(String dataType, Map<String, Object> row)
     {
+        if ("REALTIME".equals(dataType) && "SNAPSHOT".equals(String.valueOf(row.get("sourceType"))))
+            return String.valueOf(row.getOrDefault("stableIdentity", "snapshot:" + value(row, "snapshotId")));
         if ("REALTIME".equals(dataType) && "COLLECTOR".equals(String.valueOf(row.get("sourceType"))))
             return String.join(":", "collector", identityPart(row, "collectorId"),
                     identityPart(row, "channelCode"), value(row, "sequenceId"), identityPart(row, "metricCode"));
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
index 22f195f..4ba1b52 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -4,8 +4,12 @@
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.util.Date;
+import java.time.Instant;
+import java.util.Map;
+import java.util.LinkedHashMap;
 import java.util.HexFormat;
 import java.util.List;
+import com.alibaba.fastjson2.JSON;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
@@ -26,6 +30,8 @@
     private final TTestMapper mapper;
     private final ITTestService tests;
     private final BtsDevelopmentSimulator simulator;
+    private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
+    private final Map<Long, Instant> lastRawCaptureAt = new java.util.concurrent.ConcurrentHashMap<>();
 
     public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
     {
@@ -36,6 +42,87 @@
 
     @Override
     @Transactional(rollbackFor = Exception.class)
+    public void captureDevelopmentRealtimeSnapshots()
+    {
+        List<Long> testIds = mapper.selectReceivingTestIds();
+        if (testIds == null) return;
+        Date now = new Date();
+        for (Long testId : testIds)
+        {
+            TTest locked = mapper.selectTestByIdForUpdate(testId);
+            if (locked == null || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
+            TTest test = tests.selectTestById(testId);
+            if (test == null || !"RECEIVING".equals(test.getReceptionStatus())) continue;
+            // 完整快照与原始事实在同一接收事务内写入;原始事实按发现频率限流,避免 100ms 开发调度重复扫描历史。
+            Instant lastRaw = lastRawCaptureAt.get(testId);
+            if (lastRaw == null || !now.toInstant().isBefore(lastRaw.plusSeconds(5)))
+            {
+                captureTest(test);
+                for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
+                    captureCollectorMeasurements(test, battery);
+                lastRawCaptureAt.put(testId, now.toInstant());
+            }
+            for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
+                captureSnapshot(test, battery, now);
+        }
+    }
+
+    private void captureSnapshot(TTest test, TTestBattery battery, Date now)
+    {
+        TTestChannelBinding binding = battery.getBinding();
+        if (binding == null || binding.getDevid() == null || binding.getChlid() == null || battery.getCollectorId() == null) return;
+        final long interval;
+        try { interval = simulator.persistenceIntervalMillis(binding.getDevid(), binding.getChlid()); }
+        catch (IllegalArgumentException ignored) { return; }
+        String key = test.getTestId() + ":" + binding.getBindingId();
+        Instant last = lastSnapshotAt.get(key);
+        if (last != null && now.toInstant().isBefore(last.plusMillis(interval))) return;
+        List<BtsHistoryRecord> records;
+        try { records = simulator.btsRecords(binding.getDevid(), binding.getChlid()); } catch (IllegalArgumentException ignored) { return; }
+        BtsHistoryRecord cabinet = records.stream().max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).orElse(null);
+        CollectorRealtimeSnapshot collector;
+        try { collector = simulator.collectorRealtime(battery.getCollectorId().intValue()); } catch (IllegalArgumentException ignored) { return; }
+        if (cabinet == null || !collector.online()) return;
+        if (test.getStartedAtUtc() != null && cabinet.deviceTime().isBefore(test.getStartedAtUtc().toInstant())) return;
+        if (test.getUpdateTime() != null && cabinet.deviceTime().isBefore(test.getUpdateTime().toInstant())) return;
+        Map<String, CollectorRealtimeMeasurement> metrics = new LinkedHashMap<>();
+        for (CollectorRealtimeMeasurement item : collector.measurements())
+            if (battery.getCollectorChannels() != null && battery.getCollectorChannels().stream().anyMatch(mapping ->
+                    java.util.Objects.equals(mapping.getChannelCode(), item.channelCode())
+                    && metricMatches(mapping.getMeasurementCode(), item.metricCode())))
+                metrics.put(item.metricCode().toUpperCase(java.util.Locale.ROOT), item);
+        CollectorRealtimeMeasurement temp = metrics.get("TEMPERATURE");
+        CollectorRealtimeMeasurement pos = metrics.get("POSITIVE_POTENTIAL");
+        CollectorRealtimeMeasurement neg = metrics.get("NEGATIVE_POTENTIAL");
+        if (temp == null || pos == null || neg == null) return;
+        if (test.getStartedAtUtc() != null && (temp.deviceTime().isBefore(test.getStartedAtUtc().toInstant())
+                || pos.deviceTime().isBefore(test.getStartedAtUtc().toInstant())
+                || neg.deviceTime().isBefore(test.getStartedAtUtc().toInstant()))) return;
+        if (test.getUpdateTime() != null && (temp.serverReceivedAt().isBefore(test.getUpdateTime().toInstant())
+                || pos.serverReceivedAt().isBefore(test.getUpdateTime().toInstant())
+                || neg.serverReceivedAt().isBefore(test.getUpdateTime().toInstant()))) return;
+        Date slot = new Date((now.getTime() / interval) * interval);
+        byte[] hash = snapshotHash(cabinet, temp, pos, neg, slot.toInstant());
+        mapper.insertRealtimeSnapshot(test.getTestId(), binding.getBindingId(), binding.getChannelId(), battery.getCollectorId(), slot,
+                cabinet.sequenceId(), Date.from(cabinet.deviceTime()), cabinet.deviceTime().toString(),
+                temp.sequenceId(), Date.from(temp.deviceTime()), temp.deviceTimeRaw(), now,
+                cabinet.cycleNo(), cabinet.stepNo(), cabinet.stepType(), cabinet.stepName(),
+                BigDecimal.valueOf(cabinet.voltageV()), BigDecimal.valueOf(cabinet.currentA()), BigDecimal.valueOf(cabinet.capacityAh()),
+                BigDecimal.valueOf(cabinet.energyWh()), temp.value(), pos.value(), neg.value(), JSON.toJSONString(metrics), hash);
+        lastSnapshotAt.put(key, now.toInstant());
+    }
+
+    private byte[] snapshotHash(BtsHistoryRecord cabinet, CollectorRealtimeMeasurement temp,
+            CollectorRealtimeMeasurement pos, CollectorRealtimeMeasurement neg, Instant slot)
+    {
+        try { return MessageDigest.getInstance("SHA-256").digest((slot + "|" + cabinet.sequenceId() + "|" + cabinet.deviceTime()
+                + "|" + cabinet.voltageV() + "|" + cabinet.currentA() + "|" + temp.value() + "|" + pos.value() + "|" + neg.value())
+                .getBytes(StandardCharsets.UTF_8)); }
+        catch (Exception exception) { throw new IllegalStateException("无法计算实时快照内容摘要", exception); }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
     public void captureDevelopmentCabinetRecords()
     {
         List<Long> testIds = mapper.selectReceivingTestIds();
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 56b7312..ebd26ac 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -318,6 +318,47 @@
     <select id="selectReceivingTestIds" resultType="long">
         select test_id from t_test where del_flag = 0 and reception_status = 'RECEIVING' order by test_id
     </select>
+    <insert id="insertRealtimeSnapshot">
+        insert ignore into t_test_realtime_snapshot
+        (test_id, binding_id, channel_id, collector_id, capture_slot_utc,
+         cabinet_sequence_id, cabinet_device_time_utc, cabinet_device_time_raw,
+         collector_sequence_id, collector_device_time_utc, collector_device_time_raw,
+         server_received_at_utc, cycle_no, step_no, step_type, step_name,
+         voltage_v, current_a, capacity_ah, energy_wh, temperature, positive_potential,
+         negative_potential, extension_measurements_json, content_sha256)
+        values (#{testId}, #{bindingId}, #{channelId}, #{collectorId}, #{captureSlotUtc},
+                #{cabinetSequenceId}, #{cabinetDeviceTimeUtc}, #{cabinetDeviceTimeRaw},
+                #{collectorSequenceId}, #{collectorDeviceTimeUtc}, #{collectorDeviceTimeRaw},
+                #{serverReceivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName},
+                #{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, #{temperature}, #{positivePotential},
+                #{negativePotential}, #{extensionMeasurementsJson}, #{contentSha256})
+    </insert>
+    <select id="selectPersistedRealtimeSnapshots" resultType="map">
+        select concat('snapshot:', snapshot.snapshot_id) id,
+               concat('snapshot:', snapshot.snapshot_id) stableIdentity,
+               concat('RS-', snapshot.snapshot_id) recordNo,
+               'SNAPSHOT' sourceType, battery.battery_no batteryNo,
+               snapshot.cycle_no cycleNo, snapshot.step_no stepNo, snapshot.step_name stepName,
+               snapshot.cabinet_sequence_id sequenceId,
+               snapshot.cabinet_device_time_utc recordedAt, snapshot.cabinet_device_time_utc absoluteTime,
+               snapshot.cabinet_device_time_utc deviceTime, snapshot.server_received_at_utc serverReceivedAt,
+               snapshot.snapshot_id snapshotId,
+               snapshot.temperature temperature, snapshot.positive_potential positivePotential,
+               snapshot.negative_potential negativePotential,
+               snapshot.voltage_v voltage, snapshot.current_a current,
+               snapshot.capacity_ah capacity, snapshot.energy_wh energy,
+               channel.adapter_id adapterId, channel.adapter_code adapterCode,
+               channel.devtype devtype, channel.devid deviceId, channel.subdevid subdevid, channel.chlid channelId,
+               collector.collector_id collectorId, collector.collector_code collectorCode,
+               snapshot.extension_measurements_json extensionMeasurementsJson
+        from t_test_realtime_snapshot snapshot
+        join t_test_channel_binding binding on binding.binding_id = snapshot.binding_id
+        join t_test_battery battery on battery.relation_id = binding.battery_relation_id and battery.del_flag = 0
+        join t_detection_channel channel on channel.channel_id = snapshot.channel_id and channel.del_flag = 0
+        join t_collector collector on collector.collector_id = snapshot.collector_id
+        where snapshot.test_id = #{testId}
+        order by snapshot.capture_slot_utc desc, snapshot.snapshot_id desc
+    </select>
     <select id="selectPersistedRecords" resultType="map">
         select sample.sample_id id,
                concat('RD-', channel.devid, '-', channel.chlid, '-', lpad(sample.seq_id, 6, '0')) recordNo,

--
Gitblit v1.7.1