feat(device): 添加MQTT设备在线状态管理与Redis容错机制
- 集成Spring Integration MQTT与Eclipse Paho客户端 - 添加设备在线状态填充逻辑,支持批量查询心跳数据 - 优化Redis配置,使用Lazy初始化与通用序列化器 - 增加Redis异常处理与防重复提交Fail-Open机制 - 新增Redis设备模块常量与在线状态VO - 完善MQTT与Redis集群连接配置
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package com.youlai.boot.device.controller;
|
||||
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import com.youlai.boot.device.model.vo.DeviceOnlineVO;
|
||||
import com.youlai.boot.device.service.DeviceOnlineService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备在线状态控制层
|
||||
* <p>
|
||||
* 基于 Redis 记录的设备在线/离线状态查询接口。
|
||||
*
|
||||
* @author TongTongStudio
|
||||
* @since 2026/8/8
|
||||
*/
|
||||
@Tag(name = "设备在线状态")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/device/online")
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
|
||||
public class DeviceOnlineController {
|
||||
|
||||
private final DeviceOnlineService deviceOnlineService;
|
||||
|
||||
@Operation(summary = "查询单个设备在线状态")
|
||||
@GetMapping("/status")
|
||||
public Result<DeviceOnlineVO> getOnlineStatus(
|
||||
@Parameter(description = "设备序列号") @RequestParam String sn) {
|
||||
return Result.success(deviceOnlineService.getOnlineInfo(sn));
|
||||
}
|
||||
|
||||
@Operation(summary = "批量查询设备在线状态")
|
||||
@PostMapping("/status/batch")
|
||||
public Result<Map<String, DeviceOnlineVO>> getOnlineStatusBatch(
|
||||
@Parameter(description = "设备序列号列表") @RequestBody List<String> sns) {
|
||||
return Result.success(deviceOnlineService.getOnlineStatusBySns(sns));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.youlai.boot.device.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备在线状态视图对象
|
||||
*
|
||||
* @author TongTongStudio
|
||||
* @since 2026/8/8
|
||||
*/
|
||||
@Schema(description = "设备在线状态")
|
||||
@Data
|
||||
public class DeviceOnlineVO {
|
||||
|
||||
@Schema(description = "设备序列号")
|
||||
private String serialno;
|
||||
|
||||
@Schema(description = "在线状态(1:在线;0:离线)")
|
||||
private Integer online;
|
||||
|
||||
@Schema(description = "最后心跳时间")
|
||||
private LocalDateTime lastHeartbeatTime;
|
||||
|
||||
@Schema(description = "设备 IP(可选)")
|
||||
private String ip;
|
||||
|
||||
@Schema(description = "屏幕状态(1:亮屏;0:熄屏;null:未知),由设备上报")
|
||||
private Integer screenState;
|
||||
|
||||
}
|
||||
@@ -40,6 +40,13 @@ public class DevicePageVO {
|
||||
@Schema(description="是否删除(1:删除;0:未删除)")
|
||||
private Integer isDelete;
|
||||
|
||||
@Schema(description="在线状态(1:在线;0:离线)")
|
||||
private Integer online;
|
||||
|
||||
@Schema(description="最后心跳时间")
|
||||
@JsonFormat(pattern = "yyyy/MM/dd HH:mm:ss")
|
||||
private LocalDateTime lastHeartbeatTime;
|
||||
|
||||
@Schema(description="激活时间")
|
||||
@JsonFormat(pattern = "yyyy/MM/dd HH:mm")
|
||||
private LocalDateTime activateTime;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.youlai.boot.device.service;
|
||||
|
||||
import com.youlai.boot.device.model.vo.DeviceOnlineVO;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备在线状态服务
|
||||
* <p>
|
||||
* 基于 Redis 记录设备在线/离线状态:
|
||||
* <ul>
|
||||
* <li>设备周期性上报心跳(经 MQTT 到达 {@code MqttMessageHandler}),每上报一次刷新 Redis key 的 TTL;</li>
|
||||
* <li>Redis key 使用带过期时间的 {@code SET key value EX timeout},超时未上报则 key 自动过期,即判定设备离线;</li>
|
||||
* <li>查询时 key 存在 = 在线,不存在 = 离线。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author TongTongStudio
|
||||
* @since 2026/8/8
|
||||
*/
|
||||
public interface DeviceOnlineService {
|
||||
|
||||
/**
|
||||
* 设备上报心跳,记录在线状态(刷新 Redis TTL)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @param ip 设备 IP(可为空)
|
||||
*/
|
||||
void reportHeartbeat(String sn, String ip);
|
||||
|
||||
/**
|
||||
* 设备上报心跳,记录在线状态(刷新 Redis TTL)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @param ip 设备 IP(可为空)
|
||||
* @param screenState 屏幕状态(1:亮屏;0:熄屏;null:未知),可为空
|
||||
*/
|
||||
void reportHeartbeat(String sn, String ip, Integer screenState);
|
||||
|
||||
/**
|
||||
* 设备主动离线(如 MQTT Last Will / 关机通知)
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
*/
|
||||
void markOffline(String sn);
|
||||
|
||||
/**
|
||||
* 查询单个设备是否在线
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @return true=在线, false=离线
|
||||
*/
|
||||
boolean isOnline(String sn);
|
||||
|
||||
/**
|
||||
* 获取单个设备在线状态信息
|
||||
*
|
||||
* @param sn 设备序列号
|
||||
* @return 在线状态信息(离线时 online=0)
|
||||
*/
|
||||
DeviceOnlineVO getOnlineInfo(String sn);
|
||||
|
||||
/**
|
||||
* 批量查询设备在线状态
|
||||
*
|
||||
* @param sns 设备序列号集合
|
||||
* @return key 为 SN,value 为在线状态信息
|
||||
*/
|
||||
Map<String, DeviceOnlineVO> getOnlineStatusBySns(List<String> sns);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.youlai.boot.device.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.config.property.MqttProperties;
|
||||
import com.youlai.boot.device.model.vo.DeviceOnlineVO;
|
||||
import com.youlai.boot.device.service.DeviceOnlineService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 设备在线状态服务实现
|
||||
* <p>
|
||||
* 该 Bean 始终注册(不依赖 MQTT 开关),保证分页列表等无条件组件可正常注入;
|
||||
* 设备在线状态通过 Redis key 的 TTL 自动判定,MQTT 心跳仅为在线状态的一个数据来源。
|
||||
*
|
||||
* @author TongTongStudio
|
||||
* @since 2026/8/8
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceOnlineServiceImpl implements DeviceOnlineService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final MqttProperties mqttProperties;
|
||||
|
||||
@Override
|
||||
public void reportHeartbeat(String sn, String ip) {
|
||||
reportHeartbeat(sn, ip, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportHeartbeat(String sn, String ip, Integer screenState) {
|
||||
if (StrUtil.isBlank(sn)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String key = RedisConstants.Device.ONLINE.replace("{}", sn);
|
||||
DeviceOnlineVO vo = getOnlineInfo(sn);
|
||||
if (vo == null) {
|
||||
vo = new DeviceOnlineVO();
|
||||
vo.setSerialno(sn);
|
||||
}
|
||||
vo.setOnline(1);
|
||||
vo.setLastHeartbeatTime(LocalDateTime.now());
|
||||
if (StrUtil.isNotBlank(ip)) {
|
||||
vo.setIp(ip);
|
||||
}
|
||||
// 仅在本次上报携带屏幕状态时更新,避免心跳覆盖屏幕状态
|
||||
if (screenState != null) {
|
||||
vo.setScreenState(screenState);
|
||||
}
|
||||
redisTemplate.opsForValue().set(key, vo, mqttProperties.getOnlineTimeout(), TimeUnit.SECONDS);
|
||||
log.debug("设备上报心跳, sn={}, ip={}, screenState={}, timeout={}s", sn, ip, screenState, mqttProperties.getOnlineTimeout());
|
||||
} catch (Exception e) {
|
||||
log.error("设备上报心跳写入 Redis 失败, sn={}", sn, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markOffline(String sn) {
|
||||
if (StrUtil.isBlank(sn)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String key = RedisConstants.Device.ONLINE.replace("{}", sn);
|
||||
redisTemplate.delete(key);
|
||||
log.info("设备标记离线, sn={}", sn);
|
||||
} catch (Exception e) {
|
||||
log.error("设备标记离线失败, sn={}", sn, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOnline(String sn) {
|
||||
if (StrUtil.isBlank(sn)) {
|
||||
return false;
|
||||
}
|
||||
String key = RedisConstants.Device.ONLINE.replace("{}", sn);
|
||||
Boolean exists = redisTemplate.hasKey(key);
|
||||
return Boolean.TRUE.equals(exists);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceOnlineVO getOnlineInfo(String sn) {
|
||||
if (StrUtil.isBlank(sn)) {
|
||||
return null;
|
||||
}
|
||||
String key = RedisConstants.Device.ONLINE.replace("{}", sn);
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
if (value instanceof DeviceOnlineVO vo) {
|
||||
return vo;
|
||||
}
|
||||
// 离线
|
||||
DeviceOnlineVO offline = new DeviceOnlineVO();
|
||||
offline.setSerialno(sn);
|
||||
offline.setOnline(0);
|
||||
return offline;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, DeviceOnlineVO> getOnlineStatusBySns(List<String> sns) {
|
||||
Map<String, DeviceOnlineVO> result = new LinkedHashMap<>();
|
||||
if (sns == null || sns.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> keys = new ArrayList<>(sns.size());
|
||||
for (String sn : sns) {
|
||||
if (StrUtil.isBlank(sn)) {
|
||||
continue;
|
||||
}
|
||||
String key = RedisConstants.Device.ONLINE.replace("{}", sn);
|
||||
keys.add(key);
|
||||
// 预置离线状态,供缺失 key 时填充
|
||||
DeviceOnlineVO offline = new DeviceOnlineVO();
|
||||
offline.setSerialno(sn);
|
||||
offline.setOnline(0);
|
||||
result.put(sn, offline);
|
||||
}
|
||||
try {
|
||||
List<Object> values = redisTemplate.opsForValue().multiGet(keys);
|
||||
if (values != null) {
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
Object value = values.get(i);
|
||||
String sn = sns.get(i);
|
||||
if (value instanceof DeviceOnlineVO vo) {
|
||||
result.put(sn, vo);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("批量查询设备在线状态失败", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,8 +24,10 @@ import com.youlai.boot.device.model.vo.DeviceNetworkInfoVO;
|
||||
import com.youlai.boot.device.model.vo.DeviceOtherInfoVO;
|
||||
import com.youlai.boot.device.model.vo.DeviceSecurityInfoVO;
|
||||
import com.youlai.boot.device.model.vo.DeviceSystemInfoVO;
|
||||
import com.youlai.boot.device.model.vo.DeviceOnlineVO;
|
||||
import com.youlai.boot.device.model.vo.DevicePageVO;
|
||||
import com.youlai.boot.device.service.DeveloperService;
|
||||
import com.youlai.boot.device.service.DeviceOnlineService;
|
||||
import com.youlai.boot.device.service.DeviceService;
|
||||
import com.youlai.boot.device.service.HardwareInfoService;
|
||||
import com.youlai.boot.device.service.NetworkInfoService;
|
||||
@@ -37,8 +39,10 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -66,6 +70,8 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> i
|
||||
|
||||
private final SystemInfoConverter systemInfoConverter;
|
||||
|
||||
private final DeviceOnlineService deviceOnlineService;
|
||||
|
||||
@Override
|
||||
public IPage<DevicePageVO> getSnPage(DeviceQuery queryParams) {
|
||||
// 参数构建
|
||||
@@ -77,7 +83,37 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> i
|
||||
queryParams.setIsRoot(isRoot);
|
||||
|
||||
// 查询数据
|
||||
return this.baseMapper.getSnPage(page, queryParams);
|
||||
IPage<DevicePageVO> result = this.baseMapper.getSnPage(page, queryParams);
|
||||
|
||||
// 填充设备在线状态(Redis)
|
||||
fillOnlineStatus(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充设备在线状态
|
||||
*/
|
||||
private void fillOnlineStatus(List<DevicePageVO> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> sns = new ArrayList<>(records.size());
|
||||
for (DevicePageVO record : records) {
|
||||
if (record.getSerialno() != null) {
|
||||
sns.add(record.getSerialno());
|
||||
}
|
||||
}
|
||||
Map<String, DeviceOnlineVO> onlineMap =
|
||||
deviceOnlineService.getOnlineStatusBySns(sns);
|
||||
for (DevicePageVO record : records) {
|
||||
DeviceOnlineVO online = onlineMap.get(record.getSerialno());
|
||||
if (online != null) {
|
||||
record.setOnline(online.getOnline());
|
||||
record.setLastHeartbeatTime(online.getLastHeartbeatTime());
|
||||
} else {
|
||||
record.setOnline(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
Reference in New Issue
Block a user