From 76cd5948e95ddbde9dd4411b466ee75ba20b562e Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 03 九月 2026 17:28:29 +0800
Subject: [PATCH] 实现测试预警异常管理

---
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java |  118 +++++++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 106 insertions(+), 12 deletions(-)

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 b38b100..1869330 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
@@ -13,6 +13,7 @@
 import java.util.concurrent.TimeUnit;
 import jakarta.annotation.PreDestroy;
 import org.springframework.stereotype.Service;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator.CollectorSnapshot;
@@ -23,7 +24,10 @@
 import com.ruoyi.system.domain.TTest;
 import com.ruoyi.system.domain.TTestBattery;
 import com.ruoyi.system.service.ITestLiveService;
+import com.ruoyi.system.service.ITestLiveService.TestPlanRow;
+import com.ruoyi.system.service.ITestLiveService.TestPlanSnapshot;
 import com.ruoyi.system.service.ITTestService;
+import com.ruoyi.system.mapper.TTestMapper;
 
 /** 将设备最新快照转换为测试视角,并托管 SSE 连接生命周期。 */
 @Service
@@ -32,43 +36,116 @@
     private final ITTestService tests;
     private final DeviceDiscoveryCandidateStore candidates;
     private final BtsDevelopmentSimulator simulator;
+    private final TTestMapper mapper;
     private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
+
+    @Autowired
+    public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
+            BtsDevelopmentSimulator simulator, TTestMapper mapper)
+    { this.tests = tests; this.candidates = candidates; this.simulator = simulator; this.mapper = mapper; }
 
     public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
             BtsDevelopmentSimulator simulator)
-    { this.tests = tests; this.candidates = candidates; this.simulator = simulator; }
+    { this(tests, candidates, simulator, null); }
 
     @Override public LiveState current(Long testId)
     {
         TTest test = tests.selectTestById(testId);
         List<CabinetCandidate> cabinets = candidates.snapshot(Instant.now()).cabinets();
         List<LiveSource> sources = new ArrayList<>();
+        boolean receiving = "RECEIVING".equals(test.getReceptionStatus());
         for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
         {
-            if (battery.getBinding() != null) sources.add(cabinetSource(battery, cabinets));
-            if (battery.getCollectorId() != null) sources.add(collectorSource(battery));
+            if (battery.getBinding() != null)
+                sources.add(snapshotFor(cabinetSource(battery, cabinets), receiving));
+            if (battery.getCollectorId() != null)
+                sources.add(snapshotFor(collectorSource(battery), receiving));
         }
-        List<Map<String, Object>> exceptions = sources.stream().filter(source -> !"ONLINE".equals(source.deviceStatus())
+        List<Map<String, Object>> exceptions = new ArrayList<>();
+        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());
+        if (mapper != null) {
+            mapper.selectWarningExceptions(testId, "OPEN").forEach(item -> exceptions.add(Map.of(
+                    "exceptionId", item.getExceptionId(), "sourceType", item.getSourceType(),
+                    "metricCode", item.getMetricCode(), "value", item.getAbnormalValue(), "unit", item.getUnit(),
+                    "status", item.getExceptionStatus(), "message", "测试预警异常")));
+        }
+        String testStatus = test.getTestStatus();
+        TestPlanSnapshot testPlan = detectorTestPlan(test);
+        return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now(), sources, exceptions, testPlan);
+    }
+
+    private TestPlanSnapshot detectorTestPlan(TTest test)
+    {
+        if (test.getBatteries() == null) return null;
+        for (TTestBattery battery : test.getBatteries())
+        {
+            if (battery.getBinding() == null || battery.getBinding().getDevid() == null
+                    || battery.getBinding().getChlid() == null) continue;
+            try
+            {
+                var source = "NOT_STARTED".equals(test.getReceptionStatus())
+                        ? simulator.btsTest(battery.getBinding().getDevid(), battery.getBinding().getChlid(), 1)
+                        : simulator.btsTest(battery.getBinding().getDevid(), battery.getBinding().getChlid());
+                List<TestPlanRow> rows = source.rows().stream()
+                        .map(row -> new TestPlanRow(row.rowType(), row.stepNo(), row.cyclePosition(), row.stepName(), row.durationSeconds(),
+                                row.voltageV(), row.executionStatus(), row.cycleCount(), row.recordCount()))
+                        .toList();
+                return new TestPlanSnapshot(source.currentCycleNo(), source.currentStepNo(),
+                        source.plannedCycleCount(), rows);
+            }
+            catch (IllegalArgumentException ignored) { /* 登记设备尚未被开发模拟器提供时保持无方案事实。 */ }
+        }
+        return null;
+    }
+
+    private LiveSource snapshotFor(LiveSource fresh, boolean receiving)
+    {
+        // 暂停或停止只停止数据请求;设备状态仍以本次查询的最新事实为准,采集快照不继续展示。
+        if (!receiving)
+            return withoutSnapshot(fresh);
+        return fresh;
+    }
+
+    private LiveSource withoutSnapshot(LiveSource source)
+    {
+        return new LiveSource(source.sourceType(), source.sourceId(), source.batteryNo(), source.deviceStatus(), Map.of());
     }
 
     @Override public SseEmitter stream(Long testId)
     {
-        SseEmitter emitter = new SseEmitter(0L);
+        SseEmitter emitter = createEmitter();
         try { sendAll(emitter, testId); }
-        catch (RuntimeException | IOException exception) { emitter.completeWithError(exception); return emitter; }
+        catch (IOException exception)
+        {
+            // 客户端刷新或离开页面会断开连接,不能再交给全局异常处理器写 SSE JSON。
+            emitter.complete();
+            return emitter;
+        }
+        catch (RuntimeException exception) { emitter.completeWithError(exception); return emitter; }
         ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
             try { sendAll(emitter, testId); }
-            catch (RuntimeException | IOException exception) { emitter.completeWithError(exception); }
+            catch (IOException exception)
+            {
+                // 写出失败表示 SSE 客户端已断开,静默结束连接。
+                emitter.complete();
+            }
+            catch (RuntimeException exception) { emitter.completeWithError(exception); }
         }, 3, 3, TimeUnit.SECONDS);
         emitter.onCompletion(() -> future.cancel(false));
         emitter.onTimeout(() -> future.cancel(false));
         emitter.onError(error -> future.cancel(false));
         return emitter;
+    }
+
+    /** 创建实时流发射器,供断连生命周期测试替换。 */
+    protected SseEmitter createEmitter()
+    {
+        return new SseEmitter(0L);
     }
 
     private void sendAll(SseEmitter emitter, Long testId) throws IOException
@@ -91,7 +168,8 @@
                     && Objects.equals(channel.identity().subdevid(), binding.getSubdevid())
                     && Objects.equals(channel.identity().chlid(), binding.getChlid()))
                 return new LiveSource("CABINET_CHANNEL", binding.getChannelId(), battery.getBatteryNo(),
-                        channel.online() ? channel.workStatus().name() : "OFFLINE", snapshot(channel));
+                        channel.online() ? channel.workStatus().name() : "OFFLINE",
+                        channel.online() ? snapshot(channel) : Map.of());
         return new LiveSource("CABINET_CHANNEL", binding.getChannelId(), battery.getBatteryNo(), "UNKNOWN", Map.of());
     }
 
@@ -101,11 +179,27 @@
         if (channel.latestSnapshot() == null) return value;
         value.put("source", "检测柜");
         value.put("observedAt", channel.latestSnapshot().receivedAt());
+        // BTS 核心采样字段是快照的一等事实,不能只依赖动态 measurements 展示。
+        value.put("currentA", channel.latestSnapshot().currentA());
+        value.put("voltageV", channel.latestSnapshot().voltageV());
+        value.put("capacityAh", channel.latestSnapshot().capacityAh());
+        value.put("energyWh", channel.latestSnapshot().energyWh());
+        // 实时方案需要设备当前循环和实际工步;这些字段只用于快照展示,不形成历史采集事实。
+        value.put("cycleNo", channel.latestSnapshot().cycleNo());
+        value.put("stepNo", channel.latestSnapshot().stepNo());
+        value.put("stepType", channel.latestSnapshot().stepType());
+        value.put("totalTimeSeconds", channel.latestSnapshot().totalTimeSeconds());
         value.put("measurements", channel.latestSnapshot().measurements().entrySet().stream().map(entry -> {
             Measurement measurement = entry.getValue();
             return Map.of("metricCode", entry.getKey(), "value", measurement.value(), "unit", measurement.unit());
         }).toList());
-        value.put("units", channel.latestSnapshot().measurements().values().stream().map(Measurement::unit).distinct().toList());
+        List<String> units = new ArrayList<>();
+        if (channel.latestSnapshot().currentA() != null) units.add("A");
+        if (channel.latestSnapshot().voltageV() != null) units.add("V");
+        if (channel.latestSnapshot().capacityAh() != null) units.add("Ah");
+        if (channel.latestSnapshot().energyWh() != null) units.add("Wh");
+        units.addAll(channel.latestSnapshot().measurements().values().stream().map(Measurement::unit).toList());
+        value.put("units", units.stream().filter(item -> item != null && !item.isBlank()).distinct().toList());
         return value;
     }
 
@@ -120,7 +214,7 @@
             collector.latestMeasurements().stream().map(item -> item.serverReceivedAt()).max(Instant::compareTo)
                     .ifPresent(observedAt -> value.put("observedAt", observedAt));
             return new LiveSource("COLLECTOR", (long) collector.collectorId(), battery.getBatteryNo(),
-                    collector.online() ? "ONLINE" : "OFFLINE", value);
+                    collector.online() ? "ONLINE" : "OFFLINE", collector.online() ? value : Map.of());
         }
         return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(), "UNKNOWN", Map.of());
     }

--
Gitblit v1.7.1