From 0fcd0642efdff8d4e0584fa991aaea2178bc932b Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期二, 01 九月 2026 22:48:35 +0800
Subject: [PATCH] 测试控制台持续更新

---
 ruoyi-admin/src/main/resources/db/migration/V27__persist_bts_step_name.sql                 |    2 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java             |   28 ++++
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java     |    7 
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java |    9 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataQueryService.java             |   12 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestBackfillServiceImpl.java      |   24 +++
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java         |    8 +
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                              |   70 +++++++++
 ruoyi-admin/src/test/java/com/ruoyi/config/TestPersistedDataQueryContractTest.java         |   56 ++++++++
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                        |   10 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java     |   47 ++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java |  110 +++++++++++++++
 12 files changed, 378 insertions(+), 5 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
index 2c067bf..de5cf4b 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
@@ -6,6 +6,7 @@
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
 import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService;
 import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService.DiscoverySource;
+import com.ruoyi.system.service.ITestDataReceptionService;
 
 /** 轮询代码固定装配的 dev-sim;该运行时不依赖数据库适配器记录。 */
 @Component
@@ -13,12 +14,14 @@
 {
     private final DeviceAdapterRegistry registry;
     private final DeviceDiscoveryPersistenceService persistence;
+    private final ITestDataReceptionService reception;
 
     public DevelopmentSimulatorDiscoveryScheduler(DeviceAdapterRegistry registry,
-            DeviceDiscoveryPersistenceService persistence)
+            DeviceDiscoveryPersistenceService persistence, ITestDataReceptionService reception)
     {
         this.registry = registry;
         this.persistence = persistence;
+        this.reception = reception;
     }
 
     /** 注册表已包含固定开发适配器时,将一轮检测柜观测写入 Redis 候选。 */
@@ -28,5 +31,7 @@
         if (!registry.contains(BtsDevelopmentSimulator.ADAPTER_CODE)) return;
         persistence.discoverForReception(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null,
                 BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS));
+        // 发现轮询成功后立即把接收窗口内的模拟 record 写入测试事实;页面不再读取模拟器历史。
+        reception.captureDevelopmentCabinetRecords();
     }
 }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
index e111730..5594c72 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
@@ -25,6 +25,7 @@
 import com.ruoyi.system.domain.TTestDataOverride;
 import com.ruoyi.system.service.ITTestService;
 import com.ruoyi.system.service.ITestConsoleDataService;
+import com.ruoyi.system.service.ITestDataQueryService;
 
 /** 测试管理 Controller。 */
 @RestController
@@ -36,6 +37,9 @@
 
     @Autowired
     private ITestConsoleDataService consoleDataService;
+
+    @Autowired
+    private ITestDataQueryService testDataQueryService;
 
     @PreAuthorize("@ss.hasAnyPermi('system:test:add,system:test:edit')")
     @GetMapping("/options")
@@ -161,6 +165,30 @@
         return success(consoleDataService.selectOverrides(testId, dataType));
     }
 
+    /** 只查询已归属并持久化到当前测试的检测柜 record。 */
+    @PreAuthorize("@ss.hasPermi('system:test:query')")
+    @GetMapping("/{testId}/data/records")
+    public AjaxResult persistedRecords(@PathVariable Long testId)
+    {
+        return success(testDataQueryService.records(testId));
+    }
+
+    /** 只查询当前测试持久化采样汇总出的工步事实。 */
+    @PreAuthorize("@ss.hasPermi('system:test:query')")
+    @GetMapping("/{testId}/data/steps")
+    public AjaxResult persistedSteps(@PathVariable Long testId)
+    {
+        return success(testDataQueryService.steps(testId));
+    }
+
+    /** 只查询当前测试持久化采样汇总出的循环事实。 */
+    @PreAuthorize("@ss.hasPermi('system:test:query')")
+    @GetMapping("/{testId}/data/cycles")
+    public AjaxResult persistedCycles(@PathVariable Long testId)
+    {
+        return success(testDataQueryService.cycles(testId));
+    }
+
     /** 一次提交编辑、导入或删除;服务端只追加一条修订行为。 */
     @PreAuthorize("@ss.hasPermi('system:test:edit')")
     @Log(title = "测试控制台数据修订", businessType = BusinessType.UPDATE)
diff --git a/ruoyi-admin/src/main/resources/db/migration/V27__persist_bts_step_name.sql b/ruoyi-admin/src/main/resources/db/migration/V27__persist_bts_step_name.sql
new file mode 100644
index 0000000..1de7c6f
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V27__persist_bts_step_name.sql
@@ -0,0 +1,2 @@
+ALTER TABLE t_test_sample
+    ADD COLUMN step_name VARCHAR(128) NULL COMMENT 'BTS 实际工步名称;旧数据为空时回退工步类型' AFTER step_type;
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/TestPersistedDataQueryContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/TestPersistedDataQueryContractTest.java
new file mode 100644
index 0000000..42b1ad9
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/TestPersistedDataQueryContractTest.java
@@ -0,0 +1,56 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.junit.jupiter.api.Test;
+
+/** 测试数据列表必须通过测试级持久化查询,不得回读模拟器历史。 */
+class TestPersistedDataQueryContractTest
+{
+    @Test
+    void exposesTestScopedPersistedDataQueries()
+    {
+        String mapper = resource("mapper/system/TTestMapper.xml");
+        assertTrue(mapper.contains("id=\"selectPersistedRecords\""));
+        assertTrue(mapper.contains("id=\"selectPersistedSteps\""));
+        assertTrue(mapper.contains("id=\"selectPersistedCycles\""));
+        assertTrue(mapper.contains("id=\"selectTestSampleContentHash\""));
+        assertTrue(mapper.contains("sample.step_name"));
+        assertTrue(mapper.contains("where sample.test_id = #{testId}"));
+        assertTrue(mapper.contains("from t_test_sample sample"));
+    }
+
+    @Test
+    void mapperXmlIsWellFormed()
+    {
+        try (InputStream stream = TestPersistedDataQueryContractTest.class.getClassLoader()
+                .getResourceAsStream("mapper/system/TTestMapper.xml"))
+        {
+            if (stream == null) throw new AssertionError("缺少资源 mapper/system/TTestMapper.xml");
+            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+            factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+            factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+            factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+            factory.newDocumentBuilder().parse(stream);
+        }
+        catch (Exception exception)
+        {
+            throw new AssertionError("TTestMapper.xml 必须是可解析的 XML", exception);
+        }
+    }
+
+    private static String resource(String path)
+    {
+        try (InputStream stream = TestPersistedDataQueryContractTest.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/web/controller/system/TTestControllerContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
index f91bc15..8ac19ca 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
@@ -30,6 +30,15 @@
         assertPermission("endReception", "@ss.hasPermi('system:test:stop')", Long.class);
         assertEquals("/{testId}/reception/end", TTestController.class.getMethod("endReception", Long.class)
                 .getAnnotation(PostMapping.class).value()[0]);
+        assertPermission("persistedRecords", "@ss.hasPermi('system:test:query')", Long.class);
+        assertEquals("/{testId}/data/records", TTestController.class.getMethod("persistedRecords", Long.class)
+                .getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()[0]);
+        assertPermission("persistedSteps", "@ss.hasPermi('system:test:query')", Long.class);
+        assertEquals("/{testId}/data/steps", TTestController.class.getMethod("persistedSteps", Long.class)
+                .getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()[0]);
+        assertPermission("persistedCycles", "@ss.hasPermi('system:test:query')", Long.class);
+        assertEquals("/{testId}/data/cycles", TTestController.class.getMethod("persistedCycles", Long.class)
+                .getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()[0]);
     }
 
     private void assertPermission(String methodName, String permission, Class<?>... parameterTypes) throws Exception
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 c000e5c..25139a7 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
@@ -58,6 +58,11 @@
     List<TTestWarningThreshold> selectTestThresholds(Long testId);
     /** 按来源和起始边界顺序返回测试接收区间。 */
     List<TTestReceptionInterval> selectTestReceptionIntervals(Long testId);
+    /** 查询当前正在接收的测试,供接收调度绑定测试事实。 */
+    List<Long> selectReceivingTestIds();
+    List<java.util.Map<String, Object>> selectPersistedRecords(@org.apache.ibatis.annotations.Param("testId") Long testId);
+    List<java.util.Map<String, Object>> selectPersistedSteps(@org.apache.ibatis.annotations.Param("testId") Long testId);
+    List<java.util.Map<String, Object>> selectPersistedCycles(@org.apache.ibatis.annotations.Param("testId") Long testId);
     List<TTestResult> selectTestResults(TTestResult result);
     String selectLogicStepMappingsJson(Long testId);
     int updateLogicStepMappings(@Param("testId") Long testId, @Param("mappingsJson") String mappingsJson,
@@ -139,12 +144,15 @@
     int updateTestNaturallyEnded(@Param("testId") Long testId, @Param("endedAtUtc") Date endedAtUtc);
     String selectSampleContentHash(@Param("channelId") Long channelId, @Param("btsTestId") String btsTestId,
             @Param("sequenceId") Long sequenceId);
+    /** 按测试事实查询通道序号,避免补采重复写入已即时接收的采样。 */
+    String selectTestSampleContentHash(@Param("testId") Long testId, @Param("channelId") Long channelId,
+            @Param("sequenceId") Long sequenceId);
     int insertBtsSample(@Param("testId") Long testId, @Param("bindingId") Long bindingId,
             @Param("channelId") Long channelId, @Param("intervalId") Long intervalId,
             @Param("btsTestId") String btsTestId, @Param("sequenceId") Long sequenceId,
             @Param("deviceTimeUtc") Date deviceTimeUtc, @Param("deviceTimeRaw") String deviceTimeRaw,
             @Param("receivedAtUtc") Date receivedAtUtc, @Param("cycleNo") Integer cycleNo,
-            @Param("stepNo") Integer stepNo, @Param("stepType") String stepType,
+            @Param("stepNo") Integer stepNo, @Param("stepType") String stepType, @Param("stepName") String stepName,
             @Param("testTimeMs") Long testTimeMs, @Param("voltageV") java.math.BigDecimal voltageV,
             @Param("currentA") java.math.BigDecimal currentA, @Param("capacityAh") java.math.BigDecimal capacityAh,
             @Param("energyWh") java.math.BigDecimal energyWh, @Param("contentSha256") byte[] contentSha256);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataQueryService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataQueryService.java
new file mode 100644
index 0000000..ec2e5a7
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataQueryService.java
@@ -0,0 +1,12 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import java.util.Map;
+
+/** 只读取已经归属测试的持久化数据。 */
+public interface ITestDataQueryService
+{
+    List<Map<String, Object>> records(Long testId);
+    List<Map<String, Object>> steps(Long testId);
+    List<Map<String, Object>> cycles(Long testId);
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java
new file mode 100644
index 0000000..4b29eea
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITestDataReceptionService.java
@@ -0,0 +1,8 @@
+package com.ruoyi.system.service;
+
+/** 将设备已接收测量追加到当前测试事实。 */
+public interface ITestDataReceptionService
+{
+    /** 扫描当前接收中的开发检测柜,并把接收窗口内的新记录持久化。 */
+    void captureDevelopmentCabinetRecords();
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestBackfillServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestBackfillServiceImpl.java
index 8e725bf..7f85409 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestBackfillServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestBackfillServiceImpl.java
@@ -190,6 +190,15 @@
         byte[] hash = hash(sample.sequenceId() + "|" + sample.stepNo() + "|" + sample.cycleNo() + "|"
                 + sample.stepType() + "|" + sample.deviceTimeRaw() + "|" + sample.voltageV() + "|"
                 + sample.currentA() + "|" + sample.capacityAh() + "|" + sample.energyWh());
+        String testScoped = mapper.selectTestSampleContentHash(test.getTestId(), binding.getChannelId(),
+                sample.sequenceId());
+        if (testScoped != null)
+        {
+            if (!testScoped.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
+                mapper.insertBackfillConflict(test.getTestId(), binding.getChannelId(),
+                        "测试内采样内容冲突 seqid=" + sample.sequenceId());
+            return;
+        }
         String old = mapper.selectSampleContentHash(binding.getChannelId(), btsTestId, sample.sequenceId());
         if (old != null && !old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
         {
@@ -199,7 +208,8 @@
         }
         mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(),
                 intervalId(intervals, sample.deviceTime()), btsTestId, sample.sequenceId(), Date.from(sample.deviceTime()),
-                sample.deviceTimeRaw(), new Date(), sample.cycleNo(), sample.stepNo(), sample.stepType(), sample.testTimeMs(),
+                sample.deviceTimeRaw(), new Date(), sample.cycleNo(), sample.stepNo(), sample.stepType(),
+                stepNameForType(sample.stepType()), sample.testTimeMs(),
                 sample.voltageV(), sample.currentA(), sample.capacityAh(), sample.energyWh(), hash);
         Long sampleId = mapper.selectBtsSampleId(binding.getChannelId(), btsTestId, sample.sequenceId());
         if (sampleId != null)
@@ -255,6 +265,18 @@
                 Date.from(record.serverReceivedAt()), record.metricCode(), record.value(), record.unit(), hash);
     }
 
+    /** BTS download 协议只提供类型码;演示协议按已确认的类型语义补出名称。 */
+    private String stepNameForType(String stepType)
+    {
+        return switch (stepType == null ? "" : stepType.toLowerCase(java.util.Locale.ROOT))
+        {
+            case "cc" -> "恒流充电";
+            case "dc" -> "恒流放电";
+            case "rest" -> "搁置";
+            default -> stepType;
+        };
+    }
+
     private boolean checkIntegrity(TTest test, Date checkedAt)
     {
         // 序号只在同一来源、同一接收区间内比较;区间边界和暂停间隔不构成缺口。
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
new file mode 100644
index 0000000..10226fb
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
@@ -0,0 +1,47 @@
+package com.ruoyi.system.service.impl;
+
+import java.util.List;
+import java.util.Map;
+import org.springframework.stereotype.Service;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.system.mapper.TTestMapper;
+import com.ruoyi.system.service.ITTestService;
+import com.ruoyi.system.service.ITestDataQueryService;
+
+/** 测试数据查询模块;实现不访问设备或模拟器历史。 */
+@Service
+public class TestDataQueryServiceImpl implements ITestDataQueryService
+{
+    private final TTestMapper mapper;
+    private final ITTestService tests;
+
+    public TestDataQueryServiceImpl(TTestMapper mapper, ITTestService tests)
+    {
+        this.mapper = mapper;
+        this.tests = tests;
+    }
+
+    @Override public List<Map<String, Object>> records(Long testId)
+    {
+        requireTest(testId);
+        return mapper.selectPersistedRecords(testId);
+    }
+
+    @Override public List<Map<String, Object>> steps(Long testId)
+    {
+        requireTest(testId);
+        return mapper.selectPersistedSteps(testId);
+    }
+
+    @Override public List<Map<String, Object>> cycles(Long testId)
+    {
+        requireTest(testId);
+        return mapper.selectPersistedCycles(testId);
+    }
+
+    private void requireTest(Long testId)
+    {
+        if (testId == null || tests.selectTestById(testId) == null)
+            throw new ServiceException("测试不存在");
+    }
+}
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
new file mode 100644
index 0000000..025c217
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -0,0 +1,110 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
+import com.ruoyi.system.domain.TTest;
+import com.ruoyi.system.domain.TTestBattery;
+import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestReceptionInterval;
+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 final TTestMapper mapper;
+    private final ITTestService tests;
+    private final BtsDevelopmentSimulator simulator;
+
+    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
+    {
+        this.mapper = mapper;
+        this.tests = tests;
+        this.simulator = simulator;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void captureDevelopmentCabinetRecords()
+    {
+        List<Long> testIds = mapper.selectReceivingTestIds();
+        if (testIds == null) return;
+        for (Long testId : testIds)
+        {
+            TTest test = tests.selectTestById(testId);
+            if (test == null || !"RECEIVING".equals(test.getReceptionStatus())) continue;
+            captureTest(test);
+        }
+    }
+
+    private void captureTest(TTest test)
+    {
+        for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
+        {
+            TTestChannelBinding binding = battery.getBinding();
+            if (binding == null || binding.getDevid() == null || binding.getChlid() == null) continue;
+            Optional<TTestReceptionInterval> open = mapper.selectTestReceptionIntervals(test.getTestId()).stream()
+                    .filter(item -> Objects.equals(item.getBindingId(), binding.getBindingId())
+                            && "CABINET_CHANNEL".equals(item.getSourceType())
+                            && Objects.equals(item.getSourceId(), binding.getChannelId())
+                            && "OPEN".equals(item.getIntervalStatus()))
+                    .findFirst();
+            if (open.isEmpty()) continue;
+            Date start = open.get().getStartBoundaryAtUtc();
+            List<BtsHistoryRecord> records;
+            try { records = simulator.btsRecords(binding.getDevid(), binding.getChlid()); }
+            catch (IllegalArgumentException ignored) { continue; }
+            for (BtsHistoryRecord record : records)
+            {
+                if (start != null && record.deviceTime().isBefore(start.toInstant())) continue;
+                save(test, battery, binding, open.get(), record);
+            }
+        }
+    }
+
+    private void save(TTest test, TTestBattery battery, TTestChannelBinding binding,
+            TTestReceptionInterval interval, BtsHistoryRecord record)
+    {
+        // 模拟器历史 testid 按通道固定;接收事实必须再带测试作用域,避免顺序测试共用全局幂等键。
+        String btsTestId = "SIM-" + binding.getDevid() + "-" + binding.getChlid() + "-T" + test.getTestId();
+        byte[] hash = hash(record);
+        String old = mapper.selectSampleContentHash(binding.getChannelId(), btsTestId, record.sequenceId());
+        if (old != null)
+        {
+            if (!old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
+                mapper.insertBackfillConflict(test.getTestId(), binding.getChannelId(),
+                        "实时接收内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
+            return;
+        }
+        mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(), interval.getIntervalId(),
+                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);
+    }
+
+    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); }
+    }
+}
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 1f0e210..4ba5d0a 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -367,6 +367,67 @@
         where test_id = #{testId}
         order by source_type, source_id, source_channel_code, start_boundary_at_utc, interval_id
     </select>
+    <select id="selectReceivingTestIds" resultType="long">
+        select test_id from t_test where del_flag = 0 and reception_status = 'RECEIVING' order by test_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,
+               battery.battery_no batteryNo, sample.cycle_no cycleNo, sample.step_no stepNo,
+               coalesce(sample.step_name, sample.step_type) stepName, sample.step_type stepType, sample.seq_id sequenceId,
+               sample.device_time_utc recordedAt, sample.device_time_utc absoluteTime,
+               sample.voltage_v voltage, sample.current_a current, sample.capacity_ah capacity,
+               sample.energy_wh energy, channel.devid deviceId, channel.chlid channelId
+        from t_test_sample sample
+        join t_test_battery battery on battery.relation_id = sample.binding_id and battery.del_flag = 0
+        join t_detection_channel channel on channel.channel_id = sample.channel_id and channel.del_flag = 0
+        where sample.test_id = #{testId}
+        order by sample.device_time_utc desc, sample.seq_id desc, sample.sample_id desc
+    </select>
+    <select id="selectPersistedSteps" resultType="map">
+        select concat('step:', channel.devid, ':', channel.chlid, ':', sample.cycle_no, ':', sample.step_no) id,
+               concat('STEP-', channel.devid, '-', channel.chlid, '-', sample.cycle_no, '-', sample.step_no) recordNo,
+               battery.battery_no batteryNo, sample.cycle_no cycleNo, sample.step_no stepNo,
+               coalesce(sample.step_name, sample.step_type) stepName,
+               sample.step_type stepType, coalesce(sample.step_name, sample.step_type) actualStepLabel,
+               count(*) recordCount,
+               min(sample.device_time_utc) recordedAt, min(sample.device_time_utc) absoluteTime,
+               timestampdiff(second, min(sample.device_time_utc), max(sample.device_time_utc)) stepDuration,
+               max(sample.capacity_ah) capacity, max(sample.energy_wh) energy,
+               min(sample.voltage_v) startVoltage,
+               max(sample.voltage_v) endVoltage,
+               max(case when sample.current_a &gt;= 0 then sample.voltage_v end) chargePeakVoltage,
+               min(case when sample.current_a &lt; 0 then sample.voltage_v end) dischargeValleyVoltage,
+               channel.devid deviceId, channel.chlid channelId
+        from t_test_sample sample
+        join t_test_battery battery on battery.relation_id = sample.binding_id and battery.del_flag = 0
+        join t_detection_channel channel on channel.channel_id = sample.channel_id and channel.del_flag = 0
+        where sample.test_id = #{testId}
+        group by sample.binding_id, battery.battery_no, channel.devid, channel.chlid, sample.cycle_no, sample.step_no,
+                 sample.step_type, sample.step_name
+        order by sample.cycle_no, sample.step_no, battery.battery_no
+    </select>
+    <select id="selectPersistedCycles" resultType="map">
+        select concat('cycle:', channel.devid, ':', channel.chlid, ':', sample.cycle_no) id,
+               concat('CYCLE-', channel.devid, '-', channel.chlid, '-', sample.cycle_no) recordNo,
+               battery.battery_no batteryNo, sample.cycle_no cycleNo, count(*) recordCount,
+               min(sample.device_time_utc) recordedAt, min(sample.device_time_utc) absoluteTime,
+               greatest(max(case when sample.current_a &gt;= 0 then sample.capacity_ah end), 0) chargeCapacity,
+               greatest(max(case when sample.current_a &lt; 0 then sample.capacity_ah end), 0) dischargeCapacity,
+               greatest(max(case when sample.current_a &gt;= 0 then sample.energy_wh end), 0) chargeEnergy,
+               greatest(max(case when sample.current_a &lt; 0 then abs(sample.energy_wh) end), 0) dischargeEnergy,
+               case when max(case when sample.current_a &gt;= 0 then sample.capacity_ah end) &gt; 0
+                    then greatest(max(case when sample.current_a &lt; 0 then sample.capacity_ah end), 0)
+                         / max(case when sample.current_a &gt;= 0 then sample.capacity_ah end) * 100
+                    else 0 end efficiency,
+               channel.devid deviceId, channel.chlid channelId
+        from t_test_sample sample
+        join t_test_battery battery on battery.relation_id = sample.binding_id and battery.del_flag = 0
+        join t_detection_channel channel on channel.channel_id = sample.channel_id and channel.del_flag = 0
+        where sample.test_id = #{testId}
+        group by sample.binding_id, battery.battery_no, channel.devid, channel.chlid, sample.cycle_no
+        order by sample.cycle_no, battery.battery_no
+    </select>
     <select id="selectTestResults" parameterType="TTestResult" resultMap="TTestResultResultMap">
         select result.*, battery.test_id from t_test_result result
         join t_test_battery battery on battery.relation_id = result.relation_id
@@ -559,13 +620,18 @@
         select hex(content_sha256) from t_test_sample
         where channel_id = #{channelId} and bts_test_id = #{btsTestId} and seq_id = #{sequenceId}
     </select>
+    <select id="selectTestSampleContentHash" resultType="string">
+        select hex(content_sha256) from t_test_sample
+        where test_id = #{testId} and channel_id = #{channelId} and seq_id = #{sequenceId}
+        order by sample_id limit 1
+    </select>
     <insert id="insertBtsSample">
         insert ignore into t_test_sample
         (test_id, binding_id, channel_id, reception_interval_id, bts_test_id, seq_id, device_time_utc,
-         device_time_raw, received_at_utc, cycle_no, step_no, step_type, test_time_ms, voltage_v,
+         device_time_raw, received_at_utc, cycle_no, step_no, step_type, step_name, test_time_ms, voltage_v,
          current_a, capacity_ah, energy_wh, content_sha256)
         values (#{testId}, #{bindingId}, #{channelId}, #{intervalId}, #{btsTestId}, #{sequenceId}, #{deviceTimeUtc},
-                #{deviceTimeRaw}, #{receivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{testTimeMs}, #{voltageV},
+                #{deviceTimeRaw}, #{receivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName}, #{testTimeMs}, #{voltageV},
                 #{currentA}, #{capacityAh}, #{energyWh}, #{contentSha256})
     </insert>
     <insert id="insertBtsMeasurement">

--
Gitblit v1.7.1