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:
22
pom.xml
22
pom.xml
@@ -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
54
sql/menu_device_sn.sql
Normal 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';
|
||||
54
src/main/java/com/youlai/boot/config/DataSourceConfig.java
Normal file
54
src/main/java/com/youlai/boot/config/DataSourceConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "设备基本信息")
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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 设备序列号
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user