Sfoglia il codice sorgente

设备离线预计

skyline 1 mese fa
parent
commit
ba33b0ecc9

+ 108 - 0
haha-admin/src/main/java/com/haha/admin/controller/DeviceAlertController.java

@@ -0,0 +1,108 @@
+package com.haha.admin.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.haha.common.enums.DeviceAlertType;
+import com.haha.common.vo.PageResult;
+import com.haha.common.vo.Result;
+import com.haha.entity.DeviceAlertRecord;
+import com.haha.service.DeviceAlertService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 设备告警记录控制器
+ */
+@Slf4j
+@RestController
+@RequestMapping("/device-alerts")
+@RequiredArgsConstructor
+public class DeviceAlertController {
+
+    private final DeviceAlertService deviceAlertService;
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    /**
+     * 分页查询告警记录
+     * 支持按 deviceId、alertType、时间范围筛选
+     *
+     * @param deviceId  设备编号
+     * @param alertType 告警类型
+     * @param startTime 开始时间
+     * @param endTime   结束时间
+     * @param pageNo    页码
+     * @param pageSize  每页数量
+     * @return 分页结果
+     */
+    @GetMapping("/list")
+    public Result<PageResult<DeviceAlertRecord>> list(
+            @RequestParam(required = false) String deviceId,
+            @RequestParam(required = false) Integer alertType,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(defaultValue = "1") Integer pageNo,
+            @RequestParam(defaultValue = "20") Integer pageSize) {
+
+        LambdaQueryWrapper<DeviceAlertRecord> wrapper = new LambdaQueryWrapper<>();
+
+        if (StringUtils.hasText(deviceId)) {
+            wrapper.eq(DeviceAlertRecord::getDeviceId, deviceId);
+        }
+        if (alertType != null) {
+            wrapper.eq(DeviceAlertRecord::getAlertType, alertType);
+        }
+        if (StringUtils.hasText(startTime)) {
+            wrapper.ge(DeviceAlertRecord::getAlertTime, LocalDateTime.parse(startTime, DATE_TIME_FORMATTER));
+        }
+        if (StringUtils.hasText(endTime)) {
+            wrapper.le(DeviceAlertRecord::getAlertTime, LocalDateTime.parse(endTime, DATE_TIME_FORMATTER));
+        }
+
+        wrapper.orderByDesc(DeviceAlertRecord::getAlertTime);
+
+        IPage<DeviceAlertRecord> page = deviceAlertService.page(new Page<>(pageNo, pageSize), wrapper);
+
+        return Result.success("查询成功", PageResult.of(page));
+    }
+
+    /**
+     * 告警统计
+     * 返回:今日告警数、各类型分布
+     *
+     * @return 统计数据
+     */
+    @GetMapping("/statistics")
+    public Result<Map<String, Object>> statistics() {
+        LocalDateTime todayStart = LocalDate.now().atStartOfDay();
+
+        LambdaQueryWrapper<DeviceAlertRecord> wrapper = new LambdaQueryWrapper<>();
+        wrapper.ge(DeviceAlertRecord::getAlertTime, todayStart);
+
+        List<DeviceAlertRecord> todayAlerts = deviceAlertService.list(wrapper);
+
+        // 按告警类型分组统计
+        Map<String, Long> typeDistribution = todayAlerts.stream()
+                .collect(Collectors.groupingBy(record -> {
+                    DeviceAlertType type = DeviceAlertType.fromCode(record.getAlertType());
+                    return type != null ? type.getDescription() : "未知类型";
+                }, Collectors.counting()));
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("todayTotal", todayAlerts.size());
+        result.put("typeDistribution", typeDistribution);
+
+        return Result.success("查询成功", result);
+    }
+}

+ 95 - 0
haha-admin/src/main/java/com/haha/admin/task/DeviceMonitorTask.java

@@ -0,0 +1,95 @@
+package com.haha.admin.task;
+
+import com.haha.entity.Device;
+import com.haha.sdk.HahaClient;
+import com.haha.sdk.model.DeviceOnlineStatus;
+import com.haha.service.DeviceAlertService;
+import com.haha.service.DeviceService;
+import com.haha.service.config.DeviceAlertProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DeviceMonitorTask {
+
+    private final DeviceService deviceService;
+    private final DeviceAlertService deviceAlertService;
+    private final DeviceAlertProperties alertProperties;
+    private final HahaClient hahaClient;
+
+    /**
+     * 定时巡检设备在线状态
+     * 定时扫描,间隔由配置 device.alert.scan-interval-minutes 控制,默认5分钟
+     */
+    @Scheduled(fixedDelayString = "#{${device.alert.scan-interval-minutes:5} * 60 * 1000}")
+    public void scanDeviceOnlineStatus() {
+        if (!alertProperties.isEnabled()) {
+            return;
+        }
+
+        log.info("[设备巡检] 开始执行设备在线状态巡检...");
+        long startTime = System.currentTimeMillis();
+        int totalChecked = 0;
+        int offlineCount = 0;
+
+        try {
+            // 1. 查询所有状态为正常(status=1)的设备
+            List<Device> devices = deviceService.lambdaQuery()
+                    .eq(Device::getStatus, 1)
+                    .list();
+
+            if (devices.isEmpty()) {
+                log.info("[设备巡检] 无需巡检的设备");
+                return;
+            }
+
+            log.info("[设备巡检] 待巡检设备数量: {}", devices.size());
+
+            // 2. 分批处理,每批50台
+            int batchSize = 50;
+            for (int i = 0; i < devices.size(); i += batchSize) {
+                List<Device> batch = devices.subList(i, Math.min(i + batchSize, devices.size()));
+
+                for (Device device : batch) {
+                    try {
+                        // 调用SDK查询在线状态
+                        DeviceOnlineStatus onlineStatus = hahaClient.getDeviceApi().getOnlineStatus(device.getDeviceId());
+                        totalChecked++;
+
+                        if (onlineStatus != null && onlineStatus.getIsOnline() != null && onlineStatus.getIsOnline() == 0) {
+                            // 设备离线
+                            offlineCount++;
+                            deviceAlertService.processOfflineAlert(device.getDeviceId(), "SCHEDULED_SCAN");
+                        } else if (onlineStatus != null && onlineStatus.getIsOnline() != null && onlineStatus.getIsOnline() == 1) {
+                            // 设备在线,检查是否需要发恢复通知
+                            deviceAlertService.processBackOnlineNotify(device.getDeviceId());
+                        }
+
+                        // 限流:每次API调用间隔200ms
+                        Thread.sleep(200);
+
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        log.warn("[设备巡检] 巡检任务被中断");
+                        return;
+                    } catch (Exception e) {
+                        log.warn("[设备巡检] 查询设备 {} 在线状态失败: {}", device.getDeviceId(), e.getMessage());
+                    }
+                }
+            }
+
+        } catch (Exception e) {
+            log.error("[设备巡检] 巡检任务执行异常", e);
+        }
+
+        long costTime = System.currentTimeMillis() - startTime;
+        log.info("[设备巡检] 巡检完成,共检查 {} 台设备,发现 {} 台离线,耗时 {}ms",
+                totalChecked, offlineCount, costTime);
+    }
+}

+ 10 - 0
haha-common/src/main/java/com/haha/common/constant/RedisConstants.java

@@ -25,6 +25,16 @@ public class RedisConstants {
      */
     public static final String USER_INFO_KEY = "user:info:%s";
     
+    /**
+     * 设备告警冷却键前缀,参数:设备ID、告警类型
+     */
+    public static final String DEVICE_ALERT_COOLDOWN_KEY = "device:alert:cooldown:%s:%s";
+    
+    /**
+     * 设备最后在线时间键前缀,参数:设备ID
+     */
+    public static final String DEVICE_LAST_ONLINE_KEY = "device:last:online:%s";
+    
     private RedisConstants() {
         // 私有构造函数,防止实例化
     }

+ 36 - 0
haha-common/src/main/java/com/haha/common/enums/DeviceAlertType.java

@@ -0,0 +1,36 @@
+package com.haha.common.enums;
+
+public enum DeviceAlertType {
+
+    OFFLINE(1, "设备离线"),
+    WEAK_SIGNAL(2, "信号弱"),
+    BACK_ONLINE(3, "恢复上线");
+
+    private final int code;
+    private final String description;
+
+    DeviceAlertType(int code, String description) {
+        this.code = code;
+        this.description = description;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public static DeviceAlertType fromCode(Integer code) {
+        if (code == null) {
+            return null;
+        }
+        for (DeviceAlertType type : values()) {
+            if (type.code == code) {
+                return type;
+            }
+        }
+        return null;
+    }
+}

+ 79 - 0
haha-entity/src/main/java/com/haha/entity/DeviceAlertRecord.java

@@ -0,0 +1,79 @@
+package com.haha.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("t_device_alert_record")
+public class DeviceAlertRecord implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /**
+     * 设备编号
+     */
+    private String deviceId;
+
+    /**
+     * 所属门店ID
+     */
+    private Long shopId;
+
+    /**
+     * 设备名称
+     */
+    private String deviceName;
+
+    /**
+     * 告警类型:1-离线, 2-信号弱, 3-恢复上线
+     */
+    private Integer alertType;
+
+    /**
+     * 告警级别:1-普通, 2-紧急
+     */
+    private Integer alertLevel;
+
+    /**
+     * 告警内容描述
+     */
+    private String alertContent;
+
+    /**
+     * 信号值(如有)
+     */
+    private Integer signalValue;
+
+    /**
+     * 延迟值(如有)
+     */
+    private Integer pingValue;
+
+    /**
+     * 通知状态:0-未发送, 1-已发送, 2-发送失败
+     */
+    private Integer notifyStatus;
+
+    /**
+     * 通知渠道:WECHAT_WORK
+     */
+    private String notifyChannel;
+
+    /**
+     * 触发来源:CALLBACK / SCHEDULED_SCAN
+     */
+    private String triggerSource;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime alertTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
+    private LocalDateTime createTime;
+}

+ 7 - 0
haha-mapper/src/main/java/com/haha/mapper/DeviceAlertRecordMapper.java

@@ -0,0 +1,7 @@
+package com.haha.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.haha.entity.DeviceAlertRecord;
+
+public interface DeviceAlertRecordMapper extends BaseMapper<DeviceAlertRecord> {
+}

+ 34 - 0
haha-service/src/main/java/com/haha/service/DeviceAlertService.java

@@ -0,0 +1,34 @@
+package com.haha.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.haha.entity.DeviceAlertRecord;
+
+/**
+ * 设备告警服务接口
+ */
+public interface DeviceAlertService extends IService<DeviceAlertRecord> {
+
+    /**
+     * 处理设备离线告警
+     *
+     * @param deviceId      设备编号
+     * @param triggerSource 触发来源:CALLBACK / SCHEDULED_SCAN
+     */
+    void processOfflineAlert(String deviceId, String triggerSource);
+
+    /**
+     * 处理信号弱告警
+     *
+     * @param deviceId      设备编号
+     * @param signalValue   信号值
+     * @param triggerSource 触发来源
+     */
+    void processWeakSignalAlert(String deviceId, Integer signalValue, String triggerSource);
+
+    /**
+     * 处理设备恢复上线通知
+     *
+     * @param deviceId 设备编号
+     */
+    void processBackOnlineNotify(String deviceId);
+}

+ 36 - 0
haha-service/src/main/java/com/haha/service/config/DeviceAlertProperties.java

@@ -0,0 +1,36 @@
+package com.haha.service.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 设备告警配置属性类
+ * 用于配置设备离线/信号弱等告警参数及企业微信通知
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "device.alert")
+public class DeviceAlertProperties {
+
+    /** 是否启用设备告警 */
+    private boolean enabled = true;
+
+    /** 同设备同类型告警冷却时间(分钟) */
+    private int cooldownMinutes = 30;
+
+    /** 定时巡检间隔(分钟) */
+    private int scanIntervalMinutes = 5;
+
+    /** 信号弱阈值(signal_val <= 此值视为信号弱) */
+    private int weakSignalThreshold = 2;
+
+    /** 企业微信配置 */
+    private WeChatWork wechatWork = new WeChatWork();
+
+    @Data
+    public static class WeChatWork {
+        /** 群机器人Webhook地址 */
+        private String webhookUrl;
+    }
+}

+ 277 - 0
haha-service/src/main/java/com/haha/service/impl/DeviceAlertServiceImpl.java

@@ -0,0 +1,277 @@
+package com.haha.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.haha.common.constant.RedisConstants;
+import com.haha.common.enums.DeviceAlertType;
+import com.haha.entity.Device;
+import com.haha.entity.DeviceAlertRecord;
+import com.haha.entity.Shop;
+import com.haha.mapper.DeviceAlertRecordMapper;
+import com.haha.service.DeviceAlertService;
+import com.haha.service.DeviceService;
+import com.haha.service.ShopService;
+import com.haha.service.config.DeviceAlertProperties;
+import com.haha.service.notify.WeChatWorkNotifyService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 设备告警服务实现
+ */
+@Slf4j
+@Service
+public class DeviceAlertServiceImpl extends ServiceImpl<DeviceAlertRecordMapper, DeviceAlertRecord>
+        implements DeviceAlertService {
+
+    private static final String NOTIFY_CHANNEL = "WECHAT_WORK";
+    private static final int NOTIFY_SUCCESS = 1;
+    private static final int NOTIFY_FAIL = 2;
+    private static final int ALERT_LEVEL_NORMAL = 1;
+    private static final int ALERT_LEVEL_URGENT = 2;
+
+    @Autowired
+    private DeviceAlertRecordMapper deviceAlertRecordMapper;
+    @Autowired
+    private DeviceService deviceService;
+    @Autowired
+    private ShopService shopService;
+    @Autowired
+    private StringRedisTemplate stringRedisTemplate;
+    @Autowired
+    private WeChatWorkNotifyService weChatWorkNotifyService;
+    @Autowired
+    private DeviceAlertProperties properties;
+
+    @Override
+    public void processOfflineAlert(String deviceId, String triggerSource) {
+        try {
+            if (!properties.isEnabled()) {
+                log.debug("[设备告警] 告警功能已禁用,跳过离线告警处理 - deviceId: {}", deviceId);
+                return;
+            }
+
+            if (isInCooldown(deviceId, DeviceAlertType.OFFLINE)) {
+                log.debug("[设备告警] 设备离线告警处于冷却期,跳过 - deviceId: {}", deviceId);
+                return;
+            }
+
+            // 查询设备信息
+            Device device = deviceService.getDeviceBySn(deviceId);
+            if (device == null) {
+                log.warn("[设备告警] 设备不存在,跳过离线告警 - deviceId: {}", deviceId);
+                return;
+            }
+
+            String deviceName = device.getName() != null ? device.getName() : deviceId;
+            String shopName = getShopName(device.getShopId());
+            LocalDateTime alertTime = LocalDateTime.now();
+
+            // 构建告警记录
+            DeviceAlertRecord record = new DeviceAlertRecord();
+            record.setDeviceId(deviceId);
+            record.setShopId(device.getShopId());
+            record.setDeviceName(deviceName);
+            record.setAlertType(DeviceAlertType.OFFLINE.getCode());
+            record.setAlertLevel(ALERT_LEVEL_URGENT);
+            record.setAlertContent(String.format("设备[%s](%s)离线,所属门店:%s,触发来源:%s",
+                    deviceName, deviceId, shopName, triggerSource));
+            record.setTriggerSource(triggerSource);
+            record.setAlertTime(alertTime);
+            record.setNotifyChannel(NOTIFY_CHANNEL);
+
+            // 发送通知
+            int notifyStatus = NOTIFY_FAIL;
+            try {
+                weChatWorkNotifyService.sendOfflineAlert(deviceId, deviceName, shopName, triggerSource, alertTime);
+                notifyStatus = NOTIFY_SUCCESS;
+                log.info("[设备告警] 离线告警通知发送成功 - deviceId: {}", deviceId);
+            } catch (Exception e) {
+                log.error("[设备告警] 离线告警通知发送失败 - deviceId: {}", deviceId, e);
+            }
+
+            record.setNotifyStatus(notifyStatus);
+            this.save(record);
+
+            // 设置冷却期
+            setCooldown(deviceId, DeviceAlertType.OFFLINE);
+
+            // 在Redis中标记设备为离线状态(用于恢复上线判断)
+            String offlineKey = String.format(RedisConstants.DEVICE_LAST_ONLINE_KEY, deviceId);
+            stringRedisTemplate.opsForValue().set(offlineKey, alertTime.toString());
+
+            log.info("[设备告警] 离线告警处理完成 - deviceId: {}, notifyStatus: {}", deviceId, notifyStatus);
+        } catch (Exception e) {
+            log.error("[设备告警] 处理离线告警异常 - deviceId: {}", deviceId, e);
+        }
+    }
+
+    @Override
+    public void processWeakSignalAlert(String deviceId, Integer signalValue, String triggerSource) {
+        try {
+            if (!properties.isEnabled()) {
+                log.debug("[设备告警] 告警功能已禁用,跳过信号弱告警处理 - deviceId: {}", deviceId);
+                return;
+            }
+
+            if (signalValue == null || signalValue > properties.getWeakSignalThreshold()) {
+                log.debug("[设备告警] 信号值正常,跳过 - deviceId: {}, signalValue: {}, threshold: {}",
+                        deviceId, signalValue, properties.getWeakSignalThreshold());
+                return;
+            }
+
+            if (isInCooldown(deviceId, DeviceAlertType.WEAK_SIGNAL)) {
+                log.debug("[设备告警] 设备信号弱告警处于冷却期,跳过 - deviceId: {}", deviceId);
+                return;
+            }
+
+            // 查询设备信息
+            Device device = deviceService.getDeviceBySn(deviceId);
+            if (device == null) {
+                log.warn("[设备告警] 设备不存在,跳过信号弱告警 - deviceId: {}", deviceId);
+                return;
+            }
+
+            String deviceName = device.getName() != null ? device.getName() : deviceId;
+            String shopName = getShopName(device.getShopId());
+            LocalDateTime alertTime = LocalDateTime.now();
+
+            // 构建告警记录
+            DeviceAlertRecord record = new DeviceAlertRecord();
+            record.setDeviceId(deviceId);
+            record.setShopId(device.getShopId());
+            record.setDeviceName(deviceName);
+            record.setAlertType(DeviceAlertType.WEAK_SIGNAL.getCode());
+            record.setAlertLevel(ALERT_LEVEL_NORMAL);
+            record.setAlertContent(String.format("设备[%s](%s)信号弱,信号值:%d,所属门店:%s",
+                    deviceName, deviceId, signalValue, shopName));
+            record.setSignalValue(signalValue);
+            record.setTriggerSource(triggerSource);
+            record.setAlertTime(alertTime);
+            record.setNotifyChannel(NOTIFY_CHANNEL);
+
+            // 发送通知
+            int notifyStatus = NOTIFY_FAIL;
+            try {
+                weChatWorkNotifyService.sendWeakSignalAlert(deviceId, deviceName, shopName, signalValue, alertTime);
+                notifyStatus = NOTIFY_SUCCESS;
+                log.info("[设备告警] 信号弱告警通知发送成功 - deviceId: {}, signalValue: {}", deviceId, signalValue);
+            } catch (Exception e) {
+                log.error("[设备告警] 信号弱告警通知发送失败 - deviceId: {}", deviceId, e);
+            }
+
+            record.setNotifyStatus(notifyStatus);
+            this.save(record);
+
+            // 设置冷却期
+            setCooldown(deviceId, DeviceAlertType.WEAK_SIGNAL);
+
+            log.info("[设备告警] 信号弱告警处理完成 - deviceId: {}, signalValue: {}, notifyStatus: {}",
+                    deviceId, signalValue, notifyStatus);
+        } catch (Exception e) {
+            log.error("[设备告警] 处理信号弱告警异常 - deviceId: {}", deviceId, e);
+        }
+    }
+
+    @Override
+    public void processBackOnlineNotify(String deviceId) {
+        try {
+            if (!properties.isEnabled()) {
+                log.debug("[设备告警] 告警功能已禁用,跳过恢复上线通知 - deviceId: {}", deviceId);
+                return;
+            }
+
+            // 检查Redis中该设备是否有离线标记
+            String offlineKey = String.format(RedisConstants.DEVICE_LAST_ONLINE_KEY, deviceId);
+            Boolean hasOfflineMark = stringRedisTemplate.hasKey(offlineKey);
+            if (!Boolean.TRUE.equals(hasOfflineMark)) {
+                log.debug("[设备告警] 设备无离线标记,跳过恢复上线通知 - deviceId: {}", deviceId);
+                return;
+            }
+
+            // 查询设备信息
+            Device device = deviceService.getDeviceBySn(deviceId);
+            if (device == null) {
+                log.warn("[设备告警] 设备不存在,跳过恢复上线通知 - deviceId: {}", deviceId);
+                return;
+            }
+
+            String deviceName = device.getName() != null ? device.getName() : deviceId;
+            String shopName = getShopName(device.getShopId());
+            LocalDateTime now = LocalDateTime.now();
+
+            // 构建告警记录
+            DeviceAlertRecord record = new DeviceAlertRecord();
+            record.setDeviceId(deviceId);
+            record.setShopId(device.getShopId());
+            record.setDeviceName(deviceName);
+            record.setAlertType(DeviceAlertType.BACK_ONLINE.getCode());
+            record.setAlertLevel(ALERT_LEVEL_NORMAL);
+            record.setAlertContent(String.format("设备[%s](%s)已恢复上线,所属门店:%s", deviceName, deviceId, shopName));
+            record.setTriggerSource("SYSTEM");
+            record.setAlertTime(now);
+            record.setNotifyChannel(NOTIFY_CHANNEL);
+
+            // 发送通知
+            int notifyStatus = NOTIFY_FAIL;
+            try {
+                weChatWorkNotifyService.sendBackOnlineNotify(deviceId, deviceName, shopName, now);
+                notifyStatus = NOTIFY_SUCCESS;
+                log.info("[设备告警] 恢复上线通知发送成功 - deviceId: {}", deviceId);
+            } catch (Exception e) {
+                log.error("[设备告警] 恢复上线通知发送失败 - deviceId: {}", deviceId, e);
+            }
+
+            record.setNotifyStatus(notifyStatus);
+            this.save(record);
+
+            // 删除Redis中的离线标记
+            stringRedisTemplate.delete(offlineKey);
+
+            // 删除该设备的离线告警冷却key(设备已恢复,下次再离线应立即告警)
+            String cooldownKey = String.format(RedisConstants.DEVICE_ALERT_COOLDOWN_KEY,
+                    deviceId, DeviceAlertType.OFFLINE.getCode());
+            stringRedisTemplate.delete(cooldownKey);
+
+            log.info("[设备告警] 恢复上线通知处理完成 - deviceId: {}, notifyStatus: {}", deviceId, notifyStatus);
+        } catch (Exception e) {
+            log.error("[设备告警] 处理恢复上线通知异常 - deviceId: {}", deviceId, e);
+        }
+    }
+
+    /**
+     * 判断设备告警是否在冷却期内
+     */
+    private boolean isInCooldown(String deviceId, DeviceAlertType alertType) {
+        String key = String.format(RedisConstants.DEVICE_ALERT_COOLDOWN_KEY, deviceId, alertType.getCode());
+        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key));
+    }
+
+    /**
+     * 设置设备告警冷却期
+     */
+    private void setCooldown(String deviceId, DeviceAlertType alertType) {
+        String key = String.format(RedisConstants.DEVICE_ALERT_COOLDOWN_KEY, deviceId, alertType.getCode());
+        stringRedisTemplate.opsForValue().set(key, "1", properties.getCooldownMinutes(), TimeUnit.MINUTES);
+    }
+
+    /**
+     * 获取门店名称
+     */
+    private String getShopName(Long shopId) {
+        if (shopId == null) {
+            return "未关联门店";
+        }
+        try {
+            Shop shop = shopService.getById(shopId);
+            return shop != null ? shop.getName() : "未知门店";
+        } catch (Exception e) {
+            log.warn("[设备告警] 查询门店信息失败 - shopId: {}", shopId, e);
+            return "未知门店";
+        }
+    }
+}

+ 51 - 0
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -16,12 +16,14 @@ import com.haha.entity.CouponTemplate;
 import com.haha.mapper.DeviceMapper;
 import com.haha.mapper.OrderGoodsMapper;
 import com.haha.sdk.HahaClient;
+import com.haha.service.DeviceAlertService;
 import com.haha.service.HahaCallbackService;
 import com.haha.service.NewProductApplyService;
 import com.haha.service.OrderService;
 import com.haha.service.DoorRecordService;
 import com.haha.service.UserCouponService;
 import com.haha.service.CouponTemplateService;
+import com.haha.service.config.DeviceAlertProperties;
 import com.haha.service.payment.payscore.PayScoreResult;
 import com.haha.service.payment.payscore.PayScoreService;
 import org.springframework.data.redis.core.StringRedisTemplate;
@@ -79,6 +81,12 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
     @Autowired
     private CouponTemplateService couponTemplateService;
 
+    @Autowired
+    private DeviceAlertService deviceAlertService;
+
+    @Autowired
+    private DeviceAlertProperties alertProperties;
+
     private static final String DEVICE_STATUS_KEY = "haha:device:status:";
     private static final String RECOGNIZE_RESULT_KEY = "haha:recognize:result:";
     private static final String ORDER_INFO_KEY = "haha:order:info:";
@@ -174,6 +182,28 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
             String onlineKey = "device:online:" + deviceId;
             stringRedisTemplate.opsForValue().set(onlineKey, isOnline.toString(), 10, TimeUnit.MINUTES);
 
+            // === 新增:设备离线预警处理 ===
+            // 解析 signal_val(信号值数组)
+            List<Map<String, Object>> signalVals = null;
+            Object signalValObj = params.get("signal_val");
+            if (signalValObj instanceof List) {
+                signalVals = (List<Map<String, Object>>) signalValObj;
+            }
+            Integer latestSignalVal = extractLatestValue(signalVals);
+
+            if (isOnline == 0) {
+                // 设备离线 -> 触发离线告警
+                deviceAlertService.processOfflineAlert(deviceId, "CALLBACK");
+            } else if (isOnline == 1) {
+                // 设备上线 -> 检查是否需要发送恢复通知
+                deviceAlertService.processBackOnlineNotify(deviceId);
+
+                // 检查信号强度
+                if (latestSignalVal != null && latestSignalVal <= alertProperties.getWeakSignalThreshold()) {
+                    deviceAlertService.processWeakSignalAlert(deviceId, latestSignalVal, "CALLBACK");
+                }
+            }
+
         } catch (Exception e) {
             log.error("处理设备在线状态通知失败", e);
         }
@@ -815,6 +845,27 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
         }
     }
 
+    /**
+     * 从信号值/延迟值数组中提取最新的值
+     * 数据格式:[{"occur_time": 1573401600, "value": "1"}, ...]
+     */
+    private Integer extractLatestValue(List<Map<String, Object>> values) {
+        if (values == null || values.isEmpty()) {
+            return null;
+        }
+        try {
+            // 取最后一个(最新的)
+            Map<String, Object> latest = values.get(values.size() - 1);
+            Object val = latest.get("value");
+            if (val != null) {
+                return Integer.valueOf(val.toString());
+            }
+        } catch (Exception e) {
+            log.warn("解析信号值失败", e);
+        }
+        return null;
+    }
+
     private Integer parseInteger(Object value) {
         if (value == null) {
             return null;

+ 164 - 0
haha-service/src/main/java/com/haha/service/notify/WeChatWorkNotifyService.java

@@ -0,0 +1,164 @@
+package com.haha.service.notify;
+
+import com.haha.service.config.DeviceAlertProperties;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 企业微信群机器人通知服务
+ * 通过Webhook发送设备告警和恢复通知到企业微信群
+ */
+@Slf4j
+@Service
+public class WeChatWorkNotifyService {
+
+    private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+    private static final MediaType JSON_TYPE = MediaType.parse("application/json; charset=utf-8");
+
+    private final DeviceAlertProperties alertProperties;
+    private final OkHttpClient httpClient;
+
+    public WeChatWorkNotifyService(DeviceAlertProperties alertProperties) {
+        this.alertProperties = alertProperties;
+        this.httpClient = new OkHttpClient.Builder()
+                .connectTimeout(10, TimeUnit.SECONDS)
+                .readTimeout(30, TimeUnit.SECONDS)
+                .writeTimeout(30, TimeUnit.SECONDS)
+                .retryOnConnectionFailure(true)
+                .build();
+    }
+
+    /**
+     * 发送设备离线告警
+     *
+     * @param deviceId      设备编号
+     * @param deviceName    设备名称
+     * @param shopName      所属门店
+     * @param triggerSource 触发来源(如:定时巡检、心跳超时)
+     * @param alertTime     告警时间
+     */
+    public void sendOfflineAlert(String deviceId, String deviceName, String shopName,
+                                 String triggerSource, LocalDateTime alertTime) {
+        String content = "### ⚠️ 设备离线告警\n" +
+                "> **设备名称**:" + deviceName + "\n" +
+                "> **设备编号**:" + deviceId + "\n" +
+                "> **所属门店**:" + shopName + "\n" +
+                "> **告警类型**:<font color=\"warning\">设备离线</font>\n" +
+                "> **告警时间**:" + alertTime.format(DATETIME_FORMATTER) + "\n" +
+                "> **触发来源**:" + triggerSource + "\n" +
+                "> \n" +
+                "> 请及时检查设备网络连接情况";
+        sendMarkdownMessage(content);
+    }
+
+    /**
+     * 发送设备信号弱告警
+     *
+     * @param deviceId    设备编号
+     * @param deviceName  设备名称
+     * @param shopName    所属门店
+     * @param signalValue 信号值
+     * @param alertTime   告警时间
+     */
+    public void sendWeakSignalAlert(String deviceId, String deviceName, String shopName,
+                                    Integer signalValue, LocalDateTime alertTime) {
+        String content = "### ⚠️ 设备信号弱告警\n" +
+                "> **设备名称**:" + deviceName + "\n" +
+                "> **设备编号**:" + deviceId + "\n" +
+                "> **所属门店**:" + shopName + "\n" +
+                "> **告警类型**:<font color=\"warning\">信号弱</font>\n" +
+                "> **信号值**:" + signalValue + "\n" +
+                "> **告警时间**:" + alertTime.format(DATETIME_FORMATTER) + "\n" +
+                "> \n" +
+                "> 设备信号较弱,可能导致离线,请关注";
+        sendMarkdownMessage(content);
+    }
+
+    /**
+     * 发送设备恢复上线通知
+     *
+     * @param deviceId   设备编号
+     * @param deviceName 设备名称
+     * @param shopName   所属门店
+     * @param time       恢复时间
+     */
+    public void sendBackOnlineNotify(String deviceId, String deviceName, String shopName,
+                                     LocalDateTime time) {
+        String content = "### ✅ 设备恢复上线\n" +
+                "> **设备名称**:" + deviceName + "\n" +
+                "> **设备编号**:" + deviceId + "\n" +
+                "> **所属门店**:" + shopName + "\n" +
+                "> **状态**:<font color=\"info\">已恢复上线</font>\n" +
+                "> **恢复时间**:" + time.format(DATETIME_FORMATTER);
+        sendMarkdownMessage(content);
+    }
+
+    /**
+     * 发送Markdown消息到企业微信群机器人
+     *
+     * @param content Markdown格式的消息内容
+     */
+    public void sendMarkdownMessage(String content) {
+        if (!alertProperties.isEnabled()) {
+            log.debug("[企业微信通知] 设备告警已禁用,跳过发送");
+            return;
+        }
+
+        String webhookUrl = alertProperties.getWechatWork().getWebhookUrl();
+        if (!StringUtils.hasText(webhookUrl)) {
+            log.warn("[企业微信通知] 未配置企业微信Webhook地址,跳过发送");
+            return;
+        }
+
+        // 构建请求JSON(手动拼接,避免额外依赖)
+        String json = "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":" + escapeJson(content) + "}}";
+
+        RequestBody body = RequestBody.create(json, JSON_TYPE);
+        Request request = new Request.Builder()
+                .url(webhookUrl)
+                .post(body)
+                .build();
+
+        try (Response response = httpClient.newCall(request).execute()) {
+            ResponseBody responseBody = response.body();
+            String responseStr = responseBody != null ? responseBody.string() : "";
+
+            if (response.isSuccessful()) {
+                log.info("[企业微信通知] 消息发送成功,响应:{}", responseStr);
+            } else {
+                log.error("[企业微信通知] 消息发送失败,HTTP状态码:{},响应:{}", response.code(), responseStr);
+            }
+        } catch (IOException e) {
+            log.error("[企业微信通知] 消息发送异常", e);
+        }
+    }
+
+    /**
+     * 将字符串转义为JSON字符串值(带双引号)
+     */
+    private String escapeJson(String value) {
+        if (value == null) {
+            return "null";
+        }
+        StringBuilder sb = new StringBuilder("\"");
+        for (char c : value.toCharArray()) {
+            switch (c) {
+                case '"' -> sb.append("\\\"");
+                case '\\' -> sb.append("\\\\");
+                case '\n' -> sb.append("\\n");
+                case '\r' -> sb.append("\\r");
+                case '\t' -> sb.append("\\t");
+                default -> sb.append(c);
+            }
+        }
+        sb.append("\"");
+        return sb.toString();
+    }
+}