diff --git a/pom.xml b/pom.xml index 5cec8b87..f2ce1adf 100644 --- a/pom.xml +++ b/pom.xml @@ -64,9 +64,6 @@ 4.8.1.B - - 7.0.4 - 1.2.5 @@ -188,9 +185,10 @@ runtime + com.alibaba - druid-spring-boot-starter + druid ${druid.version} @@ -338,20 +336,20 @@ org.springframework.boot spring-boot-starter-integration - - - org.springframework.integration - spring-integration-mqtt - ${spring-integration.version} - - + org.eclipse.paho - org.eclipse.paho.client.mqttv3 + org.eclipse.paho.mqttv5.client ${paho-client.version} + + + com.taosdata.jdbc + taos-jdbcdriver + 3.5.1 + diff --git a/sql/menu_device_sn.sql b/sql/menu_device_sn.sql new file mode 100644 index 00000000..7797c7ff --- /dev/null +++ b/sql/menu_device_sn.sql @@ -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'; diff --git a/src/main/java/com/youlai/boot/config/DataSourceConfig.java b/src/main/java/com/youlai/boot/config/DataSourceConfig.java new file mode 100644 index 00000000..6151d511 --- /dev/null +++ b/src/main/java/com/youlai/boot/config/DataSourceConfig.java @@ -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; + +/** + * 多数据源配置 + *

+ * MySQL 为主数据源(@Primary,供 MyBatis-Plus 使用), + * TDengine 为时序数据源,通过专属 {@code tdengineJdbcTemplate} 访问设备心跳数据。 + *

+ * 注意: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); + } +} diff --git a/src/main/java/com/youlai/boot/config/MqttConfig.java b/src/main/java/com/youlai/boot/config/MqttConfig.java index 826941ee..3be1621a 100644 --- a/src/main/java/com/youlai/boot/config/MqttConfig.java +++ b/src/main/java/com/youlai/boot/config/MqttConfig.java @@ -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 配置 *

- * 基于 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 客户端 + *

同一客户端同时用于发布与订阅。连接成功后立即订阅配置的主题。

+ */ + @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 客户端。 + *

先 {@code disconnect} 断开连接,再 {@code close} 释放 Paho 后台线程池, + * 避免容器已销毁(Redisson 已 shutdown)后回调线程仍在处理消息而报错。

+ */ + @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; - } } diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceController.java b/src/main/java/com/youlai/boot/device/controller/DeviceController.java index 69cef2f2..cb545bd4 100644 --- a/src/main/java/com/youlai/boot/device/controller/DeviceController.java +++ b/src/main/java/com/youlai/boot/device/controller/DeviceController.java @@ -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 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 = "设备基本信息") diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceOnlineController.java b/src/main/java/com/youlai/boot/device/controller/DeviceOnlineController.java index 110ee6ec..09cb1919 100644 --- a/src/main/java/com/youlai/boot/device/controller/DeviceOnlineController.java +++ b/src/main/java/com/youlai/boot/device/controller/DeviceOnlineController.java @@ -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 getOnlineStatus( @@ -49,4 +62,35 @@ public class DeviceOnlineController { return Result.success(deviceOnlineService.getOnlineStatusBySns(sns)); } + @Operation(summary = "通过SN查询设备最后在线时间(TDengine心跳)") + @GetMapping("/heartbeat/last") + public Result 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 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()); + } + } + } diff --git a/src/main/java/com/youlai/boot/device/model/vo/DeviceHeartbeatVO.java b/src/main/java/com/youlai/boot/device/model/vo/DeviceHeartbeatVO.java new file mode 100644 index 00000000..25a01a9c --- /dev/null +++ b/src/main/java/com/youlai/boot/device/model/vo/DeviceHeartbeatVO.java @@ -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; + +} diff --git a/src/main/java/com/youlai/boot/device/service/DeviceService.java b/src/main/java/com/youlai/boot/device/service/DeviceService.java index 9ff508d5..04b0ef7b 100644 --- a/src/main/java/com/youlai/boot/device/service/DeviceService.java +++ b/src/main/java/com/youlai/boot/device/service/DeviceService.java @@ -75,6 +75,14 @@ public interface DeviceService extends IService { */ boolean addSn(SnDeviceInfo device); + /** + * 更新设备信息(仅允许修改设备昵称) + * @param id 设备ID + * @param snName 设备昵称 + * @return 是否成功 + */ + boolean updateSn(Long id, String snName); + /** * 删除设备 * @param sn 设备序列号 diff --git a/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java b/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java index dcf1eef9..ef90c182 100644 --- a/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java +++ b/src/main/java/com/youlai/boot/device/service/impl/DeviceServiceImpl.java @@ -131,6 +131,15 @@ public class DeviceServiceImpl extends ServiceImpl 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 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; diff --git a/src/main/java/com/youlai/boot/support/mqtt/MqttMessageHandler.java b/src/main/java/com/youlai/boot/support/mqtt/MqttMessageHandler.java index 64eb060a..b9bc5905 100644 --- a/src/main/java/com/youlai/boot/support/mqtt/MqttMessageHandler.java +++ b/src/main/java/com/youlai/boot/support/mqtt/MqttMessageHandler.java @@ -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 订阅消息处理器 *

- * 消费 {@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 中的在线状态 *

- * 消息体为 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 { diff --git a/src/main/java/com/youlai/boot/support/mqtt/MqttProducer.java b/src/main/java/com/youlai/boot/support/mqtt/MqttProducer.java index 129fbef3..b1bcef38 100644 --- a/src/main/java/com/youlai/boot/support/mqtt/MqttProducer.java +++ b/src/main/java/com/youlai/boot/support/mqtt/MqttProducer.java @@ -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 消息发布服务 *

- * 提供向指定主题发布消息的能力,消息经过 {@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 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 message = builder.build(); - messagingTemplate.send(message); - log.info("发布 MQTT 消息, topic={}, payload={}", topic, payload); } } diff --git a/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java index 1702bfc7..17a4d5a3 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/ConfigServiceImpl.java @@ -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 implements ConfigService { @@ -39,10 +41,15 @@ public class ConfigServiceImpl extends ServiceImpl impleme /** * 系统启动完成后,加载系统配置到缓存 + *

预热失败仅记录告警,不阻断应用启动(避免数据库临时不可用时整站起不来)。

*/ @PostConstruct public void init() { - refreshCache(); + try { + refreshCache(); + } catch (Exception e) { + log.error("系统配置缓存预热失败,应用将继续启动(配置相关接口首次访问时再加载)", e); + } } /** diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 896bc358..3115e336 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -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 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 1c7ae635..a9d319c9 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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