feat(device): 添加MQTT设备在线状态管理与Redis容错机制

- 集成Spring Integration MQTT与Eclipse Paho客户端
- 添加设备在线状态填充逻辑,支持批量查询心跳数据
- 优化Redis配置,使用Lazy初始化与通用序列化器
- 增加Redis异常处理与防重复提交Fail-Open机制
- 新增Redis设备模块常量与在线状态VO
- 完善MQTT与Redis集群连接配置
This commit is contained in:
TongTongStudio
2026-08-08 20:31:29 +08:00
parent e5e3f2c741
commit 927332ad5d
23 changed files with 1368 additions and 14 deletions

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 为 SNvalue 为在线状态信息
*/
Map<String, DeviceOnlineVO> getOnlineStatusBySns(List<String> sns);
}

View File

@@ -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;
}
}

View File

@@ -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