fix: 设备在离线,溶解氧和温度报警逻辑修改。

This commit is contained in:
tianyongbao
2026-03-08 19:28:30 +08:00
parent 85949ab4a0
commit 456d1af39a
6 changed files with 308 additions and 93 deletions

View File

@@ -237,6 +237,10 @@ aliyun:
endpoint: https://1943695596114318.mns.cn-hangzhou.aliyuncs.com/
# MNS 队列名称(保持默认即可)
queue-name: Alicom-Queue-1572610294777992-VoiceReport
# 告警通知间隔时间(分钟),同一设备在此时间内不重复发电话通知
alarm-notification-interval-minutes: 30
# 设备离线告警延迟触发时间(分钟),设备离线超过此时间后才发送告警
offline-warn-delay-minutes: 6
# AMQP 服务端订阅配置(使用数据同步的 AppKey + AppSecret
amqp:
# 是否启用

View File

@@ -72,6 +72,16 @@ public class AliyunIotProperties {
*/
private VmsMnsConfig vms = new VmsMnsConfig();
/**
* 告警通知间隔时间(分钟),同一设备同一类型告警在此时间内不重复发送
*/
private int alarmNotificationIntervalMinutes = 30;
/**
* 离线告警延迟触发时间(分钟),设备离线超过此时间才触发告警
*/
private int offlineWarnDelayMinutes = 6;
@Data
public static class VmsMnsConfig {
/**

View File

@@ -21,12 +21,15 @@ import com.intc.fishery.mapper.MessageWarnMapper;
import com.intc.fishery.mapper.PondMapper;
import com.intc.tdengine.domain.DeviceSensorData;
import com.intc.tdengine.service.IDeviceSensorDataService;
import com.intc.iot.config.AliyunIotProperties;
import com.intc.common.redis.utils.RedisUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
@@ -89,6 +92,11 @@ public class DeviceDataHandler {
*/
private final VmsNoticeService vmsNoticeService;
/**
* 阿里云 IoT 配置(包含告警间隔、离线延迟等可配置项)
*/
private final AliyunIotProperties aliyunIotProperties;
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
@@ -106,11 +114,8 @@ public class DeviceDataHandler {
/**
* 告警间隔时间(分钟)- 同一设备同一类型告警在此时间内不重复发送
*/
private static final int ALARM_NOTIFICATION_INTERVAL = 30;
/**
* 告警类型常量(对应 warn_type 字段)
* 配置项: aliyun.living-iot.alarm-notification-interval-minutes默认 30 分钟
* 离线延迟触发时间: aliyun.living-iot.offline-warn-delay-minutes默认 6 分钟
*/
private static final int WARN_TYPE_DISSOLVED_OXYGEN = 2; // 溶解氧
private static final int WARN_TYPE_TEMPERATURE = 2; // 温度
@@ -137,6 +142,17 @@ public class DeviceDataHandler {
*/
private static final int MSG_IS_READ_NO = 0;
/**
* 设备离线等待告警的 Redis key 前缀
* value = 离线时间ISO格式TTL 由配置参数决定
*/
private static final String DEVICE_OFFLINE_WAIT_KEY_PREFIX = "device:offline:wait:";
/**
* 离线等待 key 的 Redis TTL 倍数(相对于延迟触发时间)
*/
private static final int OFFLINE_WAIT_TTL_MULTIPLIER = 5;
/**
* 处理设备属性上报数据
*
@@ -431,7 +447,9 @@ public class DeviceDataHandler {
/**
* 触发告警通知
* 创建电话通知记录,并发送语音通知
* 按 title 分组,每种类型独立判断告警间隔,避免不同类型告警互相屏蔽
* 免打扰开启时每个时段08:00-14:00、14:00-20:00、20:00-08:00内只通知一次
* 免打扰关闭时:按固定间隔时间判断
*
* @param device 设备信息
* @param alarmMessage 告警信息
@@ -444,48 +462,8 @@ public class DeviceDataHandler {
Long deviceId = device.getId();
String deviceName = device.getDeviceName();
// 检查设备的免打扰设置
boolean shouldSkip = false;
for (MessageWarn warn : warnList) {
if (WARN_TYPE_DISSOLVED_OXYGEN == warn.getWarnType() &&
device.getOxyWarnCallNoDis() != null && device.getOxyWarnCallNoDis() == 1) {
shouldSkip = true;
break;
}
if (WARN_TYPE_TEMPERATURE == warn.getWarnType() &&
device.getTempWarnCallNoDis() != null && device.getTempWarnCallNoDis() == 1) {
shouldSkip = true;
break;
}
if (WARN_TYPE_BATTERY == warn.getWarnType() &&
device.getBatteryWarnCallNoDis() != null && device.getBatteryWarnCallNoDis() == 1) {
shouldSkip = true;
break;
}
}
if (shouldSkip) {
return;
}
// 检查是否在告警间隔时间内已经发送过通知(防止频繁通知)
LocalDateTime intervalTime = LocalDateTime.now().minusMinutes(ALARM_NOTIFICATION_INTERVAL);
// 查询最近的通知记录
long recentNoticeCount = warnCallNoticeMapper.selectCount(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AquWarnCallNotice>()
.eq(AquWarnCallNotice::getDeviceId, deviceId)
.ge(AquWarnCallNotice::getCallTime, intervalTime)
);
if (recentNoticeCount > 0) {
log.debug("[告警] {} - {}分钟内已通知,跳过电话通知", deviceName, ALARM_NOTIFICATION_INTERVAL);
return;
}
// 从设备信息获取用户信息
// 一次性查询用户手机号
Long userId = device.getUserId();
// 从 aqu_user 表查询手机号
String mobilePhone = null;
if (userId != null) {
AquUser aquUser = aquUserMapper.selectById(userId);
@@ -493,44 +471,110 @@ public class DeviceDataHandler {
mobilePhone = aquUser.getMobilePhone();
}
}
String pondName = warnList.isEmpty() ? deviceName : warnList.get(0).getPondName();
// 验证必要信息
if (userId == null || StrUtil.isBlank(mobilePhone)) {
log.warn("[告警] {} - 信息不完整", deviceName);
return;
}
final String finalMobilePhone = mobilePhone;
// 创建告警通知记录
AquWarnCallNotice callNotice = new AquWarnCallNotice();
callNotice.setUserId(userId);
callNotice.setMobilePhone(mobilePhone);
callNotice.setDeviceId(deviceId);
callNotice.setPondName(pondName);
callNotice.setCallTime(LocalDateTime.now());
callNotice.setCallStatus(CALL_STATUS_NO_CALL);
String pondName = warnList.isEmpty() ? deviceName : warnList.get(0).getPondName();
LocalDateTime intervalTime = LocalDateTime.now().minusMinutes(aliyunIotProperties.getAlarmNotificationIntervalMinutes());
LocalDateTime periodStart = getCurrentPeriodStart();
// 保存到数据库
warnCallNoticeMapper.insert(callNotice);
// 保存报警消息与通知记录的关联关系
// 按 title 分组,每种类型独立判断间隔并发送通知
Map<String, java.util.List<MessageWarn>> warnsByTitle = new java.util.LinkedHashMap<>();
for (MessageWarn warn : warnList) {
if (warn.getId() != null) {
AquMapMessageWarnCallNotice mapRecord = new AquMapMessageWarnCallNotice();
mapRecord.setMessageWarnId(warn.getId());
mapRecord.setCallNoticeId(callNotice.getId());
mapMessageWarnCallNoticeMapper.insert(mapRecord);
}
warnsByTitle.computeIfAbsent(warn.getTitle(), k -> new java.util.ArrayList<>()).add(warn);
}
// 发送语音通知
sendVoiceNotification(callNotice, deviceName, alarmMessage);
for (Map.Entry<String, java.util.List<MessageWarn>> entry : warnsByTitle.entrySet()) {
String title = entry.getKey();
java.util.List<MessageWarn> typeWarnList = entry.getValue();
int warnType = typeWarnList.get(0).getWarnType();
// 判断该类型是否开启了免打扰
boolean noDisturbEnabled = false;
if (WARN_TYPE_DISSOLVED_OXYGEN == warnType &&
device.getOxyWarnCallNoDis() != null && device.getOxyWarnCallNoDis() == 1) {
noDisturbEnabled = true;
} else if (WARN_TYPE_TEMPERATURE == warnType &&
device.getTempWarnCallNoDis() != null && device.getTempWarnCallNoDis() == 1) {
noDisturbEnabled = true;
} else if (WARN_TYPE_BATTERY == warnType &&
device.getBatteryWarnCallNoDis() != null && device.getBatteryWarnCallNoDis() == 1) {
noDisturbEnabled = true;
}
// 按设备+title检查是否已通知
long recentCount;
if (noDisturbEnabled) {
// 免打扰开启:当前时段内已通知过则跳过
recentCount = warnCallNoticeMapper.countByDeviceAndTitleInPeriod(deviceId, title, periodStart);
if (recentCount > 0) {
log.debug("[告警] {} title={} 免打扰:当前时段内已通知,跳过", deviceName, title);
continue;
}
} else {
// 免打扰关闭:按固定间隔判断
recentCount = warnCallNoticeMapper.countByDeviceAndTitleAfter(deviceId, title, intervalTime);
if (recentCount > 0) {
log.debug("[告警] {} title={} - {}min内已通知跳过", deviceName, title, aliyunIotProperties.getAlarmNotificationIntervalMinutes());
continue;
}
}
// 创建这个类型的通知记录
AquWarnCallNotice callNotice = new AquWarnCallNotice();
callNotice.setUserId(userId);
callNotice.setMobilePhone(finalMobilePhone);
callNotice.setDeviceId(deviceId);
callNotice.setPondName(pondName);
callNotice.setCallTime(LocalDateTime.now());
callNotice.setCallStatus(CALL_STATUS_NO_CALL);
warnCallNoticeMapper.insert(callNotice);
// 保存报警消息与通知记录的关联关系
for (MessageWarn warn : typeWarnList) {
if (warn.getId() != null) {
AquMapMessageWarnCallNotice mapRecord = new AquMapMessageWarnCallNotice();
mapRecord.setMessageWarnId(warn.getId());
mapRecord.setCallNoticeId(callNotice.getId());
mapMessageWarnCallNoticeMapper.insert(mapRecord);
}
}
// 发送语音通知取该类型第一条的告警内容为TTS参数
String typeAlarmMessage = typeWarnList.get(0).getMessage();
sendVoiceNotification(callNotice, deviceName, typeAlarmMessage);
}
} catch (Exception e) {
log.error("[告警通知] 触发告警通知失败: {}", e.getMessage(), e);
}
}
/**
* 获取当前所在时段的起始时间
* 时段划分08:00-14:00 / 14:00-20:00 / 20:00-08:00(跨日)
*
* @return 当前时段起始的 LocalDateTime
*/
private LocalDateTime getCurrentPeriodStart() {
LocalDateTime now = LocalDateTime.now();
int hour = now.getHour();
java.time.LocalDate today = now.toLocalDate();
if (hour >= 8 && hour < 14) {
return today.atTime(8, 0);
} else if (hour >= 14 && hour < 20) {
return today.atTime(14, 0);
} else if (hour >= 20) {
return today.atTime(20, 0);
} else {
// 00:00-08:00属于前一天 20:00 开始的时段
return today.minusDays(1).atTime(20, 0);
}
}
/**
* 发送语音通知
*
@@ -591,12 +635,47 @@ public class DeviceDataHandler {
}
/**
* 处理设备离线告警
* 当设备离线时,查询设备信息并发送语音通知
* 处理设备离线事件(参考 C# UpdateDeviceStatus 逻辑)
* 收到离线消息后写入 Redis 等待标记,不立即告警。
* 由定时任务扫描超过配置的 offline-warn-delay-minutes 分钟的标记后才真正触发告警。
*
* @param deviceName 设备名称serialNum
*/
public void handleDeviceOffline(String deviceName) {
String redisKey = DEVICE_OFFLINE_WAIT_KEY_PREFIX + deviceName;
// 已有等待标记则不重复写入(等待时间从首次离线算起)
if (Boolean.TRUE.equals(RedisUtils.hasKey(redisKey))) {
log.debug("[离线告警] 已有等待标记,跳过重复写入: {}", deviceName);
return;
}
String offlineTime = LocalDateTime.now().format(DATETIME_FORMATTER);
int offlineDelayMinutes = aliyunIotProperties.getOfflineWarnDelayMinutes();
int ttlMinutes = offlineDelayMinutes * OFFLINE_WAIT_TTL_MULTIPLIER;
RedisUtils.setCacheObject(redisKey, offlineTime, Duration.ofMinutes(ttlMinutes));
log.info("[离线告警] 设备离线,已记录等待标记,将在 {} 分钟后触发告警: {}", offlineDelayMinutes, deviceName);
}
/**
* 处理设备上线事件(参考 C# UpdateDeviceStatus 逻辑)
* 上线时清除 Redis 等待标记,取消离线告警。
*
* @param deviceName 设备名称serialNum
*/
public void handleDeviceOnline(String deviceName) {
String redisKey = DEVICE_OFFLINE_WAIT_KEY_PREFIX + deviceName;
boolean removed = RedisUtils.deleteObject(redisKey);
if (removed) {
log.info("[离线告警] 设备已上线,取消离线告警: {}", deviceName);
}
}
/**
* 真正触发设备离线告警(由定时任务调用)
* 对应 C# CheckDeviceMessageWarn(warnCode=DeviceOffline) 逻辑
*
* @param deviceName 设备名称serialNum
*/
public void triggerOfflineAlarm(String deviceName) {
try {
Device device = deviceMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Device>()
@@ -626,30 +705,29 @@ public class DeviceDataHandler {
String mobilePhone = aquUser.getMobilePhone();
// 检查告警间隔(防止频繁通知)
LocalDateTime intervalTime = LocalDateTime.now().minusMinutes(ALARM_NOTIFICATION_INTERVAL);
long recentCount = warnCallNoticeMapper.selectCount(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<AquWarnCallNotice>()
.eq(AquWarnCallNotice::getDeviceId, deviceId)
.ge(AquWarnCallNotice::getCallTime, intervalTime)
);
LocalDateTime intervalTime = LocalDateTime.now().minusMinutes(aliyunIotProperties.getAlarmNotificationIntervalMinutes());
long recentCount = warnCallNoticeMapper.countByDeviceAndTitleAfter(deviceId, "设备离线", intervalTime);
if (recentCount > 0) {
log.debug("[离线告警] {}分钟内已通知,跳过: {}", ALARM_NOTIFICATION_INTERVAL, deviceName);
log.debug("[离线告警] {}分钟内已通知,跳过: {}", aliyunIotProperties.getAlarmNotificationIntervalMinutes(), deviceName);
return;
}
// 查询塘口名称
String pondName = deviceName;
if (device.getPondId() != null) {
Pond pond = pondMapper.selectById(device.getPondId());
if (pond != null) {
pondName = pond.getPondName();
}
// 查询塘口名称(设备未绑定塘口则跳过,参考 C# 只处理塘口中的设备)
if (device.getPondId() == null) {
log.debug("[离线告警] 设备未绑定塘口,跳过: {}", deviceName);
return;
}
Pond pond = pondMapper.selectById(device.getPondId());
if (pond == null) {
log.debug("[离线告警] 塘口不存在,跳过: {}", deviceName);
return;
}
String pondName = pond.getPondName();
String displayDeviceName = device.getDeviceName() != null ? device.getDeviceName() : deviceName;
// 写入告警消息
String warnMsg = "设备离线";
MessageWarn warn = createMessageWarn(deviceId, userId, pondName,
"设备离线", WARN_TYPE_OFFLINE, warnMsg);
// 告警消息内容与 C# 保持一致:{pondName}塘口中{deviceName}({serialNum})设备离线
String warnMsg = String.format("%s塘口中%s(%s)设备离线", pondName, displayDeviceName, deviceName);
MessageWarn warn = createMessageWarn(deviceId, userId, pondName, "设备离线", WARN_TYPE_OFFLINE, warnMsg);
messageWarnMapper.insert(warn);
// 创建通知记录
@@ -670,10 +748,10 @@ public class DeviceDataHandler {
mapMessageWarnCallNoticeMapper.insert(mapRecord);
}
// 发送语音通知
sendVoiceNotification(callNotice, device.getDeviceName() != null ? device.getDeviceName() : deviceName, warnMsg);
// 发送语音通知TTS 参数用简短告警类型)
sendVoiceNotification(callNotice, displayDeviceName, "设备离线");
log.info("[离线告警] 已触发通知 - 设备: {}, 手机: {}", deviceName, mobilePhone);
log.info("[离线告警] 已触发通知 - 设备: {}, 塘口: {}, 手机: {}", deviceName, pondName, mobilePhone);
} catch (Exception e) {
log.error("[离线告警] 处理失败: {}", e.getMessage(), e);

View File

@@ -3,6 +3,10 @@ package com.intc.iot.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.intc.iot.domain.AquWarnCallNotice;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
/**
* 告警电话通知记录 Mapper
@@ -11,4 +15,41 @@ import org.apache.ibatis.annotations.Mapper;
*/
@Mapper
public interface AquWarnCallNoticeMapper extends BaseMapper<AquWarnCallNotice> {
/**
* 查询指定设备、告警标题、时间范围内的通知数量(通过关联表反查 title
*
* @param deviceId 设备ID
* @param title 告警标题(如"溶解氧"、"设备离线"等)
* @param afterTime 起始时间
* @return 满足条件的通知数量
*/
@Select("SELECT COUNT(1) FROM aqu_call_notice n " +
"INNER JOIN aqu_map_message_warn_call_notice m ON m.call_notice_id = n.id " +
"INNER JOIN aqu_message_warn w ON w.id = m.message_warn_id " +
"WHERE n.device_id = #{deviceId} " +
"AND w.title = #{title} " +
"AND n.call_time >= #{afterTime}")
long countByDeviceAndTitleAfter(@Param("deviceId") Long deviceId,
@Param("title") String title,
@Param("afterTime") LocalDateTime afterTime);
/**
* 查询指定设备、告警标题、时段范围内的通知数量(免打扰模式:每个时段只通知一次)
* 时段划分08:00-14:00、14:00-20:00、20:00-08:00
*
* @param deviceId 设备ID
* @param title 告警标题
* @param periodStart 当前时段起始时间
* @return 满足条件的通知数量
*/
@Select("SELECT COUNT(1) FROM aqu_call_notice n " +
"INNER JOIN aqu_map_message_warn_call_notice m ON m.call_notice_id = n.id " +
"INNER JOIN aqu_message_warn w ON w.id = m.message_warn_id " +
"WHERE n.device_id = #{deviceId} " +
"AND w.title = #{title} " +
"AND n.call_time >= #{periodStart}")
long countByDeviceAndTitleInPeriod(@Param("deviceId") Long deviceId,
@Param("title") String title,
@Param("periodStart") LocalDateTime periodStart);
}

View File

@@ -246,6 +246,8 @@ public class AmqpMessageHandlerImpl implements AmqpMessageHandler {
if ("offline".equals(status) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOffline(deviceName);
} else if ("online".equals(status) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOnline(deviceName);
}
}
@@ -264,6 +266,8 @@ public class AmqpMessageHandlerImpl implements AmqpMessageHandler {
if ("offline".equals(action) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOffline(deviceName);
} else if ("online".equals(action) && deviceDataHandler != null && deviceName != null) {
deviceDataHandler.handleDeviceOnline(deviceName);
}
}

View File

@@ -0,0 +1,78 @@
package com.intc.iot.task;
import com.intc.common.redis.utils.RedisUtils;
import com.intc.iot.config.AliyunIotProperties;
import com.intc.iot.handler.DeviceDataHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
/**
* 设备离线告警延迟触发定时任务
* 对应 C# UpdateDataSecondlyJob.CheckWarnAndNotice 中对 DeviceOffline WarnWait 的处理:
* 离线超过配置的 offline-warn-delay-minutes 分钟才真正触发告警。
*
* @author intc-iot
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnBean(DeviceDataHandler.class)
public class DeviceOfflineWarnTask {
private static final String OFFLINE_WAIT_KEY_PREFIX = "device:offline:wait:";
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final DeviceDataHandler deviceDataHandler;
private final AliyunIotProperties aliyunIotProperties;
/**
* 每分钟扫描一次离线等待标记
* 对超过配置的 offline-warn-delay-minutes 分钟的设备触发告警
*/
@Scheduled(fixedDelay = 60000)
public void checkOfflineWaitAndTrigger() {
try {
Collection<String> keys = RedisUtils.keys(OFFLINE_WAIT_KEY_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
for (String key : keys) {
try {
String offlineTimeStr = RedisUtils.getCacheObject(key);
if (offlineTimeStr == null) {
continue;
}
LocalDateTime offlineTime = LocalDateTime.parse(offlineTimeStr, FORMATTER);
long elapsedMinutes = java.time.Duration.between(offlineTime, now).toMinutes();
if (elapsedMinutes >= aliyunIotProperties.getOfflineWarnDelayMinutes()) {
// 提取 deviceName去掉前缀
String deviceName = key.substring(OFFLINE_WAIT_KEY_PREFIX.length());
// 触发告警前先删除标记,防止重复触发
RedisUtils.deleteObject(key);
log.info("[离线告警] 设备离线已超过 {} 分钟,触发告警: {}", elapsedMinutes, deviceName);
deviceDataHandler.triggerOfflineAlarm(deviceName);
}
} catch (Exception e) {
log.error("[离线告警] 处理单条离线标记异常key={}: {}", key, e.getMessage(), e);
}
}
} catch (Exception e) {
log.error("[离线告警] 扫描离线标记异常: {}", e.getMessage(), e);
}
}
}