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

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