From 27428b31edee97703b529559146cdd7eef87a7a9 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 11:00:58 +0800
Subject: [PATCH] 完成测试数据接收持久化与查询

---
 ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java                            |   42 ++
 battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java                             |   41 +
 ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql                                     |   64 +++
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                                                       |   91 ++++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java                                             |   29 +
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                                                 |   15 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java                                            |   27 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java                                         |    3 
 battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java                    |   59 ++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java                  |   15 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java                                         |   83 +++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java                       |  323 ++++++++------
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java                              |   32 +
 battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java |    3 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java                          |  422 +++++++------------
 15 files changed, 840 insertions(+), 409 deletions(-)

diff --git a/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
new file mode 100644
index 0000000..849ad5a
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
@@ -0,0 +1,41 @@
+package com.battery.device.api;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+import com.battery.device.model.ChannelIdentity;
+import com.battery.device.model.Measurement;
+
+/** 供业务持久化使用的厂商无关测试数据观测扩展。 */
+public interface TestDataObservationAdapter extends DeviceObservationAdapter
+{
+    CabinetRealtime latestCabinet(ChannelIdentity channel);
+
+    CollectorRealtime latestCollector(long collectorId);
+
+    List<StepRecord> stepRecords(ChannelIdentity channel);
+
+    List<CycleRecord> cycleRecords(ChannelIdentity channel);
+
+    long persistenceIntervalMillis(ChannelIdentity channel);
+
+    record CabinetRealtime(long sequenceId, Instant deviceTime, double currentA, double voltageV,
+            double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType,
+            Instant serverReceivedAt) { }
+
+    record CollectorRealtime(String collectorCode, boolean online, int sampleIntervalMs,
+            List<CollectorMeasurement> measurements)
+    {
+        public CollectorRealtime { measurements = measurements == null ? List.of() : List.copyOf(measurements); }
+    }
+
+    record CollectorMeasurement(String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw,
+            Instant serverReceivedAt, String metricCode, BigDecimal value, String unit) { }
+
+    record StepRecord(String sourceRecordId, int cycleNo, int stepNo, String stepName, String stepType,
+            Instant startedAt, Instant endedAt, double endVoltageV, double capacityAh, double energyWh) { }
+
+    record CycleRecord(String sourceRecordId, int cycleNo, Instant startedAt, Instant endedAt,
+            double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh,
+            double dischargeEnergyWh, double efficiency) { }
+}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
index fdb900b..5479ccc 100644
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
+++ b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -20,6 +20,7 @@
 import com.battery.device.model.ChannelIdentity;
 import com.battery.device.model.AdapterCapabilities;
 import com.battery.device.api.DeviceObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter;
 import com.battery.device.simulator.BtsSimulatorStateStore;
 import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
 import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
@@ -43,7 +44,7 @@
 import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
 
 /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
-public final class BtsDevelopmentSimulator implements DeviceObservationAdapter
+public final class BtsDevelopmentSimulator implements TestDataObservationAdapter
 {
     public static final String HOST = "127.0.0.1";
     public static final int DEFAULT_PORT = 15020;
@@ -494,6 +495,46 @@
         BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
         return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier()));
     }
+
+    @Override
+    public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel)
+    {
+        BtsHistoryRecord record = btsRecords(channel.devid(), channel.chlid()).stream()
+                .max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).orElse(null);
+        return record == null ? null : new CabinetRealtime(record.sequenceId(), record.deviceTime(), record.currentA(),
+                record.voltageV(), record.capacityAh(), record.energyWh(), record.cycleNo(), record.stepNo(),
+                record.stepName(), record.stepType(), record.serverReceivedAt());
+    }
+
+    @Override
+    public synchronized CollectorRealtime latestCollector(long collectorId)
+    {
+        CollectorRealtimeSnapshot snapshot = collectorRealtime((int) collectorId);
+        return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(),
+                snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(),
+                        item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(),
+                        item.unit())).toList());
+    }
+
+    @Override
+    public synchronized List<StepRecord> stepRecords(ChannelIdentity channel)
+    {
+        return btsSteps(channel.devid(), channel.chlid()).stream().map(item -> new StepRecord(item.recordNo(),
+                item.cycleNo(), item.stepNo(), item.stepName(), item.stepType(), item.startedAt(), item.endedAt(),
+                item.endVoltageV(), item.capacityAh(), item.energyWh())).toList();
+    }
+
+    @Override
+    public synchronized List<CycleRecord> cycleRecords(ChannelIdentity channel)
+    {
+        return btsCycles(channel.devid(), channel.chlid()).stream().map(item -> new CycleRecord(item.recordNo(),
+                item.cycleNo(), item.startedAt(), item.endedAt(), item.chargeCapacityAh(), item.dischargeCapacityAh(),
+                item.chargeEnergyWh(), item.dischargeEnergyWh(), item.efficiency())).toList();
+    }
+
+    @Override
+    public synchronized long persistenceIntervalMillis(ChannelIdentity channel)
+    { return persistenceIntervalMillis(channel.devid(), channel.chlid()); }
 
     /** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
     public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
@@ -1004,7 +1045,19 @@
 
     public record BtsStepRecord(int cycleNo, int stepNo, Integer cyclePosition, String stepName, String stepType, long recordCount,
             Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh, double energyWh,
-            double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV) { }
+            double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV,
+            String recordNo)
+    {
+        public BtsStepRecord(int cycleNo, int stepNo, Integer cyclePosition, String stepName, String stepType,
+                long recordCount, Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh,
+                double energyWh, double startVoltageV, double endVoltageV, double chargePeakVoltageV,
+                double dischargeValleyVoltageV)
+        {
+            this(cycleNo, stepNo, cyclePosition, stepName, stepType, recordCount, startedAt, endedAt,
+                    durationSeconds, capacityAh, energyWh, startVoltageV, endVoltageV, chargePeakVoltageV,
+                    dischargeValleyVoltageV, null);
+        }
+    }
 
     public record BtsCycleRecord(String recordNo, int cycleNo, long recordCount, Instant startedAt, Instant endedAt,
             double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh,
@@ -1031,7 +1084,7 @@
         private double startVoltage; private double endVoltage; private double chargePeak = Double.NEGATIVE_INFINITY; private double dischargeValley = Double.POSITIVE_INFINITY;
         BtsStepAccumulator(BtsHistoryRecord first, int position) { cycle = first.cycleNo(); step = first.stepNo(); this.position = position; name = first.stepName(); type = first.stepType(); }
         void add(BtsHistoryRecord item) { count++; if (first == null) { first = item.deviceTime(); startVoltage = item.voltageV(); } last = item.deviceTime(); endVoltage = item.voltageV(); duration = first == null ? 0 : Math.max(0, java.time.Duration.between(first, last).toMillis() / 1000d); capacity = item.capacityAh(); energy = item.energyWh(); if (item.currentA() >= 0) chargePeak = Math.max(chargePeak, item.voltageV()); else dischargeValley = Math.min(dischargeValley, item.voltageV()); }
-        BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley); }
+        BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley, "STEP-" + cycle + "-" + step + "-" + last.toEpochMilli()); }
     }
 
     private static final class BtsCycleAccumulator
diff --git a/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java b/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
index 63b0910..9e9efee 100644
--- a/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
+++ b/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
@@ -31,7 +31,8 @@
             assertEquals(1L, records.get(0).sequenceId());
             assertEquals(60L, records.get(1).deviceTime().getEpochSecond() - records.get(0).deviceTime().getEpochSecond());
             assertEquals(1, records.get(0).cycleNo());
-            assertTrue(simulator.btsSteps(1, 1).stream().anyMatch(step -> step.stepNo() == 1));
+            assertTrue(simulator.btsSteps(1, 1).stream().anyMatch(step -> step.stepNo() == 1
+                    && step.recordNo() != null && !step.recordNo().isBlank()));
             var cycle = simulator.btsCycles(1, 1).stream().filter(item -> item.cycleNo() == 1).findFirst().orElseThrow();
             assertTrue(cycle.chargeCapacityAh() >= 0d);
             assertTrue(cycle.dischargeCapacityAh() >= 0d);
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql b/ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql
new file mode 100644
index 0000000..9170246
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql
@@ -0,0 +1,64 @@
+ALTER TABLE t_test_realtime_record
+    ADD COLUMN source_identity VARCHAR(256) NULL AFTER adapter_code,
+    ADD COLUMN source_record_id VARCHAR(128) NULL AFTER source_identity;
+
+UPDATE t_test_realtime_record
+SET source_identity = adapter_code,
+    source_record_id = CASE WHEN cabinet_sequence_id IS NOT NULL THEN CONCAT('cabinet:', cabinet_sequence_id)
+                            WHEN collector_sequence_id IS NOT NULL THEN CONCAT('collector:', collector_sequence_id)
+                            ELSE NULL END
+WHERE source_identity IS NULL;
+
+ALTER TABLE t_test_realtime_record
+    MODIFY COLUMN source_identity VARCHAR(256) NOT NULL,
+    ADD KEY idx_realtime_source (test_battery_id, source_identity, source_record_id);
+
+ALTER TABLE t_test_step_record
+    ADD COLUMN binding_id BIGINT NULL AFTER test_battery_id,
+    ADD COLUMN dedupe_key VARCHAR(512) NULL AFTER source_record_id,
+    ADD COLUMN server_received_at_utc DATETIME(3) NULL AFTER device_time_raw;
+
+UPDATE t_test_step_record record_fact
+JOIN t_test_channel_binding binding ON binding.test_id = record_fact.test_id
+    AND binding.test_battery_id = record_fact.test_battery_id
+SET record_fact.binding_id = binding.binding_id,
+    record_fact.server_received_at_utc = record_fact.device_time_utc,
+    record_fact.dedupe_key = CASE
+        WHEN record_fact.source_record_id IS NOT NULL AND TRIM(record_fact.source_record_id) <> ''
+            THEN CONCAT('record:', record_fact.source_record_id)
+        ELSE CONCAT('fallback:', DATE_FORMAT(record_fact.device_time_utc, '%Y-%m-%dT%H:%i:%s.%fZ'),
+                    ':', record_fact.cycle_no, ':', record_fact.step_no)
+    END;
+
+ALTER TABLE t_test_step_record
+    MODIFY COLUMN binding_id BIGINT NOT NULL,
+    MODIFY COLUMN dedupe_key VARCHAR(512) NOT NULL,
+    MODIFY COLUMN server_received_at_utc DATETIME(3) NOT NULL,
+    DROP INDEX uk_step_identity,
+    ADD UNIQUE KEY uk_step_dedupe (test_battery_id, source_identity(128), dedupe_key(128)),
+    ADD KEY idx_step_binding_time (binding_id, server_received_at_utc);
+
+ALTER TABLE t_test_cycle_record
+    ADD COLUMN binding_id BIGINT NULL AFTER test_battery_id,
+    ADD COLUMN dedupe_key VARCHAR(512) NULL AFTER source_record_id,
+    ADD COLUMN server_received_at_utc DATETIME(3) NULL AFTER device_time_raw;
+
+UPDATE t_test_cycle_record record_fact
+JOIN t_test_channel_binding binding ON binding.test_id = record_fact.test_id
+    AND binding.test_battery_id = record_fact.test_battery_id
+SET record_fact.binding_id = binding.binding_id,
+    record_fact.server_received_at_utc = record_fact.device_time_utc,
+    record_fact.dedupe_key = CASE
+        WHEN record_fact.source_record_id IS NOT NULL AND TRIM(record_fact.source_record_id) <> ''
+            THEN CONCAT('record:', record_fact.source_record_id)
+        ELSE CONCAT('fallback:', DATE_FORMAT(record_fact.device_time_utc, '%Y-%m-%dT%H:%i:%s.%fZ'),
+                    ':', record_fact.cycle_no)
+    END;
+
+ALTER TABLE t_test_cycle_record
+    MODIFY COLUMN binding_id BIGINT NOT NULL,
+    MODIFY COLUMN dedupe_key VARCHAR(512) NOT NULL,
+    MODIFY COLUMN server_received_at_utc DATETIME(3) NOT NULL,
+    DROP INDEX uk_cycle_identity,
+    ADD UNIQUE KEY uk_cycle_dedupe (test_battery_id, source_identity(128), dedupe_key(128)),
+    ADD KEY idx_cycle_binding_time (binding_id, server_received_at_utc);
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java
new file mode 100644
index 0000000..b984698
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java
@@ -0,0 +1,42 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+
+/** Ticket08 三类测试数据持久化结构契约。 */
+class TestDataPersistenceMigrationContractTest
+{
+    @Test
+    void stepAndCycleRecordsKeepBindingReceptionTimeAndStableDedupeKey()
+    {
+        String sql = resource("db/migration-final/V3__test_data_persistence.sql");
+
+        assertTrue(sql.contains("ALTER TABLE t_test_realtime_record"));
+        assertTrue(sql.contains("source_identity VARCHAR(256) NOT NULL"));
+        assertTrue(sql.contains("source_record_id VARCHAR(128) NULL"));
+        assertTrue(sql.contains("ALTER TABLE t_test_step_record"));
+        assertTrue(sql.contains("ALTER TABLE t_test_cycle_record"));
+        assertTrue(sql.contains("binding_id BIGINT NOT NULL"));
+        assertTrue(sql.contains("server_received_at_utc DATETIME(3) NOT NULL"));
+        assertTrue(sql.contains("dedupe_key VARCHAR(512) NOT NULL"));
+        assertTrue(sql.contains("UNIQUE KEY uk_step_dedupe"));
+        assertTrue(sql.contains("UNIQUE KEY uk_cycle_dedupe"));
+    }
+
+    private static String resource(String path)
+    {
+        try (InputStream stream = TestDataPersistenceMigrationContractTest.class.getClassLoader()
+                .getResourceAsStream(path))
+        {
+            if (stream == null) throw new AssertionError("缺少资源 " + path);
+            return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        catch (Exception exception)
+        {
+            throw new AssertionError("无法读取资源 " + path, exception);
+        }
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
index bc9aefa..8158ab4 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
@@ -67,6 +67,21 @@
     }
 
     @Test
+    void readsFinalPersistedTablesWhenAvailableAndReturnsEpochMillis()
+    {
+        when(tests.selectTestById(11L)).thenReturn(new TTest());
+        Map<String, Object> row = row("recordNo", "RT-1", "deviceTime", java.util.Date.from(Instant.parse("2026-09-02T00:00:00Z")),
+                "serverReceivedAt", java.util.Date.from(Instant.parse("2026-09-02T00:00:01Z")));
+        when(mapper.selectPersistedFinalRealtimeRecords(11L)).thenReturn(List.of(row));
+        when(mapper.selectDataOverrides(11L, "REALTIME")).thenReturn(List.of());
+
+        Map<String, Object> actual = new TestDataQueryServiceImpl(mapper, tests).records(11L).get(0);
+
+        assertEquals(Instant.parse("2026-09-02T00:00:00Z").toEpochMilli(), actual.get("deviceTime"));
+        assertEquals(Instant.parse("2026-09-02T00:00:01Z").toEpochMilli(), actual.get("serverReceivedAt"));
+    }
+
+    @Test
     void assignsCollectorObservationToCabinetStepByDeviceTime()
     {
         when(tests.selectTestById(9L)).thenReturn(new TTest());
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
index 5a14a85..16cab20 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
@@ -1,188 +1,241 @@
 package com.ruoyi.system.service.impl;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
-import java.time.Instant;
-import java.util.Date;
-import java.util.List;
 import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
 import com.battery.device.simulator.BtsDevelopmentSimulator;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsCycleRecord;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsStepRecord;
 import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
 import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
 import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
-import com.ruoyi.system.domain.TTestCollectorChannel;
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCollectorChannel;
+import com.ruoyi.system.domain.TTestCycleRecord;
+import com.ruoyi.system.domain.TTestRealtimeRecord;
+import com.ruoyi.system.domain.TTestStepRecord;
 import com.ruoyi.system.mapper.TTestMapper;
 import com.ruoyi.system.service.ITTestService;
 
-/** 接收服务只应把本次测试开始后的模拟器事实归入测试。 */
+/** Ticket08 通过接收入口验证三类持久化事实。 */
+@ExtendWith(OutputCaptureExtension.class)
 class TestDataReceptionServiceImplTest
 {
+    private static final Instant CAPTURED_AT = Instant.parse("2026-09-02T04:01:00Z");
+    private static final Clock CLOCK = Clock.fixed(CAPTURED_AT, ZoneOffset.UTC);
+
     @Test
-    void ignoresRecordsBeforeReceptionStart()
+    void savesOneCombinedRealtimeRowForOneBatteryPollingSlot()
+    {
+        Fixture fixture = fixture();
+        when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord()));
+        when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(true, List.of(
+                collector("S-01", 10L, "TEMPERATURE", "25.100", "℃"),
+                collector("S-02", 10L, "POSITIVE_POTENTIAL", "1.620", "V"),
+                collector("S-03", 10L, "NEGATIVE_POTENTIAL", "1.610", "V"),
+                collector("S-04", 10L, "AMBIENT_TEMPERATURE", "23.400", "℃"))));
+        when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
+
+        fixture.service().captureDevelopmentRealtimeSnapshots();
+
+        ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+        verify(fixture.mapper).insertRealtimeRecord(saved.capture());
+        TTestRealtimeRecord row = saved.getValue();
+        assertEquals(1L, row.getTestId());
+        assertEquals(4L, row.getTestBatteryId());
+        assertEquals(2L, row.getBindingId());
+        assertEquals("dev-sim:22:1:1:1", row.getSourceIdentity());
+        assertEquals("cabinet:7:collector:10", row.getSourceRecordId());
+        assertEquals(Date.from(Instant.parse("2026-09-02T04:00:30Z")), row.getCabinetDeviceTimeUtc());
+        assertEquals(Date.from(Instant.parse("2026-09-02T04:00:40Z")), row.getCollectorDeviceTimeUtc());
+        assertEquals(new BigDecimal("25.100"), row.getTemperature());
+        assertEquals(new BigDecimal("1.620"), row.getPositivePotential());
+        assertEquals(new BigDecimal("1.610"), row.getNegativePotential());
+        assertTrue(row.getExtensionMeasurementsJson().contains("AMBIENT_TEMPERATURE"));
+        assertTrue(!row.getExtensionMeasurementsJson().contains("POSITIVE_POTENTIAL"));
+    }
+
+    @Test
+    void savesCollectorFieldsWhenCabinetSourceFails()
+    {
+        Fixture fixture = fixture();
+        when(fixture.simulator.btsRecords(1, 1)).thenThrow(new IllegalArgumentException("offline"));
+        when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(true,
+                List.of(collector("S-01", 11L, "TEMPERATURE", "26.2", "℃"))));
+        when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
+
+        fixture.service().captureDevelopmentRealtimeSnapshots();
+
+        ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+        verify(fixture.mapper).insertRealtimeRecord(saved.capture());
+        assertNull(saved.getValue().getCabinetSequenceId());
+        assertNull(saved.getValue().getCabinetDevtype());
+        assertNull(saved.getValue().getCabinetAdapterTimezoneId());
+        assertNull(saved.getValue().getVoltageV());
+        assertEquals(11L, saved.getValue().getCollectorSequenceId());
+        assertEquals("collector:11", saved.getValue().getSourceRecordId());
+        assertEquals(new BigDecimal("26.2"), saved.getValue().getTemperature());
+        assertNull(saved.getValue().getPositivePotential());
+    }
+
+    @Test
+    void savesOnlyDeviceReturnedStepAndCycleRecords()
+    {
+        Fixture fixture = fixture();
+        Instant started = Instant.parse("2026-09-02T04:00:10Z");
+        Instant ended = Instant.parse("2026-09-02T04:00:50Z");
+        when(fixture.simulator.btsSteps(1, 1)).thenReturn(List.of(new BtsStepRecord(
+                1, 2, 2, "恒流充电", "cc", 41, started, ended, 40,
+                2.5, 8.4, 3.1, 3.4, 3.4, 0)));
+        when(fixture.simulator.btsCycles(1, 1)).thenReturn(List.of(new BtsCycleRecord(
+                "CYCLE-1-1-1", 1, 80, started, ended, 2.5, 2.4, 8.4, 8.0, 96)));
+        when(fixture.mapper.insertStepRecord(any())).thenReturn(1);
+        when(fixture.mapper.insertCycleRecord(any())).thenReturn(1);
+
+        fixture.service().captureDevelopmentCabinetRecords();
+
+        ArgumentCaptor<TTestStepRecord> step = ArgumentCaptor.forClass(TTestStepRecord.class);
+        ArgumentCaptor<TTestCycleRecord> cycle = ArgumentCaptor.forClass(TTestCycleRecord.class);
+        verify(fixture.mapper).insertStepRecord(step.capture());
+        verify(fixture.mapper).insertCycleRecord(cycle.capture());
+        assertEquals("fallback:2026-09-02T04:00:50Z:1:2", step.getValue().getDedupeKey());
+        assertEquals("CYCLE-1-1-1", cycle.getValue().getSourceRecordId());
+        assertEquals("record:CYCLE-1-1-1", cycle.getValue().getDedupeKey());
+        assertEquals(Date.from(CAPTURED_AT), step.getValue().getServerReceivedAtUtc());
+        assertEquals(Date.from(CAPTURED_AT), cycle.getValue().getServerReceivedAtUtc());
+    }
+
+    @Test
+    void ignoresSameRealtimeContentOnRepeatedReception()
+    {
+        Fixture fixture = fixture();
+        when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord()));
+        when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(false, List.of()));
+        when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
+
+        fixture.service().captureDevelopmentRealtimeSnapshots();
+        ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+        verify(fixture.mapper).insertRealtimeRecord(saved.capture());
+        when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class)))
+                .thenReturn(HexFormat.of().formatHex(saved.getValue().getContentSha256()));
+
+        fixture.service().captureDevelopmentRealtimeSnapshots();
+
+        verify(fixture.mapper, times(1)).insertRealtimeRecord(any());
+    }
+
+    @Test
+    void rejectsConflictingRealtimeContentAndWritesApplicationLog(CapturedOutput output)
+    {
+        Fixture fixture = fixture();
+        when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord()));
+        when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(false, List.of()));
+        when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class)))
+                .thenReturn("different-content");
+
+        fixture.service().captureDevelopmentRealtimeSnapshots();
+
+        verify(fixture.mapper, never()).insertRealtimeRecord(any());
+        assertTrue(output.getOut().contains("实时采样内容冲突"));
+    }
+
+    private static Fixture fixture()
     {
         TTestMapper mapper = mock(TTestMapper.class);
         ITTestService tests = mock(ITTestService.class);
         BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
-        TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
+        TTest test = receivingTest();
         when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
         when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
         when(tests.selectTestById(1L)).thenReturn(test);
-        when(simulator.btsRecords(1, 1)).thenReturn(List.of(
-                record(1L, "2026-09-02T03:59:59Z"), record(2L, "2026-09-02T04:00:01Z")));
-        when(mapper.selectBtsSampleContentHash(any(), anyString(), any())).thenReturn(null);
-        when(mapper.insertBtsSample(any(), any(), any(), anyString(), any(), any(Date.class), anyString(),
-                any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(), any(BigDecimal.class),
-                any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(byte[].class))).thenReturn(1);
-
-        new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCabinetRecords();
-
-        verify(mapper, times(1)).insertBtsSample(eq(1L), eq(2L), eq(3L), anyString(), eq(2L), any(Date.class),
-                anyString(), any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(),
-                any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class),
-                any(byte[].class));
+        when(simulator.persistenceIntervalMillis(1, 1)).thenReturn(60000L);
+        return new Fixture(mapper, tests, simulator);
     }
 
-    @Test
-    void doesNotWriteSamplesWhenNoSuccessfulRecordsAreAvailable()
-    {
-        TTestMapper mapper = mock(TTestMapper.class);
-        ITTestService tests = mock(ITTestService.class);
-        BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
-        TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
-        when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
-        when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
-        when(tests.selectTestById(1L)).thenReturn(test);
-        when(simulator.btsRecords(1, 1)).thenReturn(List.of());
-
-        new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCabinetRecords();
-
-        verify(mapper, never()).insertBtsSample(any(), any(), any(), anyString(), any(), any(Date.class), anyString(),
-                any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(), any(BigDecimal.class),
-                any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(byte[].class));
-    }
-
-    @Test
-    void ordinaryEditsDoNotMoveReceptionBoundary()
-    {
-        TTestMapper mapper = mock(TTestMapper.class);
-        ITTestService tests = mock(ITTestService.class);
-        BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
-        TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
-        test.setUpdateTime(Date.from(Instant.parse("2026-09-02T04:00:10Z")));
-        when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
-        when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
-        when(tests.selectTestById(1L)).thenReturn(test);
-        when(simulator.btsRecords(1, 1)).thenReturn(List.of(
-                record(1L, "2026-09-02T04:00:05Z"), record(2L, "2026-09-02T04:00:11Z")));
-        when(mapper.selectBtsSampleContentHash(any(), anyString(), any())).thenReturn(null);
-        when(mapper.insertBtsSample(any(), any(), any(), anyString(), any(), any(Date.class), anyString(),
-                any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(), any(BigDecimal.class),
-                any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(byte[].class))).thenReturn(1);
-
-        new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCabinetRecords();
-
-        verify(mapper, times(2)).insertBtsSample(eq(1L), eq(2L), eq(3L), anyString(), any(), any(Date.class),
-                anyString(), any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(),
-                any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class),
-                any(byte[].class));
-    }
-
-    @Test
-    void capturesOnlyMappedCollectorRealtimeMeasurementsAfterReceptionStart()
-    {
-        TTestMapper mapper = mock(TTestMapper.class);
-        ITTestService tests = mock(ITTestService.class);
-        BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
-        TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
-        test.setReceptionBoundaryAtUtc(Date.from(Instant.parse("2026-09-02T04:00:01.500Z")));
-        test.getBatteries().get(0).setCollectorId(9L);
-        TTestCollectorChannel mapping = new TTestCollectorChannel();
-        mapping.setMeasurementCode("temperature"); mapping.setChannelCode("S-01");
-        test.getBatteries().get(0).setCollectorChannels(List.of(mapping));
-        when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
-        when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
-        when(tests.selectTestById(1L)).thenReturn(test);
-        CollectorRealtimeMeasurement matched = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-01",
-                3L, Instant.parse("2026-09-02T04:00:01Z"), "2026-09-02 12:00:01.000",
-                Instant.parse("2026-09-02T04:00:02Z"), "temperature", new BigDecimal("25.1"), "℃");
-        CollectorRealtimeMeasurement ignored = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-02",
-                3L, matched.deviceTime(), matched.deviceTimeRaw(), matched.serverReceivedAt(), "temperature",
-                new BigDecimal("25.2"), "℃");
-        CollectorRealtimeMeasurement beforeBoundary = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-01",
-                2L, Instant.parse("2026-09-02T04:00:00Z"), "2026-09-02 12:00:00.000",
-                Instant.parse("2026-09-02T04:00:01Z"), "temperature", new BigDecimal("24.9"), "℃");
-        when(simulator.collectorRealtime(9)).thenReturn(new CollectorRealtimeSnapshot("collector-9", true, 1000,
-                List.of(matched, ignored, beforeBoundary)));
-        when(mapper.selectCollectorSampleContentHash(any(), anyString(), any(), anyString())).thenReturn(null);
-        when(mapper.insertCollectorSample(any(), any(), any(), anyString(), anyString(), any(), any(Date.class), anyString(),
-                any(Date.class), anyString(), any(BigDecimal.class), anyString(), any(byte[].class))).thenReturn(1);
-
-        new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCollectorMeasurements();
-
-        verify(mapper).insertCollectorSample(eq(1L), eq(2L), eq(9L), eq("dev-sim"), eq("S-01"), eq(3L), any(Date.class),
-                eq("2026-09-02 12:00:01.000"), any(Date.class), eq("temperature"), eq(new BigDecimal("25.1")),
-                eq("℃"), any(byte[].class));
-        verify(mapper, never()).insertCollectorSample(eq(1L), eq(2L), eq(9L), anyString(), eq("S-02"), any(), any(Date.class),
-                anyString(), any(Date.class), anyString(), any(BigDecimal.class), anyString(), any(byte[].class));
-    }
-
-    @Test
-    void recordsCollectorContentConflictWithoutOverwritingOriginal()
-    {
-        TTestMapper mapper = mock(TTestMapper.class);
-        ITTestService tests = mock(ITTestService.class);
-        BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
-        TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
-        test.getBatteries().get(0).setCollectorId(9L);
-        TTestCollectorChannel mapping = new TTestCollectorChannel(); mapping.setMeasurementCode("temperature"); mapping.setChannelCode("S-01");
-        test.getBatteries().get(0).setCollectorChannels(List.of(mapping));
-        CollectorRealtimeMeasurement measurement = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-01", 3L,
-                Instant.parse("2026-09-02T04:00:01Z"), "2026-09-02 12:00:01.000", Instant.now(), "temperature", new BigDecimal("25.1"), "℃");
-        when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L)); when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
-        when(tests.selectTestById(1L)).thenReturn(test); when(simulator.collectorRealtime(9)).thenReturn(new CollectorRealtimeSnapshot("collector-9", true, 1000, List.of(measurement)));
-        when(mapper.selectCollectorSampleContentHash(9L, "S-01", 3L, "temperature")).thenReturn("different");
-
-        new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCollectorMeasurements();
-
-        verify(mapper).insertSampleConflict(eq(1L), eq(3L), org.mockito.ArgumentMatchers.contains("采集器实时接收内容冲突"));
-        verify(mapper, never()).insertCollectorSample(any(), any(), any(), anyString(), anyString(), any(), any(Date.class), anyString(), any(Date.class), anyString(), any(BigDecimal.class), anyString(), any(byte[].class));
-    }
-
-    private static TTest test(Date startedAt)
+    private static TTest receivingTest()
     {
         TTestChannelBinding binding = new TTestChannelBinding();
         binding.setBindingId(2L);
         binding.setChannelId(3L);
+        binding.setAdapterCode("dev-sim");
+        binding.setAdapterTimezoneId("Asia/Hong_Kong");
+        binding.setDevtype(22);
         binding.setDevid(1);
+        binding.setSubdevid(1);
         binding.setChlid(1);
         TTestBattery battery = new TTestBattery();
+        battery.setTestBatteryId(4L);
+        battery.setCollectorId(9L);
         battery.setBinding(binding);
+        battery.setCollectorChannels(List.of(
+                mapping("TEMPERATURE", "S-01"), mapping("POSITIVE_POTENTIAL", "S-02"),
+                mapping("NEGATIVE_POTENTIAL", "S-03"), mapping("AMBIENT_TEMPERATURE", "S-04")));
         TTest test = new TTest();
         test.setTestId(1L);
         test.setTestStatus("RUNNING");
         test.setReceptionStatus("RECEIVING");
-        test.setStartedAtUtc(startedAt);
+        test.setStartedAtUtc(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
+        test.setReceptionBoundaryAtUtc(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
         test.setBatteries(List.of(battery));
         return test;
     }
 
-    private static BtsHistoryRecord record(long sequenceId, String deviceTime)
+    private static TTestCollectorChannel mapping(String metric, String channel)
     {
-        Instant parsed = Instant.parse(deviceTime);
-        return new BtsHistoryRecord(sequenceId, parsed, 1d, 2d, 3d, 4d,
-                1, 1, "恒流充电", "cc", parsed);
+        TTestCollectorChannel mapping = new TTestCollectorChannel();
+        mapping.setMeasurementCode(metric);
+        mapping.setChannelCode(channel);
+        return mapping;
+    }
+
+    private static BtsHistoryRecord cabinetRecord()
+    {
+        Instant deviceTime = Instant.parse("2026-09-02T04:00:30Z");
+        return new BtsHistoryRecord(7L, deviceTime, 1, 3.2, 2.5, 8.4,
+                1, 2, "恒流充电", "cc", Instant.parse("2026-09-02T04:00:35Z"));
+    }
+
+    private static CollectorRealtimeMeasurement collector(String channel, long sequenceId, String metric,
+            String value, String unit)
+    {
+        return new CollectorRealtimeMeasurement("dev-sim", "collector-9", channel, sequenceId,
+                Instant.parse("2026-09-02T04:00:40Z"), "2026-09-02 12:00:40.000",
+                Instant.parse("2026-09-02T04:00:50Z"), metric, new BigDecimal(value), unit);
+    }
+
+    private static CollectorRealtimeSnapshot collectorSnapshot(boolean online,
+            List<CollectorRealtimeMeasurement> measurements)
+    {
+        return new CollectorRealtimeSnapshot("collector-9", online, 1000, measurements);
+    }
+
+    private record Fixture(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
+    {
+        TestDataReceptionServiceImpl service()
+        {
+            return new TestDataReceptionServiceImpl(mapper, tests, simulator, CLOCK);
+        }
     }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
index 4aff58f..f4fe472 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
@@ -28,6 +28,7 @@
     private Integer subdevid;
     private Integer chlid;
     private String displayNo;
+    private String adapterTimezoneId;
 
     public Long getBindingId() { return bindingId; }
     public void setBindingId(Long value) { bindingId = value; }
@@ -61,4 +62,6 @@
     public void setChlid(Integer value) { chlid = value; }
     public String getDisplayNo() { return displayNo; }
     public void setDisplayNo(String value) { displayNo = value; }
+    public String getAdapterTimezoneId() { return adapterTimezoneId; }
+    public void setAdapterTimezoneId(String value) { adapterTimezoneId = value; }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java
new file mode 100644
index 0000000..f6a72b9
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java
@@ -0,0 +1,27 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 检测柜直接返回的一条实际循环统计记录。 */
+public class TTestCycleRecord implements Serializable
+{
+    private Long cycleRecordId, testId, testBatteryId, bindingId;
+    private String adapterCode, sourceIdentity, sourceRecordId, adapterTimezoneId, dedupeKey, extensionMeasurementsJson;
+    private Date deviceTimeUtc, serverReceivedAtUtc;
+    private String deviceTimeRaw;
+    private Integer cycleNo;
+    private BigDecimal chargeCapacityAh, dischargeCapacityAh, chargeEnergyWh, dischargeEnergyWh, efficiency;
+    private byte[] contentSha256;
+    public Long getCycleRecordId(){return cycleRecordId;} public void setCycleRecordId(Long v){cycleRecordId=v;} public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;}
+    public Long getTestBatteryId(){return testBatteryId;} public void setTestBatteryId(Long v){testBatteryId=v;} public Long getBindingId(){return bindingId;} public void setBindingId(Long v){bindingId=v;}
+    public String getAdapterCode(){return adapterCode;} public void setAdapterCode(String v){adapterCode=v;} public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;}
+    public String getSourceRecordId(){return sourceRecordId;} public void setSourceRecordId(String v){sourceRecordId=v;} public String getAdapterTimezoneId(){return adapterTimezoneId;} public void setAdapterTimezoneId(String v){adapterTimezoneId=v;}
+    public Date getDeviceTimeUtc(){return deviceTimeUtc;} public void setDeviceTimeUtc(Date v){deviceTimeUtc=v;} public String getDeviceTimeRaw(){return deviceTimeRaw;} public void setDeviceTimeRaw(String v){deviceTimeRaw=v;}
+    public Date getServerReceivedAtUtc(){return serverReceivedAtUtc;} public void setServerReceivedAtUtc(Date v){serverReceivedAtUtc=v;} public Integer getCycleNo(){return cycleNo;} public void setCycleNo(Integer v){cycleNo=v;}
+    public BigDecimal getChargeCapacityAh(){return chargeCapacityAh;} public void setChargeCapacityAh(BigDecimal v){chargeCapacityAh=v;} public BigDecimal getDischargeCapacityAh(){return dischargeCapacityAh;} public void setDischargeCapacityAh(BigDecimal v){dischargeCapacityAh=v;}
+    public BigDecimal getChargeEnergyWh(){return chargeEnergyWh;} public void setChargeEnergyWh(BigDecimal v){chargeEnergyWh=v;} public BigDecimal getDischargeEnergyWh(){return dischargeEnergyWh;} public void setDischargeEnergyWh(BigDecimal v){dischargeEnergyWh=v;}
+    public BigDecimal getEfficiency(){return efficiency;} public void setEfficiency(BigDecimal v){efficiency=v;} public String getExtensionMeasurementsJson(){return extensionMeasurementsJson;} public void setExtensionMeasurementsJson(String v){extensionMeasurementsJson=v;}
+    public String getDedupeKey(){return dedupeKey;} public void setDedupeKey(String v){dedupeKey=v;} public byte[] getContentSha256(){return contentSha256;} public void setContentSha256(byte[] v){contentSha256=v;}
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java
new file mode 100644
index 0000000..af25d13
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java
@@ -0,0 +1,83 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 一次测试电池轮询槽位的检测柜与采集器组合观测。 */
+public class TTestRealtimeRecord implements Serializable
+{
+    private Long recordId;
+    private Long testId;
+    private Long testBatteryId;
+    private Long bindingId;
+    private String adapterCode;
+    private String sourceIdentity;
+    private String sourceRecordId;
+    private Integer cabinetDevtype;
+    private Integer cabinetDevid;
+    private Integer cabinetSubdevid;
+    private Integer cabinetChlid;
+    private Long cabinetSequenceId;
+    private Date cabinetDeviceTimeUtc;
+    private String cabinetDeviceTimeRaw;
+    private String cabinetAdapterTimezoneId;
+    private Long collectorId;
+    private String collectorChannelCode;
+    private Long collectorSequenceId;
+    private Date collectorDeviceTimeUtc;
+    private String collectorDeviceTimeRaw;
+    private String collectorAdapterTimezoneId;
+    private Date captureSlotUtc;
+    private Date serverReceivedAtUtc;
+    private Integer cycleNo;
+    private Integer stepNo;
+    private String stepType;
+    private String stepName;
+    private BigDecimal voltageV;
+    private BigDecimal currentA;
+    private BigDecimal capacityAh;
+    private BigDecimal energyWh;
+    private BigDecimal temperature;
+    private BigDecimal positivePotential;
+    private BigDecimal negativePotential;
+    private String extensionMeasurementsJson;
+    private byte[] contentSha256;
+
+    public Long getRecordId() { return recordId; } public void setRecordId(Long v) { recordId = v; }
+    public Long getTestId() { return testId; } public void setTestId(Long v) { testId = v; }
+    public Long getTestBatteryId() { return testBatteryId; } public void setTestBatteryId(Long v) { testBatteryId = v; }
+    public Long getBindingId() { return bindingId; } public void setBindingId(Long v) { bindingId = v; }
+    public String getAdapterCode() { return adapterCode; } public void setAdapterCode(String v) { adapterCode = v; }
+    public String getSourceIdentity() { return sourceIdentity; } public void setSourceIdentity(String v) { sourceIdentity = v; }
+    public String getSourceRecordId() { return sourceRecordId; } public void setSourceRecordId(String v) { sourceRecordId = v; }
+    public Integer getCabinetDevtype() { return cabinetDevtype; } public void setCabinetDevtype(Integer v) { cabinetDevtype = v; }
+    public Integer getCabinetDevid() { return cabinetDevid; } public void setCabinetDevid(Integer v) { cabinetDevid = v; }
+    public Integer getCabinetSubdevid() { return cabinetSubdevid; } public void setCabinetSubdevid(Integer v) { cabinetSubdevid = v; }
+    public Integer getCabinetChlid() { return cabinetChlid; } public void setCabinetChlid(Integer v) { cabinetChlid = v; }
+    public Long getCabinetSequenceId() { return cabinetSequenceId; } public void setCabinetSequenceId(Long v) { cabinetSequenceId = v; }
+    public Date getCabinetDeviceTimeUtc() { return cabinetDeviceTimeUtc; } public void setCabinetDeviceTimeUtc(Date v) { cabinetDeviceTimeUtc = v; }
+    public String getCabinetDeviceTimeRaw() { return cabinetDeviceTimeRaw; } public void setCabinetDeviceTimeRaw(String v) { cabinetDeviceTimeRaw = v; }
+    public String getCabinetAdapterTimezoneId() { return cabinetAdapterTimezoneId; } public void setCabinetAdapterTimezoneId(String v) { cabinetAdapterTimezoneId = v; }
+    public Long getCollectorId() { return collectorId; } public void setCollectorId(Long v) { collectorId = v; }
+    public String getCollectorChannelCode() { return collectorChannelCode; } public void setCollectorChannelCode(String v) { collectorChannelCode = v; }
+    public Long getCollectorSequenceId() { return collectorSequenceId; } public void setCollectorSequenceId(Long v) { collectorSequenceId = v; }
+    public Date getCollectorDeviceTimeUtc() { return collectorDeviceTimeUtc; } public void setCollectorDeviceTimeUtc(Date v) { collectorDeviceTimeUtc = v; }
+    public String getCollectorDeviceTimeRaw() { return collectorDeviceTimeRaw; } public void setCollectorDeviceTimeRaw(String v) { collectorDeviceTimeRaw = v; }
+    public String getCollectorAdapterTimezoneId() { return collectorAdapterTimezoneId; } public void setCollectorAdapterTimezoneId(String v) { collectorAdapterTimezoneId = v; }
+    public Date getCaptureSlotUtc() { return captureSlotUtc; } public void setCaptureSlotUtc(Date v) { captureSlotUtc = v; }
+    public Date getServerReceivedAtUtc() { return serverReceivedAtUtc; } public void setServerReceivedAtUtc(Date v) { serverReceivedAtUtc = v; }
+    public Integer getCycleNo() { return cycleNo; } public void setCycleNo(Integer v) { cycleNo = v; }
+    public Integer getStepNo() { return stepNo; } public void setStepNo(Integer v) { stepNo = v; }
+    public String getStepType() { return stepType; } public void setStepType(String v) { stepType = v; }
+    public String getStepName() { return stepName; } public void setStepName(String v) { stepName = v; }
+    public BigDecimal getVoltageV() { return voltageV; } public void setVoltageV(BigDecimal v) { voltageV = v; }
+    public BigDecimal getCurrentA() { return currentA; } public void setCurrentA(BigDecimal v) { currentA = v; }
+    public BigDecimal getCapacityAh() { return capacityAh; } public void setCapacityAh(BigDecimal v) { capacityAh = v; }
+    public BigDecimal getEnergyWh() { return energyWh; } public void setEnergyWh(BigDecimal v) { energyWh = v; }
+    public BigDecimal getTemperature() { return temperature; } public void setTemperature(BigDecimal v) { temperature = v; }
+    public BigDecimal getPositivePotential() { return positivePotential; } public void setPositivePotential(BigDecimal v) { positivePotential = v; }
+    public BigDecimal getNegativePotential() { return negativePotential; } public void setNegativePotential(BigDecimal v) { negativePotential = v; }
+    public String getExtensionMeasurementsJson() { return extensionMeasurementsJson; } public void setExtensionMeasurementsJson(String v) { extensionMeasurementsJson = v; }
+    public byte[] getContentSha256() { return contentSha256; } public void setContentSha256(byte[] v) { contentSha256 = v; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java
new file mode 100644
index 0000000..8b4fa46
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java
@@ -0,0 +1,29 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 检测柜直接返回的一条实际工步统计记录。 */
+public class TTestStepRecord implements Serializable
+{
+    private Long stepRecordId, testId, testBatteryId, bindingId;
+    private String adapterCode, sourceIdentity, sourceRecordId, adapterTimezoneId, dedupeKey, stepType, stepName, extensionMeasurementsJson;
+    private Date deviceTimeUtc, serverReceivedAtUtc;
+    private String deviceTimeRaw;
+    private Integer cycleNo, stepNo;
+    private BigDecimal voltageV, currentA, capacityAh, energyWh;
+    private byte[] contentSha256;
+    public Long getStepRecordId(){return stepRecordId;} public void setStepRecordId(Long v){stepRecordId=v;}
+    public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;} public Long getTestBatteryId(){return testBatteryId;} public void setTestBatteryId(Long v){testBatteryId=v;}
+    public Long getBindingId(){return bindingId;} public void setBindingId(Long v){bindingId=v;} public String getAdapterCode(){return adapterCode;} public void setAdapterCode(String v){adapterCode=v;}
+    public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;} public String getSourceRecordId(){return sourceRecordId;} public void setSourceRecordId(String v){sourceRecordId=v;}
+    public String getAdapterTimezoneId(){return adapterTimezoneId;} public void setAdapterTimezoneId(String v){adapterTimezoneId=v;} public Date getDeviceTimeUtc(){return deviceTimeUtc;} public void setDeviceTimeUtc(Date v){deviceTimeUtc=v;}
+    public String getDeviceTimeRaw(){return deviceTimeRaw;} public void setDeviceTimeRaw(String v){deviceTimeRaw=v;} public Date getServerReceivedAtUtc(){return serverReceivedAtUtc;} public void setServerReceivedAtUtc(Date v){serverReceivedAtUtc=v;}
+    public Integer getCycleNo(){return cycleNo;} public void setCycleNo(Integer v){cycleNo=v;} public Integer getStepNo(){return stepNo;} public void setStepNo(Integer v){stepNo=v;}
+    public String getStepType(){return stepType;} public void setStepType(String v){stepType=v;} public String getStepName(){return stepName;} public void setStepName(String v){stepName=v;}
+    public BigDecimal getVoltageV(){return voltageV;} public void setVoltageV(BigDecimal v){voltageV=v;} public BigDecimal getCurrentA(){return currentA;} public void setCurrentA(BigDecimal v){currentA=v;}
+    public BigDecimal getCapacityAh(){return capacityAh;} public void setCapacityAh(BigDecimal v){capacityAh=v;} public BigDecimal getEnergyWh(){return energyWh;} public void setEnergyWh(BigDecimal v){energyWh=v;}
+    public String getExtensionMeasurementsJson(){return extensionMeasurementsJson;} public void setExtensionMeasurementsJson(String v){extensionMeasurementsJson=v;} public String getDedupeKey(){return dedupeKey;} public void setDedupeKey(String v){dedupeKey=v;}
+    public byte[] getContentSha256(){return contentSha256;} public void setContentSha256(byte[] v){contentSha256=v;}
+}
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 b19f56f..d0e401b 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
@@ -17,6 +17,9 @@
 import com.ruoyi.system.domain.TTestDataRevisionAction;
 import com.ruoyi.system.domain.TTestConsoleImportRecord;
 import com.ruoyi.system.domain.TTestDataOverride;
+import com.ruoyi.system.domain.TTestRealtimeRecord;
+import com.ruoyi.system.domain.TTestStepRecord;
+import com.ruoyi.system.domain.TTestCycleRecord;
 import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
 import com.ruoyi.system.domain.vo.TestChannelOptionVo;
 import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
@@ -84,6 +87,18 @@
             @Param("positivePotential") java.math.BigDecimal positivePotential,
             @Param("negativePotential") java.math.BigDecimal negativePotential,
             @Param("extensionMeasurementsJson") String extensionMeasurementsJson, @Param("contentSha256") byte[] contentSha256);
+    int insertRealtimeRecord(TTestRealtimeRecord record);
+    String selectRealtimeRecordContentHash(@Param("testId") Long testId, @Param("testBatteryId") Long testBatteryId,
+            @Param("captureSlotUtc") Date captureSlotUtc);
+    int insertStepRecord(TTestStepRecord record);
+    int insertCycleRecord(TTestCycleRecord record);
+    String selectStepRecordContentHash(@Param("testBatteryId") Long testBatteryId,
+            @Param("sourceIdentity") String sourceIdentity, @Param("dedupeKey") String dedupeKey);
+    String selectCycleRecordContentHash(@Param("testBatteryId") Long testBatteryId,
+            @Param("sourceIdentity") String sourceIdentity, @Param("dedupeKey") String dedupeKey);
+    List<java.util.Map<String, Object>> selectPersistedFinalRealtimeRecords(@Param("testId") Long testId);
+    List<java.util.Map<String, Object>> selectPersistedFinalStepRecords(@Param("testId") Long testId);
+    List<java.util.Map<String, Object>> selectPersistedFinalCycleRecords(@Param("testId") Long testId);
     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);
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 06ee3c8..5d28386 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,7 +37,7 @@
     {
         requireTest(testId);
         List<Map<String, Object>> source = new java.util.ArrayList<>();
-        List<Map<String, Object>> snapshots = mapper.selectPersistedRealtimeSnapshots(testId);
+        List<Map<String, Object>> snapshots = mapper.selectPersistedFinalRealtimeRecords(testId);
         if (snapshots != null && !snapshots.isEmpty()) source.addAll(snapshots);
         else {
             // 迁移完成前兼容旧数据;新采集只从统一快照读取。
@@ -48,19 +48,43 @@
         }
         List<Map<String, Object>> effective = applyEffectiveData(testId, "REALTIME", source);
         attachCollectorStepContext(effective);
-        return effective;
+        return normalizeApiTimes(effective);
     }
 
     @Override public List<Map<String, Object>> steps(Long testId)
     {
         requireTest(testId);
-        return applyEffectiveData(testId, "STEP", mapper.selectPersistedSteps(testId));
+        List<Map<String, Object>> source = mapper.selectPersistedFinalStepRecords(testId);
+        if (source == null || source.isEmpty()) {
+            List<Map<String, Object>> legacy = mapper.selectPersistedSteps(testId);
+            if (legacy != null && !legacy.isEmpty()) source = legacy;
+        }
+        return normalizeApiTimes(applyEffectiveData(testId, "STEP", source));
     }
 
     @Override public List<Map<String, Object>> cycles(Long testId)
     {
         requireTest(testId);
-        return applyEffectiveData(testId, "CYCLE", mapper.selectPersistedCycles(testId));
+        List<Map<String, Object>> source = mapper.selectPersistedFinalCycleRecords(testId);
+        if (source == null || source.isEmpty()) {
+            List<Map<String, Object>> legacy = mapper.selectPersistedCycles(testId);
+            if (legacy != null && !legacy.isEmpty()) source = legacy;
+        }
+        return normalizeApiTimes(applyEffectiveData(testId, "CYCLE", source));
+    }
+
+    /** 自研业务接口统一以 Unix epoch 毫秒返回时间,避免前端依赖数据库日期格式。 */
+    private List<Map<String, Object>> normalizeApiTimes(List<Map<String, Object>> rows)
+    {
+        if (rows == null) return List.of();
+        for (Map<String, Object> row : rows)
+            for (String key : List.of("deviceTime", "recordedAt", "absoluteTime", "serverReceivedAt"))
+            {
+                Object value = row.get(key);
+                if (value instanceof Date date) row.put(key, date.getTime());
+                else if (value instanceof Instant instant) row.put(key, instant.toEpochMilli());
+            }
+        return rows;
     }
 
     /** 将最新人工覆盖应用到设备事实,删除只影响当前有效视图,不抹除原始采样。 */
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 aabbe55..7aa0abf 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
@@ -3,329 +3,219 @@
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
-import java.util.Date;
+import java.time.Clock;
 import java.time.Instant;
-import java.util.Map;
-import java.util.LinkedHashMap;
+import java.util.Date;
 import java.util.HexFormat;
+import java.util.LinkedHashMap;
 import java.util.List;
-import com.alibaba.fastjson2.JSON;
-import org.springframework.stereotype.Service;
+import java.util.Locale;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import com.alibaba.fastjson2.JSON;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
+import com.battery.device.api.TestDataObservationAdapter.CollectorMeasurement;
+import com.battery.device.api.TestDataObservationAdapter.CollectorRealtime;
+import com.battery.device.api.TestDataObservationAdapter.StepRecord;
+import com.battery.device.model.ChannelIdentity;
 import com.battery.device.simulator.BtsDevelopmentSimulator;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsCycleRecord;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsStepRecord;
 import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
 import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
 import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCycleRecord;
+import com.ruoyi.system.domain.TTestRealtimeRecord;
+import com.ruoyi.system.domain.TTestStepRecord;
 import com.ruoyi.system.mapper.TTestMapper;
 import com.ruoyi.system.service.ITTestService;
 import com.ruoyi.system.service.ITestDataReceptionService;
 
-/** 接收时确定测试归属,分别保留检测柜、采集器原始事实,并形成统一实时快照。 */
+/** 接收期间将成功观测直接归属测试,并写入实时、工步和循环事实表。 */
 @Service
 public class TestDataReceptionServiceImpl implements ITestDataReceptionService
 {
+    private static final Logger log = LoggerFactory.getLogger(TestDataReceptionServiceImpl.class);
     private final TTestMapper mapper;
     private final ITTestService tests;
-    private final BtsDevelopmentSimulator simulator;
+    private final TestDataObservationAdapter adapter;
     private final TestWarningExceptionService warningExceptions;
+    private final Clock clock;
     private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
-    private final Map<Long, Instant> lastRawCaptureAt = new java.util.concurrent.ConcurrentHashMap<>();
 
     @Autowired
-    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable BtsDevelopmentSimulator simulator,
+    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable TestDataObservationAdapter adapter,
             TestWarningExceptionService warningExceptions)
-    {
-        this.mapper = mapper;
-        this.tests = tests;
-        this.simulator = simulator;
-        this.warningExceptions = warningExceptions;
-    }
+    { this(mapper, tests, adapter, warningExceptions, Clock.systemUTC()); }
 
-    /** 保留单元测试及旧调用方构造契约。 */
+    /** 允许测试固定接收时间,生产运行仍使用 UTC 系统时钟。 */
+    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator, Clock clock)
+    { this(mapper, tests, new LegacySimulatorAdapter(simulator), new TestWarningExceptionService(mapper), clock); }
+
     public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
-    { this(mapper, tests, simulator, new TestWarningExceptionService(mapper)); }
+    { this(mapper, tests, new LegacySimulatorAdapter(simulator), new TestWarningExceptionService(mapper), Clock.systemUTC()); }
 
-    @Override
-    @Transactional(rollbackFor = Exception.class)
+    private TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, TestDataObservationAdapter adapter,
+            TestWarningExceptionService warningExceptions, Clock clock)
+    { this.mapper = mapper; this.tests = tests; this.adapter = adapter; this.warningExceptions = warningExceptions; this.clock = clock; }
+
+    /** 轮询接收中测试,只保存本次成功观测的实时槽位及设备直接返回汇总。 */
+    @Override @Transactional(rollbackFor = Exception.class)
     public void captureDevelopmentRealtimeSnapshots()
     {
-        if (simulator == null) return;
-        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 || !"RUNNING".equals(locked.getTestStatus())
-                    || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
-            TTest test = tests.selectTestById(testId);
-            if (test == null || !"RUNNING".equals(test.getTestStatus())
-                    || !"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);
-        }
+        for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) { saveRealtime(test, battery); saveDeviceAggregates(test, battery); }
     }
 
-    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.getReceptionBoundaryAtUtc() != null && cabinet.serverReceivedAt() != null
-                && cabinet.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().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.getReceptionBoundaryAtUtc() != null
-                && (temp.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())
-                || pos.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())
-                || neg.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().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)
+    /** 接收检测柜直接返回的实际工步和循环记录,不从实时数据推导。 */
+    @Override @Transactional(rollbackFor = Exception.class)
     public void captureDevelopmentCabinetRecords()
-    {
-        if (simulator == null) return;
-        List<Long> testIds = mapper.selectReceivingTestIds();
-        if (testIds == null) return;
-        for (Long testId : testIds)
-        {
-            // 锁住测试行直到本轮采样写入完成,暂停/停止动作只能在本轮接收提交后生效。
-            TTest locked = mapper.selectTestByIdForUpdate(testId);
-            if (locked == null || !"RUNNING".equals(locked.getTestStatus())
-                    || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
-            TTest test = tests.selectTestById(testId);
-            if (test == null || !"RUNNING".equals(test.getTestStatus())
-                    || !"RECEIVING".equals(test.getReceptionStatus())) continue;
-            captureTest(test);
-        }
-    }
+    { for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) saveDeviceAggregates(test, battery); }
 
-    @Override
-    @Transactional(rollbackFor = Exception.class)
+    /** 接收采集器实时指标并并入测试电池轮询槽位,不读取历史补采。 */
+    @Override @Transactional(rollbackFor = Exception.class)
     public void captureDevelopmentCollectorMeasurements()
+    { for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) saveRealtime(test, battery); }
+
+    private List<TTest> receivingTests()
     {
-        if (simulator == null) return;
-        List<Long> testIds = mapper.selectReceivingTestIds();
-        if (testIds == null) return;
-        for (Long testId : testIds)
+        List<Long> ids = mapper.selectReceivingTestIds(); if (ids == null) return List.of();
+        return ids.stream().map(id -> {
+            TTest locked = mapper.selectTestByIdForUpdate(id);
+            if (locked == null || !"RUNNING".equals(locked.getTestStatus()) || !"RECEIVING".equals(locked.getReceptionStatus())) return null;
+            TTest current = tests.selectTestById(id);
+            return current != null && "RUNNING".equals(current.getTestStatus()) && "RECEIVING".equals(current.getReceptionStatus()) ? current : null;
+        }).filter(java.util.Objects::nonNull).toList();
+    }
+    private List<TTestBattery> batteries(TTest test) { return test.getBatteries() == null ? List.of() : test.getBatteries(); }
+
+    private void saveRealtime(TTest test, TTestBattery battery)
+    {
+        if (adapter == null) return;
+        TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
+        Instant now = clock.instant(); long interval = persistenceInterval(binding); String key = test.getTestId() + ":" + battery.getTestBatteryId();
+        Instant previous = lastSnapshotAt.get(key); if (previous != null && now.isBefore(previous.plusMillis(interval))) return;
+        CabinetRealtime cabinet = latestCabinet(binding);
+        if (cabinet != null && !accepted(test, cabinet.deviceTime(), cabinet.serverReceivedAt())) cabinet = null;
+        CollectorRealtime collector = latestCollector(battery);
+        Map<String, CollectorMeasurement> mapped = mappedCollectorMetrics(test, battery, collector);
+        if (cabinet == null && (collector == null || !collector.online() || mapped.isEmpty())) return;
+        Instant collectorTime = mapped.values().stream().map(CollectorMeasurement::deviceTime).max(Instant::compareTo).orElse(null);
+        Instant slot = Instant.ofEpochMilli((now.toEpochMilli() / interval) * interval);
+        TTestRealtimeRecord row = new TTestRealtimeRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId());
+        row.setAdapterCode(binding.getAdapterCode()); row.setCaptureSlotUtc(Date.from(slot)); row.setServerReceivedAtUtc(Date.from(now));
+        if (cabinet != null) { row.setCabinetDevtype(binding.getDevtype()); row.setCabinetDevid(binding.getDevid()); row.setCabinetSubdevid(binding.getSubdevid()); row.setCabinetChlid(binding.getChlid()); row.setCabinetAdapterTimezoneId(timezone(binding)); }
+        if (collector != null && collector.online() && !mapped.isEmpty()) { row.setCollectorId(battery.getCollectorId()); row.setCollectorAdapterTimezoneId(timezone(binding)); }
+        row.setCabinetSequenceId(cabinet == null ? null : cabinet.sequenceId()); row.setCabinetDeviceTimeUtc(cabinet == null ? null : Date.from(cabinet.deviceTime())); row.setCabinetDeviceTimeRaw(cabinet == null ? null : cabinet.deviceTime().toString());
+        row.setCollectorSequenceId(mapped.values().stream().map(CollectorMeasurement::sequenceId).max(Long::compareTo).orElse(null)); row.setCollectorDeviceTimeUtc(collectorTime == null ? null : Date.from(collectorTime)); row.setCollectorDeviceTimeRaw(collectorTime == null ? null : collectorTime.toString());
+        if (cabinet != null) { row.setCycleNo(cabinet.cycleNo()); row.setStepNo(cabinet.stepNo()); row.setStepType(cabinet.stepType()); row.setStepName(cabinet.stepName()); row.setVoltageV(BigDecimal.valueOf(cabinet.voltageV())); row.setCurrentA(BigDecimal.valueOf(cabinet.currentA())); row.setCapacityAh(BigDecimal.valueOf(cabinet.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(cabinet.energyWh())); }
+        row.setTemperature(metricValue(mapped, "TEMPERATURE")); row.setPositivePotential(metricValue(mapped, "POSITIVE_POTENTIAL")); row.setNegativePotential(metricValue(mapped, "NEGATIVE_POTENTIAL")); row.setCollectorChannelCode(mapped.values().stream().map(CollectorMeasurement::channelCode).findFirst().orElse(null));
+        Map<String, Object> extension = new LinkedHashMap<>();
+        for (Map.Entry<String, CollectorMeasurement> e : mapped.entrySet()) if (!isFixedMetric(e.getKey()))
         {
-            TTest locked = mapper.selectTestByIdForUpdate(testId);
-            if (locked == null || !"RUNNING".equals(locked.getTestStatus())
-                    || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
-            TTest test = tests.selectTestById(testId);
-            if (test == null || !"RUNNING".equals(test.getTestStatus())
-                    || !"RECEIVING".equals(test.getReceptionStatus())) continue;
-            for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
-                captureCollectorMeasurements(test, battery);
+            Map<String, Object> value = new LinkedHashMap<>(); value.put("value", e.getValue().value()); value.put("unit", e.getValue().unit()); value.put("channelCode", e.getValue().channelCode()); extension.put(e.getKey(), value);
         }
+        row.setExtensionMeasurementsJson(JSON.toJSONString(extension)); row.setSourceIdentity(sourceIdentity(binding));
+        row.setSourceRecordId(sourceRecordId(cabinet, mapped));
+        row.setContentSha256(hash(row));
+        String old = mapper.selectRealtimeRecordContentHash(test.getTestId(), battery.getTestBatteryId(), row.getCaptureSlotUtc());
+        if (old != null) { if (!old.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256()))) log.warn("实时采样内容冲突,拒绝覆盖 testId={}, testBatteryId={}, captureSlotUtc={}", test.getTestId(), battery.getTestBatteryId(), slot); return; }
+        int inserted = mapper.insertRealtimeRecord(row);
+        if (inserted == 0)
+        {
+            String actual = mapper.selectRealtimeRecordContentHash(test.getTestId(), battery.getTestBatteryId(), row.getCaptureSlotUtc());
+            if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())))
+                log.warn("实时采样并发内容冲突,拒绝覆盖 testId={}, testBatteryId={}, captureSlotUtc={}", test.getTestId(), battery.getTestBatteryId(), slot);
+        }
+        lastSnapshotAt.put(key, now);
     }
 
-    private void captureTest(TTest test)
+    private void saveDeviceAggregates(TTest test, TTestBattery battery)
     {
-        Date startedAt = test.getStartedAtUtc();
-        for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
+        if (adapter == null) return; TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
+        ChannelIdentity channel = new ChannelIdentity(binding.getAdapterCode(), binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid());
+        List<StepRecord> steps; List<CycleRecord> cycles; try { steps = adapter.stepRecords(channel); cycles = adapter.cycleRecords(channel); } catch (RuntimeException ex) { return; }
+        Date receivedAt = Date.from(clock.instant()); String identity = sourceIdentity(binding);
+        for (StepRecord source : steps == null ? List.<StepRecord>of() : steps)
         {
-            TTestChannelBinding binding = battery.getBinding();
-            if (binding == null || binding.getDevid() == null || binding.getChlid() == null) continue;
-            List<BtsHistoryRecord> records;
-            try { records = simulator.btsRecords(binding.getDevid(), binding.getChlid()); }
-            catch (IllegalArgumentException ignored) { continue; }
-            for (BtsHistoryRecord record : records)
+            if (!accepted(test, source.endedAt(), null)) continue;
+            TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(timezone(binding)); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.endedAt().toString()); row.setServerReceivedAtUtc(receivedAt); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCapacityAh(BigDecimal.valueOf(source.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
+            if (mapper.insertStepRecord(row) == 0)
             {
-                // 设备时间隔离顺序测试,服务器接收边界排除暂停期间历史;两种时间不可交叉比较。
-                if (startedAt != null && record.deviceTime().isBefore(startedAt.toInstant())) continue;
-                if (test.getReceptionBoundaryAtUtc() != null && record.serverReceivedAt() != null
-                        && record.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())) continue;
-                if (save(test, binding, record)) {
-                    String source = String.join(":", java.util.Objects.toString(binding.getAdapterCode(), ""),
-                            java.util.Objects.toString(binding.getDevtype(), ""), java.util.Objects.toString(binding.getDevid(), ""),
-                            java.util.Objects.toString(binding.getSubdevid(), ""), java.util.Objects.toString(binding.getChlid(), ""));
-                    warningExceptions.evaluate(test, "CABINET", source, "VOLTAGE", BigDecimal.valueOf(record.voltageV()), "V", Date.from(record.deviceTime()));
-                    warningExceptions.evaluate(test, "CABINET", source, "CURRENT", BigDecimal.valueOf(record.currentA()), "A", Date.from(record.deviceTime()));
-                    warningExceptions.evaluate(test, "CABINET", source, "CAPACITY", BigDecimal.valueOf(record.capacityAh()), "Ah", Date.from(record.deviceTime()));
-                    warningExceptions.evaluate(test, "CABINET", source, "ENERGY", BigDecimal.valueOf(record.energyWh()), "Wh", Date.from(record.deviceTime()));
-                }
+                String actual = mapper.selectStepRecordContentHash(row.getTestBatteryId(), row.getSourceIdentity(), row.getDedupeKey());
+                log.warn(actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())) ? "工步记录内容冲突,拒绝覆盖 testId={}, dedupeKey={}" : "工步记录重复,保留首次事实 testId={}, dedupeKey={}", test.getTestId(), row.getDedupeKey());
+            }
+        }
+        for (CycleRecord source : cycles == null ? List.<CycleRecord>of() : cycles)
+        {
+            if (!accepted(test, source.endedAt(), null)) continue;
+            TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(timezone(binding)); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.endedAt().toString()); row.setServerReceivedAtUtc(receivedAt); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(BigDecimal.valueOf(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
+            if (mapper.insertCycleRecord(row) == 0)
+            {
+                String actual = mapper.selectCycleRecordContentHash(row.getTestBatteryId(), row.getSourceIdentity(), row.getDedupeKey());
+                log.warn(actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())) ? "循环记录内容冲突,拒绝覆盖 testId={}, dedupeKey={}" : "循环记录重复,保留首次事实 testId={}, dedupeKey={}", test.getTestId(), row.getDedupeKey());
             }
         }
     }
 
-    private void captureCollectorMeasurements(TTest test, TTestBattery battery)
+    private CabinetRealtime latestCabinet(TTestChannelBinding b) { try { return adapter.latestCabinet(new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid())); } catch (RuntimeException ex) { return null; } }
+    private CollectorRealtime latestCollector(TTestBattery b) { if (b.getCollectorId() == null) return null; try { return adapter.latestCollector(b.getCollectorId()); } catch (RuntimeException ex) { return null; } }
+    private Map<String, CollectorMeasurement> mappedCollectorMetrics(TTest test, TTestBattery b, CollectorRealtime s) { Map<String, CollectorMeasurement> out = new LinkedHashMap<>(); if (s == null || !s.online() || s.measurements() == null || b.getCollectorChannels() == null) return out; for (CollectorMeasurement m : s.measurements()) if (accepted(test, m.deviceTime(), m.serverReceivedAt()) && b.getCollectorChannels().stream().anyMatch(x -> java.util.Objects.equals(x.getChannelCode(), m.channelCode()) && metricMatches(x.getMeasurementCode(), m.metricCode()))) out.put(m.metricCode().toUpperCase(Locale.ROOT), m); return out; }
+    private boolean metricMatches(String c, String a) { if (c == null || a == null) return false; c = c.toUpperCase(Locale.ROOT); a = a.toUpperCase(Locale.ROOT); return c.equals(a) || (c.contains("TEMPERATURE") && a.equals("TEMPERATURE")) || (c.contains("POSITIVE_POTENTIAL") && a.equals("POSITIVE_POTENTIAL")) || (c.contains("NEGATIVE_POTENTIAL") && a.equals("NEGATIVE_POTENTIAL")); }
+    private static boolean isFixedMetric(String code) { return "TEMPERATURE".equals(code) || "POSITIVE_POTENTIAL".equals(code) || "NEGATIVE_POTENTIAL".equals(code); }
+    private static BigDecimal metricValue(Map<String, CollectorMeasurement> values, String code) { return values.get(code) == null ? null : values.get(code).value(); }
+    private static String sourceRecordId(CabinetRealtime cabinet, Map<String, CollectorMeasurement> measurements)
     {
-        if (battery.getCollectorId() == null || battery.getCollectorChannels() == null
-                || battery.getCollectorChannels().isEmpty()) return;
-        final CollectorRealtimeSnapshot realtime;
-        try { realtime = simulator.collectorRealtime(battery.getCollectorId().intValue()); }
-        catch (IllegalArgumentException ignored) { return; }
-        if (!realtime.online()) return;
-        Date startedAt = test.getStartedAtUtc();
-        for (CollectorRealtimeMeasurement measurement : realtime.measurements())
-        {
-            if (startedAt != null && measurement.deviceTime().isBefore(startedAt.toInstant())) continue;
-            // 继续接收只接受恢复动作之后服务器收到的采样;暂停期间采集器历史不回补。
-            if (test.getReceptionBoundaryAtUtc() != null
-                    && measurement.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())) continue;
-            boolean mapped = battery.getCollectorChannels().stream().anyMatch(mapping ->
-                    java.util.Objects.equals(mapping.getChannelCode(), measurement.channelCode())
-                    && metricMatches(mapping.getMeasurementCode(), measurement.metricCode()));
-            if (!mapped) continue;
-            if (saveCollector(test, battery, measurement))
-                warningExceptions.evaluate(test, "COLLECTOR", measurement.adapterCode() + ":" + battery.getCollectorId() + ":" + measurement.channelCode(), measurement.metricCode(), measurement.value(), measurement.unit(), Date.from(measurement.deviceTime()));
-        }
+        List<String> parts = new java.util.ArrayList<>();
+        if (cabinet != null) parts.add("cabinet:" + cabinet.sequenceId());
+        measurements.values().stream().map(CollectorMeasurement::sequenceId).max(Long::compareTo)
+                .ifPresent(sequence -> parts.add("collector:" + sequence));
+        return String.join(":", parts);
+    }
+    private long persistenceInterval(TTestChannelBinding b) { try { return Math.max(100L, adapter.persistenceIntervalMillis(new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid()))); } catch (RuntimeException ex) { return 60000L; } }
+    private static String sourceIdentity(TTestChannelBinding b) { return String.join(":", String.valueOf(b.getAdapterCode()), String.valueOf(b.getDevtype()), String.valueOf(b.getDevid()), String.valueOf(b.getSubdevid()), String.valueOf(b.getChlid())); }
+    private static String timezone(TTestChannelBinding b) { return b.getAdapterTimezoneId() == null || b.getAdapterTimezoneId().isBlank() ? "Asia/Hong_Kong" : b.getAdapterTimezoneId(); }
+    private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
+    private static boolean accepted(TTest test, Instant deviceTime, Instant serverReceivedAt)
+    {
+        if (deviceTime != null && test.getStartedAtUtc() != null && deviceTime.isBefore(test.getStartedAtUtc().toInstant())) return false;
+        return serverReceivedAt == null || test.getReceptionBoundaryAtUtc() == null || !serverReceivedAt.isBefore(test.getReceptionBoundaryAtUtc().toInstant());
     }
 
-    private boolean saveCollector(TTest test, TTestBattery battery, CollectorRealtimeMeasurement measurement)
+    /** 将开发模拟器的历史扩展映射到业务观测契约,仅供兼容测试构造使用。 */
+    private static final class LegacySimulatorAdapter implements TestDataObservationAdapter
     {
-        byte[] hash = hash(measurement);
-        String old = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), measurement.channelCode(),
-                measurement.sequenceId(), measurement.metricCode());
-        String expected = HexFormat.of().formatHex(hash);
-        if (old != null)
+        private final BtsDevelopmentSimulator simulator;
+        private LegacySimulatorAdapter(BtsDevelopmentSimulator simulator) { this.simulator = simulator; }
+        @Override public String adapterCode() { return simulator.adapterCode(); }
+        @Override public com.battery.device.model.AdapterCapabilities capabilities() { return simulator.capabilities(); }
+        @Override public List<com.battery.device.model.DetectedChannel> discoverChannels() { return simulator.discoverChannels(); }
+        @Override public Map<ChannelIdentity, com.battery.device.model.DeviceObservation> observe(java.util.Set<ChannelIdentity> channels) { return simulator.observe(channels); }
+        @Override public CabinetRealtime latestCabinet(ChannelIdentity channel)
         {
-            if (!old.equalsIgnoreCase(expected))
-                mapper.insertSampleConflict(test.getTestId(), battery.getBinding().getChannelId(),
-                        "采集器实时接收内容冲突 collector=" + battery.getCollectorId() + ", channel="
-                                + measurement.channelCode() + ", metric=" + measurement.metricCode()
-                                + ", seqid=" + measurement.sequenceId());
-            return false;
+            return simulator.btsRecords(channel.devid(), channel.chlid()).stream()
+                    .max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).map(r -> new CabinetRealtime(r.sequenceId(), r.deviceTime(), r.currentA(), r.voltageV(), r.capacityAh(), r.energyWh(), r.cycleNo(), r.stepNo(), r.stepName(), r.stepType(), r.serverReceivedAt())).orElse(null);
         }
-        Date receivedAt = new Date();
-        int inserted = mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), battery.getCollectorId(),
-                measurement.adapterCode(),
-                measurement.channelCode(), measurement.sequenceId(), Date.from(measurement.deviceTime()),
-                measurement.deviceTimeRaw(), receivedAt, measurement.metricCode(),
-                measurement.value(), measurement.unit(), hash);
-        // 唯一键负责并发幂等;内容冲突保留首次事实,不覆盖原始记录。
-        if (inserted == 0)
+        @Override public CollectorRealtime latestCollector(long collectorId)
         {
-            String actual = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), measurement.channelCode(),
-                    measurement.sequenceId(), measurement.metricCode());
-            if (actual != null && !actual.equalsIgnoreCase(expected))
-                mapper.insertSampleConflict(test.getTestId(), battery.getBinding().getChannelId(),
-                        "采集器实时并发内容冲突 collector=" + battery.getCollectorId() + ", channel="
-                                + measurement.channelCode() + ", metric=" + measurement.metricCode()
-                                + ", seqid=" + measurement.sequenceId());
+            CollectorRealtimeSnapshot snapshot = simulator.collectorRealtime((int) collectorId);
+            return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(), snapshot.measurements().stream().map(m -> new CollectorMeasurement(m.channelCode(), m.sequenceId(), m.deviceTime(), m.deviceTimeRaw(), m.serverReceivedAt(), m.metricCode(), m.value(), m.unit())).toList());
         }
-        return inserted > 0;
+        @Override public List<StepRecord> stepRecords(ChannelIdentity channel)
+        { return simulator.btsSteps(channel.devid(), channel.chlid()).stream().map(s -> new StepRecord(s.recordNo(), s.cycleNo(), s.stepNo(), s.stepName(), s.stepType(), s.startedAt(), s.endedAt(), s.endVoltageV(), s.capacityAh(), s.energyWh())).toList(); }
+        @Override public List<CycleRecord> cycleRecords(ChannelIdentity channel)
+        { return simulator.btsCycles(channel.devid(), channel.chlid()).stream().map(s -> new CycleRecord(s.recordNo(), s.cycleNo(), s.startedAt(), s.endedAt(), s.chargeCapacityAh(), s.dischargeCapacityAh(), s.chargeEnergyWh(), s.dischargeEnergyWh(), s.efficiency())).toList(); }
+        @Override public long persistenceIntervalMillis(ChannelIdentity channel) { return simulator.persistenceIntervalMillis(channel.devid(), channel.chlid()); }
     }
 
-    private boolean save(TTest test, TTestChannelBinding binding,
-            BtsHistoryRecord record)
-    {
-        // 模拟器历史 testid 按通道固定;接收事实必须再带测试作用域,避免顺序测试共用全局幂等键。
-        String btsTestId = "SIM-" + binding.getDevid() + "-" + binding.getChlid() + "-T" + test.getTestId();
-        byte[] hash = hash(record);
-        String old = mapper.selectBtsSampleContentHash(binding.getChannelId(), btsTestId, record.sequenceId());
-        if (old != null)
-        {
-            if (!old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
-                mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
-                        "实时接收内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
-            return false;
-        }
-        int inserted = mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(),
-                btsTestId, record.sequenceId(), Date.from(record.deviceTime()),
-                record.deviceTime().toString(), new Date(), record.cycleNo(), record.stepNo(), record.stepType(),
-                record.stepName(), null,
-                BigDecimal.valueOf(record.voltageV()), BigDecimal.valueOf(record.currentA()),
-                BigDecimal.valueOf(record.capacityAh()), BigDecimal.valueOf(record.energyWh()), hash);
-        if (inserted == 0)
-        {
-            String actual = mapper.selectBtsSampleContentHash(binding.getChannelId(), btsTestId, record.sequenceId());
-            if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
-                mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
-                        "实时接收并发内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
-        }
-        return inserted > 0;
-    }
-
-    private byte[] hash(BtsHistoryRecord record)
-    {
-        try
-        {
-            String value = record.sequenceId() + "|" + record.deviceTime() + "|" + record.currentA() + "|"
-                    + record.voltageV() + "|" + record.capacityAh() + "|" + record.energyWh() + "|"
-                    + record.cycleNo() + "|" + record.stepNo() + "|" + record.stepType();
-            return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
-        }
-        catch (Exception exception) { throw new IllegalStateException("无法计算采样内容摘要", exception); }
-    }
-
-    private byte[] hash(CollectorRealtimeMeasurement measurement)
-    {
-        try
-        {
-            String value = measurement.adapterCode() + "|" + measurement.collectorCode() + "|"
-                    + measurement.channelCode() + "|" + measurement.sequenceId() + "|"
-                    + measurement.deviceTimeRaw() + "|" + measurement.metricCode() + "|"
-                    + measurement.value() + "|" + measurement.unit();
-            return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
-        }
-        catch (Exception exception) { throw new IllegalStateException("无法计算采集器采样内容摘要", exception); }
-    }
-
-    private boolean metricMatches(String configured, String actual)
-    {
-        if (configured == null || actual == null) return false;
-        if (configured.equals(actual)) return true;
-        String normalized = configured.toUpperCase(java.util.Locale.ROOT);
-        return (normalized.contains("TEMPERATURE") && "TEMPERATURE".equals(actual.toUpperCase(java.util.Locale.ROOT)))
-                || (normalized.contains("POSITIVE_POTENTIAL") && "POSITIVE_POTENTIAL".equals(actual.toUpperCase(java.util.Locale.ROOT)))
-                || (normalized.contains("NEGATIVE_POTENTIAL") && "NEGATIVE_POTENTIAL".equals(actual.toUpperCase(java.util.Locale.ROOT)));
-    }
+    private byte[] hash(Object value) { try { return MessageDigest.getInstance("SHA-256").digest(JSON.toJSONString(value).getBytes(StandardCharsets.UTF_8)); } catch (Exception ex) { throw new IllegalStateException("无法计算测试数据内容摘要", ex); } }
 }
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 999f31d..ec8d3a0 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -70,6 +70,7 @@
         <result property="subdevid" column="subdevid" />
         <result property="chlid" column="chlid" />
         <result property="displayNo" column="display_no" />
+        <result property="adapterTimezoneId" column="adapter_timezone_id" />
     </resultMap>
 
     <resultMap id="TTestCollectorChannelResultMap" type="TTestCollectorChannel">
@@ -302,6 +303,7 @@
                binding.expected_barcode, binding.claim_status, channel_fact.cabinet_id,
                cabinet.cabinet_code, cabinet.cabinet_name,
                adapter.adapter_code adapter_code,
+               adapter.timezone_id adapter_timezone_id,
                channel_fact.devtype, channel_fact.devid, channel_fact.subdevid, channel_fact.chlid, channel_fact.display_no
         from t_test_channel_binding binding
         join t_detection_channel channel_fact on channel_fact.channel_id = binding.channel_id
@@ -370,6 +372,54 @@
                 #{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, #{temperature}, #{positivePotential},
                 #{negativePotential}, #{extensionMeasurementsJson}, #{contentSha256})
     </insert>
+    <insert id="insertRealtimeRecord" parameterType="com.ruoyi.system.domain.TTestRealtimeRecord" useGeneratedKeys="true" keyProperty="recordId">
+        insert ignore into t_test_realtime_record
+        (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, cabinet_devtype, cabinet_devid, cabinet_subdevid, cabinet_chlid,
+         cabinet_sequence_id, cabinet_device_time_utc, cabinet_device_time_raw, cabinet_adapter_timezone_id,
+         collector_id, collector_channel_code, collector_sequence_id, collector_device_time_utc, collector_device_time_raw,
+         collector_adapter_timezone_id, capture_slot_utc, 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}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{cabinetDevtype}, #{cabinetDevid}, #{cabinetSubdevid}, #{cabinetChlid},
+         #{cabinetSequenceId}, #{cabinetDeviceTimeUtc}, #{cabinetDeviceTimeRaw}, #{cabinetAdapterTimezoneId},
+         #{collectorId}, #{collectorChannelCode}, #{collectorSequenceId}, #{collectorDeviceTimeUtc}, #{collectorDeviceTimeRaw},
+         #{collectorAdapterTimezoneId}, #{captureSlotUtc}, #{serverReceivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName},
+         #{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, #{temperature}, #{positivePotential}, #{negativePotential},
+         cast(#{extensionMeasurementsJson} as json), #{contentSha256})
+    </insert>
+    <select id="selectRealtimeRecordContentHash" resultType="string">
+        select lower(hex(content_sha256)) from t_test_realtime_record
+        where test_id = #{testId} and test_battery_id = #{testBatteryId} and capture_slot_utc = #{captureSlotUtc}
+        limit 1
+    </select>
+    <insert id="insertStepRecord" parameterType="com.ruoyi.system.domain.TTestStepRecord" useGeneratedKeys="true" keyProperty="stepRecordId">
+        insert ignore into t_test_step_record
+        (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id,
+         device_time_utc, device_time_raw, server_received_at_utc, cycle_no, step_no, step_type, step_name,
+         voltage_v, current_a, capacity_ah, energy_wh, extension_measurements_json, content_sha256)
+        values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{dedupeKey}, #{adapterTimezoneId},
+         #{deviceTimeUtc}, #{deviceTimeRaw}, #{serverReceivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName},
+         #{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, cast(#{extensionMeasurementsJson} as json), #{contentSha256})
+    </insert>
+    <insert id="insertCycleRecord" parameterType="com.ruoyi.system.domain.TTestCycleRecord" useGeneratedKeys="true" keyProperty="cycleRecordId">
+        insert ignore into t_test_cycle_record
+        (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id,
+         device_time_utc, device_time_raw, server_received_at_utc, cycle_no, charge_capacity_ah, discharge_capacity_ah,
+         charge_energy_wh, discharge_energy_wh, efficiency, extension_measurements_json, content_sha256)
+        values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{dedupeKey}, #{adapterTimezoneId},
+         #{deviceTimeUtc}, #{deviceTimeRaw}, #{serverReceivedAtUtc}, #{cycleNo}, #{chargeCapacityAh}, #{dischargeCapacityAh},
+         #{chargeEnergyWh}, #{dischargeEnergyWh}, #{efficiency}, cast(#{extensionMeasurementsJson} as json), #{contentSha256})
+    </insert>
+    <select id="selectStepRecordContentHash" resultType="string">
+        select lower(hex(content_sha256)) from t_test_step_record
+        where test_battery_id = #{testBatteryId} and source_identity = #{sourceIdentity} and dedupe_key = #{dedupeKey}
+        limit 1
+    </select>
+    <select id="selectCycleRecordContentHash" resultType="string">
+        select lower(hex(content_sha256)) from t_test_cycle_record
+        where test_battery_id = #{testBatteryId} and source_identity = #{sourceIdentity} and dedupe_key = #{dedupeKey}
+        limit 1
+    </select>
     <select id="selectPersistedRealtimeSnapshots" resultType="map">
         select concat('snapshot:', snapshot.snapshot_id) id,
                concat('snapshot:', snapshot.snapshot_id) stableIdentity,
@@ -396,6 +446,47 @@
         where snapshot.test_id = #{testId}
         order by snapshot.capture_slot_utc desc, snapshot.snapshot_id desc
     </select>
+    <select id="selectPersistedFinalRealtimeRecords" resultType="map">
+        select concat('realtime:', r.record_id) id, concat('realtime:', r.record_id) stableIdentity,
+               concat('RT-', r.record_id) recordNo, 'SNAPSHOT' sourceType, b.battery_no batteryNo,
+               r.cycle_no cycleNo, r.step_no stepNo, r.step_name stepName, r.step_type stepType,
+               r.cabinet_sequence_id sequenceId, r.cabinet_device_time_utc deviceTime,
+               r.server_received_at_utc serverReceivedAt, r.voltage_v voltage, r.current_a current,
+               r.capacity_ah capacity, r.energy_wh energy, r.temperature temperature,
+               r.positive_potential positivePotential, r.negative_potential negativePotential,
+               cast(r.extension_measurements_json as char) extensionMeasurements,
+               r.adapter_code adapterCode, r.cabinet_devtype devtype, r.cabinet_devid deviceId,
+               r.cabinet_subdevid subdevid, r.cabinet_chlid channelId, r.collector_id collectorId,
+               r.collector_channel_code channelCode, r.source_identity sourceIdentity, r.source_record_id sourceRecordId
+        from t_test_realtime_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
+        where r.test_id = #{testId} and b.del_flag = 0
+        order by r.capture_slot_utc desc, r.record_id desc
+    </select>
+    <select id="selectPersistedFinalStepRecords" resultType="map">
+        select concat('step:', r.step_record_id) id, concat('step:', r.step_record_id) stableIdentity,
+               concat('STEP-', r.step_record_id) recordNo, b.battery_no batteryNo, r.cycle_no cycleNo,
+               r.step_no stepNo, r.step_name stepName, r.step_type stepType,
+               r.device_time_utc recordedAt, r.device_time_utc absoluteTime,
+               r.server_received_at_utc serverReceivedAt, r.voltage_v voltage, r.current_a current,
+               r.capacity_ah capacity, r.energy_wh energy, r.adapter_code adapterCode,
+               r.source_identity sourceIdentity, r.source_record_id sourceRecordId
+        from t_test_step_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
+        where r.test_id = #{testId} and b.del_flag = 0
+        order by r.cycle_no, r.step_no, r.device_time_utc, r.step_record_id
+    </select>
+    <select id="selectPersistedFinalCycleRecords" resultType="map">
+        select concat('cycle:', r.cycle_record_id) id, concat('cycle:', r.cycle_record_id) stableIdentity,
+               coalesce(r.source_record_id, concat('CYCLE-', r.cycle_record_id)) recordNo,
+               b.battery_no batteryNo, r.cycle_no cycleNo, r.device_time_utc recordedAt,
+               r.device_time_utc absoluteTime, r.server_received_at_utc serverReceivedAt,
+               r.charge_capacity_ah chargeCapacity, r.discharge_capacity_ah dischargeCapacity,
+               r.charge_energy_wh chargeEnergy, r.discharge_energy_wh dischargeEnergy,
+               r.efficiency efficiency, r.adapter_code adapterCode, r.source_identity sourceIdentity,
+               r.source_record_id sourceRecordId
+        from t_test_cycle_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
+        where r.test_id = #{testId} and b.del_flag = 0
+        order by r.cycle_no, b.battery_no, r.cycle_record_id
+    </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