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

收紧测试和接收状态转换,增加接收服务器时间边界并阻止暂停历史回补。
9个文件已修改
206 ■■■■■ 已修改文件
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 9 ●●●●● 补丁 | 查看 | 原始文档 | 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,
            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">