From ec3308617909c8d6280e0d00ffb18350e372fbdf Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 30 八月 2026 17:36:29 +0800
Subject: [PATCH] 实现采集器断线恢复与来源异常持久化

---
 ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java              |    7 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorExceptionInterval.java                 |   54 +++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/ICollectorDisconnectRecoveryService.java            |   11 +
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                             |    7 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImpl.java    |   62 ++++++++
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java                  |   18 ++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImplTest.java |   67 +++++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java                      |   35 ++++
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                                           |   61 ++++++++
 ruoyi-admin/src/main/resources/db/migration/V18__collector_disconnect_recovery.sql                      |   26 +++
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                                     |   13 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorSource.java                            |   25 +++
 12 files changed, 376 insertions(+), 10 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java
index 431da82..e827d08 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java
@@ -10,6 +10,7 @@
 import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
 import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
 import com.ruoyi.system.mapper.TCollectorMapper;
+import com.ruoyi.system.service.ICollectorDisconnectRecoveryService;
 
 /** 将模拟采集器的发现写入 Redis,并同步已登记采集器的实时在线事实。 */
 @Component
@@ -18,14 +19,24 @@
     private final BtsDevelopmentSimulator simulator;
     private final DeviceDiscoveryCandidateStore candidateStore;
     private final TCollectorMapper collectorMapper;
+    private final ICollectorDisconnectRecoveryService disconnectRecovery;
 
     @Autowired
     public DevelopmentCollectorStatusSynchronizer(BtsDevelopmentSimulator simulator,
-            DeviceDiscoveryCandidateStore candidateStore, TCollectorMapper collectorMapper)
+            DeviceDiscoveryCandidateStore candidateStore, TCollectorMapper collectorMapper,
+            ICollectorDisconnectRecoveryService disconnectRecovery)
     {
         this.simulator = simulator;
         this.candidateStore = candidateStore;
         this.collectorMapper = collectorMapper;
+        this.disconnectRecovery = disconnectRecovery;
+    }
+
+    /** 保留单元测试及独立模拟器装配使用的旧构造入口。 */
+    public DevelopmentCollectorStatusSynchronizer(BtsDevelopmentSimulator simulator,
+            DeviceDiscoveryCandidateStore candidateStore, TCollectorMapper collectorMapper)
+    {
+        this(simulator, candidateStore, collectorMapper, (codes, at) -> { });
     }
 
     /** 完整替换 dev-sim 采集器快照,删除配置后候选立即消失。 */
@@ -33,7 +44,8 @@
     public synchronized void synchronizeOnlineStatus()
     {
         Instant observedAt = Instant.now();
-        List<CollectorCandidate> candidates = simulator.snapshot().collectors().stream()
+        var simulatorState = simulator.snapshot();
+        List<CollectorCandidate> candidates = simulatorState.collectors().stream()
                 .map(item -> new CollectorCandidate(BtsDevelopmentSimulator.ADAPTER_CODE, item.deviceCode(),
                         item.ip(), item.mac(), item.name(),
                         item.manufacturer(), item.model(), item.channelCount(), item.online(), observedAt,
@@ -42,5 +54,7 @@
         candidateStore.replaceCollectors(BtsDevelopmentSimulator.ADAPTER_CODE, candidates);
         candidates.forEach(candidate -> collectorMapper.updateRegisteredObservation(candidate.deviceCode(),
                 candidate.online() ? "ONLINE" : "OFFLINE"));
+        disconnectRecovery.reconcile(simulatorState.collectors().stream()
+                .filter(item -> item.online()).map(item -> item.deviceCode()).collect(java.util.stream.Collectors.toSet()), observedAt);
     }
 }
diff --git a/ruoyi-admin/src/main/resources/db/migration/V18__collector_disconnect_recovery.sql b/ruoyi-admin/src/main/resources/db/migration/V18__collector_disconnect_recovery.sql
new file mode 100644
index 0000000..ddbe27c
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V18__collector_disconnect_recovery.sql
@@ -0,0 +1,26 @@
+-- Ticket11:持久化采集器来源级断线、缺失范围和恢复边界。
+CREATE TABLE t_test_collector_exception_interval (
+    interval_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    battery_relation_id BIGINT NOT NULL,
+    collector_id BIGINT NOT NULL,
+    collector_channel_code VARCHAR(64) NOT NULL,
+    exception_type VARCHAR(64) NOT NULL DEFAULT 'COLLECTOR_DISCONNECTED',
+    opened_at_utc DATETIME(3) NOT NULL,
+    missing_from_utc DATETIME(3) NOT NULL,
+    recovered_at_utc DATETIME(3) NULL,
+    missing_to_utc DATETIME(3) NULL,
+    interval_status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
+    detail VARCHAR(1000) NULL,
+    PRIMARY KEY (interval_id),
+    KEY idx_collector_exception_test (test_id, interval_status, opened_at_utc),
+    KEY idx_collector_exception_source (collector_id, collector_channel_code, interval_status),
+    CONSTRAINT fk_collector_exception_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+    CONSTRAINT fk_collector_exception_battery FOREIGN KEY (battery_relation_id) REFERENCES t_test_battery (relation_id),
+    CONSTRAINT fk_collector_exception_collector FOREIGN KEY (collector_id) REFERENCES t_collector (collector_id),
+    CONSTRAINT chk_collector_exception_status CHECK (interval_status IN ('OPEN', 'CLOSED')),
+    CONSTRAINT chk_collector_exception_range CHECK (missing_to_utc IS NULL OR missing_to_utc >= missing_from_utc)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='采集器来源级断线缺失区间';
+
+ALTER TABLE t_test_collector_exception_interval
+    ADD UNIQUE KEY uk_collector_exception_open (test_id, battery_relation_id, collector_id, collector_channel_code, exception_type, interval_status);
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java
index 042f79a..5473902 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java
@@ -16,9 +16,10 @@
     void productionConstructorIsExplicitlyAutowired()
     {
         var constructors = DevelopmentCollectorStatusSynchronizer.class.getDeclaredConstructors();
-        assertEquals(1, constructors.length);
-        assertTrue(constructors[0].isAnnotationPresent(Autowired.class));
-        assertEquals(3, constructors[0].getParameterCount());
+        assertEquals(2, constructors.length);
+        var production = java.util.Arrays.stream(constructors)
+                .filter(item -> item.isAnnotationPresent(Autowired.class)).findFirst().orElseThrow();
+        assertEquals(4, production.getParameterCount());
     }
     @Test
     void synchronizesOnlyConfiguredCollectorsWithoutRegisteringThem() throws Exception
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
index d3c6e01..5106b4b 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -128,6 +128,13 @@
         assertTrue(v17.contains("AFTER ended_at_utc"));
         assertFalse(v17.matches("(?s).*\\bDROP\\s+TABLE\\b.*"));
 
+        String v18 = resource("db/migration/V18__collector_disconnect_recovery.sql");
+        assertTrue(v18.contains("CREATE TABLE t_test_collector_exception_interval"));
+        assertTrue(v18.contains("missing_from_utc"));
+        assertTrue(v18.contains("recovered_at_utc"));
+        assertTrue(v18.contains("uk_collector_exception_open"));
+        assertFalse(v18.matches("(?s).*\\bDROP\\s+TABLE\\b.*"));
+
         String testMapper = resource("mapper/system/TTestMapper.xml");
         assertTrue(testMapper.contains("selectReceivingChannelsForAdapter"));
         assertTrue(testMapper.contains("RECEPTION_RESUMED"));
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImplTest.java
new file mode 100644
index 0000000..dd11996
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImplTest.java
@@ -0,0 +1,67 @@
+package com.ruoyi.system.service.impl;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import com.ruoyi.system.domain.TTestCollectorExceptionInterval;
+import com.ruoyi.system.domain.TTestCollectorSource;
+import com.ruoyi.system.mapper.TTestMapper;
+
+/** 采集器断线对账的来源级幂等与恢复边界测试。 */
+@ExtendWith(MockitoExtension.class)
+class CollectorDisconnectRecoveryServiceImplTest
+{
+    @Mock private TTestMapper mapper;
+    @InjectMocks private CollectorDisconnectRecoveryServiceImpl service;
+
+    @Test
+    void opensOnlyOfflineSourceAndKeepsOtherSourcesIndependent()
+    {
+        TTestCollectorSource offline = source(10L, 20L, 8L, "COL-1", "S-01");
+        TTestCollectorSource online = source(10L, 20L, 9L, "COL-2", "S-01");
+        when(mapper.selectReceivingCollectorSources()).thenReturn(List.of(offline, online));
+        when(mapper.selectOpenCollectorException(10L, 20L, 8L, "S-01")).thenReturn(null);
+        when(mapper.selectOpenCollectorException(10L, 20L, 9L, "S-01")).thenReturn(null);
+
+        service.reconcile(Set.of("COL-2"), Instant.parse("2026-08-30T09:00:00Z"));
+
+        verify(mapper).insertCollectorExceptionInterval(any(TTestCollectorExceptionInterval.class));
+        verify(mapper, never()).closeCollectorExceptionInterval(any(), any(), any());
+    }
+
+    @Test
+    void closesOpenIntervalAtRecoveryBoundary()
+    {
+        TTestCollectorSource source = source(10L, 20L, 8L, "COL-1", "S-01");
+        TTestCollectorExceptionInterval open = new TTestCollectorExceptionInterval();
+        open.setIntervalId(99L);
+        open.setIntervalStatus("OPEN");
+        when(mapper.selectReceivingCollectorSources()).thenReturn(List.of(source));
+        when(mapper.selectOpenCollectorException(10L, 20L, 8L, "S-01")).thenReturn(open);
+
+        service.reconcile(Set.of("COL-1"), Instant.parse("2026-08-30T09:01:00Z"));
+
+        verify(mapper).closeCollectorExceptionInterval(eq(99L), any(), any());
+        verify(mapper, never()).insertCollectorExceptionInterval(any());
+    }
+
+    private TTestCollectorSource source(Long testId, Long relationId, Long collectorId,
+            String collectorCode, String channelCode)
+    {
+        TTestCollectorSource source = new TTestCollectorSource();
+        source.setTestId(testId); source.setBatteryRelationId(relationId); source.setCollectorId(collectorId);
+        source.setCollectorCode(collectorCode); source.setBatteryNo("BAT-01"); source.setChannelCode(channelCode);
+        return source;
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorExceptionInterval.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorExceptionInterval.java
new file mode 100644
index 0000000..798f595
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorExceptionInterval.java
@@ -0,0 +1,54 @@
+package com.ruoyi.system.domain;
+
+import java.util.Date;
+
+/** 采集器来源断线期间的只追加事实。关闭动作只补写恢复边界。 */
+public class TTestCollectorExceptionInterval
+{
+    private Long intervalId;
+    private Long testId;
+    private Long batteryRelationId;
+    private Long collectorId;
+    private String collectorCode;
+    private String collectorName;
+    private String collectorChannelCode;
+    private String measurementName;
+    private String exceptionType;
+    private Date openedAtUtc;
+    private Date missingFromUtc;
+    private Date recoveredAtUtc;
+    private Date missingToUtc;
+    private String intervalStatus;
+    private String detail;
+
+    public Long getIntervalId() { return intervalId; }
+    public void setIntervalId(Long intervalId) { this.intervalId = intervalId; }
+    public Long getTestId() { return testId; }
+    public void setTestId(Long testId) { this.testId = testId; }
+    public Long getBatteryRelationId() { return batteryRelationId; }
+    public void setBatteryRelationId(Long batteryRelationId) { this.batteryRelationId = batteryRelationId; }
+    public Long getCollectorId() { return collectorId; }
+    public void setCollectorId(Long collectorId) { this.collectorId = collectorId; }
+    public String getCollectorCode() { return collectorCode; }
+    public void setCollectorCode(String collectorCode) { this.collectorCode = collectorCode; }
+    public String getCollectorName() { return collectorName; }
+    public void setCollectorName(String collectorName) { this.collectorName = collectorName; }
+    public String getCollectorChannelCode() { return collectorChannelCode; }
+    public void setCollectorChannelCode(String collectorChannelCode) { this.collectorChannelCode = collectorChannelCode; }
+    public String getMeasurementName() { return measurementName; }
+    public void setMeasurementName(String measurementName) { this.measurementName = measurementName; }
+    public String getExceptionType() { return exceptionType; }
+    public void setExceptionType(String exceptionType) { this.exceptionType = exceptionType; }
+    public Date getOpenedAtUtc() { return openedAtUtc; }
+    public void setOpenedAtUtc(Date openedAtUtc) { this.openedAtUtc = openedAtUtc; }
+    public Date getMissingFromUtc() { return missingFromUtc; }
+    public void setMissingFromUtc(Date missingFromUtc) { this.missingFromUtc = missingFromUtc; }
+    public Date getRecoveredAtUtc() { return recoveredAtUtc; }
+    public void setRecoveredAtUtc(Date recoveredAtUtc) { this.recoveredAtUtc = recoveredAtUtc; }
+    public Date getMissingToUtc() { return missingToUtc; }
+    public void setMissingToUtc(Date missingToUtc) { this.missingToUtc = missingToUtc; }
+    public String getIntervalStatus() { return intervalStatus; }
+    public void setIntervalStatus(String intervalStatus) { this.intervalStatus = intervalStatus; }
+    public String getDetail() { return detail; }
+    public void setDetail(String detail) { this.detail = detail; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorSource.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorSource.java
new file mode 100644
index 0000000..766915d
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCollectorSource.java
@@ -0,0 +1,25 @@
+package com.ruoyi.system.domain;
+
+/** 接收中的测试采集器来源,供断线恢复协调器使用。 */
+public class TTestCollectorSource
+{
+    private Long testId;
+    private Long batteryRelationId;
+    private String batteryNo;
+    private Long collectorId;
+    private String collectorCode;
+    private String channelCode;
+
+    public Long getTestId() { return testId; }
+    public void setTestId(Long testId) { this.testId = testId; }
+    public Long getBatteryRelationId() { return batteryRelationId; }
+    public void setBatteryRelationId(Long batteryRelationId) { this.batteryRelationId = batteryRelationId; }
+    public String getBatteryNo() { return batteryNo; }
+    public void setBatteryNo(String batteryNo) { this.batteryNo = batteryNo; }
+    public Long getCollectorId() { return collectorId; }
+    public void setCollectorId(Long collectorId) { this.collectorId = collectorId; }
+    public String getCollectorCode() { return collectorCode; }
+    public void setCollectorCode(String collectorCode) { this.collectorCode = collectorCode; }
+    public String getChannelCode() { return channelCode; }
+    public void setChannelCode(String channelCode) { this.channelCode = channelCode; }
+}
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 76f31d2..ee5884e 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
@@ -11,6 +11,8 @@
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TTestCollectorSource;
+import com.ruoyi.system.domain.TTestCollectorExceptionInterval;
 import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
 import com.ruoyi.system.domain.vo.TestChannelOptionVo;
@@ -87,4 +89,15 @@
             @Param("updatedAtUtc") Date updatedAtUtc);
     int updateReceptionStopped(@Param("testId") Long testId, @Param("updateBy") String updateBy,
             @Param("endedAtUtc") Date endedAtUtc);
+
+    /** 查询仍在接收中的采集器来源。 */
+    List<TTestCollectorSource> selectReceivingCollectorSources();
+    /** 查询来源当前开放的断线区间。 */
+    TTestCollectorExceptionInterval selectOpenCollectorException(@Param("testId") Long testId,
+            @Param("batteryRelationId") Long batteryRelationId, @Param("collectorId") Long collectorId,
+            @Param("channelCode") String channelCode);
+    int insertCollectorExceptionInterval(TTestCollectorExceptionInterval interval);
+    int closeCollectorExceptionInterval(@Param("intervalId") Long intervalId,
+            @Param("recoveredAtUtc") Date recoveredAtUtc, @Param("missingToUtc") Date missingToUtc);
+    List<TTestCollectorExceptionInterval> selectCollectorExceptionIntervals(Long testId);
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ICollectorDisconnectRecoveryService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ICollectorDisconnectRecoveryService.java
new file mode 100644
index 0000000..e75128d
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ICollectorDisconnectRecoveryService.java
@@ -0,0 +1,11 @@
+package com.ruoyi.system.service;
+
+import java.time.Instant;
+import java.util.Set;
+
+/** 维护接收中采集器来源的断线异常与恢复边界。 */
+public interface ICollectorDisconnectRecoveryService
+{
+    /** 根据当前模拟器在线设备编码对账所有接收中来源。 */
+    void reconcile(Set<String> onlineCollectorCodes, Instant observedAt);
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImpl.java
new file mode 100644
index 0000000..d7b264e
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDisconnectRecoveryServiceImpl.java
@@ -0,0 +1,62 @@
+package com.ruoyi.system.service.impl;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.system.domain.TTestCollectorExceptionInterval;
+import com.ruoyi.system.domain.TTestCollectorSource;
+import com.ruoyi.system.mapper.TTestMapper;
+import com.ruoyi.system.service.ICollectorDisconnectRecoveryService;
+
+/** 将采集器在线事实对账为可恢复的来源级异常区间。 */
+@Service
+public class CollectorDisconnectRecoveryServiceImpl implements ICollectorDisconnectRecoveryService
+{
+    private final TTestMapper mapper;
+
+    public CollectorDisconnectRecoveryServiceImpl(TTestMapper mapper) { this.mapper = mapper; }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void reconcile(Set<String> onlineCollectorCodes, Instant observedAt)
+    {
+        Set<String> online = onlineCollectorCodes == null ? Collections.emptySet()
+                : onlineCollectorCodes.stream().filter(code -> code != null && !code.isBlank()).collect(Collectors.toSet());
+        for (TTestCollectorSource source : mapper.selectReceivingCollectorSources())
+        {
+            TTestCollectorExceptionInterval open = mapper.selectOpenCollectorException(source.getTestId(),
+                    source.getBatteryRelationId(), source.getCollectorId(), source.getChannelCode());
+            boolean isOnline = online.contains(source.getCollectorCode());
+            if (!isOnline && open == null)
+            {
+                TTestCollectorExceptionInterval interval = new TTestCollectorExceptionInterval();
+                interval.setTestId(source.getTestId());
+                interval.setBatteryRelationId(source.getBatteryRelationId());
+                interval.setCollectorId(source.getCollectorId());
+                interval.setCollectorChannelCode(source.getChannelCode());
+                interval.setExceptionType("COLLECTOR_DISCONNECTED");
+                java.util.Date at = java.util.Date.from(observedAt);
+                interval.setOpenedAtUtc(at);
+                interval.setMissingFromUtc(at);
+                interval.setDetail(JSON.toJSONString(java.util.Map.of("batteryNo", source.getBatteryNo(),
+                        "collectorCode", source.getCollectorCode(), "message", "采集器离线,检测柜及其他来源继续接收")));
+                mapper.insertCollectorExceptionInterval(interval);
+                mapper.insertTestAudit(source.getTestId(), "COLLECTOR_DISCONNECTED", null, "system", null,
+                        JSON.toJSONString(java.util.Map.of("collectorId", source.getCollectorId(),
+                                "channelCode", source.getChannelCode(), "missingFrom", observedAt.toString())));
+            }
+            else if (isOnline && open != null)
+            {
+                java.util.Date at = java.util.Date.from(observedAt);
+                mapper.closeCollectorExceptionInterval(open.getIntervalId(), at, at);
+                mapper.insertTestAudit(source.getTestId(), "COLLECTOR_RECONNECTED", null, "system", null,
+                        JSON.toJSONString(java.util.Map.of("collectorId", source.getCollectorId(),
+                                "channelCode", source.getChannelCode(), "recoveredAt", observedAt.toString())));
+            }
+        }
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java
index 02f04cf..1765131 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java
@@ -24,6 +24,8 @@
 import com.ruoyi.device.model.Measurement;
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
+import com.ruoyi.system.domain.TTestCollectorExceptionInterval;
+import com.ruoyi.system.mapper.TTestMapper;
 import com.ruoyi.system.service.ITestLiveService;
 import com.ruoyi.system.service.ITTestService;
 
@@ -34,12 +36,13 @@
     private final ITTestService tests;
     private final DeviceDiscoveryCandidateStore candidates;
     private final BtsDevelopmentSimulator simulator;
+    private final TTestMapper testMapper;
     private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
     private final ConcurrentMap<String, LiveSource> lastSources = new ConcurrentHashMap<>();
 
     public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
-            BtsDevelopmentSimulator simulator)
-    { this.tests = tests; this.candidates = candidates; this.simulator = simulator; }
+            BtsDevelopmentSimulator simulator, TTestMapper testMapper)
+    { this.tests = tests; this.candidates = candidates; this.simulator = simulator; this.testMapper = testMapper; }
 
     @Override public LiveState current(Long testId)
     {
@@ -55,12 +58,34 @@
             if (battery.getCollectorId() != null)
                 sources.add(snapshotFor(testId, collectorSource(battery), freezeSnapshots));
         }
-        List<Map<String, Object>> exceptions = sources.stream().filter(source -> !"ONLINE".equals(source.deviceStatus())
+        List<TTestCollectorExceptionInterval> persisted = testMapper.selectCollectorExceptionIntervals(testId);
+        if (persisted == null) persisted = List.of();
+        List<Map<String, Object>> exceptions = new ArrayList<>(persisted.stream().map(interval -> {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("sourceType", "COLLECTOR");
+            item.put("sourceId", interval.getCollectorId());
+            item.put("collectorCode", interval.getCollectorCode());
+            item.put("collectorName", interval.getCollectorName());
+            item.put("channelCode", interval.getCollectorChannelCode());
+            item.put("measurementName", interval.getMeasurementName());
+            item.put("exceptionType", interval.getExceptionType());
+            item.put("status", interval.getIntervalStatus());
+            item.put("openedAt", interval.getOpenedAtUtc());
+            item.put("missingFrom", interval.getMissingFromUtc());
+            item.put("recoveredAt", interval.getRecoveredAtUtc());
+            item.put("missingTo", interval.getMissingToUtc());
+            item.put("detail", interval.getDetail());
+            return item;
+        }).toList());
+        exceptions.addAll(sources.stream().filter(source -> !"COLLECTOR".equals(source.sourceType())
+                && !"ONLINE".equals(source.deviceStatus())
                 && !"WORKING".equals(source.deviceStatus()) && !"PAUSED".equals(source.deviceStatus()))
                 .map(source -> Map.<String, Object>of("sourceType", source.sourceType(), "sourceId", source.sourceId(),
                         "batteryNo", source.batteryNo(), "status", source.deviceStatus(), "message", "数据来源当前不可观测"))
-                .toList();
-        return new LiveState(testId, test.getTestStatus(), test.getReceptionStatus(), Instant.now(), sources, exceptions);
+                .toList());
+        boolean abnormal = persisted.stream().anyMatch(item -> "OPEN".equals(item.getIntervalStatus()));
+        String testStatus = abnormal && "RUNNING".equals(test.getTestStatus()) ? "RUNNING_ABNORMAL" : test.getTestStatus();
+        return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now(), sources, exceptions);
     }
 
     private LiveSource snapshotFor(Long testId, LiveSource fresh, boolean freeze)
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 657dab0..4bd94cf 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -108,6 +108,23 @@
         <result property="exceptionInfo" column="exception_info" />
         <result property="delFlag" column="del_flag" />
     </resultMap>
+    <resultMap id="TTestCollectorExceptionIntervalResultMap" type="TTestCollectorExceptionInterval">
+        <id property="intervalId" column="interval_id" />
+        <result property="testId" column="test_id" />
+        <result property="batteryRelationId" column="battery_relation_id" />
+        <result property="collectorId" column="collector_id" />
+        <result property="collectorCode" column="collector_code" />
+        <result property="collectorName" column="collector_name" />
+        <result property="collectorChannelCode" column="collector_channel_code" />
+        <result property="measurementName" column="measurement_name" />
+        <result property="exceptionType" column="exception_type" />
+        <result property="openedAtUtc" column="opened_at_utc" />
+        <result property="missingFromUtc" column="missing_from_utc" />
+        <result property="recoveredAtUtc" column="recovered_at_utc" />
+        <result property="missingToUtc" column="missing_to_utc" />
+        <result property="intervalStatus" column="interval_status" />
+        <result property="detail" column="detail" />
+    </resultMap>
 
     <sql id="selectTestBase">
         select t.test_id, t.test_name, t.creator_id, creator.nick_name creator_name,
@@ -458,4 +475,48 @@
             update_by = #{updateBy}, update_time = #{endedAtUtc}
         where test_id = #{testId} and reception_status in ('RECEIVING', 'PAUSED') and del_flag = 0
     </update>
+
+    <select id="selectReceivingCollectorSources" resultType="com.ruoyi.system.domain.TTestCollectorSource">
+        select test_fact.test_id testId, battery.relation_id batteryRelationId, battery.battery_no batteryNo,
+               collector.collector_id collectorId, collector.collector_code collectorCode,
+               mapping.channel_code channelCode
+        from t_test test_fact
+        join t_test_battery battery on battery.test_id = test_fact.test_id and battery.del_flag = 0
+        join t_collector collector on collector.collector_id = battery.collector_id
+            and collector.del_flag = 0
+        join t_test_collector_channel mapping on mapping.battery_relation_id = battery.relation_id
+            and mapping.test_id = test_fact.test_id
+        where test_fact.reception_status = 'RECEIVING' and test_fact.del_flag = 0
+        order by test_fact.test_id, battery.relation_id, mapping.mapping_id
+    </select>
+    <select id="selectOpenCollectorException" resultMap="TTestCollectorExceptionIntervalResultMap">
+        select * from t_test_collector_exception_interval
+        where test_id = #{testId} and battery_relation_id = #{batteryRelationId}
+          and collector_id = #{collectorId} and collector_channel_code = #{channelCode}
+          and exception_type = 'COLLECTOR_DISCONNECTED' and interval_status = 'OPEN'
+        order by interval_id desc limit 1
+    </select>
+    <insert id="insertCollectorExceptionInterval" parameterType="TTestCollectorExceptionInterval" useGeneratedKeys="true" keyProperty="intervalId">
+        insert into t_test_collector_exception_interval
+        (test_id, battery_relation_id, collector_id, collector_channel_code, exception_type,
+         opened_at_utc, missing_from_utc, detail)
+        values (#{testId}, #{batteryRelationId}, #{collectorId}, #{collectorChannelCode},
+                #{exceptionType}, #{openedAtUtc}, #{missingFromUtc}, #{detail})
+    </insert>
+    <update id="closeCollectorExceptionInterval">
+        update t_test_collector_exception_interval
+        set recovered_at_utc = #{recoveredAtUtc}, missing_to_utc = #{missingToUtc}, interval_status = 'CLOSED'
+        where interval_id = #{intervalId} and interval_status = 'OPEN'
+    </update>
+    <select id="selectCollectorExceptionIntervals" parameterType="Long" resultMap="TTestCollectorExceptionIntervalResultMap">
+        select exception_fact.*, collector.collector_code, collector.collector_name,
+               mapping.measurement_name
+        from t_test_collector_exception_interval exception_fact
+        left join t_collector collector on collector.collector_id = exception_fact.collector_id
+        left join t_test_collector_channel mapping on mapping.battery_relation_id = exception_fact.battery_relation_id
+            and mapping.collector_id = exception_fact.collector_id
+            and mapping.channel_code = exception_fact.collector_channel_code
+        where exception_fact.test_id = #{testId}
+        order by exception_fact.opened_at_utc, exception_fact.interval_id
+    </select>
 </mapper>

--
Gitblit v1.7.1