yeg
2026-09-05 acfb83068486766f63284b479d30f78e3ec17dca
完成测试生命周期与接收边界

收紧测试和接收状态转换,增加接收服务器时间边界并阻止暂停历史回补。
9个文件已修改
208 ■■■■■ 已修改文件
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration-final/V2__final_business_baseline.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -891,7 +891,8 @@
    {
        return item.stepName() == null || item.stepName().isBlank()
                ? new BtsHistoryRecord(item.sequenceId(), item.deviceTime(), item.currentA(), item.voltageV(),
                        item.capacityAh(), item.energyWh()) : item;
                        item.capacityAh(), item.energyWh(), item.cycleNo(), item.stepNo(), item.stepName(), item.stepType(),
                        item.serverReceivedAt()) : item;
    }
    private String btsKey(DeviceState device, int channelId)
@@ -963,7 +964,7 @@
            budgetMillis = Math.max(0L, budgetMillis - deltaMillis);
            simulated = simulated.plusSeconds(deltaSeconds);
            BtsHistoryRecord record = new BtsHistoryRecord(fixture.sequenceId(), simulated, fixture.currentA(), fixture.voltageV(),
                    fixture.capacityAh(), fixture.energyWh(), fixture.cycleNo(), fixture.stepNo(), fixture.stepName(), fixture.stepType());
                    fixture.capacityAh(), fixture.energyWh(), fixture.cycleNo(), fixture.stepNo(), fixture.stepName(), fixture.stepType(), now);
            history.add(record);
            appended.add(record);
            lastOffset = fixture.offsetSeconds();
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsSimulatorStateStore.java
@@ -167,8 +167,17 @@
    }
    record BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV,
            double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType)
            double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType,
            Instant serverReceivedAt)
    {
        /** 兼容现有调用方;新生成记录应传入服务器接收时间。 */
        public BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV,
                double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType)
        {
            this(sequenceId, deviceTime, currentA, voltageV, capacityAh, energyWh, cycleNo, stepNo,
                    stepName, stepType, Instant.now());
        }
        /** 兼容 Ticket13 之前仅包含核心测量值的历史记录。 */
        public BtsHistoryRecord(long sequenceId, Instant deviceTime, double currentA, double voltageV,
                double capacityAh, double energyWh)
ruoyi-admin/src/main/resources/db/migration-final/V2__final_business_baseline.sql
@@ -212,6 +212,7 @@
    reception_status VARCHAR(32) NOT NULL DEFAULT 'NOT_STARTED',
    monitor_timeout_minutes INT NOT NULL DEFAULT 30,
    started_at_utc DATETIME(3) NULL,
    reception_boundary_at_utc DATETIME(3) NULL,
    ended_at_utc DATETIME(3) NULL,
    logic_step_mappings_json JSON NULL,
    console_detail_json JSON NULL,
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
@@ -360,10 +360,27 @@
    }
    @Test
    void deleteShouldRejectMismatchedTestAndReceptionStates()
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("RUNNING");
        current.setReceptionStatus("NOT_STARTED");
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
        ServiceException exception = assertThrows(ServiceException.class,
                () -> testService.deleteTestByIds(new Long[] { 10L }, 7L, "operator"));
        assertEquals("开始接收后不能删除测试", exception.getMessage());
        verify(testMapper, never()).deleteTestById(anyLong(), any(String.class));
    }
    @Test
    void startReceptionShouldPersistReceptionWithoutLegacyIntervals()
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("WAITING");
        current.setReceptionStatus("NOT_STARTED");
        current.getBatteries().get(0).getBinding().setBindingId(30L);
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
@@ -385,6 +402,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("WAITING");
        current.setReceptionStatus("NOT_STARTED");
        current.getBatteries().get(0).getBinding().setBindingId(30L);
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
@@ -409,6 +427,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("WAITING");
        current.setReceptionStatus("NOT_STARTED");
        current.getBatteries().get(0).getBinding().setBindingId(30L);
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
@@ -430,6 +449,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("WAITING");
        current.setReceptionStatus("NOT_STARTED");
        current.getBatteries().get(0).getBinding().setBindingId(30L);
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
@@ -449,6 +469,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("WAITING");
        current.setReceptionStatus("NOT_STARTED");
        current.getBatteries().get(0).getBinding().setBindingId(30L);
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
@@ -468,6 +489,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("WAITING");
        current.setReceptionStatus("NOT_STARTED");
        current.getBatteries().get(0).getBinding().setBindingId(30L);
        current.getBatteries().get(0).setCollectorChannels(Collections.emptyList());
@@ -498,10 +520,43 @@
    }
    @Test
    void startReceptionShouldRejectMismatchedTestAndReceptionStates()
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("COMPLETED");
        current.setReceptionStatus("NOT_STARTED");
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
        ServiceException exception = assertThrows(ServiceException.class,
                () -> testService.startReception(10L, 7L, "operator"));
        assertEquals("当前测试不是未开始接收状态", exception.getMessage());
        verify(testMapper, never()).updateReceptionStarted(any(), any(), anyBoolean(), any());
    }
    @Test
    void pauseReceptionShouldRejectMismatchedTestAndReceptionStates()
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("PAUSED");
        current.setReceptionStatus("RECEIVING");
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
        ServiceException exception = assertThrows(ServiceException.class,
                () -> testService.pauseReception(10L, 7L, "operator"));
        assertEquals("当前测试不是接收中状态", exception.getMessage());
        verify(testMapper, never()).updateReceptionPaused(any(), any(), any());
    }
    @Test
    void pauseReceptionShouldStopPollingAndAppendAudit()
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("RUNNING");
        current.setReceptionStatus("RECEIVING");
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
        when(testMapper.updateReceptionPaused(eq(10L), eq("operator"), any())).thenReturn(1);
@@ -517,6 +572,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("PAUSED");
        current.setReceptionStatus("PAUSED");
        List<TTestBattery> configuredBatteries = current.getBatteries();
        current.setBatteries(null);
@@ -535,6 +591,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("PAUSED");
        current.setReceptionStatus("PAUSED");
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
        when(testMapper.updateReceptionStopped(eq(10L), eq("operator"), any())).thenReturn(1);
@@ -550,6 +607,7 @@
    {
        TTest current = validTest();
        current.setTestId(10L);
        current.setTestStatus("RUNNING");
        current.setReceptionStatus("RECEIVING");
        when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
        when(testMapper.updateReceptionStopped(eq(10L), eq("operator"), any())).thenReturn(1);
@@ -615,6 +673,8 @@
        test.setPrincipalId(9L);
        test.setTestTypeId(3L);
        test.setTestPurpose("验证低温循环性能");
        test.setTestStatus("WAITING");
        test.setReceptionStatus("NOT_STARTED");
        test.setMonitorTimeoutMinutes(30);
        test.setBatteries(Collections.singletonList(validBattery("BAT-001", 1L, 100L)));
        test.setPlanSteps(Collections.singletonList(step));
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
@@ -75,12 +75,39 @@
    }
    @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");
@@ -94,8 +121,11 @@
        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)));
                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);
@@ -142,6 +172,7 @@
        battery.setBinding(binding);
        TTest test = new TTest();
        test.setTestId(1L);
        test.setTestStatus("RUNNING");
        test.setReceptionStatus("RECEIVING");
        test.setStartedAtUtc(startedAt);
        test.setBatteries(List.of(battery));
@@ -150,7 +181,8 @@
    private static BtsHistoryRecord record(long sequenceId, String deviceTime)
    {
        return new BtsHistoryRecord(sequenceId, Instant.parse(deviceTime), 1d, 2d, 3d, 4d,
                1, 1, "恒流充电", "cc");
        Instant parsed = Instant.parse(deviceTime);
        return new BtsHistoryRecord(sequenceId, parsed, 1d, 2d, 3d, 4d,
                1, 1, "恒流充电", "cc", parsed);
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
@@ -37,6 +37,8 @@
    private String receptionStatus;
    private Integer monitorTimeoutMinutes;
    private Date startedAtUtc;
    /** 最近一次开始或继续接收动作建立的服务器时间边界。 */
    private Date receptionBoundaryAtUtc;
    private Date endedAtUtc;
    /** 是否从已运行设备的后续观测边界接入。 */
    private Boolean runtimeAttach;
@@ -93,6 +95,8 @@
    public void setMonitorTimeoutMinutes(Integer value) { monitorTimeoutMinutes = value; }
    public Date getStartedAtUtc() { return startedAtUtc; }
    public void setStartedAtUtc(Date value) { startedAtUtc = value; }
    public Date getReceptionBoundaryAtUtc() { return receptionBoundaryAtUtc; }
    public void setReceptionBoundaryAtUtc(Date value) { receptionBoundaryAtUtc = value; }
    public Date getEndedAtUtc() { return endedAtUtc; }
    public void setEndedAtUtc(Date value) { endedAtUtc = value; }
    public Boolean getRuntimeAttach() { return runtimeAttach; }
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
@@ -37,8 +37,8 @@
{
    private static final String WAITING = "WAITING";
    private static final String NOT_STARTED = "NOT_STARTED";
    private static final Set<String> TEST_STATUSES = Set.of(
            WAITING, "RUNNING", "PAUSED", "RUNNING_ABNORMAL", "COMPLETION_PENDING", "COMPLETED", "TERMINATED");
    /** 新流程只允许四种测试状态;历史状态仅在旧数据展示层做兼容映射。 */
    private static final Set<String> TEST_STATUSES = Set.of(WAITING, "RUNNING", "PAUSED", "COMPLETED");
    private static final Set<String> RECEPTION_STATUSES = Set.of(NOT_STARTED, "RECEIVING", "PAUSED", "ENDED");
    private static final Set<String> THRESHOLD_UNITS = Set.of("A", "V", "Ah", "Wh", "℃");
    private static final Set<String> REQUIRED_COLLECTOR_MEASUREMENTS = Set.of(
@@ -109,7 +109,7 @@
        TTest current = testMapper.selectTestByIdForUpdate(test.getTestId());
        if (current == null) throw new ServiceException("测试不存在");
        loadConfiguration(current);
        if (!NOT_STARTED.equals(current.getReceptionStatus()))
        if (!stateMatches(current, WAITING, NOT_STARTED))
            throw new ServiceException("开始接收后不能修改测试配置");
        test.setCreatorId(current.getCreatorId());
@@ -149,7 +149,7 @@
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!NOT_STARTED.equals(current.getReceptionStatus()))
        if (!stateMatches(current, WAITING, NOT_STARTED))
            throw new ServiceException("当前测试不是未开始接收状态");
        loadConfiguration(current);
        if (current.getBatteries().isEmpty()) throw new ServiceException("测试未配置电池号");
@@ -169,7 +169,8 @@
            if (channel == null || !isEnabled(channel.getStatus()))
                throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 不存在或未启用");
            // 只有设备事实为运行中时才标记“运行中接入”;设备暂停属于独立设备状态。
            runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus());
            runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus())
                    || "RUNNING".equalsIgnoreCase(channel.getWorkStatus());
            if (battery.getCollectorId() == null || testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
                throw new ServiceException("电池号 " + battery.getBatteryNo() + " 的采集器不存在或未启用");
            Integer collectorChannelCount = testMapper.selectCollectorChannelCount(battery.getCollectorId());
@@ -200,7 +201,7 @@
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!"RECEIVING".equals(current.getReceptionStatus()))
        if (!stateMatches(current, "RUNNING", "RECEIVING"))
            throw new ServiceException("当前测试不是接收中状态");
        Date actionAt = new Date();
@@ -220,7 +221,7 @@
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (!"PAUSED".equals(current.getReceptionStatus()))
        if (!stateMatches(current, "PAUSED", "PAUSED"))
            throw new ServiceException("当前测试不是暂停接收状态");
        // 加锁查询只返回测试主表,继续接收前重新加载绑定配置以校验来源仍可用。
        loadConfiguration(current);
@@ -240,7 +241,8 @@
        if (operatorId == null) throw new ServiceException("操作人不能为空");
        TTest current = testMapper.selectTestByIdForUpdate(testId);
        if (current == null) throw new ServiceException("测试不存在");
        if (NOT_STARTED.equals(current.getReceptionStatus()) || "ENDED".equals(current.getReceptionStatus()))
        if (!(stateMatches(current, "RUNNING", "RECEIVING")
                || stateMatches(current, "PAUSED", "PAUSED")))
            throw new ServiceException("当前测试不能停止");
        Date actionAt = new Date();
        if (testMapper.updateReceptionStopped(testId, updateBy, actionAt) != 1)
@@ -256,6 +258,13 @@
        return status == null || "0".equals(status);
    }
    /** 生命周期动作必须同时匹配测试状态和接收状态,避免并发请求后两个状态字段互相矛盾。 */
    private boolean stateMatches(TTest test, String expectedTestStatus, String expectedReceptionStatus)
    {
        return expectedReceptionStatus.equals(test.getReceptionStatus())
                && expectedTestStatus.equals(test.getTestStatus());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int deleteTestByIds(Long[] testIds, Long operatorId, String updateBy)
@@ -266,7 +275,7 @@
        {
            TTest current = testMapper.selectTestByIdForUpdate(testId);
            if (current == null) throw new ServiceException("测试不存在");
            if (!NOT_STARTED.equals(current.getReceptionStatus())) throw new ServiceException("开始接收后不能删除测试");
            if (!stateMatches(current, WAITING, NOT_STARTED)) throw new ServiceException("开始接收后不能删除测试");
            loadConfiguration(current);
            // 测试整体删除是无痕例外:先清理全部测试绑定和阈值,再删除主记录,不写审计。
            testMapper.deleteTestCollectorChannelsByTestId(testId);
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -61,9 +61,11 @@
        for (Long testId : testIds)
        {
            TTest locked = mapper.selectTestByIdForUpdate(testId);
            if (locked == null || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
            if (locked == null || !"RUNNING".equals(locked.getTestStatus())
                    || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
            TTest test = tests.selectTestById(testId);
            if (test == null || !"RECEIVING".equals(test.getReceptionStatus())) continue;
            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)))
@@ -95,7 +97,8 @@
        try { collector = simulator.collectorRealtime(battery.getCollectorId().intValue()); } catch (IllegalArgumentException ignored) { return; }
        if (cabinet == null || !collector.online()) return;
        if (test.getStartedAtUtc() != null && cabinet.deviceTime().isBefore(test.getStartedAtUtc().toInstant())) return;
        if (test.getUpdateTime() != null && cabinet.deviceTime().isBefore(test.getUpdateTime().toInstant())) return;
        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 ->
@@ -109,9 +112,10 @@
        if (test.getStartedAtUtc() != null && (temp.deviceTime().isBefore(test.getStartedAtUtc().toInstant())
                || pos.deviceTime().isBefore(test.getStartedAtUtc().toInstant())
                || neg.deviceTime().isBefore(test.getStartedAtUtc().toInstant()))) return;
        if (test.getUpdateTime() != null && (temp.serverReceivedAt().isBefore(test.getUpdateTime().toInstant())
                || pos.serverReceivedAt().isBefore(test.getUpdateTime().toInstant())
                || neg.serverReceivedAt().isBefore(test.getUpdateTime().toInstant()))) return;
        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,
@@ -143,9 +147,11 @@
        {
            // 锁住测试行直到本轮采样写入完成,暂停/停止动作只能在本轮接收提交后生效。
            TTest locked = mapper.selectTestByIdForUpdate(testId);
            if (locked == null || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
            if (locked == null || !"RUNNING".equals(locked.getTestStatus())
                    || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
            TTest test = tests.selectTestById(testId);
            if (test == null || !"RECEIVING".equals(test.getReceptionStatus())) continue;
            if (test == null || !"RUNNING".equals(test.getTestStatus())
                    || !"RECEIVING".equals(test.getReceptionStatus())) continue;
            captureTest(test);
        }
    }
@@ -160,9 +166,11 @@
        for (Long testId : testIds)
        {
            TTest locked = mapper.selectTestByIdForUpdate(testId);
            if (locked == null || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
            if (locked == null || !"RUNNING".equals(locked.getTestStatus())
                    || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
            TTest test = tests.selectTestById(testId);
            if (test == null || !"RECEIVING".equals(test.getReceptionStatus())) continue;
            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);
        }
@@ -182,6 +190,8 @@
            {
                // 模拟器保留通道历史;只接收本次测试开始后产生的设备时间记录,避免顺序测试串入旧事实。
                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(), ""),
@@ -207,8 +217,9 @@
        for (CollectorRealtimeMeasurement measurement : realtime.measurements())
        {
            if (startedAt != null && measurement.deviceTime().isBefore(startedAt.toInstant())) continue;
            // 继续接收只接受恢复动作之后查询到的快照;暂停期间采集器自行产生的历史不回补。
            if (test.getUpdateTime() != null && measurement.serverReceivedAt().isBefore(test.getUpdateTime().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()));
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -17,6 +17,7 @@
        <result property="receptionStatus" column="reception_status" />
        <result property="monitorTimeoutMinutes" column="monitor_timeout_minutes" />
        <result property="startedAtUtc" column="started_at_utc" />
        <result property="receptionBoundaryAtUtc" column="reception_boundary_at_utc" />
        <result property="endedAtUtc" column="ended_at_utc" />
        <result property="runtimeAttach" column="runtime_attach" />
        <result property="delFlag" column="del_flag" />
@@ -133,7 +134,7 @@
               t.test_type_id, ty.test_type_name, t.test_purpose,
               t.test_status,
               t.reception_status, t.monitor_timeout_minutes,
               t.started_at_utc, t.ended_at_utc, t.del_flag,
               t.started_at_utc, t.reception_boundary_at_utc, t.ended_at_utc, t.del_flag,
               t.create_by, t.create_time, t.update_by, t.update_time, t.remark,
               cast(t.logic_step_mappings_json as char) logic_step_mappings_json,
               cast(t.console_detail_json as char) console_detail_json,
@@ -241,11 +242,12 @@
        set test_name = #{testName}, principal_id = #{principalId}, test_type_id = #{testTypeId},
            test_purpose = #{testPurpose}, monitor_timeout_minutes = #{monitorTimeoutMinutes},
            remark = #{remark}, update_by = #{updateBy}, update_time = #{updateTime}
        where test_id = #{testId} and reception_status = 'NOT_STARTED' and del_flag = 0
        where test_id = #{testId} and test_status = 'WAITING' and reception_status = 'NOT_STARTED' and del_flag = 0
    </update>
    <delete id="deleteTestById">
        delete from t_test where test_id = #{testId} and reception_status = 'NOT_STARTED' and del_flag = 0
        delete from t_test
        where test_id = #{testId} and test_status = 'WAITING' and reception_status = 'NOT_STARTED' and del_flag = 0
    </delete>
    <delete id="deleteTestBindingsByTestId">delete from t_test_channel_binding where test_id = #{testId}</delete>
@@ -349,7 +351,9 @@
        where exception_id=#{exceptionId} and exception_status='OPEN'
    </update>
    <select id="selectReceivingTestIds" resultType="long">
        select test_id from t_test where del_flag = 0 and reception_status = 'RECEIVING' order by test_id
        select test_id from t_test
        where del_flag = 0 and test_status = 'RUNNING' and reception_status = 'RECEIVING'
        order by test_id
    </select>
    <insert id="insertRealtimeSnapshot">
        insert ignore into t_test_realtime_snapshot
@@ -685,24 +689,29 @@
        update t_test
        set test_status = 'RUNNING', reception_status = 'RECEIVING',
            started_at_utc = coalesce(started_at_utc, #{startedAtUtc}),
            reception_boundary_at_utc = #{startedAtUtc},
            update_by = #{updateBy}, update_time = #{startedAtUtc}
        where test_id = #{testId} and reception_status = 'NOT_STARTED' and del_flag = 0
        where test_id = #{testId} and test_status = 'WAITING' and reception_status = 'NOT_STARTED' and del_flag = 0
    </update>
    <update id="updateReceptionPaused">
        update t_test
        set test_status = 'PAUSED', reception_status = 'PAUSED', update_by = #{updateBy}, update_time = #{updatedAtUtc}
        where test_id = #{testId} and reception_status = 'RECEIVING' and del_flag = 0
        where test_id = #{testId} and test_status = 'RUNNING' and reception_status = 'RECEIVING' and del_flag = 0
    </update>
    <update id="updateReceptionResumed">
        update t_test
        set test_status = 'RUNNING', reception_status = 'RECEIVING', update_by = #{updateBy}, update_time = #{updatedAtUtc}
        where test_id = #{testId} and reception_status = 'PAUSED' and del_flag = 0
        set test_status = 'RUNNING', reception_status = 'RECEIVING',
            reception_boundary_at_utc = #{updatedAtUtc}, update_by = #{updateBy}, update_time = #{updatedAtUtc}
        where test_id = #{testId} and test_status = 'PAUSED' and reception_status = 'PAUSED' and del_flag = 0
    </update>
    <update id="updateReceptionStopped">
        update t_test
        set test_status = 'COMPLETED', reception_status = 'ENDED', ended_at_utc = #{endedAtUtc},
            update_by = #{updateBy}, update_time = #{endedAtUtc}
        where test_id = #{testId} and reception_status in ('RECEIVING', 'PAUSED') and del_flag = 0
        where test_id = #{testId}
          and ((test_status = 'RUNNING' and reception_status = 'RECEIVING')
            or (test_status = 'PAUSED' and reception_status = 'PAUSED'))
          and del_flag = 0
    </update>
    <resultMap id="TTestDataOverrideResultMap" type="com.ruoyi.system.domain.TTestDataOverride">