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 6f599ab..3f2a6ab 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 @@ -157,6 +157,7 @@ public class DeviceDataHandler { private static final String ALARM_TYPE_BATTERY = "电池电量"; private static final String ALARM_TYPE_SATURA_HIGH = "饱和度过高"; private static final String ALARM_TYPE_DETECTOR_OXY_OFFLINE = "溶解氧离线"; + private static final String ALARM_TYPE_DEVICE_EXPIRATION = "设备即将到期"; /** @@ -1371,6 +1372,127 @@ public class DeviceDataHandler { } } + /** + * 触发设备即将到期告警(由定时任务调用) + * 设备到期时间距离当前时间不到24小时时,打电话通知用户提醒设备即将到期。 + * + * @param device 设备信息(由定时任务查询后传入) + */ + public void triggerDeviceExpirationAlarm(Device device) { + // 全局电话通知开关检查 + if (!aliyunIotProperties.isCallNoticeEnabled()) { + log.debug("[到期告警] 电话通知已全局关闭,跳过: {}", device.getSerialNum()); + return; + } + try { + Long deviceId = device.getId(); + Long userId = device.getUserId(); + String deviceName = device.getSerialNum(); + + if (userId == null) { + log.debug("[到期告警] 设备未绑定用户,跳过: {}", deviceName); + return; + } + + // 从 warnPhoneJson 获取告警手机号列表 + AquUser aquUser = aquUserMapper.selectById(userId); + if (aquUser == null) { + log.warn("[到期告警] 用户不存在,跳过:{}", deviceName); + return; + } + java.util.List phoneList = parseWarnPhoneList(aquUser.getWarnPhoneJson()); + if (phoneList == null || phoneList.isEmpty()) { + log.warn("[到期告警] 用户未配置告警手机号(warnPhoneJson为空),跳过:{}", deviceName); + return; + } + + // 双重抑制:1) 活跃批次检查(防止当前轮重复); 2) 成功通知抑制(24h内已成功则不再打) + int maxBatchMinutes = CALL_NOTICE_RETRY_COUNT * CALL_NOTICE_MINUTE_INTERVAL * 2; + LocalDateTime activeBatchStart = LocalDateTime.now().minusMinutes(maxBatchMinutes); + long activeCount = warnCallNoticeMapper.countActiveByDeviceAndTitleAfter(deviceId, ALARM_TYPE_DEVICE_EXPIRATION, activeBatchStart); + if (activeCount > 0) { + log.debug("[到期告警] 当前有活跃通知批次在进行,跳过: {}", deviceName); + return; + } + LocalDateTime suppressTime = LocalDateTime.now().minusHours(aliyunIotProperties.getCallSuccessSuppressHours()); + long recentCount = warnCallNoticeMapper.countSuccessByDeviceAndTitleAfter(deviceId, ALARM_TYPE_DEVICE_EXPIRATION, suppressTime); + if (recentCount > 0) { + log.debug("[到期告警] {}h内已有回执成功的通知,跳过: {}", aliyunIotProperties.getCallSuccessSuppressHours(), deviceName); + return; + } + + // 查询塘口名称(设备未绑定塘口时使用"未绑定塘口") + String pondName = "未绑定塘口"; + if (device.getPondId() != null) { + Pond pond = pondMapper.selectById(device.getPondId()); + if (pond != null) { + pondName = pond.getPondName(); + } + } + String displayDeviceName = device.getDeviceName() != null ? device.getDeviceName() : deviceName; + + // 计算剩余小时数 + long remainingHours = java.time.Duration.between( + LocalDateTime.now(), + device.getDeadTime().toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime() + ).toHours(); + + // 告警消息内容 + String warnMsg = String.format("%s(%s)服务即将到期,剩余不到%d小时,请及时续费", + displayDeviceName, deviceName, Math.max(remainingHours, 1)); + MessageWarn warn = createMessageWarn(deviceId, userId, pondName, ALARM_TYPE_DEVICE_EXPIRATION, WARN_TYPE_OFFLINE, warnMsg); + messageWarnMapper.insert(warn); + + // 创建通知记录(为每个手机号 × 重试次数) + 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); + + // 保存关联记录 + if (warn.getId() != null) { + AquMapMessageWarnCallNotice mapRecord = new AquMapMessageWarnCallNotice(); + mapRecord.setMessageWarnId(warn.getId()); + mapRecord.setCallNoticeId(callNotice.getId()); + mapMessageWarnCallNoticeMapper.insert(mapRecord); + } + + if (firstCallNotice == null) { + firstCallNotice = callNotice; + } + } + } + + // 立即尝试发送第一条通知 + if (firstCallNotice != null) { + sendVoiceNotification(firstCallNotice, displayDeviceName, ALARM_TYPE_DEVICE_EXPIRATION); + } + + log.info("[到期告警] 已触发通知 - 设备:{}, 手机号数:{}, 剩余小时:{}", deviceName, phoneList.size(), remainingHours); + + } catch (Exception e) { + log.error("[到期告警] 处理失败: {}", e.getMessage(), e); + } + } + /** * 触发设备故障告警(对应 IoT 事件路径 error 事件) * 参考 C# CreateNoticeCall 逻辑:查设备→查塘口→写告警消息→创建电话通知记录→立即发送 diff --git a/intc-modules/intc-iot/src/main/java/com/intc/iot/task/DeviceExpirationWarnTask.java b/intc-modules/intc-iot/src/main/java/com/intc/iot/task/DeviceExpirationWarnTask.java new file mode 100644 index 0000000..83659ad --- /dev/null +++ b/intc-modules/intc-iot/src/main/java/com/intc/iot/task/DeviceExpirationWarnTask.java @@ -0,0 +1,75 @@ +package com.intc.iot.task; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.intc.fishery.domain.Device; +import com.intc.fishery.mapper.DeviceMapper; +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.util.Date; +import java.util.List; + +/** + * 设备即将到期告警定时任务 + * 每小时扫描一次设备表,对到期时间距离当前不到24小时的设备触发电话通知,提醒用户续费。 + * + * @author intc-iot + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnBean(DeviceDataHandler.class) +public class DeviceExpirationWarnTask { + + private final DeviceDataHandler deviceDataHandler; + + private final DeviceMapper deviceMapper; + + /** + * 每2小时执行一次,扫描即将到期(24小时内)的设备并触发电话通知 + */ + @Scheduled(fixedDelay = 7200000) + public void checkDeviceExpirationAndNotify() { + try { + Date now = new Date(); + // 24小时后的时间点 + Date deadline = new Date(now.getTime() + 24L * 60 * 60 * 1000); + + // 查询所有满足条件的设备: + // 1. deadTime 不为空 + // 2. deadTime > 当前时间(尚未到期) + // 3. deadTime <= 当前时间 + 24小时(即将到期) + // 4. userId 不为空(已绑定用户) + List expiringDevices = deviceMapper.selectList( + new LambdaQueryWrapper() + .isNotNull(Device::getDeadTime) + .gt(Device::getDeadTime, now) + .le(Device::getDeadTime, deadline) + .isNotNull(Device::getUserId) + .select(Device::getId, Device::getUserId, Device::getSerialNum, + Device::getDeviceName, Device::getDeviceType, Device::getPondId, + Device::getDeadTime) + ); + + if (expiringDevices == null || expiringDevices.isEmpty()) { + return; + } + + log.info("[到期告警] 扫描到 {} 台即将到期的设备,开始逐一通知", expiringDevices.size()); + + for (Device device : expiringDevices) { + try { + deviceDataHandler.triggerDeviceExpirationAlarm(device); + } catch (Exception e) { + log.error("[到期告警] 处理单台设备异常,deviceId={}: {}", device.getId(), e.getMessage(), e); + } + } + } catch (Exception e) { + log.error("[到期告警] 扫描设备到期告警异常: {}", e.getMessage(), e); + } + } +}