| | |
| | | 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; |
| | |
| | | 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); |
| | | } |
| | |
| | | && outside(value, rule.getMinValue(), rule.getMaxValue())).toList(); |
| | | } |
| | | |
| | | /** 由测试负责人关闭开放异常,处理内容保存在异常事实中。 */ |
| | | @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); |
| | | return rows; |
| | | /** |
| | | * 按测试和异常状态查询已持久化的阈值与离线异常事实。 |
| | | * |
| | | * @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; |