diff --git a/intc-admin/src/main/resources/application-dev.yml b/intc-admin/src/main/resources/application-dev.yml index d75435f..d5add72 100644 --- a/intc-admin/src/main/resources/application-dev.yml +++ b/intc-admin/src/main/resources/application-dev.yml @@ -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: # 是否启用 diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/config/AliyunIotProperties.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/config/AliyunIotProperties.java index ee54dd9..9814a93 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/config/AliyunIotProperties.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/config/AliyunIotProperties.java @@ -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 { /** diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/handler/DeviceDataHandler.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/handler/DeviceDataHandler.java index e717d28..0899eed 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/handler/DeviceDataHandler.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/handler/DeviceDataHandler.java @@ -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() - .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> 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> entry : warnsByTitle.entrySet()) { + String title = entry.getKey(); + java.util.List 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() @@ -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() - .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); diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/mapper/AquWarnCallNoticeMapper.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/mapper/AquWarnCallNoticeMapper.java index 42edeeb..d65ba56 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/mapper/AquWarnCallNoticeMapper.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/mapper/AquWarnCallNoticeMapper.java @@ -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 { + + /** + * 查询指定设备、告警标题、时间范围内的通知数量(通过关联表反查 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); } diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/AmqpMessageHandlerImpl.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/AmqpMessageHandlerImpl.java index 3eda2a1..fc0570e 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/AmqpMessageHandlerImpl.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/AmqpMessageHandlerImpl.java @@ -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); } } diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/task/DeviceOfflineWarnTask.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/task/DeviceOfflineWarnTask.java new file mode 100644 index 0000000..95ad6e0 --- /dev/null +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/task/DeviceOfflineWarnTask.java @@ -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 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); + } + } +}