feat: 新增 TDengine 多数据源与 MQTT v5 客户端支持

- 新增 DataSourceConfig 配置类,支持 MySQL(主)与 TDengine(时序)多数据源,使用 Druid 手动配置以兼容 Spring Boot 4.x
- 重构 MQTT 配置,从 spring-integration-mqtt 迁移至 Paho MQTT v5 原生客户端,实现消息发布/订阅与优雅关闭
- 重构 MqttMessageHandler 与 MqttProducer,适配 v5 客户端回调,优化设备状态消息处理逻辑(online/offline 字段调整)
- 新增设备修改昵称接口(PUT /api/v1/devices/{id})与 TDengine 心跳最后在线时间查询接口
- 设备列表查询填充在线状态(Redis),增强设备管理功能
- 配置缓存预热失败时仅记录日志,避免数据库不可用导致应用启动失败
- 更新开发与生产环境配置,添加 TDengine 数据源及 MQTT 认证信息
This commit is contained in:
TongTongStudio
2026-08-12 08:26:30 +08:00
parent bc68004c6e
commit 035e21a432
14 changed files with 463 additions and 141 deletions

22
pom.xml
View File

@@ -64,9 +64,6 @@
<weixin-java-miniapp.version>4.8.1.B</weixin-java-miniapp.version>
<!-- Spring Integration MQTT 版本 (Spring Boot 4 / Spring Framework 7 配套版本) -->
<spring-integration.version>7.0.4</spring-integration.version>
<!-- Eclipse Paho MQTT 客户端版本 -->
<paho-client.version>1.2.5</paho-client.version>
@@ -188,9 +185,10 @@
<scope>runtime</scope>
</dependency>
<!-- Druid 数据库连接池Spring Boot 4.x 兼容:直接使用 druid 核心 + 手动配置数据源) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
@@ -338,20 +336,20 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<!-- Spring Integration MQTT (与 Spring Boot 4 / Spring Framework 7 配套的 7.x 版本) -->
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-mqtt</artifactId>
<version>${spring-integration.version}</version>
</dependency>
<!-- Eclipse Paho MQTT 客户端 (org.eclipse.paho.client.mqttv3) -->
<!-- Eclipse Paho MQTT v5 客户端 (org.eclipse.paho.mqttv5.client) -->
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<artifactId>org.eclipse.paho.mqttv5.client</artifactId>
<version>${paho-client.version}</version>
</dependency>
<!-- TDengine JDBC Driver -->
<dependency>
<groupId>com.taosdata.jdbc</groupId>
<artifactId>taos-jdbcdriver</artifactId>
<version>3.5.1</version>
</dependency>
</dependencies>
<build>

54
sql/menu_device_sn.sql Normal file
View File

@@ -0,0 +1,54 @@
-- ----------------------------------------------------------------------------
-- SN管理 菜单 & 按钮权限初始化脚本(可重复执行 / 幂等)
--
-- 依赖
-- 1. 前端页面 vue3-element-admin-ttstd/src/views/devices/sn/index.vue
-- 2. 后端接口 com.youlai.boot.device.controller.DeviceController (/api/v1/device)
--
-- 说明
-- - 菜单本身(type=M)不携带权限字符串,权限必须来自按钮行(type=B, perm 非空)
-- - 权限字符串中含冒号,部分客户端会把 :xxx 当成绑定变量,故本脚本用 CONCAT 拼接
-- 且授权改用「菜单下所有按钮」子查询SQL 文本中避免出现 :token
-- - 权限每次请求实时查库,执行后刷新页面即可,无需重启
-- ----------------------------------------------------------------------------
-- 0. 定位「SN管理」菜单按前端组件路径唯一标识
SET @menu_id = (SELECT `id` FROM `sys_menu` WHERE `component` = 'devices/sn/index' LIMIT 1);
-- 若菜单不存在则创建(挂到「设备调试」目录下;如目录 id 不同请自行调整 parent_id
SET @parent_id = (SELECT `id` FROM `sys_menu` WHERE `name` = '设备调试' AND `parent_id` = 0 LIMIT 1);
INSERT INTO `sys_menu` (`parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`,
`perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`,
`create_time`, `update_time`, `params`)
SELECT @parent_id, CONCAT('0,', @parent_id), 'SN管理', 'M', 'SnManagement', 'sn-management',
'devices/sn/index', NULL, NULL, 1, 1, 1, 'device', NULL, now(), now(), NULL
FROM DUAL
WHERE @parent_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `component` = 'devices/sn/index');
-- 重新取一次菜单 id可能刚创建
SET @menu_id = (SELECT `id` FROM `sys_menu` WHERE `component` = 'devices/sn/index' LIMIT 1);
-- 计算按钮的 tree_path = 菜单 tree_path + ',' + 菜单 id
SET @tree_path = (SELECT CONCAT(`tree_path`, ',', `id`) FROM `sys_menu` WHERE `id` = @menu_id);
-- 1. 插入按钮权限(不存在才插入);权限字符串用 CONCAT 拼接,避免 :token 被客户端误判
INSERT INTO `sys_menu` (`parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`,
`perm`, `always_show`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`,
`create_time`, `update_time`, `params`)
SELECT @menu_id, @tree_path, t.`name`, 'B', NULL, NULL, NULL,
t.`perm`, NULL, NULL, 1, t.`sort`, NULL, NULL, now(), now(), NULL
FROM (
SELECT '查询' AS `name`, CONCAT('sys', ':', 'device', ':', 'list') AS `perm`, 1 AS `sort` UNION ALL
SELECT '新增', CONCAT('sys', ':', 'device', ':', 'add'), 2 UNION ALL
SELECT '编辑', CONCAT('sys', ':', 'device', ':', 'edit'), 3 UNION ALL
SELECT '删除', CONCAT('sys', ':', 'device', ':', 'delete'), 4
) t
WHERE @menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`perm` = t.`perm`);
-- 2. 授权给 ADMIN 角色role_id = 2如实际角色 id 不同请修改)
-- 直接授权该菜单下的全部按钮,避免 SQL 文本中出现带冒号的权限字符串
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
SELECT 2, `id`
FROM `sys_menu`
WHERE `parent_id` = @menu_id AND `type` = 'B';

View File

@@ -0,0 +1,54 @@
package com.youlai.boot.config;
import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.sql.DataSource;
/**
* 多数据源配置
* <p>
* MySQL 为主数据源(@Primary供 MyBatis-Plus 使用),
* TDengine 为时序数据源,通过专属 {@code tdengineJdbcTemplate} 访问设备心跳数据。
* <p>
* 注意Spring Boot 4.x 下 Druid 1.2.24 的 spring-boot-starter 存在兼容性问题
* (依赖已移除的 DataSourceProperties 类),此处直接使用 DruidDataSource 手动配置。
*
* @author TongTongStudio
*/
@Configuration
public class DataSourceConfig {
// 1. MySQL 主数据源 (Druid)
@Primary
@Bean(name = "mysqlDataSource")
@ConfigurationProperties("spring.datasource.mysql")
public DataSource mysqlDataSource() {
return new DruidDataSource();
}
// 2. TDengine 时序数据源 (Druid 连接池 + Restful 驱动)
@Bean(name = "tdengineDataSource")
@ConfigurationProperties("spring.datasource.tdengine")
public DataSource tdengineDataSource() {
return new DruidDataSource();
}
// 3. 用于操作 MySQL 的 JdbcTemplate (可选)
@Primary
@Bean(name = "mysqlJdbcTemplate")
public JdbcTemplate mysqlJdbcTemplate(@Qualifier("mysqlDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
// 4. 用于操作 TDengine 的 JdbcTemplate
@Bean(name = "tdengineJdbcTemplate")
public JdbcTemplate tdengineJdbcTemplate(@Qualifier("tdengineDataSource") DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}

View File

@@ -1,53 +1,145 @@
package com.youlai.boot.config;
import com.youlai.boot.config.property.MqttProperties;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import com.youlai.boot.support.mqtt.MqttMessageHandler;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.paho.mqttv5.client.*;
import org.eclipse.paho.mqttv5.common.MqttException;
import org.eclipse.paho.mqttv5.common.MqttMessage;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.channel.DirectChannel;
import org.springframework.integration.core.MessageProducer;
import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory;
import org.springframework.integration.mqtt.inbound.MqttPahoMessageDrivenChannelAdapter;
import org.springframework.integration.mqtt.outbound.MqttPahoMessageHandler;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandler;
import jakarta.annotation.PreDestroy;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* MQTT 配置
* <p>
* 基于 spring-integration-mqtt (Eclipse Paho) 实现消息的发布与订阅。
* 基于 Eclipse Paho MQTT v5 原生客户端 (org.eclipse.paho.mqttv5.client) 实现消息的发布与订阅。
* 通过 {@code mqtt.enabled=false} 可整体关闭 MQTT 功能。
*
* @author TongTongStudio
* @since 2026/8/8
*/
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public class MqttConfig {
/**
* MQTT 连接工厂
* MQTT 客户端实例(保存引用,供 @PreDestroy 优雅关闭)
*/
@Bean
public DefaultMqttPahoClientFactory mqttClientFactory(MqttProperties properties) {
MqttConnectOptions options = new MqttConnectOptions();
options.setServerURIs(new String[]{normalizeServerUri(properties.getUrl())});
private IMqttClient client;
/**
* MQTT 客户端
* <p>同一客户端同时用于发布与订阅。连接成功后立即订阅配置的主题。</p>
*/
@Bean(destroyMethod = "disconnect")
public IMqttClient mqttClient(MqttProperties properties, MqttMessageHandler mqttMessageHandler) throws MqttException {
String serverUri = normalizeServerUri(properties.getUrl());
String clientId = properties.getClientId();
client = new MqttClient(serverUri, clientId);
MqttConnectionOptionsBuilder builder = new MqttConnectionOptionsBuilder()
.serverURI(serverUri)
.connectionTimeout(properties.getConnectionTimeout())
.keepAliveInterval(properties.getKeepAliveInterval())
.automaticReconnect(properties.isAutomaticReconnect())
.sessionExpiryInterval(TimeUnit.DAYS.toSeconds(1))
.cleanStart(false);
if (properties.getUsername() != null && !properties.getUsername().isEmpty()) {
options.setUserName(properties.getUsername());
builder.username(properties.getUsername());
}
if (properties.getPassword() != null && !properties.getPassword().isEmpty()) {
options.setPassword(properties.getPassword().toCharArray());
builder.password(properties.getPassword().getBytes(StandardCharsets.UTF_8));
}
options.setConnectionTimeout(properties.getConnectionTimeout());
options.setKeepAliveInterval(properties.getKeepAliveInterval());
options.setAutomaticReconnect(properties.isAutomaticReconnect());
options.setCleanSession(true);
DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
factory.setConnectionOptions(options);
return factory;
MqttConnectionOptions options = builder.build();
// 消息到达回调:分发到业务处理器
client.setCallback(new org.eclipse.paho.mqttv5.client.MqttCallback() {
@Override
public void messageArrived(String topic, MqttMessage message) {
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
mqttMessageHandler.handleMessage(topic, payload);
}
@Override
public void deliveryComplete(IMqttToken iMqttToken) {
}
@Override
public void disconnected(org.eclipse.paho.mqttv5.client.MqttDisconnectResponse disconnectResponse) {
log.warn("MQTT 连接断开: reasonCode={}, reasonString={}",
disconnectResponse.getReturnCode(), disconnectResponse.getReasonString());
}
@Override
public void mqttErrorOccurred(MqttException exception) {
log.error("MQTT 发生异常", exception);
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
log.info("MQTT 连接成功, reconnect={}, serverURI={}", reconnect, serverURI);
// 连接/重连成功后订阅主题
subscribeTopics(client, properties);
}
@Override
public void authPacketArrived(int i, org.eclipse.paho.mqttv5.common.packet.MqttProperties mqttProperties) {
}
});
client.connect(options);
log.info("MQTT 客户端已启动, serverURI={}, clientId={}", serverUri, clientId);
// 首次连接成功后订阅主题(重连场景由 connectComplete 处理)
if (client.isConnected()) {
subscribeTopics(client, properties);
}
return client;
}
/**
* 应用关闭时优雅停止 MQTT 客户端。
* <p>先 {@code disconnect} 断开连接,再 {@code close} 释放 Paho 后台线程池,
* 避免容器已销毁Redisson 已 shutdown后回调线程仍在处理消息而报错。</p>
*/
@PreDestroy
public void destroy() {
try {
if (client != null) {
if (client.isConnected()) {
client.disconnect();
}
client.close();
}
} catch (MqttException e) {
log.warn("关闭 MQTT 客户端失败", e);
}
}
/**
* 订阅配置中的所有主题
*/
private void subscribeTopics(IMqttClient client, MqttProperties properties) {
for (String topic : properties.getTopics()) {
try {
client.subscribe(topic, properties.getQos());
log.info("已订阅 MQTT 主题: {}", topic);
} catch (MqttException e) {
log.error("订阅 MQTT 主题失败: {}", topic, e);
}
}
}
/**
@@ -67,51 +159,4 @@ public class MqttConfig {
}
return uri;
}
/* ============================ 发布 (Publish) ============================ */
/**
* 发布消息的出站通道
*/
@Bean
public MessageChannel mqttOutboundChannel() {
return new DirectChannel();
}
/**
* 发布消息处理器,通过 {@link org.springframework.messaging.support.MessageBuilder} 发送到出站通道即可发布
*/
@Bean
@ServiceActivator(inputChannel = "mqttOutboundChannel")
public MessageHandler mqttOutbound(DefaultMqttPahoClientFactory factory, MqttProperties properties) {
MqttPahoMessageHandler handler = new MqttPahoMessageHandler(properties.getClientId() + "-pub", factory);
handler.setAsync(false);
handler.setDefaultQos(properties.getQos());
return handler;
}
/* ============================ 订阅 (Subscribe) ============================ */
/**
* 订阅消息的入站通道
*/
@Bean
public MessageChannel mqttInboundChannel() {
return new DirectChannel();
}
/**
* 入站订阅适配器:订阅 {@link MqttProperties#getTopics()} 中配置的主题
*/
@Bean
public MessageProducer mqttInbound(DefaultMqttPahoClientFactory factory, MqttProperties properties) {
String[] topics = properties.getTopics().toArray(new String[0]);
MqttPahoMessageDrivenChannelAdapter adapter =
new MqttPahoMessageDrivenChannelAdapter(
properties.getClientId() + "-sub", factory, topics);
adapter.setCompletionTimeout(5000L);
adapter.setQos(properties.getQos());
adapter.setOutputChannel(mqttInboundChannel());
return adapter;
}
}

View File

@@ -30,6 +30,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -82,6 +83,18 @@ public class DeviceController {
return Result.success();
}
@Operation(summary = "修改设备(仅允许修改设备昵称)")
@PutMapping("/{id}")
public Result<Void> updateDevice(
@Parameter(description = "设备ID") @PathVariable Long id,
@RequestBody SnDeviceInfo device) {
boolean success = deviceService.updateSn(id, device.getSnName());
if (!success) {
return Result.failed("设备不存在或修改失败");
}
return Result.success();
}
// ===================== 设备系统信息(分面板独立接口) =====================
@Operation(summary = "设备基本信息")

View File

@@ -1,13 +1,16 @@
package com.youlai.boot.device.controller;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.device.model.vo.DeviceHeartbeatVO;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -15,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Timestamp;
import java.util.List;
import java.util.Map;
@@ -29,12 +33,21 @@ import java.util.Map;
@Tag(name = "设备在线状态")
@RestController
@RequestMapping("/api/v1/device/online")
@RequiredArgsConstructor
@Slf4j
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public class DeviceOnlineController {
private final DeviceOnlineService deviceOnlineService;
private final JdbcTemplate tdengineJdbcTemplate;
public DeviceOnlineController(
DeviceOnlineService deviceOnlineService,
@Qualifier("tdengineJdbcTemplate") JdbcTemplate tdengineJdbcTemplate) {
this.deviceOnlineService = deviceOnlineService;
this.tdengineJdbcTemplate = tdengineJdbcTemplate;
}
@Operation(summary = "查询单个设备在线状态")
@GetMapping("/status")
public Result<DeviceOnlineVO> getOnlineStatus(
@@ -49,4 +62,35 @@ public class DeviceOnlineController {
return Result.success(deviceOnlineService.getOnlineStatusBySns(sns));
}
@Operation(summary = "通过SN查询设备最后在线时间TDengine心跳")
@GetMapping("/heartbeat/last")
public Result<DeviceHeartbeatVO> getLastHeartbeat(
@Parameter(description = "设备序列号") @RequestParam String sn) {
if (sn == null || sn.isBlank()) {
return Result.failed("设备SN不能为空");
}
// 从超级表 device_heartbeat 中按 tag sn 取最后一条记录
String sql = "SELECT ts, online, ip, rssi, screen_on FROM device_heartbeat WHERE sn = ? ORDER BY ts DESC LIMIT 1";
try {
List<DeviceHeartbeatVO> list = tdengineJdbcTemplate.query(sql, (rs, rowNum) -> {
DeviceHeartbeatVO vo = new DeviceHeartbeatVO();
vo.setSn(sn);
Timestamp ts = rs.getTimestamp("ts");
vo.setLastTime(ts != null ? ts.toLocalDateTime() : null);
vo.setOnline(rs.getObject("online") != null ? (rs.getBoolean("online") ? 1 : 0) : null);
vo.setIp(rs.getString("ip"));
vo.setRssi(rs.getObject("rssi") != null ? rs.getInt("rssi") : null);
vo.setScreenOn(rs.getObject("screen_on") != null ? (rs.getBoolean("screen_on") ? 1 : 0) : null);
return vo;
}, sn);
if (list.isEmpty()) {
return Result.success(null);
}
return Result.success(list.get(0));
} catch (Exception e) {
log.error("查询设备最后在线时间失败, sn={}", sn, e);
return Result.failed("查询设备心跳失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,36 @@
package com.youlai.boot.device.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备心跳TDengine视图对象
*
* @author TongTongStudio
* @since 2026/8/11
*/
@Schema(description = "设备心跳信息")
@Data
public class DeviceHeartbeatVO {
@Schema(description = "设备序列号")
private String sn;
@Schema(description = "最后上报时间戳")
private LocalDateTime lastTime;
@Schema(description = "最后在线状态(1:在线;0:离线)")
private Integer online;
@Schema(description = "设备 IP")
private String ip;
@Schema(description = "信号强度")
private Integer rssi;
@Schema(description = "屏幕状态(1:亮屏;0:熄屏)")
private Integer screenOn;
}

View File

@@ -75,6 +75,14 @@ public interface DeviceService extends IService<SnDeviceInfo> {
*/
boolean addSn(SnDeviceInfo device);
/**
* 更新设备信息(仅允许修改设备昵称)
* @param id 设备ID
* @param snName 设备昵称
* @return 是否成功
*/
boolean updateSn(Long id, String snName);
/**
* 删除设备
* @param sn 设备序列号

View File

@@ -131,6 +131,15 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> i
DevicePageVO devicePageVO = new DevicePageVO();
BeanUtils.copyProperties(device, devicePageVO);
// 填充设备在线状态Redis
DeviceOnlineVO online = deviceOnlineService.getOnlineInfo(sn);
if (online != null) {
devicePageVO.setOnline(online.getOnline());
devicePageVO.setLastHeartbeatTime(online.getLastHeartbeatTime());
} else {
devicePageVO.setOnline(0);
}
return devicePageVO;
}
@@ -202,6 +211,24 @@ public class DeviceServiceImpl extends ServiceImpl<DeviceMapper, SnDeviceInfo> i
return saved;
}
@Override
public boolean updateSn(Long id, String snName) {
if (id == null) {
return false;
}
SnDeviceInfo device = this.getById(id);
if (device == null) {
log.warn("更新设备失败,设备不存在, id: {}", id);
return false;
}
device.setSnName(snName);
boolean updated = this.updateById(device);
if (!updated) {
log.error("更新设备失败, id: {}, snName: {}", id, snName);
}
return updated;
}
@Override
public boolean deleteSn(String sn) {
return false;

View File

@@ -7,28 +7,20 @@ import com.youlai.boot.config.property.MqttProperties;
import com.youlai.boot.device.service.DeviceOnlineService;
import com.youlai.boot.support.sse.SseService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.integration.annotation.ServiceActivator;
import org.springframework.integration.mqtt.support.MqttHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHandler;
import org.springframework.messaging.MessagingException;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* MQTT 订阅消息处理器
* <p>
* 消费 {@code mqttInboundChannel} 通道中的消息,根据主题分发处理。
* {@link com.youlai.boot.config.MqttConfig} 中 v5 客户端的
* {@code messageArrived} 回调调用,根据主题分发处理消息。
*
* @author TongTongStudio
* @since 2026/8/8
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public class MqttMessageHandler implements MessageHandler {
public class MqttMessageHandler {
private final DeviceOnlineService deviceOnlineService;
private final MqttProperties mqttProperties;
@@ -45,16 +37,10 @@ public class MqttMessageHandler implements MessageHandler {
/**
* 处理从 MQTT Broker 订阅到的消息
*
* @param message MQTT 入站消息,可通过 {@link MqttHeaders#RECEIVED_TOPIC} 获取主题
* @param topic 消息主题
* @param payload 消息内容UTF-8 字符串)
*/
@ServiceActivator(inputChannel = "mqttInboundChannel")
@Override
public void handleMessage(Message<?> message) throws MessagingException {
String topic = message.getHeaders().get(MqttHeaders.RECEIVED_TOPIC, String.class);
String payload = message.getPayload() instanceof byte[]
? new String((byte[]) message.getPayload(), StandardCharsets.UTF_8)
: String.valueOf(message.getPayload());
public void handleMessage(String topic, String payload) {
log.info("收到 MQTT 消息, topic={}, payload={}", topic, payload);
// 根据业务需要,按主题分发处理
@@ -69,7 +55,7 @@ public class MqttMessageHandler implements MessageHandler {
/**
* 处理设备状态上报(心跳/屏幕状态),并同步更新 Redis 中的在线状态
* <p>
* 消息体为 JSON格式{"sn":"xxx", "status":"online|offline", "ip":"1.2.3.4", "screenOn":true|false}
* 消息体为 JSON格式{"sn":"xxx", "online":"true online|false offline", "ip":"1.2.3.4", "screenOn":true|false}
* 其中 screenOn 为可选字段,表示设备亮屏(亮屏=屏幕点亮)状态。
*/
private void handleDeviceStatus(String payload) {
@@ -84,7 +70,7 @@ public class MqttMessageHandler implements MessageHandler {
log.warn("设备状态上报缺少 sn 字段, payload={}", payload);
return;
}
String status = json.getStr("status");
boolean online = json.getBool("online");
String ip = json.getStr("ip");
// 解析屏幕状态可选true=亮屏, false=熄屏, 缺省=null未知
@@ -93,7 +79,7 @@ public class MqttMessageHandler implements MessageHandler {
screenState = json.getBool("screenOn") ? 1 : 0;
}
if ("offline".equalsIgnoreCase(status)) {
if (!online) {
// 设备主动上报离线(如关机/退出),立即标记离线
deviceOnlineService.markOffline(sn);
} else {

View File

@@ -1,19 +1,20 @@
package com.youlai.boot.support.mqtt;
import com.youlai.boot.config.property.MqttProperties;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.paho.mqttv5.client.IMqttClient;
import org.eclipse.paho.mqttv5.common.MqttException;
import org.eclipse.paho.mqttv5.common.MqttMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.integration.core.MessagingTemplate;
import org.springframework.integration.mqtt.support.MqttHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
/**
* MQTT 消息发布服务
* <p>
* 提供向指定主题发布消息的能力,消息经过 {@code mqttOutboundChannel} 通道发送到 MQTT Broker。
* 基于 Eclipse Paho MQTT v5 原生客户端发布消息,直接通过 {@link IMqttClient} 发送到 Broker。
* 当 {@code mqtt.enabled=false} 时该 Bean 不创建。
*
* @author TongTongStudio
@@ -24,15 +25,17 @@ import org.springframework.stereotype.Component;
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public class MqttProducer {
private final MessagingTemplate messagingTemplate;
private final IMqttClient mqttClient;
private final MqttProperties mqttProperties;
@Autowired
public MqttProducer(MessageChannel mqttOutboundChannel) {
this.messagingTemplate = new MessagingTemplate(mqttOutboundChannel);
public MqttProducer(IMqttClient mqttClient, MqttProperties mqttProperties) {
this.mqttClient = mqttClient;
this.mqttProperties = mqttProperties;
}
/**
* 发布消息
* 发布消息(使用默认 QoS
*
* @param topic 主题
* @param payload 消息内容(字符串)
@@ -49,14 +52,19 @@ public class MqttProducer {
* @param qos QoS 级别,为 null 时使用默认 QoS
*/
public void publish(String topic, String payload, Integer qos) {
MessageBuilder<String> builder = MessageBuilder.withPayload(payload)
.setHeader(MqttHeaders.TOPIC, topic);
if (qos != null) {
builder.setHeader(MqttHeaders.QOS, qos);
if (mqttClient == null || !mqttClient.isConnected()) {
log.warn("MQTT 客户端未连接,无法发布消息, topic={}", topic);
return;
}
int useQos = qos != null ? qos : mqttProperties.getQos();
try {
MqttMessage message = new MqttMessage(payload.getBytes(StandardCharsets.UTF_8));
message.setQos(useQos);
mqttClient.publish(topic, message);
log.info("发布 MQTT 消息, topic={}, qos={}, payload={}", topic, useQos, payload);
} catch (MqttException e) {
log.error("发布 MQTT 消息失败, topic={}", topic, e);
}
Message<String> message = builder.build();
messagingTemplate.send(message);
log.info("发布 MQTT 消息, topic={}, payload={}", topic, payload);
}
}

View File

@@ -14,6 +14,7 @@ import com.youlai.boot.system.model.vo.ConfigVO;
import com.youlai.boot.system.service.ConfigService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@@ -29,6 +30,7 @@ import java.util.stream.Collectors;
* @author Theo
* @since 2024-07-29 11:17:26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ConfigServiceImpl extends ServiceImpl<ConfigMapper, Config> implements ConfigService {
@@ -39,10 +41,15 @@ public class ConfigServiceImpl extends ServiceImpl<ConfigMapper, Config> impleme
/**
* 系统启动完成后,加载系统配置到缓存
* <p>预热失败仅记录告警,不阻断应用启动(避免数据库临时不可用时整站起不来)。</p>
*/
@PostConstruct
public void init() {
refreshCache();
try {
refreshCache();
} catch (Exception e) {
log.error("系统配置缓存预热失败,应用将继续启动(配置相关接口首次访问时再加载)", e);
}
}
/**

View File

@@ -7,11 +7,32 @@ spring:
enabled: true # 开启虚拟线程
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong
mysql:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong
# TDengine 设备心跳时序数据库配置
tdengine:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
# JDBC 地址RESTful 方式TAOS-RS://host:6041/db无需本地客户端库
url: jdbc:TAOS-RS://175.178.213.60:6041/mqtt
username: tt
password: fht19961207..
initial-size: 1
min-idle: 1
max-active: 8
# 获取连接最大等待时间(毫秒)
max-wait: 10000
# 空闲连接检测,避免使用 TDengine 不支持的默认 SELECT 1 校验
test-while-idle: true
test-on-borrow: false
test-on-return: false
validation-query: select server_status()
time-between-eviction-runs-millis: 60000
data:
redis:
@@ -272,10 +293,10 @@ mqtt:
# Broker 地址
url: mqtt://175.178.213.60:1883
# 用户名/密码(按需配置)
username:
password:
username: tt
password: fanhuitong
# 客户端 ID
client-id: youlai-boot-server
client-id: youlai_boot_server
# 默认 QoS
qos: 1
connection-timeout: 10

View File

@@ -3,11 +3,32 @@ server:
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong
mysql:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
username: root
password: fanhuitong
# TDengine 设备心跳时序数据库配置
tdengine:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
# JDBC 地址RESTful 方式TAOS-RS://host:6041/db无需本地客户端库
url: jdbc:TAOS-RS://175.178.213.60:6041/mqtt
username: tt
password: fht19961207..
initial-size: 1
min-idle: 1
max-active: 8
# 获取连接最大等待时间(毫秒)
max-wait: 10000
# 空闲连接检测,避免使用 TDengine 不支持的默认 SELECT 1 校验
test-while-idle: true
test-on-borrow: false
test-on-return: false
validation-query: select server_status()
time-between-eviction-runs-millis: 60000
data:
redis:
database: 1
@@ -253,8 +274,8 @@ mqtt:
# Broker 地址
url: mqtt://175.178.213.60:1883
# 用户名/密码(按需配置)
username:
password:
username: tt
password: fanhuitong
# 客户端 ID
client-id: youlai-boot-server
# 默认 QoS