yeg
2026-09-02 c2dd019c1fddaeae412e2b6f44fb86ff0dc635d4
实现主动轮询完整实时快照
2个文件已添加
9个文件已修改
249 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorSamplingScheduler.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentRealtimeSnapshotScheduler.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V31__unified_realtime_snapshots.sql 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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;
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentRealtimeSnapshotScheduler.java
New file
@@ -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(); }
}
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();
    }
}
ruoyi-admin/src/main/resources/db/migration/V31__unified_realtime_snapshots.sql
New file
@@ -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='主动轮询形成的完整实时采集快照';
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)
    {
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);
ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java
@@ -3,6 +3,9 @@
/** 将设备已接收测量追加到当前测试事实。 */
public interface ITestDataReceptionService
{
    /** 按开发模拟器持久化槽位主动查询并保存完整实时采集快照。 */
    void captureDevelopmentRealtimeSnapshots();
    /** 扫描当前接收中的开发检测柜,并把接收窗口内的新记录持久化。 */
    void captureDevelopmentCabinetRecords();
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;
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"));
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();
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,