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

---
 ruoyi-admin/src/main/resources/db/migration/V41__test_warning_exceptions.sql                   |   23 +++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestWarningExceptionServiceTest.java   |   60 ++++++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestWarningExceptionService.java      |   51 ++++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java             |   16 ++
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                                  |   36 ++++++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningException.java                  |   46 +++++++
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java                            |   15 ++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestWarningExceptionController.java |   47 +++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java     |   34 ++++-
 9 files changed, 320 insertions(+), 8 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestWarningExceptionController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestWarningExceptionController.java
new file mode 100644
index 0000000..3d96d85
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestWarningExceptionController.java
@@ -0,0 +1,47 @@
+package com.ruoyi.web.controller.system;
+
+import java.util.List;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.system.domain.TTestWarningException;
+import com.ruoyi.system.mapper.TTestMapper;
+import com.ruoyi.system.service.ITTestService;
+import com.ruoyi.system.service.impl.TestWarningExceptionService;
+
+/** 测试预警异常查询与人工关闭接口。 */
+@RestController
+@RequestMapping("/system/test-warning-exceptions")
+public class TTestWarningExceptionController extends BaseController {
+    @Autowired private TTestMapper mapper;
+    @Autowired private ITTestService testService;
+    @Autowired private TestWarningExceptionService warningExceptionService;
+    /** 按测试和状态查询后端异常事实。 */
+    @PreAuthorize("@ss.hasPermi('system:exception:query')")
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam(required=false) Long testId, @RequestParam(required=false) String status) {
+        startPage();
+        List<TTestWarningException> rows = mapper.selectWarningExceptions(testId, status);
+        return getDataTable(rows);
+    }
+    /** 校验测试负责人身份并关闭异常,同时追加测试审计事实。 */
+    @PreAuthorize("@ss.hasPermi('system:exception:handle')")
+    @Log(title = "测试预警异常", businessType = BusinessType.UPDATE)
+    @PutMapping("/{exceptionId}/close")
+    public AjaxResult close(@PathVariable Long exceptionId, @RequestBody CloseRequest request) {
+        if (request == null || request.handlingNote() == null || request.handlingNote().trim().isEmpty()) return error("处理结果不能为空");
+        TTestWarningException exception = mapper.selectWarningExceptionById(exceptionId);
+        if (exception == null) return error("异常不存在");
+        var test = testService.selectTestById(exception.getTestId());
+        if (test == null || !getUserId().equals(test.getPrincipalId()))
+            return error("仅测试负责人可以关闭预警异常");
+        int rows = warningExceptionService.close(exceptionId, getUserId(), getUsername(), request.handlingNote().trim());
+        return rows == 1 ? success(mapper.selectWarningExceptionById(exceptionId)) : error("异常已关闭或不存在");
+    }
+    public record CloseRequest(String handlingNote) {}
+}
diff --git a/ruoyi-admin/src/main/resources/db/migration/V41__test_warning_exceptions.sql b/ruoyi-admin/src/main/resources/db/migration/V41__test_warning_exceptions.sql
new file mode 100644
index 0000000..b4dddf6
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V41__test_warning_exceptions.sql
@@ -0,0 +1,23 @@
+CREATE TABLE t_test_warning_exception (
+    exception_id BIGINT NOT NULL AUTO_INCREMENT,
+    test_id BIGINT NOT NULL,
+    threshold_id BIGINT NOT NULL,
+    source_type VARCHAR(32) NOT NULL,
+    source_identity VARCHAR(255) NOT NULL DEFAULT '',
+    metric_code VARCHAR(100) NOT NULL,
+    abnormal_value DECIMAL(30,12) NOT NULL,
+    unit VARCHAR(32) NOT NULL,
+    rule_snapshot JSON NOT NULL,
+    severity VARCHAR(32) NOT NULL DEFAULT 'WARNING',
+    exception_status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
+    first_occurred_at_utc DATETIME(3) NOT NULL,
+    last_occurred_at_utc DATETIME(3) NOT NULL,
+    occurrence_count BIGINT NOT NULL DEFAULT 1,
+    handled_by BIGINT NULL,
+    handled_at_utc DATETIME(3) NULL,
+    handling_note VARCHAR(1000) NULL,
+    PRIMARY KEY (exception_id),
+    open_threshold_id BIGINT GENERATED ALWAYS AS (IF(exception_status = 'OPEN', threshold_id, NULL)) STORED,
+    UNIQUE KEY uk_warning_exception_open (test_id, metric_code, open_threshold_id),
+    KEY idx_warning_exception_query (test_id, exception_status, last_occurred_at_utc)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试预警阈值异常';
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestWarningExceptionServiceTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestWarningExceptionServiceTest.java
new file mode 100644
index 0000000..536c497
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestWarningExceptionServiceTest.java
@@ -0,0 +1,60 @@
+package com.ruoyi.system.service.impl;
+
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import com.ruoyi.system.domain.TTest;
+import com.ruoyi.system.domain.TTestWarningException;
+import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.mapper.TTestMapper;
+
+/** 测试预警仅由成功接收测量值触发,并通过持久层原子聚合。 */
+class TestWarningExceptionServiceTest {
+    @Test
+    void evaluatesOnlyOutOfRangeValues() {
+        TTestMapper mapper = mock(TTestMapper.class);
+        TestWarningExceptionService service = new TestWarningExceptionService(mapper);
+        TTest test = testWithThreshold("TEMPERATURE", new BigDecimal("0"), new BigDecimal("30"));
+
+        service.evaluate(test, "COLLECTOR", "adapter:9:S-01", "temperature", new BigDecimal("31"), "℃", new Date());
+        service.evaluate(test, "COLLECTOR", "adapter:9:S-01", "temperature", new BigDecimal("20"), "℃", new Date());
+
+        verify(mapper, times(1)).upsertWarningException(eq(1L), eq(11L), eq("COLLECTOR"), eq("adapter:9:S-01"),
+                eq("temperature"), eq(new BigDecimal("31")), eq("℃"), anyString(), eq("WARNING"), any(Date.class));
+    }
+
+    @Test
+    void closesOpenExceptionAndAppendsAuditInServiceOperation() {
+        TTestMapper mapper = mock(TTestMapper.class);
+        TestWarningExceptionService service = new TestWarningExceptionService(mapper);
+        TTestWarningException exception = new TTestWarningException();
+        exception.setExceptionId(7L);
+        exception.setTestId(1L);
+        when(mapper.selectWarningExceptionById(7L)).thenReturn(exception);
+        when(mapper.closeWarningException(eq(7L), eq(22L), eq("负责人"), any(Date.class), eq("已核实"))).thenReturn(1);
+
+        int rows = service.close(7L, 22L, "负责人", "已核实");
+
+        assertEquals(1, rows);
+        verify(mapper).insertTestAudit(eq(1L), eq("WARNING_EXCEPTION_CLOSED"), eq(22L), eq("负责人"), isNull(), contains("已核实"));
+    }
+
+    private static TTest testWithThreshold(String metric, BigDecimal min, BigDecimal max) {
+        TTestWarningThreshold threshold = new TTestWarningThreshold();
+        threshold.setThresholdId(11L);
+        threshold.setMetricCode(metric);
+        threshold.setMinValue(min);
+        threshold.setMaxValue(max);
+        threshold.setUnit("℃");
+        threshold.setSeverity("WARNING");
+        TTest test = new TTest();
+        test.setTestId(1L);
+        test.setThresholds(List.of(threshold));
+        return test;
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningException.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningException.java
new file mode 100644
index 0000000..cce2cdb
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestWarningException.java
@@ -0,0 +1,46 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 测试阈值预警异常事实。 */
+public class TTestWarningException implements Serializable {
+    private static final long serialVersionUID = 1L;
+    private Long exceptionId;
+    private Long testId;
+    private String testName;
+    private String sourceType;
+    private String sourceIdentity;
+    private String metricCode;
+    private BigDecimal abnormalValue;
+    private String unit;
+    private String ruleSnapshot;
+    private String severity;
+    private String exceptionStatus;
+    private Date firstOccurredAtUtc;
+    private Date lastOccurredAtUtc;
+    private Long occurrenceCount;
+    private Long handledBy;
+    private String handledByName;
+    private Date handledAtUtc;
+    private String handlingNote;
+    public Long getExceptionId(){return exceptionId;} public void setExceptionId(Long v){exceptionId=v;}
+    public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;}
+    public String getTestName(){return testName;} public void setTestName(String v){testName=v;}
+    public String getSourceType(){return sourceType;} public void setSourceType(String v){sourceType=v;}
+    public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;}
+    public String getMetricCode(){return metricCode;} public void setMetricCode(String v){metricCode=v;}
+    public BigDecimal getAbnormalValue(){return abnormalValue;} public void setAbnormalValue(BigDecimal v){abnormalValue=v;}
+    public String getUnit(){return unit;} public void setUnit(String v){unit=v;}
+    public String getRuleSnapshot(){return ruleSnapshot;} public void setRuleSnapshot(String v){ruleSnapshot=v;}
+    public String getSeverity(){return severity;} public void setSeverity(String v){severity=v;}
+    public String getExceptionStatus(){return exceptionStatus;} public void setExceptionStatus(String v){exceptionStatus=v;}
+    public Date getFirstOccurredAtUtc(){return firstOccurredAtUtc;} public void setFirstOccurredAtUtc(Date v){firstOccurredAtUtc=v;}
+    public Date getLastOccurredAtUtc(){return lastOccurredAtUtc;} public void setLastOccurredAtUtc(Date v){lastOccurredAtUtc=v;}
+    public Long getOccurrenceCount(){return occurrenceCount;} public void setOccurrenceCount(Long v){occurrenceCount=v;}
+    public Long getHandledBy(){return handledBy;} public void setHandledBy(Long v){handledBy=v;}
+    public String getHandledByName(){return handledByName;} public void setHandledByName(String v){handledByName=v;}
+    public Date getHandledAtUtc(){return handledAtUtc;} public void setHandledAtUtc(Date v){handledAtUtc=v;}
+    public String getHandlingNote(){return handlingNote;} public void setHandlingNote(String v){handlingNote=v;}
+}
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 fce88a0..e5a2f5f 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,7 @@
 import com.ruoyi.system.domain.TTestPlanStep;
 import com.ruoyi.system.domain.TTestResult;
 import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TTestWarningException;
 import com.ruoyi.system.domain.TTestLogicStepMapping;
 import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.domain.TTestDataRevisionAction;
@@ -51,6 +52,20 @@
     List<TTestChannelBinding> selectTestChannelBindings(Long testId);
     List<TTestCollectorChannel> selectTestCollectorChannels(Long testId);
     List<TTestWarningThreshold> selectTestThresholds(Long testId);
+    /** 以测试、指标和阈值规则为开放异常键原子聚合成功接收的超限值。 */
+    int upsertWarningException(@Param("testId") Long testId, @Param("thresholdId") Long thresholdId,
+            @Param("sourceType") String sourceType, @Param("sourceIdentity") String sourceIdentity,
+            @Param("metricCode") String metricCode, @Param("value") java.math.BigDecimal value,
+            @Param("unit") String unit, @Param("ruleSnapshot") String ruleSnapshot,
+            @Param("severity") String severity, @Param("occurredAt") Date occurredAt);
+    /** 查询异常事实;不从设备状态或缺失数据推导异常。 */
+    List<TTestWarningException> selectWarningExceptions(@Param("testId") Long testId,
+            @Param("status") String status);
+    TTestWarningException selectWarningExceptionById(Long exceptionId);
+    /** 仅将开放异常转为已关闭,原始异常事实仍保留。 */
+    int closeWarningException(@Param("exceptionId") Long exceptionId, @Param("handledBy") Long handledBy,
+            @Param("handledByName") String handledByName, @Param("handledAt") Date handledAt,
+            @Param("handlingNote") String handlingNote);
     /** 查询当前正在接收的测试,供接收调度绑定测试事实。 */
     List<Long> selectReceivingTestIds();
     int insertRealtimeSnapshot(@Param("testId") Long testId, @Param("bindingId") Long bindingId,
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 2b24d8e..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;
@@ -26,6 +27,7 @@
 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
@@ -34,11 +36,17 @@
     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)
     {
@@ -60,6 +68,12 @@
                 .map(source -> Map.<String, Object>of("sourceType", source.sourceType(), "sourceId", source.sourceId(),
                         "batteryNo", source.batteryNo(), "status", source.deviceStatus(), "message", "数据来源当前不可观测"))
                 .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);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
index a0f426c..107d501 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -11,6 +11,7 @@
 import java.util.List;
 import com.alibaba.fastjson2.JSON;
 import org.springframework.stereotype.Service;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
 import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
@@ -30,15 +31,23 @@
     private final TTestMapper mapper;
     private final ITTestService tests;
     private final BtsDevelopmentSimulator simulator;
+    private final TestWarningExceptionService warningExceptions;
     private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
     private final Map<Long, Instant> lastRawCaptureAt = new java.util.concurrent.ConcurrentHashMap<>();
 
-    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
+    @Autowired
+    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator,
+            TestWarningExceptionService warningExceptions)
     {
         this.mapper = mapper;
         this.tests = tests;
         this.simulator = simulator;
+        this.warningExceptions = warningExceptions;
     }
+
+    /** 保留单元测试及旧调用方构造契约。 */
+    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
+    { this(mapper, tests, simulator, new TestWarningExceptionService(mapper)); }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
@@ -169,7 +178,15 @@
             {
                 // 模拟器保留通道历史;只接收本次测试开始后产生的设备时间记录,避免顺序测试串入旧事实。
                 if (startedAt != null && record.deviceTime().isBefore(startedAt.toInstant())) continue;
-                save(test, binding, record);
+                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(), ""),
+                            java.util.Objects.toString(binding.getSubdevid(), ""), java.util.Objects.toString(binding.getChlid(), ""));
+                    warningExceptions.evaluate(test, "CABINET", source, "VOLTAGE", BigDecimal.valueOf(record.voltageV()), "V", Date.from(record.deviceTime()));
+                    warningExceptions.evaluate(test, "CABINET", source, "CURRENT", BigDecimal.valueOf(record.currentA()), "A", Date.from(record.deviceTime()));
+                    warningExceptions.evaluate(test, "CABINET", source, "CAPACITY", BigDecimal.valueOf(record.capacityAh()), "Ah", Date.from(record.deviceTime()));
+                    warningExceptions.evaluate(test, "CABINET", source, "ENERGY", BigDecimal.valueOf(record.energyWh()), "Wh", Date.from(record.deviceTime()));
+                }
             }
         }
     }
@@ -192,11 +209,12 @@
                     java.util.Objects.equals(mapping.getChannelCode(), measurement.channelCode())
                     && metricMatches(mapping.getMeasurementCode(), measurement.metricCode()));
             if (!mapped) continue;
-            saveCollector(test, battery, measurement);
+            if (saveCollector(test, battery, measurement))
+                warningExceptions.evaluate(test, "COLLECTOR", measurement.adapterCode() + ":" + battery.getCollectorId() + ":" + measurement.channelCode(), measurement.metricCode(), measurement.value(), measurement.unit(), Date.from(measurement.deviceTime()));
         }
     }
 
-    private void saveCollector(TTest test, TTestBattery battery, CollectorRealtimeMeasurement measurement)
+    private boolean saveCollector(TTest test, TTestBattery battery, CollectorRealtimeMeasurement measurement)
     {
         byte[] hash = hash(measurement);
         String old = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), measurement.channelCode(),
@@ -209,7 +227,7 @@
                         "采集器实时接收内容冲突 collector=" + battery.getCollectorId() + ", channel="
                                 + measurement.channelCode() + ", metric=" + measurement.metricCode()
                                 + ", seqid=" + measurement.sequenceId());
-            return;
+            return false;
         }
         Date receivedAt = new Date();
         int inserted = mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), battery.getCollectorId(),
@@ -228,9 +246,10 @@
                                 + measurement.channelCode() + ", metric=" + measurement.metricCode()
                                 + ", seqid=" + measurement.sequenceId());
         }
+        return inserted > 0;
     }
 
-    private void save(TTest test, TTestChannelBinding binding,
+    private boolean save(TTest test, TTestChannelBinding binding,
             BtsHistoryRecord record)
     {
         // 模拟器历史 testid 按通道固定;接收事实必须再带测试作用域,避免顺序测试共用全局幂等键。
@@ -242,7 +261,7 @@
             if (!old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
                 mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
                         "实时接收内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
-            return;
+            return false;
         }
         int inserted = mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(),
                 btsTestId, record.sequenceId(), Date.from(record.deviceTime()),
@@ -257,6 +276,7 @@
                 mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
                         "实时接收并发内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
         }
+        return inserted > 0;
     }
 
     private byte[] hash(BtsHistoryRecord record)
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestWarningExceptionService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestWarningExceptionService.java
new file mode 100644
index 0000000..df120bf
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestWarningExceptionService.java
@@ -0,0 +1,51 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import com.alibaba.fastjson2.JSON;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.ruoyi.system.domain.TTest;
+import com.ruoyi.system.domain.TTestWarningException;
+import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.mapper.TTestMapper;
+
+/** 对成功接收的测量值执行测试阈值判断;设备状态和缺失数据不经过此服务。 */
+@Service
+public class TestWarningExceptionService {
+    private final TTestMapper mapper;
+    public TestWarningExceptionService(TTestMapper mapper) { this.mapper = mapper; }
+    /** 仅对成功接收的非空测量值判定阈值,并聚合更新开放异常及规则快照。 */
+    public void evaluate(TTest test, String sourceType, String sourceIdentity, String metricCode, BigDecimal value, String unit, Date occurredAt) {
+        if (value == null || test == null || test.getThresholds() == null) return;
+        for (TTestWarningThreshold rule : test.getThresholds()) {
+            if (rule == null || rule.getMetricCode() == null || !matches(rule.getMetricCode(), metricCode)
+                    || !outside(value, rule.getMinValue(), rule.getMaxValue())) continue;
+            mapper.upsertWarningException(test.getTestId(), rule.getThresholdId(), sourceType, sourceIdentity, metricCode, value, unit,
+                    JSON.toJSONString(rule), rule.getSeverity(), occurredAt == null ? new Date() : occurredAt);
+        }
+    }
+
+    /** 由测试负责人关闭开放异常,并在同一事务追加不可变的测试审计事实。 */
+    @Transactional(rollbackFor = Exception.class)
+    public int close(Long exceptionId, Long actorId, String actorName, String handlingNote) {
+        TTestWarningException exception = mapper.selectWarningExceptionById(exceptionId);
+        if (exception == null) return 0;
+        int rows = mapper.closeWarningException(exceptionId, actorId, actorName, new Date(), handlingNote);
+        if (rows == 1) {
+            mapper.insertTestAudit(exception.getTestId(), "WARNING_EXCEPTION_CLOSED", actorId, actorName, null,
+                    JSON.toJSONString(java.util.Map.of("exceptionId", exceptionId, "handlingNote", handlingNote)));
+        }
+        return rows;
+    }
+    private boolean outside(BigDecimal value, BigDecimal min, BigDecimal max) { return (min != null && value.compareTo(min) < 0) || (max != null && value.compareTo(max) > 0); }
+    private boolean matches(String configured, String actual) {
+        if (actual == null) return false;
+        if (configured.equalsIgnoreCase(actual)) return true;
+        String c = configured.toUpperCase(), a = actual.toUpperCase();
+        return (c.contains("VOLTAGE") && a.contains("VOLTAGE")) || (c.contains("CURRENT") && a.contains("CURRENT"))
+                || (c.contains("CAPACITY") && a.contains("CAPACITY")) || (c.contains("ENERGY") && a.contains("ENERGY"))
+                || (c.contains("TEMPERATURE") && a.contains("TEMPERATURE")) || (c.contains("POSITIVE_POTENTIAL") && a.contains("POSITIVE_POTENTIAL"))
+                || (c.contains("NEGATIVE_POTENTIAL") && a.contains("NEGATIVE_POTENTIAL"));
+    }
+}
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 3c7a660..ad993ce 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -100,6 +100,17 @@
         <result property="severity" column="severity" />
         <result property="delFlag" column="del_flag" />
     </resultMap>
+    <resultMap id="TTestWarningExceptionResultMap" type="com.ruoyi.system.domain.TTestWarningException">
+        <id property="exceptionId" column="exception_id"/><result property="testId" column="test_id"/>
+        <result property="testName" column="test_name"/><result property="sourceType" column="source_type"/>
+        <result property="sourceIdentity" column="source_identity"/><result property="metricCode" column="metric_code"/>
+        <result property="abnormalValue" column="abnormal_value"/><result property="unit" column="unit"/>
+        <result property="ruleSnapshot" column="rule_snapshot"/><result property="severity" column="severity"/>
+        <result property="exceptionStatus" column="exception_status"/><result property="firstOccurredAtUtc" column="first_occurred_at_utc"/>
+        <result property="lastOccurredAtUtc" column="last_occurred_at_utc"/><result property="occurrenceCount" column="occurrence_count"/>
+        <result property="handledBy" column="handled_by"/><result property="handledByName" column="handled_by_name"/>
+        <result property="handledAtUtc" column="handled_at_utc"/><result property="handlingNote" column="handling_note"/>
+    </resultMap>
 
     <resultMap id="TTestResultResultMap" type="TTestResult">
         <id property="resultId" column="result_id" />
@@ -317,6 +328,31 @@
         select threshold_id, test_id, metric_code, min_value, max_value, unit, severity, del_flag
         from t_test_warning_threshold where test_id = #{testId} and del_flag = 0 order by threshold_id
     </select>
+    <insert id="upsertWarningException">
+        insert into t_test_warning_exception
+        (test_id, threshold_id, source_type, source_identity, metric_code, abnormal_value, unit, rule_snapshot, severity,
+         exception_status, first_occurred_at_utc, last_occurred_at_utc, occurrence_count)
+        values (#{testId}, #{thresholdId}, #{sourceType}, #{sourceIdentity}, #{metricCode}, #{value}, #{unit},
+                cast(#{ruleSnapshot} as json), #{severity}, 'OPEN', #{occurredAt}, #{occurredAt}, 1)
+        on duplicate key update abnormal_value = values(abnormal_value), unit = values(unit),
+            last_occurred_at_utc = values(last_occurred_at_utc), occurrence_count = occurrence_count + 1,
+            source_identity = values(source_identity), severity = values(severity), rule_snapshot = values(rule_snapshot)
+    </insert>
+    <select id="selectWarningExceptions" resultMap="TTestWarningExceptionResultMap">
+        select e.*, t.test_name, u.nick_name handled_by_name
+        from t_test_warning_exception e join t_test t on t.test_id=e.test_id
+        left join sys_user u on u.user_id=e.handled_by
+        <where><if test="testId != null">e.test_id = #{testId}</if><if test="status != null and status != ''">and e.exception_status = #{status}</if></where>
+        order by e.last_occurred_at_utc desc, e.exception_id desc
+    </select>
+    <select id="selectWarningExceptionById" parameterType="long" resultMap="TTestWarningExceptionResultMap">
+        select e.*, t.test_name, u.nick_name handled_by_name from t_test_warning_exception e join t_test t on t.test_id=e.test_id
+        left join sys_user u on u.user_id=e.handled_by where e.exception_id=#{exceptionId}
+    </select>
+    <update id="closeWarningException">
+        update t_test_warning_exception set exception_status='CLOSED', handled_by=#{handledBy}, handled_at_utc=#{handledAt}, handling_note=#{handlingNote}
+        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>

--
Gitblit v1.7.1