diff --git a/intc-admin/src/main/resources/application-dev.yml b/intc-admin/src/main/resources/application-dev.yml index efdccf3..87669ca 100644 --- a/intc-admin/src/main/resources/application-dev.yml +++ b/intc-admin/src/main/resources/application-dev.yml @@ -239,6 +239,8 @@ aliyun: queue-name: Alicom-Queue-1572610294777992-VoiceReport # 告警通知间隔时间(分钟),同一设备在此时间内不重复发电话通知 alarm-notification-interval-minutes: 30 + # 电话回执成功后的抑制时间(小时),已成功解到就在此时间内不再重复发起电话通知 + call-success-suppress-hours: 24 # 设备离线告警延迟触发时间(分钟),设备离线超过此时间后才发送告警 offline-warn-delay-minutes: 6 # AMQP 服务端订阅配置(使用数据同步的 AppKey + AppSecret) 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 9814a93..b9186ce 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 @@ -77,6 +77,13 @@ public class AliyunIotProperties { */ private int alarmNotificationIntervalMinutes = 30; + /** + * 电话已成功通知后的抑制时间(小时) + * 同一设备同一类型告警,如果已有回执成功(callStatus=3)的通知记录,则在此时间内不再重复发起电话通知 + * 默认 24 小时 + */ + private int callSuccessSuppressHours = 24; + /** * 离线告警延迟触发时间(分钟),设备离线超过此时间才触发告警 */ 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 61fcb9d..64dabb7 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 @@ -1,6 +1,7 @@ package com.intc.iot.handler; import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.intc.iot.domain.AquMapMessageWarnCallNotice; @@ -130,12 +131,6 @@ public class DeviceDataHandler { private static final String ALARM_TYPE_TEMPERATURE_LOW = "温度过低"; private static final String ALARM_TYPE_BATTERY = "电池电量"; - /** - * 告警级别 - */ - private static final int ALARM_LEVEL_NORMAL = 1; // 一般 - private static final int ALARM_LEVEL_IMPORTANT = 2; // 重要 - private static final int ALARM_LEVEL_URGENT = 3; // 紧急 /** * 消息未读状态 @@ -369,9 +364,11 @@ public class DeviceDataHandler { StringBuilder alarmMessage = new StringBuilder(); boolean hasAlarm = false; java.util.List warnList = new java.util.ArrayList<>(); + // 需要触发电话通知的告警列表(仅包含开启了电话通知开关的告警) + java.util.List callWarnList = new java.util.ArrayList<>(); - // 1. 检查溶解氧(使用设备配置的阈值) - if (sensorData.getDissolvedOxygen() != null && device.getOxyWarnCallOpen() != null && device.getOxyWarnCallOpen() == 1) { + // 1. 检查溶解氧(阈值判断与告警记录不受电话开关限制) + if (sensorData.getDissolvedOxygen() != null) { Double dissolvedOxygen = sensorData.getDissolvedOxygen(); Double oxyWarnLower = device.getOxyWarnLower(); @@ -380,14 +377,19 @@ public class DeviceDataHandler { dissolvedOxygen, oxyWarnLower); alarmMessage.append(message).append("; "); - warnList.add(createMessageWarn(deviceId, userId, pondName, - ALARM_TYPE_DISSOLVED_OXYGEN, WARN_TYPE_DISSOLVED_OXYGEN, message)); + MessageWarn warn = createMessageWarn(deviceId, userId, pondName, + ALARM_TYPE_DISSOLVED_OXYGEN, WARN_TYPE_DISSOLVED_OXYGEN, message); + warnList.add(warn); + // 仅在开启电话通知时加入电话通知列表 + if (device.getOxyWarnCallOpen() != null && device.getOxyWarnCallOpen() == 1) { + callWarnList.add(warn); + } hasAlarm = true; } } - // 2. 检查水温(使用设备配置的阈值) - if (sensorData.getTemperature() != null && device.getTempWarnCallOpen() != null && device.getTempWarnCallOpen() == 1) { + // 2. 检查水温(阈值判断与告警记录不受电话开关限制) + if (sensorData.getTemperature() != null) { Double temperature = sensorData.getTemperature(); Double tempWarnLower = device.getTempWarnLower(); Double tempWarnUpper = device.getTempWarnUpper(); @@ -397,22 +399,30 @@ public class DeviceDataHandler { temperature, tempWarnLower); alarmMessage.append(message).append("; "); - warnList.add(createMessageWarn(deviceId, userId, pondName, - ALARM_TYPE_TEMPERATURE_LOW, WARN_TYPE_TEMPERATURE, message)); + MessageWarn warn = createMessageWarn(deviceId, userId, pondName, + ALARM_TYPE_TEMPERATURE_LOW, WARN_TYPE_TEMPERATURE, message); + warnList.add(warn); + if (device.getTempWarnCallOpen() != null && device.getTempWarnCallOpen() == 1) { + callWarnList.add(warn); + } hasAlarm = true; } else if (tempWarnUpper != null && temperature > tempWarnUpper) { String message = String.format("水温过高: %.2f °C (最高: %.2f)", temperature, tempWarnUpper); alarmMessage.append(message).append("; "); - warnList.add(createMessageWarn(deviceId, userId, pondName, - ALARM_TYPE_TEMPERATURE_HIGH, WARN_TYPE_TEMPERATURE, message)); + MessageWarn warn = createMessageWarn(deviceId, userId, pondName, + ALARM_TYPE_TEMPERATURE_HIGH, WARN_TYPE_TEMPERATURE, message); + warnList.add(warn); + if (device.getTempWarnCallOpen() != null && device.getTempWarnCallOpen() == 1) { + callWarnList.add(warn); + } hasAlarm = true; } } - // 3. 检查电池电量(使用设备配置的阈值) - if (sensorData.getBattery() != null && device.getBatteryWarnCallOpen() != null && device.getBatteryWarnCallOpen() == 1) { + // 3. 检查电池电量(阈值判断与告警记录不受电话开关限制) + if (sensorData.getBattery() != null) { Double battery = sensorData.getBattery(); Double batteryWarnLower = device.getBatteryWarnLower() != null ? device.getBatteryWarnLower().doubleValue() : null; @@ -421,23 +431,29 @@ public class DeviceDataHandler { battery, batteryWarnLower); alarmMessage.append(message).append("; "); - warnList.add(createMessageWarn(deviceId, userId, pondName, - ALARM_TYPE_BATTERY, WARN_TYPE_BATTERY, message)); + MessageWarn warn = createMessageWarn(deviceId, userId, pondName, + ALARM_TYPE_BATTERY, WARN_TYPE_BATTERY, message); + warnList.add(warn); + if (device.getBatteryWarnCallOpen() != null && device.getBatteryWarnCallOpen() == 1) { + callWarnList.add(warn); + } hasAlarm = true; } } - // 如果有告警,保存报警消息并触发通知 + // 如果有告警,保存全部报警消息记录 if (hasAlarm) { log.warn("[告警] {} - {}", deviceName, alarmMessage); - // 批量保存报警消息 + // 批量保存报警消息(无论是否开启电话通知,告警记录始终生成) for (MessageWarn warn : warnList) { messageWarnMapper.insert(warn); } - // 触发电话通知 - triggerAlarmNotification(device, alarmMessage.toString(), sensorData, warnList); + // 仅对开启了电话通知开关的告警触发电话通知 + if (!callWarnList.isEmpty()) { + triggerAlarmNotification(device, alarmMessage.toString(), sensorData, callWarnList); + } } } catch (Exception e) { @@ -445,11 +461,24 @@ public class DeviceDataHandler { } } + /** + * 电话通知间隔时间(分钟) + * 参考 C# CallNoticeMinuteInterval = 3 + */ + private static final int CALL_NOTICE_MINUTE_INTERVAL = 3; + + /** + * 电话通知重试次数 + * 参考 C# CallNoticeRetryCount = 3 + */ + private static final int CALL_NOTICE_RETRY_COUNT = 3; + /** * 触发告警通知 * 按 title 分组,每种类型独立判断告警间隔,避免不同类型告警互相屏蔽 * 免打扰开启时:每个时段(08:00-14:00、14:00-20:00、20:00-08:00)内只通知一次 * 免打扰关闭时:按固定间隔时间判断 + * 手机号从 warnPhoneJson 获取,支持多号码轮转规避限流 * * @param device 设备信息 * @param alarmMessage 告警信息 @@ -462,23 +491,23 @@ public class DeviceDataHandler { Long deviceId = device.getId(); String deviceName = device.getDeviceName(); - // 一次性查询用户手机号 + // 从 warnPhoneJson 获取告警手机号列表 Long userId = device.getUserId(); - String mobilePhone = null; + java.util.List phoneList = null; if (userId != null) { AquUser aquUser = aquUserMapper.selectById(userId); if (aquUser != null) { - mobilePhone = aquUser.getMobilePhone(); + phoneList = parseWarnPhoneList(aquUser.getWarnPhoneJson()); } } - if (userId == null || StrUtil.isBlank(mobilePhone)) { - log.warn("[告警] {} - 信息不完整", deviceName); + if (userId == null || phoneList == null || phoneList.isEmpty()) { + log.warn("[告警] {} - 未配置告警手机号(warnPhoneJson为空)", deviceName); return; } - final String finalMobilePhone = mobilePhone; String pondName = warnList.isEmpty() ? deviceName : warnList.get(0).getPondName(); - LocalDateTime intervalTime = LocalDateTime.now().minusMinutes(aliyunIotProperties.getAlarmNotificationIntervalMinutes()); + // 抑制时间:根据配置的小时数展开,查找 callStatus=3(回执成功)的记录 + LocalDateTime intervalTime = LocalDateTime.now().minusHours(aliyunIotProperties.getCallSuccessSuppressHours()); LocalDateTime periodStart = getCurrentPeriodStart(); // 按 title 分组,每种类型独立判断间隔并发送通知 @@ -490,17 +519,16 @@ public class DeviceDataHandler { for (Map.Entry> entry : warnsByTitle.entrySet()) { String title = entry.getKey(); java.util.List typeWarnList = entry.getValue(); - int warnType = typeWarnList.get(0).getWarnType(); - // 判断该类型是否开启了免打扰 + // 判断该类型是否开启了免打扰(按 title 匹配,避免 warnType 值冲突导致判断失效) boolean noDisturbEnabled = false; - if (WARN_TYPE_DISSOLVED_OXYGEN == warnType && + if (ALARM_TYPE_DISSOLVED_OXYGEN.equals(title) && device.getOxyWarnCallNoDis() != null && device.getOxyWarnCallNoDis() == 1) { noDisturbEnabled = true; - } else if (WARN_TYPE_TEMPERATURE == warnType && + } else if ((ALARM_TYPE_TEMPERATURE_HIGH.equals(title) || ALARM_TYPE_TEMPERATURE_LOW.equals(title)) && device.getTempWarnCallNoDis() != null && device.getTempWarnCallNoDis() == 1) { noDisturbEnabled = true; - } else if (WARN_TYPE_BATTERY == warnType && + } else if (ALARM_TYPE_BATTERY.equals(title) && device.getBatteryWarnCallNoDis() != null && device.getBatteryWarnCallNoDis() == 1) { noDisturbEnabled = true; } @@ -515,37 +543,64 @@ public class DeviceDataHandler { continue; } } else { - // 免打扰关闭:按固定间隔判断 + // 免打扰关闭:查找指定小时内是否已有回执成功的通知 recentCount = warnCallNoticeMapper.countByDeviceAndTitleAfter(deviceId, title, intervalTime); if (recentCount > 0) { - log.debug("[告警] {} title={} - {}min内已通知,跳过", deviceName, title, aliyunIotProperties.getAlarmNotificationIntervalMinutes()); + log.debug("[告警] {} title={} - {}h内已有回执成功的通知,跳过", deviceName, title, aliyunIotProperties.getCallSuccessSuppressHours()); 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); + // 告警内容(取该类型第一条) + String typeAlarmMessage = typeWarnList.get(0).getMessage(); - // 保存报警消息与通知记录的关联关系 - for (MessageWarn warn : typeWarnList) { - if (warn.getId() != null) { - AquMapMessageWarnCallNotice mapRecord = new AquMapMessageWarnCallNotice(); - mapRecord.setMessageWarnId(warn.getId()); - mapRecord.setCallNoticeId(callNotice.getId()); - mapMessageWarnCallNoticeMapper.insert(mapRecord); + // 为每个手机号 × 重试次数,创建通知记录,callTime 按间隔错开 + // 参考 C#: CallNoticeRetryCount=3, CallNoticeMinuteInterval=3 + // 立即尝试发送第一个号码;若失败则后续通知记录会在定时任务中按时间发送 + LocalDateTime baseTime = LocalDateTime.now(); + int index = 0; + AquWarnCallNotice firstCallNotice = null; + + for (int retry = 0; retry < CALL_NOTICE_RETRY_COUNT; retry++) { + for (String phoneNum : phoneList) { + if (StrUtil.isBlank(phoneNum) || phoneNum.length() < 11) { + log.warn("[告警] 无效的手机号: {}", phoneNum); + continue; + } + + LocalDateTime callTime = baseTime.plusMinutes((long) CALL_NOTICE_MINUTE_INTERVAL * index); + index++; + + AquWarnCallNotice callNotice = new AquWarnCallNotice(); + callNotice.setUserId(userId); + callNotice.setMobilePhone(phoneNum); + callNotice.setDeviceId(deviceId); + callNotice.setPondName(pondName); + callNotice.setCallTime(callTime); + 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); + } + } + + if (firstCallNotice == null) { + firstCallNotice = callNotice; + } } } - // 发送语音通知(取该类型第一条的告警内容为TTS参数) - String typeAlarmMessage = typeWarnList.get(0).getMessage(); - sendVoiceNotification(callNotice, deviceName, typeAlarmMessage); + // 立即尝试发送第一条通知(第一个手机号,第一次重试) + // 若限流失败,后续记录的 callTime 到期后由定时任务扫描发送 + if (firstCallNotice != null) { + sendVoiceNotification(firstCallNotice, deviceName, typeAlarmMessage); + } } } catch (Exception e) { @@ -553,6 +608,36 @@ public class DeviceDataHandler { } } + /** + * 解析 warnPhoneJson 字段,返回有效手机号列表 + * 格式示例: ["13465511438","15726577528","13705354621"] + * + * @param warnPhoneJson JSON 字符串 + * @return 手机号列表,解析失败或为空返回 null + */ + private java.util.List parseWarnPhoneList(String warnPhoneJson) { + if (StrUtil.isBlank(warnPhoneJson)) { + return null; + } + try { + JSONArray jsonArray = JSONUtil.parseArray(warnPhoneJson); + // 使用 LinkedHashSet 去重,同时保持原始顺序 + java.util.LinkedHashSet phoneSet = new java.util.LinkedHashSet<>(); + for (Object obj : jsonArray) { + if (obj != null && StrUtil.isNotBlank(obj.toString())) { + String phone = obj.toString().trim(); + if (!phoneSet.add(phone)) { + log.warn("[告警] warnPhoneJson 中存在重复手机号,已自动去重: {}", phone); + } + } + } + return phoneSet.isEmpty() ? null : new java.util.ArrayList<>(phoneSet); + } catch (Exception e) { + log.error("[告警] 解析 warnPhoneJson 失败: {}", e.getMessage()); + return null; + } + } + /** * 获取当前所在时段的起始时间 * 时段划分:08:00-14:00 / 14:00-20:00 / 20:00-08:00(跨日) @@ -696,13 +781,17 @@ public class DeviceDataHandler { return; } - // 查询手机号 + // 从 warnPhoneJson 获取告警手机号列表 AquUser aquUser = aquUserMapper.selectById(userId); - if (aquUser == null || StrUtil.isBlank(aquUser.getMobilePhone())) { - log.warn("[离线告警] 用户手机号不存在,跳过: {}", deviceName); + if (aquUser == null) { + log.warn("[离线告警] 用户不存在,跳过:{}", deviceName); + return; + } + java.util.List phoneList = parseWarnPhoneList(aquUser.getWarnPhoneJson()); + if (phoneList == null || phoneList.isEmpty()) { + log.warn("[离线告警] 用户未配置告警手机号(warnPhoneJson为空),跳过:{}", deviceName); return; } - String mobilePhone = aquUser.getMobilePhone(); // 检查告警间隔(防止频繁通知) LocalDateTime intervalTime = LocalDateTime.now().minusMinutes(aliyunIotProperties.getAlarmNotificationIntervalMinutes()); @@ -730,28 +819,50 @@ public class DeviceDataHandler { MessageWarn warn = createMessageWarn(deviceId, userId, pondName, "设备离线", WARN_TYPE_OFFLINE, warnMsg); messageWarnMapper.insert(warn); - // 创建通知记录 - 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); - warnCallNoticeMapper.insert(callNotice); + // 创建通知记录(为每个手机号 × 重试次数) + LocalDateTime baseTime = LocalDateTime.now(); + int index = 0; + AquWarnCallNotice firstCallNotice = null; - // 保存关联记录 - if (warn.getId() != null) { - AquMapMessageWarnCallNotice mapRecord = new AquMapMessageWarnCallNotice(); - mapRecord.setMessageWarnId(warn.getId()); - mapRecord.setCallNoticeId(callNotice.getId()); - mapMessageWarnCallNoticeMapper.insert(mapRecord); + for (int retry = 0; retry < CALL_NOTICE_RETRY_COUNT; retry++) { + for (String phoneNum : phoneList) { + if (StrUtil.isBlank(phoneNum) || phoneNum.length() < 11) { + log.warn("[离线告警] 无效的手机号:{}", phoneNum); + continue; + } + + LocalDateTime callTime = baseTime.plusMinutes((long) CALL_NOTICE_MINUTE_INTERVAL * index); + index++; + + AquWarnCallNotice callNotice = new AquWarnCallNotice(); + callNotice.setUserId(userId); + callNotice.setMobilePhone(phoneNum); + callNotice.setDeviceId(deviceId); + callNotice.setPondName(pondName); + callNotice.setCallTime(callTime); + callNotice.setCallStatus(CALL_STATUS_NO_CALL); + warnCallNoticeMapper.insert(callNotice); + + // 保存关联记录 + if (warn.getId() != null) { + AquMapMessageWarnCallNotice mapRecord = new AquMapMessageWarnCallNotice(); + mapRecord.setMessageWarnId(warn.getId()); + mapRecord.setCallNoticeId(callNotice.getId()); + mapMessageWarnCallNoticeMapper.insert(mapRecord); + } + + if (firstCallNotice == null) { + firstCallNotice = callNotice; + } + } } - // 发送语音通知(TTS 参数用简短告警类型) - sendVoiceNotification(callNotice, displayDeviceName, "设备离线"); + // 立即尝试发送第一条通知 + if (firstCallNotice != null) { + sendVoiceNotification(firstCallNotice, displayDeviceName, "设备离线"); + } - log.info("[离线告警] 已触发通知 - 设备: {}, 塘口: {}, 手机: {}", deviceName, pondName, mobilePhone); + log.info("[离线告警] 已触发通知 - 设备:{}, 塘口:{}, 手机号数:{}", deviceName, pondName, phoneList.size()); } 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 d65ba56..32ea53f 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 @@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.time.LocalDateTime; +import java.util.List; /** * 告警电话通知记录 Mapper @@ -17,7 +18,8 @@ import java.time.LocalDateTime; public interface AquWarnCallNoticeMapper extends BaseMapper { /** - * 查询指定设备、告警标题、时间范围内的通知数量(通过关联表反查 title) + * 查询指定设备、告警标题、时间范围内「回执成功」的通知数量 + * 只有 callStatus=3(回执成功)的记录才算真正通知到人,才会触发抑制 * * @param deviceId 设备ID * @param title 告警标题(如"溶解氧"、"设备离线"等) @@ -29,14 +31,16 @@ public interface AquWarnCallNoticeMapper extends BaseMapper { "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_status = 3 " + "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 + * 只有 callStatus=3(回执成功)的记录才算真正通知到人,才会触发抑制 * * @param deviceId 设备ID * @param title 告警标题 @@ -48,8 +52,31 @@ public interface AquWarnCallNoticeMapper extends BaseMapper { "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_status = 3 " + "AND n.call_time >= #{periodStart}") long countByDeviceAndTitleInPeriod(@Param("deviceId") Long deviceId, @Param("title") String title, @Param("periodStart") LocalDateTime periodStart); + + /** + * 查询 callStatus=0 且 callTime <= now 的到期待发记录 + * 用于定时任务扫描并触发语音通知 + * + * @param now 当前时间 + * @return 到期待发的通知记录列表 + */ + @Select("SELECT * FROM aqu_call_notice WHERE call_status = 0 AND call_time <= #{now} ORDER BY call_time ASC") + List selectDuePendingNotices(@Param("now") LocalDateTime now); + + /** + * 通过 callNoticeId 查询关联的第一条告警消息内容 + * 用于定时任务重发时构建 TTS 参数 + * + * @param callNoticeId 通知记录ID + * @return 告警消息内容,不存在时返回 null + */ + @Select("SELECT w.message FROM aqu_message_warn w " + + "INNER JOIN aqu_map_message_warn_call_notice m ON m.message_warn_id = w.id " + + "WHERE m.call_notice_id = #{callNoticeId} LIMIT 1") + String selectWarnMessageByCallNoticeId(@Param("callNoticeId") Long callNoticeId); } diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/service/WarnCallNoticeService.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/service/WarnCallNoticeService.java index 853a4cf..de20236 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/service/WarnCallNoticeService.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/service/WarnCallNoticeService.java @@ -48,4 +48,13 @@ public interface WarnCallNoticeService { * @return 清理的记录数量 */ int cleanupExpiredNotifications(); + + /** + * 扫描到期待发的通知记录,调用 VMS 发送语音通知 + * 对应注释:“如失败则后续通知记录会在定时任务中按时间发送” + * 触发条件:callStatus=0 且 callTime <= 当前时间 + * + * @return 发送成功的数量 + */ + int processPendingNotifications(); } diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/WarnCallNoticeServiceImpl.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/WarnCallNoticeServiceImpl.java index e01b964..4f58d00 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/WarnCallNoticeServiceImpl.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/service/impl/WarnCallNoticeServiceImpl.java @@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.intc.iot.domain.AquWarnCallNotice; import com.intc.iot.domain.VmsCallback; +import com.intc.iot.domain.VmsNoticeResponse; import com.intc.iot.mapper.AquWarnCallNoticeMapper; import com.intc.iot.mapper.VmsCallbackMapper; +import com.intc.iot.service.VmsNoticeService; import com.intc.iot.service.WarnCallNoticeService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,7 +17,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 告警电话通知服务实现 @@ -30,6 +34,7 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { private final AquWarnCallNoticeMapper warnCallNoticeMapper; private final VmsCallbackMapper vmsCallbackMapper; + private final VmsNoticeService vmsNoticeService; /** * 呼叫状态枚举(对应 C# EnumCallNoticeStatus) @@ -48,9 +53,9 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { /** * 回执超时时间(分钟) - * 对应 C# CallNoticeMinuteInterval = 3 + * VMS 回执延迟较长,改为 5 分钟,避免回执未到就被标记为超时 */ - private static final int CALLBACK_TIMEOUT_MINUTES = 3; + private static final int CALLBACK_TIMEOUT_MINUTES = 5; @Override @Transactional(rollbackFor = Exception.class) @@ -65,6 +70,7 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { // 优先从 outId(格式:ALARM_{callNoticeId}_{timestamp})中解析主键直接查询 // 若解析失败则回退到 callId 匹配 + // 注意:不限制 callStatus,避免超时清理后回执仍可被处理 AquWarnCallNotice callNotice = null; String outId = callback.getOutId(); if (outId != null && outId.startsWith("ALARM_")) { @@ -76,10 +82,9 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { callNotice = warnCallNoticeMapper.selectOne( new LambdaQueryWrapper() .eq(AquWarnCallNotice::getId, noticeId) - .eq(AquWarnCallNotice::getCallStatus, CALL_STATUS_CALL_SUCCESS) ); if (callNotice != null) { - log.debug("[告警通知] 通过 outId 找到通知记录 - 记录ID: {}", noticeId); + log.debug("[告警通知] 通过 outId 找到通知记录 - 记录ID: {}, 当前状态: {}", noticeId, callNotice.getCallStatus()); } } } catch (NumberFormatException e) { @@ -87,12 +92,11 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { } } - // 回退:通过 callId 匹配 + // 回退:通过 callId 匹配(同样不限制 callStatus) if (callNotice == null) { callNotice = warnCallNoticeMapper.selectOne( new LambdaQueryWrapper() .eq(AquWarnCallNotice::getCallId, callback.getCallId()) - .eq(AquWarnCallNotice::getCallStatus, CALL_STATUS_CALL_SUCCESS) ); } @@ -175,6 +179,11 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { return count; } + /** + * 清理超时的通知记录 + * 对应 C# CleanupExpiredNotifications + * 清理规则:已呼叫成功(callStatus=1)但超过超时时间未收到回执的记录 + */ @Override @Transactional(rollbackFor = Exception.class) public int cleanupExpiredNotifications() { @@ -224,6 +233,85 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { return cleanedCount; } + /** + * 扫描到期待发的通知记录,调用 VMS 发送语音通知 + * 触发条件:callStatus=0 且 callTime <= 当前时间 + */ + @Override + public int processPendingNotifications() { + LocalDateTime now = LocalDateTime.now(); + List pendingList = warnCallNoticeMapper.selectDuePendingNotices(now); + + if (pendingList.isEmpty()) { + return 0; + } + + log.info("[待发通知] 发现 {} 条到期待发通知记录,开始处理", pendingList.size()); + + int sentCount = 0; + for (AquWarnCallNotice notice : pendingList) { + try { + // 查询关联的告警消息内容,用于构建 TTS 参数 + String rawMessage = warnCallNoticeMapper.selectWarnMessageByCallNoticeId(notice.getId()); + String warnMessageShort = parseWarnMessageShort(rawMessage); + + // 构建 TTS 参数 + Map ttsParams = new HashMap<>(); + ttsParams.put("pondName", notice.getPondName() != null ? notice.getPondName() : "未知塘口"); + ttsParams.put("deviceName", "设备"); + ttsParams.put("warnMessage", warnMessageShort); + + String outId = "ALARM_" + notice.getId() + "_" + System.currentTimeMillis(); + + VmsNoticeResponse response = vmsNoticeService.sendTtsCall( + notice.getMobilePhone(), + ttsParams, + outId + ); + + if (response != null && response.isSuccess()) { + notice.setCallStatus(CALL_STATUS_CALL_SUCCESS); + notice.setCallId(response.getCallId()); + notice.setOutId(outId); + warnCallNoticeMapper.updateById(notice); + sentCount++; + log.info("[待发通知] 呼叫成功: {} - 记录ID: {}", notice.getMobilePhone(), notice.getId()); + } else { + notice.setCallStatus(CALL_STATUS_CALL_FAIL); + notice.setStatusMsg(response != null ? response.getMessage() : "呼叫失败"); + warnCallNoticeMapper.updateById(notice); + log.warn("[待发通知] 呼叫失败: {} - 原因: {}", notice.getMobilePhone(), notice.getStatusMsg()); + } + } catch (Exception e) { + log.error("[待发通知] 处理待发通知异常 - 记录ID: {}, 电话: {}, 异常: {}", + notice.getId(), notice.getMobilePhone(), e.getMessage(), e); + try { + notice.setCallStatus(CALL_STATUS_CALL_FAIL); + notice.setStatusMsg("异常:" + e.getMessage()); + warnCallNoticeMapper.updateById(notice); + } catch (Exception ex) { + log.error("[待发通知] 更新失败状态异常: {}", ex.getMessage()); + } + } + } + + log.info("[待发通知] 处理完成 - 成功: {}, 总数: {}", sentCount, pendingList.size()); + return sentCount; + } + + /** + * 解析告警消息为简短形式(取 ':' 前的部分) + * 与 DeviceDataHandler.sendVoiceNotification 保持一致 + */ + private String parseWarnMessageShort(String rawMessage) { + if (rawMessage == null) { + return "异常"; + } + return rawMessage.contains(":") + ? rawMessage.substring(0, rawMessage.indexOf(":")).trim() + : rawMessage; + } + /** * 更新电话通知的回执信息 * 对应 C# UpdateCallNoticeFromCallback @@ -232,7 +320,12 @@ public class WarnCallNoticeServiceImpl implements WarnCallNoticeService { * @param callback VMS 回执 */ private void updateCallNoticeFromCallback(AquWarnCallNotice callNotice, VmsCallback callback) { - LocalDateTime now = LocalDateTime.now(); + // 如果记录已是回执成功状态,跳过不再更新,防止重复回执覆盖 + if (callNotice.getCallStatus() != null && callNotice.getCallStatus() == CALL_STATUS_CALLBACK_SUCCESS) { + log.info("[告警通知] 记录已是成功状态,跳过重复回执 - 记录ID: {}, 电话: {}", + callNotice.getId(), callNotice.getMobilePhone()); + return; + } // 更新回执详细信息(直接映射字段) callNotice.setStatusCode(callback.getStatusCode()); diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/task/VmsCallbackProcessTask.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/task/VmsCallbackProcessTask.java index dd24617..a87bd63 100644 --- a/intc-modules/intc-iot/src/main/java/com/intc/iot/task/VmsCallbackProcessTask.java +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/task/VmsCallbackProcessTask.java @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; /** * VMS 回执处理定时任务 - * 对应 C# AlarmProcessor 中的 ProcessVmsCallbacks + CleanupExpiredNotifications 定时逻辑 + * 对应 C# AlarmProcessor 中的 ProcessVmsCallbacks + CleanupExpiredNotifications + 待发通知扫描 定时逻辑 * * @author intc-iot */ @@ -51,4 +51,21 @@ public class VmsCallbackProcessTask { log.error("[VMS超时清理] 清理超时记录异常: {}", e.getMessage(), e); } } + + /** + * 定时扫描到期待发的语音通知记录并发送 + * 每 30 秒执行一次 + * 对应注释:“如失败则后续通知记录会在定时任务中按时间发送” + */ + @Scheduled(fixedDelay = 30000) + public void sendPendingNotifications() { + try { + int count = warnCallNoticeService.processPendingNotifications(); + if (count > 0) { + log.info("[待发通知扫描] 本次发送了 {} 条到期通知", count); + } + } catch (Exception e) { + log.error("[待发通知扫描] 处理待发通知异常: {}", e.getMessage(), e); + } + } }