yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package com.battery.business.service.impl;
 
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import com.alibaba.fastjson2.JSON;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestWarningException;
import com.battery.business.domain.TTestWarningThreshold;
import com.battery.business.mapper.TTestMapper;
 
/** 分别记录测量超限与接收中的离线事实;缺失测量不参与阈值判断。 */
@Service
public class TestWarningExceptionService {
    private final TTestMapper mapper;
    public TestWarningExceptionService(TTestMapper mapper) { this.mapper = mapper; }
 
    /**
     * 记录接收中测试的绑定来源离线事实。
     *
     * <p>前置条件:调用方持有该测试的事务锁。只有测试处于“检测中 + 接收中”才判定离线,
     * 离线不伪造测量值;暂停、停止、未开始接收时不新增或累加。</p>
     *
     * @param test       接收中的测试
     * @param occurredAt 离线事实发生时间
     */
    public void recordOfflineSources(TTest test, Date occurredAt) {
        // STEP 1: 只有“检测中 + 接收中”才判定离线,其他状态不新增也不累加。
        if (test == null || !"RUNNING".equals(test.getTestStatus()) || !"RECEIVING".equals(test.getReceptionStatus())) return;
        // STEP 2: 按测试和来源身份聚合离线异常,不写入测量值或阈值。
        for (var source : mapper.selectOfflineTestSources(test.getTestId())) {
            mapper.upsertOfflineException(test.getTestId(), source.sourceType(), source.sourceIdentity(), occurredAt);
        }
    }
 
    /**
     * 对接收中的一次成功实时测量判定超限并按聚合键写入开放异常。
     *
     * <p>前置条件:调用方持有该测试的事务锁。判定只依据成功观测值,不受采样入库周期限制,
     * 正常观测不会自动关闭已有异常。</p>
     *
     * @param test        接收中的测试,须已加载阈值规则
     * @param sourceType  来源类型:CABINET 检测柜、COLLECTOR 采集器
     * @param sourceIdentity 来源身份
     * @param metricCode  指标编码
     * @param value       本次成功观测值;为空时不判定
     * @param unit        观测值单位
     * @param occurredAt  观测时间,为空时取当前时间
     */
    public void evaluate(TTest test, String sourceType, String sourceIdentity, String metricCode, BigDecimal value, String unit, Date occurredAt) {
        // STEP 1: 按阈值规则筛出被本次成功观测超限的规则,缺失测量不参与判定。
        for (TTestWarningThreshold rule : exceededRules(test, metricCode, value)) {
            // STEP 2: 以测试、指标和规则为聚合键原子累加开放异常,并保存触发时的规则快照。
            mapper.upsertWarningException(test.getTestId(), rule.getThresholdId(), sourceType, sourceIdentity, metricCode, value, unit,
                    JSON.toJSONString(rule), rule.getSeverity(), occurredAt == null ? new Date() : occurredAt);
        }
    }
 
    static List<TTestWarningThreshold> exceededRules(TTest test, String metricCode, BigDecimal value) {
        if (value == null || test == null || test.getThresholds() == null
                || !"RUNNING".equals(test.getTestStatus()) || !"RECEIVING".equals(test.getReceptionStatus())) return List.of();
        return test.getThresholds().stream().filter(rule -> rule != null
                && matchesMetric(rule.getMetricCode(), metricCode)
                && outside(value, rule.getMinValue(), rule.getMaxValue())).toList();
    }
 
    /**
     * 按测试和异常状态查询已持久化的阈值与离线异常事实。
     *
     * @param testId 测试主键,为空表示不限测试
     * @param status 异常状态:OPEN 开放、CLOSED 已关闭;为空表示不限状态
     * @return 按最近触发时间倒序排列的异常事实
     */
    public List<TTestWarningException> list(Long testId, String status) {
        return mapper.selectWarningExceptions(testId, status);
    }
 
    /**
     * 测试负责人关闭开放异常:校验归属与处理结果后写入,返回关闭后的异常事实。
     *
     * <p>前置条件:操作人必须是该异常所属测试的测试负责人,且处理结果必填。
     * 只有开放异常可以关闭;关闭只改变处理状态,原始异常事实与累计触发次数仍保留。</p>
     *
     * @param exceptionId  异常主键
     * @param actorId      操作人用户 ID,同时作为处理人写入
     * @param actorName    操作人显示名称,随异常事实保存
     * @param handlingNote 处理结果,必填
     * @return 关闭后的异常事实
     * @throws ServiceException 处理结果为空、异常不存在、操作人不是测试负责人或异常已关闭
     */
    @Transactional(rollbackFor = Exception.class)
    public TTestWarningException closeByPrincipal(Long exceptionId, Long actorId, String actorName, String handlingNote) {
        // STEP 1: 处理结果为必填,先拒绝空提交。
        String note = handlingNote == null ? null : handlingNote.trim();
        if (note == null || note.isEmpty()) throw new ServiceException("处理结果不能为空");
        // STEP 2: 异常与所属测试必须存在,且操作人就是该测试的测试负责人。
        TTestWarningException exception = mapper.selectWarningExceptionById(exceptionId);
        if (exception == null) throw new ServiceException("异常不存在");
        TTest test = mapper.selectTestById(exception.getTestId());
        if (test == null || actorId == null || !actorId.equals(test.getPrincipalId()))
            throw new ServiceException("仅测试负责人可以关闭预警异常");
        // STEP 3: 仅开放异常可关闭,行数不为 1 说明已被并发关闭。
        if (mapper.closeWarningException(exceptionId, actorId, actorName, new Date(), note) != 1)
            throw new ServiceException("异常已关闭或不存在");
        return mapper.selectWarningExceptionById(exceptionId);
    }
 
    private static boolean outside(BigDecimal value, BigDecimal min, BigDecimal max) { return (min != null && value.compareTo(min) < 0) || (max != null && value.compareTo(max) > 0); }
    static boolean matchesCollectorMetric(String configured, String actual) {
        if (configured == null || actual == null) return false;
        String role = configured.toUpperCase(Locale.ROOT), metric = actual.toUpperCase(Locale.ROOT);
        return role.equals(metric) || List.of("TEMPERATURE", "POSITIVE_POTENTIAL", "NEGATIVE_POTENTIAL").stream()
                .anyMatch(code -> metric.equals(code) && role.contains(code));
    }
 
    static boolean matchesMetric(String configured, String actual) {
        if (configured == null || actual == null) return false;
        if (configured.equalsIgnoreCase(actual)) return true;
        String c = configured.toUpperCase(Locale.ROOT), a = actual.toUpperCase(Locale.ROOT);
        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"));
    }
}