mirror of
https://gitee.com/yudaocode/yudao-boot-mini.git
synced 2026-03-22 05:27:15 +08:00
Merge branch 'master' of https://gitee.com/zhijiantianya/ruoyi-vue-pro
# Conflicts: # yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java # yudao-module-ai/pom.xml # yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/chat/AiChatMessageDO.java # yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/dataobject/model/AiApiKeyDO.java # yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/dal/mysql/knowledge/AiKnowledgeSegmentMapper.java # yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java # yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java # yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java # yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentItemMapper.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinanceReceiptItemMapper.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInItemMapper.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseReturnItemMapper.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleOutItemMapper.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleReturnItemMapper.java # yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/stock/ErpStockMapper.java # yudao-module-iot/yudao-module-iot-biz/pom.xml # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceController.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceImportExcelVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductSaveReqVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcher.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java # yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java # yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java # yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceAuthReqDTO.java # yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java # yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java # yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java # yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java # yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java # yudao-module-iot/yudao-module-iot-gateway/pom.xml # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java # yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java # yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml # yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/tcp-binary-packet-examples.md # yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/tcp-json-packet-examples.md # yudao-module-mall/yudao-module-product/src/main/java/cn/iocoder/yudao/module/product/service/sku/ProductSkuServiceImpl.java # yudao-module-mall/yudao-module-product/src/main/java/cn/iocoder/yudao/module/product/service/spu/ProductSpuServiceImpl.java # yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java # yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserCreateReqVO.java # yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java # yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/framework/order/core/aop/TradeOrderLogAspect.java # yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java # yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageAddReqBO.java # yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java # yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java # yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java # yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImplTest.java
This commit is contained in:
2
pom.xml
2
pom.xml
@@ -33,7 +33,7 @@
|
||||
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
|
||||
|
||||
<properties>
|
||||
<revision>2025.12-jdk8-SNAPSHOT</revision>
|
||||
<revision>2026.01-jdk8-SNAPSHOT</revision>
|
||||
<!-- Maven 相关 -->
|
||||
<java.version>1.8</java.version>
|
||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
|
||||
|
||||
<properties>
|
||||
<revision>2025.12-jdk8-SNAPSHOT</revision>
|
||||
<revision>2026.01-jdk8-SNAPSHOT</revision>
|
||||
<flatten-maven-plugin.version>1.6.0</flatten-maven-plugin.version>
|
||||
<!-- 统一依赖管理 -->
|
||||
<spring.framework.version>5.3.39</spring.framework.version>
|
||||
@@ -70,6 +70,8 @@
|
||||
<netty.version>4.2.9.Final</netty.version>
|
||||
<mqtt.version>1.2.5</mqtt.version>
|
||||
<vertx.version>4.5.22</vertx.version>
|
||||
<okhttp.version>4.12.0</okhttp.version>
|
||||
<californium.version>3.12.0</californium.version>
|
||||
<!-- 三方云服务相关 -->
|
||||
<awssdk.version>2.40.15</awssdk.version>
|
||||
<justauth.version>1.16.7</justauth.version>
|
||||
@@ -564,6 +566,50 @@
|
||||
<version>${jsoup.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Vert.x -->
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-core</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-web</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-mqtt</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MQTT -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.paho</groupId>
|
||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
||||
<version>${mqtt.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OkHttp -->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>${okhttp.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>${okhttp.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- CoAP - Eclipse Californium -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.californium</groupId>
|
||||
<artifactId>californium-core</artifactId>
|
||||
<version>${californium.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 三方云服务相关 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
@@ -638,42 +684,6 @@
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- Vert.x -->
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-core</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-web</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
<artifactId>vertx-mqtt</artifactId>
|
||||
<version>${vertx.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MQTT -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.paho</groupId>
|
||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
||||
<version>${mqtt.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 专属于 JDK8 安全漏洞升级 -->
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-core</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.common.core.KeyValue;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.common.collect.Multimap;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -65,4 +66,47 @@ public class MapUtils {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Map 中获取 BigDecimal 值
|
||||
*
|
||||
* @param map Map 数据源
|
||||
* @param key 键名
|
||||
* @return BigDecimal 值,解析失败或值为 null 时返回 null
|
||||
*/
|
||||
public static BigDecimal getBigDecimal(Map<String, ?> map, String key) {
|
||||
return getBigDecimal(map, key, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Map 中获取 BigDecimal 值
|
||||
*
|
||||
* @param map Map 数据源
|
||||
* @param key 键名
|
||||
* @param defaultValue 默认值
|
||||
* @return BigDecimal 值,解析失败或值为 null 时返回默认值
|
||||
*/
|
||||
public static BigDecimal getBigDecimal(Map<String, ?> map, String key, BigDecimal defaultValue) {
|
||||
if (map == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
Object value = map.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return BigDecimal.valueOf(((Number) value).doubleValue());
|
||||
}
|
||||
if (value instanceof String) {
|
||||
try {
|
||||
return new BigDecimal((String) value);
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -229,4 +229,53 @@ public class JsonUtils {
|
||||
return JSONUtil.isTypeJSONObject(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为目标类型
|
||||
* <p>
|
||||
* 避免先转 jsonString 再 parseObject 的性能损耗
|
||||
*
|
||||
* @param obj 源对象(可以是 Map、POJO 等)
|
||||
* @param clazz 目标类型
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
public static <T> T convertObject(Object obj, Class<T> clazz) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
if (clazz.isInstance(obj)) {
|
||||
return clazz.cast(obj);
|
||||
}
|
||||
return objectMapper.convertValue(obj, clazz);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为目标类型(支持泛型)
|
||||
*
|
||||
* @param obj 源对象
|
||||
* @param typeReference 目标类型引用
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
public static <T> T convertObject(Object obj, TypeReference<T> typeReference) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
return objectMapper.convertValue(obj, typeReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为 List 类型
|
||||
* <p>
|
||||
* 避免先转 jsonString 再 parseArray 的性能损耗
|
||||
*
|
||||
* @param obj 源对象(可以是 List、数组等)
|
||||
* @param clazz 目标元素类型
|
||||
* @return 转换后的 List
|
||||
*/
|
||||
public static <T> List<T> convertList(Object obj, Class<T> clazz) {
|
||||
if (obj == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return objectMapper.convertValue(obj, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.expression.Alias;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import net.sf.jsqlparser.expression.NullValue;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
|
||||
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
|
||||
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
|
||||
@@ -60,8 +59,6 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
||||
private static final String DEPT_COLUMN_NAME = "dept_id";
|
||||
private static final String USER_COLUMN_NAME = "user_id";
|
||||
|
||||
static final Expression EXPRESSION_NULL = new NullValue();
|
||||
|
||||
private final PermissionCommonApi permissionApi;
|
||||
|
||||
/**
|
||||
@@ -136,7 +133,7 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
||||
JsonUtils.toJsonString(loginUser), tableName, tableAlias, JsonUtils.toJsonString(deptDataPermission));
|
||||
// throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 构建的条件为空",
|
||||
// loginUser.getId(), tableName, tableAlias.getName()));
|
||||
return EXPRESSION_NULL;
|
||||
return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空
|
||||
}
|
||||
if (deptExpression == null) {
|
||||
return userExpression;
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.function.Consumer;
|
||||
* <p>
|
||||
* 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。
|
||||
* 2. SFunction<S, ?> column + <S> 泛型:支持任意类字段(主表、子表、三表),推荐写法, 让编译器自动推断 S 类型
|
||||
*
|
||||
* @param <T> 数据类型
|
||||
*/
|
||||
public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
|
||||
@@ -122,6 +123,12 @@ public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <X> MPJLambdaWrapperX<T> orderByAsc(SFunction<X, ?> column) {
|
||||
super.orderByAsc(true, column);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MPJLambdaWrapperX<T> last(String lastSql) {
|
||||
super.last(lastSql);
|
||||
|
||||
@@ -29,6 +29,6 @@ public class FileCreateReqVO {
|
||||
private String type;
|
||||
|
||||
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer size;
|
||||
private Long size;
|
||||
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class FileRespVO {
|
||||
private String type;
|
||||
|
||||
@Schema(description = "文件大小", example = "2048", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Integer size;
|
||||
private Long size;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@@ -70,7 +70,7 @@ public class ApiAccessLogRespVO {
|
||||
|
||||
@Schema(description = "操作分类", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@ExcelProperty(value = "操作分类", converter = DictConvert.class)
|
||||
@DictFormat(cn.iocoder.yudao.module.infra.enums.DictTypeConstants.OPERATE_TYPE)
|
||||
@DictFormat(DictTypeConstants.OPERATE_TYPE)
|
||||
private Integer operateType;
|
||||
|
||||
@Schema(description = "开始请求时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -52,6 +52,6 @@ public class FileDO extends BaseDO {
|
||||
/**
|
||||
* 文件大小
|
||||
*/
|
||||
private Integer size;
|
||||
private Long size;
|
||||
|
||||
}
|
||||
|
||||
@@ -59,9 +59,9 @@ public class DatabaseTableServiceImpl implements DatabaseTableService {
|
||||
strategyConfig.addInclude(name);
|
||||
} else {
|
||||
// 移除工作流和定时任务前缀的表名
|
||||
strategyConfig.addExclude("ACT_[\\S\\s]+|QRTZ_[\\S\\s]+|FLW_[\\S\\s]+");
|
||||
strategyConfig.addExclude("ACT_[\\S\\s]+|QRTZ_[\\S\\s]+|FLW_[\\S\\s]+|act_[\\S\\s]+|qrtz_[\\S\\s]+|flw_[\\S\\s]+");
|
||||
// 移除 ORACLE 相关的系统表
|
||||
strategyConfig.addExclude("IMPDP_[\\S\\s]+|ALL_[\\S\\s]+|HS_[\\S\\\\s]+");
|
||||
strategyConfig.addExclude("IMPDP_[\\S\\s]+|ALL_[\\S\\s]+|HS_[\\S\\s]+|impdp_[\\S\\s]+|all_[\\S\\s]+|hs_[\\S\\s]+");
|
||||
strategyConfig.addExclude("[\\S\\s]+\\$[\\S\\s]+|[\\S\\s]+\\$"); // 表里不能有 $,一般有都是系统的表
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ public class FileServiceImpl implements FileService {
|
||||
// 3. 保存到数据库
|
||||
fileMapper.insert(new FileDO().setConfigId(client.getId())
|
||||
.setName(name).setPath(path).setUrl(url)
|
||||
.setType(type).setSize(content.length));
|
||||
.setType(type).setSize((long) content.length));
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { http } from '@/http/http'
|
||||
#else
|
||||
#set ($primaryTsType = "string")
|
||||
#end
|
||||
|
||||
/** ${table.classComment}信息 */
|
||||
export interface ${simpleClassName} {
|
||||
#foreach ($column in $columns)
|
||||
@@ -28,7 +27,6 @@ export interface ${simpleClassName} {
|
||||
#end
|
||||
}
|
||||
|
||||
// TODO @AI:`/system/operate-log/get?id=${id}` 类似这种
|
||||
/** 获取${table.classComment}分页列表 */
|
||||
export function get${simpleClassName}Page(params: PageParam) {
|
||||
return http.get<PageResult<${simpleClassName}>>('/${table.moduleName}/${simpleClassName_strikeCase}/page', params)
|
||||
@@ -36,7 +34,7 @@ export function get${simpleClassName}Page(params: PageParam) {
|
||||
|
||||
/** 获取${table.classComment}详情 */
|
||||
export function get${simpleClassName}(id: ${primaryTsType}) {
|
||||
return http.get<${simpleClassName}>('/${table.moduleName}/${simpleClassName_strikeCase}/get?id=' + id)
|
||||
return http.get<${simpleClassName}>(`/${table.moduleName}/${simpleClassName_strikeCase}/get?id=${id}`)
|
||||
}
|
||||
|
||||
/** 创建${table.classComment} */
|
||||
@@ -49,8 +47,7 @@ export function update${simpleClassName}(data: ${simpleClassName}) {
|
||||
return http.put<boolean>('/${table.moduleName}/${simpleClassName_strikeCase}/update', data)
|
||||
}
|
||||
|
||||
// TODO @AI:`/system/operate-log/get?id=${id}` 类似这种
|
||||
/** 删除${table.classComment} */
|
||||
export function delete${simpleClassName}(id: ${primaryTsType}) {
|
||||
return http.delete<boolean>('/${table.moduleName}/${simpleClassName_strikeCase}/delete?id=' + id)
|
||||
return http.delete<boolean>(`/${table.moduleName}/${simpleClassName_strikeCase}/delete?id=${id}`)
|
||||
}
|
||||
|
||||
@@ -294,4 +294,3 @@ function handleReset() {
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
|
||||
#set ($primaryJavaType = $primaryColumn.javaType.toLowerCase())
|
||||
#if(${primaryJavaType} == "long" || ${primaryJavaType} == "integer" || ${primaryJavaType} == "short" || ${primaryJavaType} == "double" || ${primaryJavaType} == "bigdecimal" || ${primaryJavaType} == "byte")
|
||||
#set ($primaryTsType = "number")
|
||||
@@ -204,7 +205,7 @@ const formRules = {
|
||||
#end
|
||||
#end
|
||||
}
|
||||
const formRef = ref()
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 返回上一页 */
|
||||
function handleBack() {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Set;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 设备绑定网关 Request VO")
|
||||
@Data
|
||||
public class IotDeviceBindGatewayReqVO {
|
||||
|
||||
@Schema(description = "子设备编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3")
|
||||
@NotEmpty(message = "子设备编号列表不能为空")
|
||||
private Set<Long> subIds;
|
||||
|
||||
@Schema(description = "网关设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@NotNull(message = "网关设备编号不能为空")
|
||||
private Long gatewayId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.Set;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 设备解绑网关 Request VO")
|
||||
@Data
|
||||
public class IotDeviceUnbindGatewayReqVO {
|
||||
|
||||
@Schema(description = "子设备编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3")
|
||||
@NotEmpty(message = "子设备编号列表不能为空")
|
||||
private Set<Long> subIds;
|
||||
|
||||
@Schema(description = "网关设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "网关设备编号不能为空")
|
||||
private Long gatewayId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* WebSocket 的 {@link IotDataRuleAction} 实现类
|
||||
* <p>
|
||||
* 负责将设备消息发送到外部 WebSocket 服务器
|
||||
* 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
|
||||
* 使用连接池管理 WebSocket 连接,提高性能和资源利用率
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class IotWebSocketDataRuleAction extends
|
||||
IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> {
|
||||
|
||||
/**
|
||||
* 锁等待超时时间(毫秒)
|
||||
*/
|
||||
private static final long LOCK_WAIT_TIME_MS = 5000;
|
||||
|
||||
/**
|
||||
* 重连锁,key 为 WebSocket 服务器地址
|
||||
* <p>
|
||||
* WebSocket 连接是与特定服务器实例绑定的,使用单机锁即可保证重连的线程安全
|
||||
*/
|
||||
private final ConcurrentHashMap<String, ReentrantLock> reconnectLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Integer getType() {
|
||||
return IotDataSinkTypeEnum.WEBSOCKET.getType();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected IotWebSocketClient initProducer(IotDataSinkWebSocketConfig config) throws Exception {
|
||||
// 1. 参数校验
|
||||
if (StrUtil.isBlank(config.getServerUrl())) {
|
||||
throw new IllegalArgumentException("WebSocket 服务器地址不能为空");
|
||||
}
|
||||
if (!StrUtil.startWithAny(config.getServerUrl(), "ws://", "wss://")) {
|
||||
throw new IllegalArgumentException("WebSocket 服务器地址必须以 ws:// 或 wss:// 开头");
|
||||
}
|
||||
|
||||
// 2.1 创建 WebSocket 客户端
|
||||
IotWebSocketClient webSocketClient = new IotWebSocketClient(
|
||||
config.getServerUrl(),
|
||||
config.getConnectTimeoutMs(),
|
||||
config.getSendTimeoutMs(),
|
||||
config.getDataFormat()
|
||||
);
|
||||
// 2.2 连接服务器
|
||||
webSocketClient.connect();
|
||||
log.info("[initProducer][WebSocket 客户端创建并连接成功,服务器: {},数据格式: {}]",
|
||||
config.getServerUrl(), config.getDataFormat());
|
||||
return webSocketClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void closeProducer(IotWebSocketClient producer) throws Exception {
|
||||
if (producer != null) {
|
||||
producer.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception {
|
||||
try {
|
||||
// 1.1 获取或创建 WebSocket 客户端
|
||||
IotWebSocketClient webSocketClient = getProducer(config);
|
||||
|
||||
// 1.2 检查连接状态,如果断开则使用分布式锁保证重连的线程安全
|
||||
if (!webSocketClient.isConnected()) {
|
||||
reconnectWithLock(webSocketClient, config);
|
||||
}
|
||||
|
||||
// 2.1 发送消息
|
||||
webSocketClient.sendMessage(message);
|
||||
// 2.2 记录发送成功日志
|
||||
log.info("[execute][message({}) config({}) 发送成功,WebSocket 服务器: {}]",
|
||||
message, config, config.getServerUrl());
|
||||
} catch (Exception e) {
|
||||
log.error("[execute][message({}) config({}) 发送失败,WebSocket 服务器: {}]",
|
||||
message, config, config.getServerUrl(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @puhui999:为什么这里要加锁呀?
|
||||
/**
|
||||
* 使用锁进行重连,保证同一服务器地址的重连操作线程安全
|
||||
*
|
||||
* @param webSocketClient WebSocket 客户端
|
||||
* @param config 配置信息
|
||||
*/
|
||||
private void reconnectWithLock(IotWebSocketClient webSocketClient, IotDataSinkWebSocketConfig config) throws Exception {
|
||||
ReentrantLock lock = reconnectLocks.computeIfAbsent(config.getServerUrl(), k -> new ReentrantLock());
|
||||
boolean acquired = false;
|
||||
try {
|
||||
acquired = lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS);
|
||||
if (!acquired) {
|
||||
throw new RuntimeException("获取 WebSocket 重连锁超时,服务器: " + config.getServerUrl());
|
||||
}
|
||||
// 双重检查:获取锁后再次检查连接状态,避免重复连接
|
||||
if (!webSocketClient.isConnected()) {
|
||||
log.warn("[reconnectWithLock][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
|
||||
webSocketClient.connect();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("获取 WebSocket 重连锁被中断,服务器: " + config.getServerUrl(), e);
|
||||
} finally {
|
||||
if (acquired && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.*;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* IoT WebSocket 客户端
|
||||
* <p>
|
||||
* 负责与外部 WebSocket 服务器建立连接并发送设备消息
|
||||
* 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
|
||||
* 基于 OkHttp WebSocket 实现,兼容 JDK 8+
|
||||
* <p>
|
||||
* 注意:该类的线程安全由调用方(IotWebSocketDataRuleAction)通过分布式锁保证
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotWebSocketClient {
|
||||
|
||||
private final String serverUrl;
|
||||
private final Integer connectTimeoutMs;
|
||||
private final Integer sendTimeoutMs;
|
||||
private final String dataFormat;
|
||||
|
||||
private OkHttpClient okHttpClient;
|
||||
private volatile WebSocket webSocket;
|
||||
private final AtomicBoolean connected = new AtomicBoolean(false);
|
||||
|
||||
public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_CONNECT_TIMEOUT_MS;
|
||||
this.sendTimeoutMs = sendTimeoutMs != null ? sendTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_SEND_TIMEOUT_MS;
|
||||
this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到 WebSocket 服务器
|
||||
* <p>
|
||||
* 注意:调用方需要通过分布式锁保证并发安全
|
||||
*/
|
||||
public void connect() throws Exception {
|
||||
if (connected.get()) {
|
||||
log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建 OkHttpClient
|
||||
okHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
// 创建 WebSocket 请求
|
||||
Request request = new Request.Builder()
|
||||
.url(serverUrl)
|
||||
.build();
|
||||
|
||||
// 使用 CountDownLatch 等待连接完成
|
||||
CountDownLatch connectLatch = new CountDownLatch(1);
|
||||
AtomicBoolean connectSuccess = new AtomicBoolean(false);
|
||||
// 创建 WebSocket 连接
|
||||
webSocket = okHttpClient.newWebSocket(request, new IotWebSocketListener(connectLatch, connectSuccess));
|
||||
|
||||
// 等待连接完成
|
||||
boolean await = connectLatch.await(connectTimeoutMs, TimeUnit.MILLISECONDS);
|
||||
if (!await || !connectSuccess.get()) {
|
||||
close();
|
||||
throw new Exception("WebSocket 连接超时或失败,服务器地址: " + serverUrl);
|
||||
}
|
||||
log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl);
|
||||
} catch (Exception e) {
|
||||
close();
|
||||
log.error("[connect][WebSocket 客户端连接失败,服务器地址: {}]", serverUrl, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备消息
|
||||
*
|
||||
* @param message 设备消息
|
||||
* @throws Exception 发送异常
|
||||
*/
|
||||
public void sendMessage(IotDeviceMessage message) throws Exception {
|
||||
WebSocket ws = this.webSocket;
|
||||
if (!connected.get() || ws == null) {
|
||||
throw new IllegalStateException("WebSocket 客户端未连接");
|
||||
}
|
||||
|
||||
try {
|
||||
String messageData;
|
||||
if (IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) {
|
||||
messageData = JsonUtils.toJsonString(message);
|
||||
} else {
|
||||
messageData = message.toString();
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
boolean success = ws.send(messageData);
|
||||
if (!success) {
|
||||
throw new Exception("WebSocket 发送消息失败,消息队列已满或连接已关闭");
|
||||
}
|
||||
log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]",
|
||||
message.getDeviceId(), messageData.length());
|
||||
} catch (Exception e) {
|
||||
log.error("[sendMessage][发送消息失败,设备 ID: {}]", message.getDeviceId(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
public void close() {
|
||||
try {
|
||||
if (webSocket != null) {
|
||||
// 发送正常关闭帧,状态码 1000 表示正常关闭
|
||||
// TODO @puhui999:有没 1000 的枚举哈?在 okhttp 里
|
||||
webSocket.close(1000, "客户端主动关闭");
|
||||
webSocket = null;
|
||||
}
|
||||
if (okHttpClient != null) {
|
||||
// 关闭连接池和调度器
|
||||
okHttpClient.dispatcher().executorService().shutdown();
|
||||
okHttpClient.connectionPool().evictAll();
|
||||
okHttpClient = null;
|
||||
}
|
||||
connected.set(false);
|
||||
log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("[close][关闭 WebSocket 客户端连接异常]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接状态
|
||||
*
|
||||
* @return 是否已连接
|
||||
*/
|
||||
public boolean isConnected() {
|
||||
return connected.get() && webSocket != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "IotWebSocketClient{" +
|
||||
"serverUrl='" + serverUrl + '\'' +
|
||||
", dataFormat='" + dataFormat + '\'' +
|
||||
", connected=" + connected.get() +
|
||||
'}';
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttp WebSocket 监听器
|
||||
*/
|
||||
@SuppressWarnings("NullableProblems")
|
||||
private class IotWebSocketListener extends WebSocketListener {
|
||||
|
||||
private final CountDownLatch connectLatch;
|
||||
private final AtomicBoolean connectSuccess;
|
||||
|
||||
public IotWebSocketListener(CountDownLatch connectLatch, AtomicBoolean connectSuccess) {
|
||||
this.connectLatch = connectLatch;
|
||||
this.connectSuccess = connectSuccess;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, Response response) {
|
||||
connected.set(true);
|
||||
connectSuccess.set(true);
|
||||
connectLatch.countDown();
|
||||
log.info("[onOpen][WebSocket 连接已打开,服务器: {}]", serverUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String text) {
|
||||
log.debug("[onMessage][收到消息: {}]", text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing(WebSocket webSocket, int code, String reason) {
|
||||
connected.set(false);
|
||||
log.info("[onClosing][WebSocket 正在关闭,code: {}, reason: {}]", code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
connected.set(false);
|
||||
log.info("[onClosed][WebSocket 已关闭,code: {}, reason: {}]", code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
connected.set(false);
|
||||
connectLatch.countDown(); // 确保连接失败时也释放等待
|
||||
log.error("[onFailure][WebSocket 连接失败]", t);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.text.CharPool;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotCurrentTimeConditionMatcher;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 场景规则时间匹配工具类
|
||||
* <p>
|
||||
* 提供时间条件匹配的通用方法,供 {@link IotCurrentTimeConditionMatcher} 和 {@link IotTimerConditionEvaluator} 共同使用。
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotSceneRuleTimeHelper {
|
||||
|
||||
/**
|
||||
* 时间格式化器 - HH:mm:ss
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/**
|
||||
* 时间格式化器 - HH:mm
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
// TODO @puhui999:可以使用 lombok 简化
|
||||
private IotSceneRuleTimeHelper() {
|
||||
// 工具类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为日期时间操作符
|
||||
*
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @return 是否为日期时间操作符
|
||||
*/
|
||||
public static boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) {
|
||||
return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN
|
||||
|| operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN
|
||||
|| operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为时间操作符(包括日期时间操作符和当日时间操作符)
|
||||
*
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @return 是否为时间操作符
|
||||
*/
|
||||
public static boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) {
|
||||
return operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN
|
||||
&& operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN
|
||||
&& operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_BETWEEN
|
||||
&& !isDateTimeOperator(operatorEnum);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行时间匹配逻辑
|
||||
*
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @param param 参数值
|
||||
* @return 是否匹配
|
||||
*/
|
||||
public static boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (isDateTimeOperator(operatorEnum)) {
|
||||
// 日期时间匹配(时间戳,秒级)
|
||||
long currentTimestamp = now.atZone(ZoneId.systemDefault()).toEpochSecond();
|
||||
return matchDateTime(currentTimestamp, operatorEnum, param);
|
||||
} else {
|
||||
// 当日时间匹配(HH:mm:ss)
|
||||
return matchTime(now.toLocalTime(), operatorEnum, param);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[executeTimeMatching][operatorEnum({}) param({}) 时间匹配异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配日期时间(时间戳,秒级)
|
||||
*
|
||||
* @param currentTimestamp 当前时间戳
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @param param 参数值
|
||||
* @return 是否匹配
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum,
|
||||
String param) {
|
||||
try {
|
||||
// DATE_TIME_BETWEEN 需要解析两个时间戳,单独处理
|
||||
if (operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN) {
|
||||
return matchDateTimeBetween(currentTimestamp, param);
|
||||
}
|
||||
// 其他操作符只需要解析一个时间戳
|
||||
long targetTimestamp = Long.parseLong(param);
|
||||
switch (operatorEnum) {
|
||||
case DATE_TIME_GREATER_THAN:
|
||||
return currentTimestamp > targetTimestamp;
|
||||
case DATE_TIME_LESS_THAN:
|
||||
return currentTimestamp < targetTimestamp;
|
||||
default:
|
||||
log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配日期时间区间
|
||||
*
|
||||
* @param currentTimestamp 当前时间戳
|
||||
* @param param 参数值(格式:startTimestamp,endTimestamp)
|
||||
* @return 是否匹配
|
||||
*/
|
||||
public static boolean matchDateTimeBetween(long currentTimestamp, String param) {
|
||||
List<String> timestampRange = StrUtil.splitTrim(param, CharPool.COMMA);
|
||||
if (timestampRange.size() != 2) {
|
||||
log.warn("[matchDateTimeBetween][param({}) 时间戳区间参数格式错误]", param);
|
||||
return false;
|
||||
}
|
||||
long startTimestamp = Long.parseLong(timestampRange.get(0).trim());
|
||||
long endTimestamp = Long.parseLong(timestampRange.get(1).trim());
|
||||
// TODO @puhui999:hutool 里,看看有没 between 方法
|
||||
return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配当日时间(HH:mm:ss 或 HH:mm)
|
||||
*
|
||||
* @param currentTime 当前时间
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @param param 参数值
|
||||
* @return 是否匹配
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum,
|
||||
String param) {
|
||||
try {
|
||||
// TIME_BETWEEN 需要解析两个时间,单独处理
|
||||
if (operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN) {
|
||||
return matchTimeBetween(currentTime, param);
|
||||
}
|
||||
// 其他操作符只需要解析一个时间
|
||||
LocalTime targetTime = parseTime(param);
|
||||
switch (operatorEnum) {
|
||||
case TIME_GREATER_THAN:
|
||||
return currentTime.isAfter(targetTime);
|
||||
case TIME_LESS_THAN:
|
||||
return currentTime.isBefore(targetTime);
|
||||
default:
|
||||
log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[matchTime][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配时间区间
|
||||
*
|
||||
* @param currentTime 当前时间
|
||||
* @param param 参数值(格式:startTime,endTime)
|
||||
* @return 是否匹配
|
||||
*/
|
||||
public static boolean matchTimeBetween(LocalTime currentTime, String param) {
|
||||
List<String> timeRange = StrUtil.splitTrim(param, CharPool.COMMA);
|
||||
if (timeRange.size() != 2) {
|
||||
log.warn("[matchTimeBetween][param({}) 时间区间参数格式错误]", param);
|
||||
return false;
|
||||
}
|
||||
LocalTime startTime = parseTime(timeRange.get(0).trim());
|
||||
LocalTime endTime = parseTime(timeRange.get(1).trim());
|
||||
// TODO @puhui999:hutool 里,看看有没 between 方法
|
||||
return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间字符串
|
||||
* 支持 HH:mm 和 HH:mm:ss 两种格式
|
||||
*
|
||||
* @param timeStr 时间字符串
|
||||
* @return 解析后的 LocalTime
|
||||
*/
|
||||
public static LocalTime parseTime(String timeStr) {
|
||||
Assert.isFalse(StrUtil.isBlank(timeStr), "时间字符串不能为空");
|
||||
try {
|
||||
// 尝试不同的时间格式
|
||||
if (timeStr.length() == 5) { // HH:mm
|
||||
return LocalTime.parse(timeStr, TIME_FORMATTER_SHORT);
|
||||
} else if (timeStr.length() == 8) { // HH:mm:ss
|
||||
return LocalTime.parse(timeStr, TIME_FORMATTER);
|
||||
} else {
|
||||
throw new IllegalArgumentException("时间格式长度不正确,期望 HH:mm 或 HH:mm:ss 格式");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[parseTime][timeStr({}) 时间格式解析失败]", timeStr, e);
|
||||
throw new IllegalArgumentException("时间格式无效: " + timeStr, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene.timer;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleTimeHelper;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 定时触发器条件评估器
|
||||
* <p>
|
||||
* 与设备触发器不同,定时触发器没有设备消息上下文,
|
||||
* 需要主动查询设备属性和状态来评估条件。
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class IotTimerConditionEvaluator {
|
||||
|
||||
@Resource
|
||||
private IotDevicePropertyService devicePropertyService;
|
||||
|
||||
@Resource
|
||||
private IotDeviceService deviceService;
|
||||
|
||||
/**
|
||||
* 评估条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public boolean evaluate(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1.1 基础参数校验
|
||||
if (condition == null || condition.getType() == null) {
|
||||
log.warn("[evaluate][条件为空或类型为空]");
|
||||
return false;
|
||||
}
|
||||
// 1.2 根据条件类型分发到具体的评估方法
|
||||
IotSceneRuleConditionTypeEnum conditionType =
|
||||
IotSceneRuleConditionTypeEnum.typeOf(condition.getType());
|
||||
if (conditionType == null) {
|
||||
log.warn("[evaluate][未知的条件类型: {}]", condition.getType());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 分发评估
|
||||
switch (conditionType) {
|
||||
case DEVICE_PROPERTY:
|
||||
return evaluateDevicePropertyCondition(condition);
|
||||
case DEVICE_STATE:
|
||||
return evaluateDeviceStateCondition(condition);
|
||||
case CURRENT_TIME:
|
||||
return evaluateCurrentTimeCondition(condition);
|
||||
default:
|
||||
log.warn("[evaluate][未知的条件类型: {}]", conditionType);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估设备属性条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateDevicePropertyCondition(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1. 校验必要参数
|
||||
if (condition.getDeviceId() == null) {
|
||||
log.debug("[evaluateDevicePropertyCondition][设备ID为空]");
|
||||
return false;
|
||||
}
|
||||
if (StrUtil.isBlank(condition.getIdentifier())) {
|
||||
log.debug("[evaluateDevicePropertyCondition][属性标识符为空]");
|
||||
return false;
|
||||
}
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
log.debug("[evaluateDevicePropertyCondition][操作符或参数无效]");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2.1 获取设备最新属性值
|
||||
Map<String, IotDevicePropertyDO> properties =
|
||||
devicePropertyService.getLatestDeviceProperties(condition.getDeviceId());
|
||||
if (CollUtil.isEmpty(properties)) {
|
||||
log.debug("[evaluateDevicePropertyCondition][设备({}) 无属性数据]", condition.getDeviceId());
|
||||
return false;
|
||||
}
|
||||
// 2.2 获取指定属性
|
||||
IotDevicePropertyDO property = properties.get(condition.getIdentifier());
|
||||
if (property == null || property.getValue() == null) {
|
||||
log.debug("[evaluateDevicePropertyCondition][设备({}) 属性({}) 不存在或值为空]",
|
||||
condition.getDeviceId(), condition.getIdentifier());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 使用现有的条件评估逻辑进行比较
|
||||
boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(
|
||||
property.getValue(), condition.getOperator(), condition.getParam());
|
||||
log.debug("[evaluateDevicePropertyCondition][设备({}) 属性({}) 值({}) 操作符({}) 参数({}) 匹配结果: {}]",
|
||||
condition.getDeviceId(), condition.getIdentifier(), property.getValue(),
|
||||
condition.getOperator(), condition.getParam(), matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估设备状态条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateDeviceStateCondition(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1. 校验必要参数
|
||||
if (condition.getDeviceId() == null) {
|
||||
log.debug("[evaluateDeviceStateCondition][设备ID为空]");
|
||||
return false;
|
||||
}
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
log.debug("[evaluateDeviceStateCondition][操作符或参数无效]");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2.1 获取设备信息
|
||||
IotDeviceDO device = deviceService.getDevice(condition.getDeviceId());
|
||||
if (device == null) {
|
||||
log.debug("[evaluateDeviceStateCondition][设备({}) 不存在]", condition.getDeviceId());
|
||||
return false;
|
||||
}
|
||||
// 2.2 获取设备状态
|
||||
Integer state = device.getState();
|
||||
if (state == null) {
|
||||
log.debug("[evaluateDeviceStateCondition][设备({}) 状态为空]", condition.getDeviceId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 比较状态
|
||||
boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(
|
||||
state.toString(), condition.getOperator(), condition.getParam());
|
||||
log.debug("[evaluateDeviceStateCondition][设备({}) 状态({}) 操作符({}) 参数({}) 匹配结果: {}]",
|
||||
condition.getDeviceId(), state, condition.getOperator(), condition.getParam(), matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估当前时间条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateCurrentTimeCondition(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1.1 校验必要参数
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
log.debug("[evaluateCurrentTimeCondition][操作符或参数无效]");
|
||||
return false;
|
||||
}
|
||||
// 1.2 验证操作符是否为支持的时间操作符
|
||||
IotSceneRuleConditionOperatorEnum operatorEnum =
|
||||
IotSceneRuleConditionOperatorEnum.operatorOf(condition.getOperator());
|
||||
if (operatorEnum == null) {
|
||||
log.debug("[evaluateCurrentTimeCondition][无效的操作符: {}]", condition.getOperator());
|
||||
return false;
|
||||
}
|
||||
if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) {
|
||||
log.debug("[evaluateCurrentTimeCondition][不支持的时间操作符: {}]", condition.getOperator());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 执行时间匹配
|
||||
boolean matched = IotSceneRuleTimeHelper.executeTimeMatching(operatorEnum, condition.getParam());
|
||||
log.debug("[evaluateCurrentTimeCondition][操作符({}) 参数({}) 匹配结果: {}]",
|
||||
condition.getOperator(), condition.getParam(), matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp;
|
||||
|
||||
import cn.hutool.core.util.ReflectUtil;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link IotTcpClient} 的单元测试
|
||||
* <p>
|
||||
* 测试 dataFormat 默认值行为
|
||||
* Property 1: TCP 客户端 dataFormat 默认值行为
|
||||
* Validates: Requirements 1.1, 1.2
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
class IotTcpClientTest {
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatNull() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
|
||||
// 断言:dataFormat 为 null 时应使用默认值
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatEmpty() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, "");
|
||||
|
||||
// 断言:dataFormat 为空字符串时应使用默认值
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatBlank() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, " ");
|
||||
|
||||
// 断言:dataFormat 为纯空白字符串时应使用默认值
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatValid() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
String dataFormat = "BINARY";
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, dataFormat);
|
||||
|
||||
// 断言:dataFormat 为有效值时应保持原值
|
||||
assertEquals(dataFormat, ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_defaultValues() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
|
||||
// 断言:验证所有默认值
|
||||
assertEquals(host, ReflectUtil.getFieldValue(client, "host"));
|
||||
assertEquals(port, ReflectUtil.getFieldValue(client, "port"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
ReflectUtil.getFieldValue(client, "connectTimeoutMs"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS,
|
||||
ReflectUtil.getFieldValue(client, "readTimeoutMs"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_SSL,
|
||||
ReflectUtil.getFieldValue(client, "ssl"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_customValues() {
|
||||
// 准备参数
|
||||
String host = "192.168.1.100";
|
||||
Integer port = 9090;
|
||||
Integer connectTimeoutMs = 3000;
|
||||
Integer readTimeoutMs = 8000;
|
||||
Boolean ssl = true;
|
||||
String dataFormat = "BINARY";
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, connectTimeoutMs, readTimeoutMs, ssl, dataFormat);
|
||||
|
||||
// 断言:验证自定义值
|
||||
assertEquals(host, ReflectUtil.getFieldValue(client, "host"));
|
||||
assertEquals(port, ReflectUtil.getFieldValue(client, "port"));
|
||||
assertEquals(connectTimeoutMs, ReflectUtil.getFieldValue(client, "connectTimeoutMs"));
|
||||
assertEquals(readTimeoutMs, ReflectUtil.getFieldValue(client, "readTimeoutMs"));
|
||||
assertEquals(ssl, ReflectUtil.getFieldValue(client, "ssl"));
|
||||
assertEquals(dataFormat, ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIsConnected_initialState() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
|
||||
// 断言:初始状态应为未连接
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToString() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
String result = client.toString();
|
||||
|
||||
// 断言
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("host='localhost'"));
|
||||
assertTrue(result.contains("port=8080"));
|
||||
assertTrue(result.contains("dataFormat='JSON'"));
|
||||
assertTrue(result.contains("connected=false"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link IotWebSocketClient} 的单元测试
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
class IotWebSocketClientTest {
|
||||
|
||||
private MockWebServer mockWebServer;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
mockWebServer = new MockWebServer();
|
||||
mockWebServer.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
if (mockWebServer != null) {
|
||||
mockWebServer.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 WebSocket 监听器,用于测试
|
||||
*/
|
||||
private static class TestWebSocketListener extends WebSocketListener {
|
||||
@Override
|
||||
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
|
||||
// 连接打开
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
|
||||
// 收到消息
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
||||
webSocket.close(code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
|
||||
// 连接失败
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_defaultValues() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
|
||||
// 调用
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, null, null, null);
|
||||
|
||||
// 断言:验证默认值被正确设置
|
||||
assertNotNull(client);
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_customValues() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
Integer connectTimeoutMs = 3000;
|
||||
Integer sendTimeoutMs = 5000;
|
||||
String dataFormat = "TEXT";
|
||||
|
||||
// 调用
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, connectTimeoutMs, sendTimeoutMs, dataFormat);
|
||||
|
||||
// 断言
|
||||
assertNotNull(client);
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConnect_success() throws Exception {
|
||||
// 准备参数:使用 MockWebServer 的 WebSocket 端点
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// mock:设置 MockWebServer 响应 WebSocket 升级请求
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
|
||||
// 断言
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConnect_alreadyConnected() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用:第一次连接
|
||||
client.connect();
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 调用:第二次连接(应该不会重复连接)
|
||||
client.connect();
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessage_success() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
IotDeviceMessage message = IotDeviceMessage.builder()
|
||||
.deviceId(123L)
|
||||
.method("thing.property.report")
|
||||
.params("{\"temperature\": 25.5}")
|
||||
.build();
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
client.sendMessage(message);
|
||||
|
||||
// 断言:消息发送成功不抛异常
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessage_notConnected() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
IotDeviceMessage message = IotDeviceMessage.builder()
|
||||
.deviceId(123L)
|
||||
.method("thing.property.report")
|
||||
.params("{\"temperature\": 25.5}")
|
||||
.build();
|
||||
|
||||
// 调用 & 断言:未连接时发送消息应抛出异常
|
||||
assertThrows(IllegalStateException.class, () -> client.sendMessage(message));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClose_success() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
client.close();
|
||||
|
||||
// 断言
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClose_notConnected() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// 调用:关闭未连接的客户端不应抛异常
|
||||
assertDoesNotThrow(client::close);
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIsConnected_initialState() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// 断言:初始状态应为未连接
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToString() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// 调用
|
||||
String result = client.toString();
|
||||
|
||||
// 断言
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("serverUrl='ws://localhost:8080'"));
|
||||
assertTrue(result.contains("dataFormat='JSON'"));
|
||||
assertTrue(result.contains("connected=false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessage_textFormat() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "TEXT");
|
||||
|
||||
IotDeviceMessage message = IotDeviceMessage.builder()
|
||||
.deviceId(123L)
|
||||
.method("thing.property.report")
|
||||
.params("{\"temperature\": 25.5}")
|
||||
.build();
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
client.sendMessage(message);
|
||||
|
||||
// 断言:消息发送成功不抛异常
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotSceneRuleServiceImpl} 定时触发器条件组集成测试
|
||||
* <p>
|
||||
* 测试定时触发器的条件组评估功能:
|
||||
* - 空条件组直接执行动作
|
||||
* - 条件组评估后决定是否执行动作
|
||||
* - 条件组之间的 OR 逻辑
|
||||
* - 条件组内的 AND 逻辑
|
||||
* - 所有条件组不满足时跳过执行
|
||||
* <p>
|
||||
* Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Disabled // TODO @puhui999:单测有报错,先屏蔽
|
||||
public class IotSceneRuleTimerConditionIntegrationTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotSceneRuleServiceImpl sceneRuleService;
|
||||
|
||||
@Mock
|
||||
private IotSceneRuleMapper sceneRuleMapper;
|
||||
|
||||
@Mock
|
||||
private IotDeviceService deviceService;
|
||||
|
||||
@Mock
|
||||
private IotDevicePropertyService devicePropertyService;
|
||||
|
||||
@Mock
|
||||
private List<IotSceneRuleAction> sceneRuleActions;
|
||||
|
||||
@Mock
|
||||
private IotSceneRuleTimerHandler timerHandler;
|
||||
|
||||
private IotTimerConditionEvaluator timerConditionEvaluator;
|
||||
|
||||
// 测试常量
|
||||
private static final Long SCENE_RULE_ID = 1L;
|
||||
private static final Long TENANT_ID = 1L;
|
||||
private static final Long DEVICE_ID = 100L;
|
||||
private static final String PROPERTY_IDENTIFIER = "temperature";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 创建并注入 timerConditionEvaluator 的依赖
|
||||
timerConditionEvaluator = new IotTimerConditionEvaluator();
|
||||
try {
|
||||
Field devicePropertyServiceField = IotTimerConditionEvaluator.class.getDeclaredField("devicePropertyService");
|
||||
devicePropertyServiceField.setAccessible(true);
|
||||
devicePropertyServiceField.set(timerConditionEvaluator, devicePropertyService);
|
||||
|
||||
Field deviceServiceField = IotTimerConditionEvaluator.class.getDeclaredField("deviceService");
|
||||
deviceServiceField.setAccessible(true);
|
||||
deviceServiceField.set(timerConditionEvaluator, deviceService);
|
||||
|
||||
Field evaluatorField = IotSceneRuleServiceImpl.class.getDeclaredField("timerConditionEvaluator");
|
||||
evaluatorField.setAccessible(true);
|
||||
evaluatorField.set(sceneRuleService, timerConditionEvaluator);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to inject dependencies", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
private IotSceneRuleDO createBaseSceneRule() {
|
||||
IotSceneRuleDO sceneRule = new IotSceneRuleDO();
|
||||
sceneRule.setId(SCENE_RULE_ID);
|
||||
sceneRule.setTenantId(TENANT_ID);
|
||||
sceneRule.setName("测试定时触发器");
|
||||
sceneRule.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
sceneRule.setActions(Collections.emptyList());
|
||||
return sceneRule;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.Trigger createTimerTrigger(String cronExpression,
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups) {
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.TIMER.getType());
|
||||
trigger.setCronExpression(cronExpression);
|
||||
trigger.setConditionGroups(conditionGroups);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.TriggerCondition createDevicePropertyCondition(Long deviceId, String identifier,
|
||||
String operator, String param) {
|
||||
IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition();
|
||||
condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType());
|
||||
condition.setDeviceId(deviceId);
|
||||
condition.setIdentifier(identifier);
|
||||
condition.setOperator(operator);
|
||||
condition.setParam(param);
|
||||
return condition;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.TriggerCondition createDeviceStateCondition(Long deviceId, String operator, String param) {
|
||||
IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition();
|
||||
condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType());
|
||||
condition.setDeviceId(deviceId);
|
||||
condition.setOperator(operator);
|
||||
condition.setParam(param);
|
||||
return condition;
|
||||
}
|
||||
|
||||
private void mockDeviceProperty(Long deviceId, String identifier, Object value) {
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO property = new IotDevicePropertyDO();
|
||||
property.setValue(value);
|
||||
properties.put(identifier, property);
|
||||
when(devicePropertyService.getLatestDeviceProperties(deviceId)).thenReturn(properties);
|
||||
}
|
||||
|
||||
private void mockDeviceState(Long deviceId, Integer state) {
|
||||
IotDeviceDO device = new IotDeviceDO();
|
||||
device.setId(deviceId);
|
||||
device.setState(state);
|
||||
when(deviceService.getDevice(deviceId)).thenReturn(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单条件的条件组列表
|
||||
*/
|
||||
private List<List<IotSceneRuleDO.TriggerCondition>> createSingleConditionGroups(
|
||||
IotSceneRuleDO.TriggerCondition condition) {
|
||||
List<IotSceneRuleDO.TriggerCondition> group = new ArrayList<>();
|
||||
group.add(condition);
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>();
|
||||
groups.add(group);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建两个单条件组的条件组列表
|
||||
*/
|
||||
private List<List<IotSceneRuleDO.TriggerCondition>> createTwoSingleConditionGroups(
|
||||
IotSceneRuleDO.TriggerCondition cond1, IotSceneRuleDO.TriggerCondition cond2) {
|
||||
List<IotSceneRuleDO.TriggerCondition> group1 = new ArrayList<>();
|
||||
group1.add(cond1);
|
||||
List<IotSceneRuleDO.TriggerCondition> group2 = new ArrayList<>();
|
||||
group2.add(cond2);
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>();
|
||||
groups.add(group1);
|
||||
groups.add(group2);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个多条件组的条件组列表
|
||||
*/
|
||||
private List<List<IotSceneRuleDO.TriggerCondition>> createSingleGroupWithMultipleConditions(
|
||||
IotSceneRuleDO.TriggerCondition... conditions) {
|
||||
List<IotSceneRuleDO.TriggerCondition> group = new ArrayList<>(Arrays.asList(conditions));
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>();
|
||||
groups.add(group);
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ========== 测试用例 ==========
|
||||
|
||||
@Nested
|
||||
@DisplayName("空条件组测试 - Validates: Requirement 2.1")
|
||||
class EmptyConditionGroupsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("定时触发器无条件组时,应直接执行动作")
|
||||
void testTimerTrigger_withNullConditionGroups_shouldExecuteActions() {
|
||||
// 准备数据
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", null);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(sceneRuleMapper, times(1)).selectById(SCENE_RULE_ID);
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
verify(deviceService, never()).getDevice(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("定时触发器条件组为空列表时,应直接执行动作")
|
||||
void testTimerTrigger_withEmptyConditionGroups_shouldExecuteActions() {
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", Collections.emptyList());
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(sceneRuleMapper, times(1)).selectById(SCENE_RULE_ID);
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("条件组 OR 逻辑测试 - Validates: Requirements 2.2, 2.3")
|
||||
class ConditionGroupOrLogicTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("多个条件组中第一个满足时,应执行动作")
|
||||
void testMultipleConditionGroups_firstGroupMatches_shouldExecuteActions() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createTwoSingleConditionGroups(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多个条件组中第二个满足时,应执行动作")
|
||||
void testMultipleConditionGroups_secondGroupMatches_shouldExecuteActions() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createTwoSingleConditionGroups(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("条件组内 AND 逻辑测试 - Validates: Requirement 2.4")
|
||||
class ConditionGroupAndLogicTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("条件组内所有条件都满足时,该组应匹配成功")
|
||||
void testSingleConditionGroup_allConditionsMatch_shouldPass() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "80");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createSingleGroupWithMultipleConditions(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO tempProperty = new IotDevicePropertyDO();
|
||||
tempProperty.setValue(30);
|
||||
properties.put("temperature", tempProperty);
|
||||
IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO();
|
||||
humidityProperty.setValue(60);
|
||||
properties.put("humidity", humidityProperty);
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("条件组内有一个条件不满足时,该组应匹配失败")
|
||||
void testSingleConditionGroup_oneConditionFails_shouldFail() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "50");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createSingleGroupWithMultipleConditions(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO tempProperty = new IotDevicePropertyDO();
|
||||
tempProperty.setValue(30);
|
||||
properties.put("temperature", tempProperty);
|
||||
IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO();
|
||||
humidityProperty.setValue(60); // 不满足 < 50
|
||||
properties.put("humidity", humidityProperty);
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("所有条件组不满足测试 - Validates: Requirement 2.5")
|
||||
class AllConditionGroupsFailTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("所有条件组都不满足时,应跳过动作执行")
|
||||
void testAllConditionGroups_allFail_shouldSkipExecution() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createTwoSingleConditionGroups(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("设备状态条件测试 - Validates: Requirements 4.1, 4.2")
|
||||
class DeviceStateConditionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("设备在线状态条件满足时,应匹配成功")
|
||||
void testDeviceStateCondition_online_shouldMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备不存在时,条件应不匹配")
|
||||
void testDeviceStateCondition_deviceNotExists_shouldNotMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(deviceService.getDevice(DEVICE_ID)).thenReturn(null);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("设备属性条件测试 - Validates: Requirements 3.1, 3.2, 3.3")
|
||||
class DevicePropertyConditionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属性条件满足时,应匹配成功")
|
||||
void testDevicePropertyCondition_match_shouldPass() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "25");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属性不存在时,条件应不匹配")
|
||||
void testDevicePropertyCondition_propertyNotExists_shouldNotMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition(
|
||||
DEVICE_ID, "nonexistent", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "25");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(Collections.emptyMap());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属性等于条件测试")
|
||||
void testDevicePropertyCondition_equals_shouldMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), "30");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("场景规则状态测试")
|
||||
class SceneRuleStatusTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("场景规则不存在时,应直接返回")
|
||||
void testSceneRule_notExists_shouldReturn() {
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(null);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("场景规则已禁用时,应直接返回")
|
||||
void testSceneRule_disabled_shouldReturn() {
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
sceneRule.setStatus(CommonStatusEnum.DISABLE.getStatus());
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("场景规则无定时触发器时,应直接返回")
|
||||
void testSceneRule_noTimerTrigger_shouldReturn() {
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger deviceTrigger = new IotSceneRuleDO.Trigger();
|
||||
deviceTrigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType());
|
||||
sceneRule.setTriggers(ListUtil.toList(deviceTrigger));
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("复杂条件组合测试")
|
||||
class ComplexConditionCombinationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("混合条件类型测试:设备属性 + 设备状态")
|
||||
void testMixedConditionTypes_propertyAndState() {
|
||||
IotSceneRuleDO.TriggerCondition propertyCondition = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition stateCondition = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createSingleGroupWithMultipleConditions(propertyCondition, stateCondition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多条件组 OR 逻辑 + 组内 AND 逻辑综合测试")
|
||||
void testComplexOrAndLogic() {
|
||||
// 条件组1:温度 > 30 AND 湿度 < 50(不满足)
|
||||
// 条件组2:温度 > 20 AND 设备在线(满足)
|
||||
IotSceneRuleDO.TriggerCondition group1Cond1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "30");
|
||||
IotSceneRuleDO.TriggerCondition group1Cond2 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "50");
|
||||
|
||||
IotSceneRuleDO.TriggerCondition group2Cond1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition group2Cond2 = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
// 创建两个条件组
|
||||
List<IotSceneRuleDO.TriggerCondition> group1 = new ArrayList<>();
|
||||
group1.add(group1Cond1);
|
||||
group1.add(group1Cond2);
|
||||
List<IotSceneRuleDO.TriggerCondition> group2 = new ArrayList<>();
|
||||
group2.add(group2Cond1);
|
||||
group2.add(group2Cond2);
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = new ArrayList<>();
|
||||
conditionGroups.add(group1);
|
||||
conditionGroups.add(group2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
// Mock:温度 25,湿度 60,设备在线
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO tempProperty = new IotDevicePropertyDO();
|
||||
tempProperty.setValue(25);
|
||||
properties.put("temperature", tempProperty);
|
||||
IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO();
|
||||
humidityProperty.setValue(60);
|
||||
properties.put("humidity", humidityProperty);
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties);
|
||||
mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.iocoder.yudao.module.iot.core.biz.dto;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 子设备动态注册 Request DTO
|
||||
* <p>
|
||||
* 额外包含了网关设备的标识信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotSubDeviceRegisterFullReqDTO {
|
||||
|
||||
/**
|
||||
* 网关设备 ProductKey
|
||||
*/
|
||||
@NotEmpty(message = "网关产品标识不能为空")
|
||||
private String gatewayProductKey;
|
||||
|
||||
/**
|
||||
* 网关设备 DeviceName
|
||||
*/
|
||||
@NotEmpty(message = "网关设备名称不能为空")
|
||||
private String gatewayDeviceName;
|
||||
|
||||
/**
|
||||
* 子设备注册列表
|
||||
*/
|
||||
@NotNull(message = "子设备注册列表不能为空")
|
||||
private List<IotSubDeviceRegisterReqDTO> subDevices;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
/**
|
||||
* IoT 设备标识
|
||||
*
|
||||
* 用于标识一个设备的基本信息(productKey + deviceName)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceIdentity {
|
||||
|
||||
/**
|
||||
* 产品标识
|
||||
*/
|
||||
@NotEmpty(message = "产品标识不能为空")
|
||||
private String productKey;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
@NotEmpty(message = "设备名称不能为空")
|
||||
private String deviceName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
/**
|
||||
* IoT 设备动态注册 Request DTO
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册:使用 productSecret 验证,返回 deviceSecret
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDeviceRegisterReqDTO {
|
||||
|
||||
/**
|
||||
* 产品标识
|
||||
*/
|
||||
@NotEmpty(message = "产品标识不能为空")
|
||||
private String productKey;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
@NotEmpty(message = "设备名称不能为空")
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 产品密钥
|
||||
*/
|
||||
@NotEmpty(message = "产品密钥不能为空")
|
||||
private String productSecret;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IoT 设备动态注册 Response DTO
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册响应
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceRegisterRespDTO {
|
||||
|
||||
/**
|
||||
* 产品标识
|
||||
*/
|
||||
private String productKey;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 设备密钥
|
||||
*/
|
||||
private String deviceSecret;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
/**
|
||||
* IoT 子设备动态注册 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.auth.register.sub 消息的 params 数组元素
|
||||
*
|
||||
* 特殊:网关子设备的动态注册,必须已经创建好该网关子设备(不然哪来的 {@link #deviceName} 字段)。更多的好处,是设备不用提前烧录 deviceSecret 密钥。
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/register-devices">阿里云 - 动态注册子设备</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotSubDeviceRegisterReqDTO {
|
||||
|
||||
/**
|
||||
* 子设备 ProductKey
|
||||
*/
|
||||
@NotEmpty(message = "产品标识不能为空")
|
||||
private String productKey;
|
||||
|
||||
/**
|
||||
* 子设备 DeviceName
|
||||
*/
|
||||
@NotEmpty(message = "设备名称不能为空")
|
||||
private String deviceName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IoT 子设备动态注册 Response DTO
|
||||
* <p>
|
||||
* 用于 thing.auth.register.sub 响应的设备信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/register-devices">阿里云 - 动态注册子设备</a>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotSubDeviceRegisterRespDTO {
|
||||
|
||||
/**
|
||||
* 子设备 ProductKey
|
||||
*/
|
||||
private String productKey;
|
||||
|
||||
/**
|
||||
* 子设备 DeviceName
|
||||
*/
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 分配的 DeviceSecret
|
||||
*/
|
||||
private String deviceSecret;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.event;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 设备事件上报 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.event.post 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/device-reporting-events">阿里云 - 设备上报事件</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDeviceEventPostReqDTO {
|
||||
|
||||
/**
|
||||
* 事件标识符
|
||||
*/
|
||||
private String identifier;
|
||||
|
||||
/**
|
||||
* 事件输出参数
|
||||
*/
|
||||
private Object value;
|
||||
|
||||
/**
|
||||
* 上报时间(毫秒时间戳,可选)
|
||||
*/
|
||||
private Long time;
|
||||
|
||||
/**
|
||||
* 创建事件上报 DTO
|
||||
*
|
||||
* @param identifier 事件标识符
|
||||
* @param value 事件值
|
||||
* @return DTO 对象
|
||||
*/
|
||||
public static IotDeviceEventPostReqDTO of(String identifier, Object value) {
|
||||
return of(identifier, value, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建事件上报 DTO(带时间)
|
||||
*
|
||||
* @param identifier 事件标识符
|
||||
* @param value 事件值
|
||||
* @param time 上报时间
|
||||
* @return DTO 对象
|
||||
*/
|
||||
public static IotDeviceEventPostReqDTO of(String identifier, Object value, Long time) {
|
||||
return new IotDeviceEventPostReqDTO().setIdentifier(identifier).setValue(value).setTime(time);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* IoT Topic 消息体 DTO 定义
|
||||
* <p>
|
||||
* 定义设备与平台通信的消息体结构,遵循(参考)阿里云 Alink 协议规范
|
||||
*
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/alink-protocol-1">阿里云 Alink 协议</a>
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.core.topic;
|
||||
@@ -0,0 +1,88 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.property;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 设备属性批量上报 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.event.property.pack.post 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/gateway-reports-data-in-batches">阿里云 - 网关批量上报数据</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDevicePropertyPackPostReqDTO {
|
||||
|
||||
/**
|
||||
* 网关自身属性
|
||||
* <p>
|
||||
* key: 属性标识符
|
||||
* value: 属性值
|
||||
*/
|
||||
private Map<String, Object> properties;
|
||||
|
||||
/**
|
||||
* 网关自身事件
|
||||
* <p>
|
||||
* key: 事件标识符
|
||||
* value: 事件值对象(包含 value 和 time)
|
||||
*/
|
||||
private Map<String, EventValue> events;
|
||||
|
||||
/**
|
||||
* 子设备数据列表
|
||||
*/
|
||||
private List<SubDeviceData> subDevices;
|
||||
|
||||
/**
|
||||
* 事件值对象
|
||||
*/
|
||||
@Data
|
||||
public static class EventValue {
|
||||
|
||||
/**
|
||||
* 事件参数
|
||||
*/
|
||||
private Object value;
|
||||
|
||||
/**
|
||||
* 上报时间(毫秒时间戳)
|
||||
*/
|
||||
private Long time;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 子设备数据
|
||||
*/
|
||||
@Data
|
||||
public static class SubDeviceData {
|
||||
|
||||
/**
|
||||
* 子设备标识
|
||||
*/
|
||||
private IotDeviceIdentity identity;
|
||||
|
||||
/**
|
||||
* 子设备属性
|
||||
* <p>
|
||||
* key: 属性标识符
|
||||
* value: 属性值
|
||||
*/
|
||||
private Map<String, Object> properties;
|
||||
|
||||
/**
|
||||
* 子设备事件
|
||||
* <p>
|
||||
* key: 事件标识符
|
||||
* value: 事件值对象(包含 value 和 time)
|
||||
*/
|
||||
private Map<String, EventValue> events;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.property;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 设备属性上报 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.property.post 消息的 params 参数
|
||||
* <p>
|
||||
* 本质是一个 Map,key 为属性标识符,value 为属性值
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/device-reporting-attributes">阿里云 - 设备上报属性</a>
|
||||
*/
|
||||
public class IotDevicePropertyPostReqDTO extends HashMap<String, Object> {
|
||||
|
||||
public IotDevicePropertyPostReqDTO() {
|
||||
super();
|
||||
}
|
||||
|
||||
public IotDevicePropertyPostReqDTO(Map<String, Object> properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建属性上报 DTO
|
||||
*
|
||||
* @param properties 属性数据
|
||||
* @return DTO 对象
|
||||
*/
|
||||
public static IotDevicePropertyPostReqDTO of(Map<String, Object> properties) {
|
||||
return new IotDevicePropertyPostReqDTO(properties);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 设备拓扑添加 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.add 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/add-topological-relationship">阿里云 - 添加拓扑关系</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDeviceTopoAddReqDTO {
|
||||
|
||||
/**
|
||||
* 子设备认证信息列表
|
||||
* <p>
|
||||
* 复用 {@link IotDeviceAuthReqDTO},包含 clientId、username、password
|
||||
*/
|
||||
@NotEmpty(message = "子设备认证信息列表不能为空")
|
||||
private List<IotDeviceAuthReqDTO> subDevices;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 设备拓扑关系变更通知 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.change 下行消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/notify-gateway-topology-changes">阿里云 - 通知网关拓扑关系变化</a>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceTopoChangeReqDTO {
|
||||
|
||||
public static final Integer STATUS_CREATE = 0;
|
||||
public static final Integer STATUS_DELETE = 1;
|
||||
|
||||
/**
|
||||
* 拓扑关系状态
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 子设备列表
|
||||
*/
|
||||
private List<IotDeviceIdentity> subList;
|
||||
|
||||
public static IotDeviceTopoChangeReqDTO ofCreate(List<IotDeviceIdentity> subList) {
|
||||
return new IotDeviceTopoChangeReqDTO(STATUS_CREATE, subList);
|
||||
}
|
||||
|
||||
public static IotDeviceTopoChangeReqDTO ofDelete(List<IotDeviceIdentity> subList) {
|
||||
return new IotDeviceTopoChangeReqDTO(STATUS_DELETE, subList);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 设备拓扑删除 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.delete 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/delete-a-topological-relationship">阿里云 - 删除拓扑关系</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDeviceTopoDeleteReqDTO {
|
||||
|
||||
/**
|
||||
* 子设备标识列表
|
||||
*/
|
||||
@Valid
|
||||
@NotEmpty(message = "子设备标识列表不能为空")
|
||||
private List<IotDeviceIdentity> subDevices;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 设备拓扑关系获取 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.get 请求的 params 参数(目前为空,预留扩展)
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/obtain-topological-relationship">阿里云 - 获取拓扑关系</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDeviceTopoGetReqDTO {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 设备拓扑关系获取 Response DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.get 响应
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/obtain-topological-relationship">阿里云 - 获取拓扑关系</a>
|
||||
*/
|
||||
@Data
|
||||
public class IotDeviceTopoGetRespDTO {
|
||||
|
||||
/**
|
||||
* 子设备列表
|
||||
*/
|
||||
private List<IotDeviceIdentity> subDevices;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotCoapDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotCoapUpstreamProtocol protocol;
|
||||
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
messageBus.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroup() {
|
||||
// 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
return getTopic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
// 如需支持,可通过 CoAP Observe 模式实现(设备订阅资源,服务器推送变更)
|
||||
log.warn("[onMessage][IoT 网关 CoAP 协议暂不支持下行消息,忽略消息:{}]", message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.router.*;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.CoapServer;
|
||||
import org.eclipse.californium.core.config.CoapConfig;
|
||||
import org.eclipse.californium.elements.config.Configuration;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议:接收设备上行消息
|
||||
*
|
||||
* 基于 Eclipse Californium 实现,支持:
|
||||
* 1. 认证:POST /auth
|
||||
* 2. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* 3. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapUpstreamProtocol {
|
||||
|
||||
private final IotGatewayProperties.CoapProperties coapProperties;
|
||||
|
||||
private CoapServer coapServer;
|
||||
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
public IotCoapUpstreamProtocol(IotGatewayProperties.CoapProperties coapProperties) {
|
||||
this.coapProperties = coapProperties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(coapProperties.getPort());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
try {
|
||||
// 1.1 创建网络配置(Californium 3.x API)
|
||||
Configuration config = Configuration.createStandardWithoutFile();
|
||||
config.set(CoapConfig.COAP_PORT, coapProperties.getPort());
|
||||
config.set(CoapConfig.MAX_MESSAGE_SIZE, coapProperties.getMaxMessageSize());
|
||||
config.set(CoapConfig.ACK_TIMEOUT, coapProperties.getAckTimeout(), TimeUnit.MILLISECONDS);
|
||||
config.set(CoapConfig.MAX_RETRANSMIT, coapProperties.getMaxRetransmit());
|
||||
// 1.2 创建 CoAP 服务器
|
||||
coapServer = new CoapServer(config);
|
||||
|
||||
// 2.1 添加 /auth 认证资源
|
||||
IotCoapAuthHandler authHandler = new IotCoapAuthHandler();
|
||||
IotCoapAuthResource authResource = new IotCoapAuthResource(this, authHandler);
|
||||
coapServer.add(authResource);
|
||||
// 2.2 添加 /auth/register/device 设备动态注册资源(一型一密)
|
||||
IotCoapRegisterHandler registerHandler = new IotCoapRegisterHandler();
|
||||
IotCoapRegisterResource registerResource = new IotCoapRegisterResource(registerHandler);
|
||||
authResource.add(new CoapResource("register") {{
|
||||
add(registerResource);
|
||||
}});
|
||||
// 2.3 添加 /topic 根资源(用于上行消息)
|
||||
IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler();
|
||||
IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(this, upstreamHandler);
|
||||
coapServer.add(topicResource);
|
||||
|
||||
// 3. 启动服务器
|
||||
coapServer.start();
|
||||
log.info("[start][IoT 网关 CoAP 协议启动成功,端口:{},资源:/auth, /auth/register/device, /topic]", coapProperties.getPort());
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT 网关 CoAP 协议启动失败]", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (coapServer != null) {
|
||||
try {
|
||||
coapServer.stop();
|
||||
log.info("[stop][IoT 网关 CoAP 协议已停止]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT 网关 CoAP 协议停止失败]", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* CoAP 协议实现包
|
||||
* <p>
|
||||
* 提供基于 Eclipse Californium 的 IoT 设备连接和消息处理功能
|
||||
* <p>
|
||||
* URI 路径:
|
||||
* - 认证:POST /auth
|
||||
* - 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* - 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
* <p>
|
||||
* Token 通过 CoAP Option 2088 携带
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
@@ -0,0 +1,117 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【认证】处理器
|
||||
*
|
||||
* 参考 {@link cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpAuthHandler}
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapAuthHandler {
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotCoapAuthHandler() {
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证请求
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param protocol 协议对象
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public void handle(CoapExchange exchange, IotCoapUpstreamProtocol protocol) {
|
||||
try {
|
||||
// 1.1 解析请求体
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (payload == null || payload.length == 0) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体不能为空");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body;
|
||||
try {
|
||||
body = JsonUtils.parseObject(new String(payload), Map.class);
|
||||
} catch (Exception e) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
// 1.2 解析参数
|
||||
String clientId = MapUtil.getStr(body, "clientId");
|
||||
if (StrUtil.isEmpty(clientId)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "clientId 不能为空");
|
||||
return;
|
||||
}
|
||||
String username = MapUtil.getStr(body, "username");
|
||||
if (StrUtil.isEmpty(username)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "username 不能为空");
|
||||
return;
|
||||
}
|
||||
String password = MapUtil.getStr(body, "password");
|
||||
if (StrUtil.isEmpty(password)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "password 不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 执行认证
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(clientId).setUsername(username).setPassword(password));
|
||||
if (result.isError()) {
|
||||
log.warn("[handle][认证失败,clientId: {}, 错误: {}]", clientId, result.getMsg());
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "认证失败:" + result.getMsg());
|
||||
return;
|
||||
}
|
||||
if (!BooleanUtil.isTrue(result.getData())) {
|
||||
log.warn("[handle][认证失败,clientId: {}]", clientId);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "认证失败");
|
||||
return;
|
||||
}
|
||||
// 2.2 生成 Token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(username);
|
||||
Assert.notNull(deviceInfo, "设备信息不能为空");
|
||||
String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
Assert.notBlank(token, "生成 token 不能为空");
|
||||
|
||||
// 3. 执行上线
|
||||
IotDeviceMessage message = IotDeviceMessage.buildStateUpdateOnline();
|
||||
deviceMessageService.sendDeviceMessage(message,
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName(), protocol.getServerId());
|
||||
|
||||
// 4. 返回成功响应
|
||||
log.info("[handle][认证成功,productKey: {}, deviceName: {}]",
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
IotCoapUtils.respondSuccess(exchange, MapUtil.of("token", token));
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][认证处理异常]", e);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.INTERNAL_SERVER_ERROR, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的认证资源(/auth)
|
||||
*
|
||||
* 设备通过此资源进行认证,获取 Token
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapAuthResource extends CoapResource {
|
||||
|
||||
public static final String PATH = "auth";
|
||||
|
||||
private final IotCoapUpstreamProtocol protocol;
|
||||
private final IotCoapAuthHandler authHandler;
|
||||
|
||||
public IotCoapAuthResource(IotCoapUpstreamProtocol protocol,
|
||||
IotCoapAuthHandler authHandler) {
|
||||
super(PATH);
|
||||
this.protocol = protocol;
|
||||
this.authHandler = authHandler;
|
||||
log.info("[IotCoapAuthResource][创建 CoAP 认证资源: /{}]", PATH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePOST(CoapExchange exchange) {
|
||||
log.debug("[handlePOST][收到 /auth POST 请求]");
|
||||
authHandler.handle(exchange, protocol);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【设备动态注册】处理器
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册,不需要认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
* @see cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpRegisterHandler
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapRegisterHandler {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotCoapRegisterHandler() {
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备动态注册请求
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public void handle(CoapExchange exchange) {
|
||||
try {
|
||||
// 1.1 解析请求体
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (payload == null || payload.length == 0) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体不能为空");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body;
|
||||
try {
|
||||
body = JsonUtils.parseObject(new String(payload), Map.class);
|
||||
} catch (Exception e) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.2 解析参数
|
||||
String productKey = MapUtil.getStr(body, "productKey");
|
||||
if (StrUtil.isEmpty(productKey)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "productKey 不能为空");
|
||||
return;
|
||||
}
|
||||
String deviceName = MapUtil.getStr(body, "deviceName");
|
||||
if (StrUtil.isEmpty(deviceName)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "deviceName 不能为空");
|
||||
return;
|
||||
}
|
||||
String productSecret = MapUtil.getStr(body, "productSecret");
|
||||
if (StrUtil.isEmpty(productSecret)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "productSecret 不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 调用动态注册
|
||||
IotDeviceRegisterReqDTO reqDTO = new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(productKey)
|
||||
.setDeviceName(deviceName)
|
||||
.setProductSecret(productSecret);
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(reqDTO);
|
||||
if (result.isError()) {
|
||||
log.warn("[handle][设备动态注册失败,productKey: {}, deviceName: {}, 错误: {}]",
|
||||
productKey, deviceName, result.getMsg());
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST,
|
||||
"设备动态注册失败:" + result.getMsg());
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 返回成功响应
|
||||
log.info("[handle][设备动态注册成功,productKey: {}, deviceName: {}]", productKey, deviceName);
|
||||
IotCoapUtils.respondSuccess(exchange, result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][设备动态注册处理异常]", e);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.INTERNAL_SERVER_ERROR, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的设备动态注册资源(/auth/register/device)
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册,不需要认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapRegisterResource extends CoapResource {
|
||||
|
||||
public static final String PATH = "device";
|
||||
|
||||
private final IotCoapRegisterHandler registerHandler;
|
||||
|
||||
public IotCoapRegisterResource(IotCoapRegisterHandler registerHandler) {
|
||||
super(PATH);
|
||||
this.registerHandler = registerHandler;
|
||||
log.info("[IotCoapRegisterResource][创建 CoAP 设备动态注册资源: /auth/register/{}]", PATH);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePOST(CoapExchange exchange) {
|
||||
log.debug("[handlePOST][收到设备动态注册请求]");
|
||||
registerHandler.handle(exchange);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.text.StrPool;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【上行】处理器
|
||||
*
|
||||
* 处理设备通过 CoAP 协议发送的上行消息,包括:
|
||||
* 1. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* 2. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
*
|
||||
* Token 通过自定义 CoAP Option 2088 携带
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapUpstreamHandler {
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotCoapUpstreamHandler() {
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 CoAP 请求
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param protocol 协议对象
|
||||
*/
|
||||
public void handle(CoapExchange exchange, IotCoapUpstreamProtocol protocol) {
|
||||
try {
|
||||
// 1. 解析通用参数
|
||||
List<String> uriPath = exchange.getRequestOptions().getUriPath();
|
||||
String productKey = CollUtil.get(uriPath, 2);
|
||||
String deviceName = CollUtil.get(uriPath, 3);
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (StrUtil.isEmpty(productKey)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "productKey 不能为空");
|
||||
return;
|
||||
}
|
||||
if (StrUtil.isEmpty(deviceName)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "deviceName 不能为空");
|
||||
return;
|
||||
}
|
||||
if (ArrayUtil.isEmpty(payload)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 认证:从自定义 Option 获取 token
|
||||
String token = IotCoapUtils.getTokenFromOption(exchange, IotCoapUtils.OPTION_TOKEN);
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "token 不能为空");
|
||||
return;
|
||||
}
|
||||
// 验证 token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token);
|
||||
if (deviceInfo == null) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "token 无效或已过期");
|
||||
return;
|
||||
}
|
||||
// 验证设备信息匹配
|
||||
if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey())
|
||||
|| ObjUtil.notEqual(deviceName, deviceInfo.getDeviceName())) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.FORBIDDEN, "设备信息与 token 不匹配");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 解析 method:deviceName 后面的路径,用 . 拼接
|
||||
// 路径格式:[topic, sys, productKey, deviceName, thing, property, post]
|
||||
String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size()));
|
||||
|
||||
// 2.2 解码消息
|
||||
IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName);
|
||||
if (ObjUtil.notEqual(method, message.getMethod())) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "method 不匹配");
|
||||
return;
|
||||
}
|
||||
// 2.3 发送消息到消息总线
|
||||
deviceMessageService.sendDeviceMessage(message, productKey, deviceName, protocol.getServerId());
|
||||
|
||||
// 3. 返回成功响应
|
||||
IotCoapUtils.respondSuccess(exchange, MapUtil.of("messageId", message.getId()));
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][CoAP 请求处理异常]", e);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.INTERNAL_SERVER_ERROR, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
import org.eclipse.californium.core.server.resources.Resource;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【上行】Topic 资源
|
||||
*
|
||||
* 支持任意深度的路径匹配:
|
||||
* - /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* - /topic/sys/{productKey}/{deviceName}/thing/event/{eventId}/post
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapUpstreamTopicResource extends CoapResource {
|
||||
|
||||
public static final String PATH = "topic";
|
||||
|
||||
private final IotCoapUpstreamProtocol protocol;
|
||||
private final IotCoapUpstreamHandler upstreamHandler;
|
||||
|
||||
/**
|
||||
* 创建根资源(/topic)
|
||||
*/
|
||||
public IotCoapUpstreamTopicResource(IotCoapUpstreamProtocol protocol,
|
||||
IotCoapUpstreamHandler upstreamHandler) {
|
||||
this(PATH, protocol, upstreamHandler);
|
||||
log.info("[IotCoapUpstreamTopicResource][创建 CoAP 上行 Topic 资源: /{}]", PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建子资源(动态路径)
|
||||
*/
|
||||
private IotCoapUpstreamTopicResource(String name,
|
||||
IotCoapUpstreamProtocol protocol,
|
||||
IotCoapUpstreamHandler upstreamHandler) {
|
||||
super(name);
|
||||
this.protocol = protocol;
|
||||
this.upstreamHandler = upstreamHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource getChild(String name) {
|
||||
// 递归创建动态子资源,支持任意深度路径
|
||||
return new IotCoapUpstreamTopicResource(name, protocol, upstreamHandler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleGET(CoapExchange exchange) {
|
||||
upstreamHandler.handle(exchange, protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePOST(CoapExchange exchange) {
|
||||
upstreamHandler.handle(exchange, protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePUT(CoapExchange exchange) {
|
||||
upstreamHandler.handle(exchange, protocol);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.util;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.coap.MediaTypeRegistry;
|
||||
import org.eclipse.californium.core.coap.Option;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* IoT CoAP 协议工具类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public class IotCoapUtils {
|
||||
|
||||
/**
|
||||
* 自定义 CoAP Option 编号,用于携带 Token
|
||||
* <p>
|
||||
* CoAP Option 范围 2048-65535 属于实验/自定义范围
|
||||
*/
|
||||
public static final int OPTION_TOKEN = 2088;
|
||||
|
||||
/**
|
||||
* 返回成功响应
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param data 响应数据
|
||||
*/
|
||||
public static void respondSuccess(CoapExchange exchange, Object data) {
|
||||
CommonResult<Object> result = CommonResult.success(data);
|
||||
String json = JsonUtils.toJsonString(result);
|
||||
exchange.respond(CoAP.ResponseCode.CONTENT, json, MediaTypeRegistry.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回错误响应
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param code CoAP 响应码
|
||||
* @param message 错误消息
|
||||
*/
|
||||
public static void respondError(CoapExchange exchange, CoAP.ResponseCode code, String message) {
|
||||
int errorCode = mapCoapCodeToErrorCode(code);
|
||||
CommonResult<Object> result = CommonResult.error(errorCode, message);
|
||||
String json = JsonUtils.toJsonString(result);
|
||||
exchange.respond(code, json, MediaTypeRegistry.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从自定义 CoAP Option 中获取 Token
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param optionNumber Option 编号
|
||||
* @return Token 值,如果不存在则返回 null
|
||||
*/
|
||||
public static String getTokenFromOption(CoapExchange exchange, int optionNumber) {
|
||||
Option option = CollUtil.findOne(exchange.getRequestOptions().getOthers(),
|
||||
o -> o.getNumber() == optionNumber);
|
||||
return option != null ? new String(option.getValue()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 CoAP 响应码映射到业务错误码
|
||||
*
|
||||
* @param code CoAP 响应码
|
||||
* @return 业务错误码
|
||||
*/
|
||||
public static int mapCoapCodeToErrorCode(CoAP.ResponseCode code) {
|
||||
if (code == CoAP.ResponseCode.BAD_REQUEST) {
|
||||
return BAD_REQUEST.getCode();
|
||||
} else if (code == CoAP.ResponseCode.UNAUTHORIZED) {
|
||||
return UNAUTHORIZED.getCode();
|
||||
} else if (code == CoAP.ResponseCode.FORBIDDEN) {
|
||||
return FORBIDDEN.getCode();
|
||||
} else {
|
||||
return INTERNAL_SERVER_ERROR.getCode();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* HTTP 协议实现包
|
||||
* <p>
|
||||
* 提供基于 Vert.x HTTP Server 的 IoT 设备连接和消息处理功能
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
@@ -0,0 +1,63 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 协议的【设备动态注册】处理器
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册,不需要认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
*/
|
||||
public class IotHttpRegisterHandler extends IotHttpAbstractHandler {
|
||||
|
||||
public static final String PATH = "/auth/register/device";
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotHttpRegisterHandler() {
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Object> handle0(RoutingContext context) {
|
||||
// 1. 解析参数
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
String productKey = body.getString("productKey");
|
||||
if (StrUtil.isEmpty(productKey)) {
|
||||
throw invalidParamException("productKey 不能为空");
|
||||
}
|
||||
String deviceName = body.getString("deviceName");
|
||||
if (StrUtil.isEmpty(deviceName)) {
|
||||
throw invalidParamException("deviceName 不能为空");
|
||||
}
|
||||
String productSecret = body.getString("productSecret");
|
||||
if (StrUtil.isEmpty(productSecret)) {
|
||||
throw invalidParamException("productSecret 不能为空");
|
||||
}
|
||||
|
||||
// 2. 调用动态注册
|
||||
IotDeviceRegisterReqDTO reqDTO = new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(productKey).setDeviceName(deviceName).setProductSecret(productSecret);
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(reqDTO);
|
||||
result.checkError();
|
||||
|
||||
// 3. 返回结果
|
||||
return success(result.getData());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 协议的【子设备动态注册】处理器
|
||||
* <p>
|
||||
* 用于子设备的动态注册,需要网关认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/register-devices">阿里云 - 动态注册子设备</a>
|
||||
*/
|
||||
public class IotHttpRegisterSubHandler extends IotHttpAbstractHandler {
|
||||
|
||||
/**
|
||||
* 路径:/auth/register/sub-device/:productKey/:deviceName
|
||||
* <p>
|
||||
* productKey 和 deviceName 是网关设备的标识
|
||||
*/
|
||||
public static final String PATH = "/auth/register/sub-device/:productKey/:deviceName";
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotHttpRegisterSubHandler() {
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Object> handle0(RoutingContext context) {
|
||||
// 1. 解析通用参数
|
||||
String productKey = context.pathParam("productKey");
|
||||
String deviceName = context.pathParam("deviceName");
|
||||
|
||||
// 2. 解析子设备列表
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
if (body.getJsonArray("params") == null) {
|
||||
throw invalidParamException("params 不能为空");
|
||||
}
|
||||
List<cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO> subDevices = JsonUtils.parseArray(
|
||||
body.getJsonArray("params").toString(), cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO.class);
|
||||
|
||||
// 3. 调用子设备动态注册
|
||||
IotSubDeviceRegisterFullReqDTO reqDTO = new IotSubDeviceRegisterFullReqDTO()
|
||||
.setGatewayProductKey(productKey).setGatewayDeviceName(deviceName).setSubDevices(subDevices);
|
||||
CommonResult<List<IotSubDeviceRegisterRespDTO>> result = deviceApi.registerSubDevices(reqDTO);
|
||||
result.checkError();
|
||||
|
||||
// 4. 返回结果
|
||||
return success(result.getData());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* TCP 协议实现包
|
||||
* <p>
|
||||
* 提供基于 Vert.x TCP Server 的 IoT 设备连接和消息处理功能
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.tcp;
|
||||
@@ -0,0 +1,65 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.router.IotUdpDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* IoT 网关 UDP 下游订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class IotUdpDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotUdpUpstreamProtocol protocol;
|
||||
|
||||
private final IotDeviceMessageService messageService;
|
||||
|
||||
private final IotUdpSessionManager sessionManager;
|
||||
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
private IotUdpDownstreamHandler downstreamHandler;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// 初始化下游处理器
|
||||
this.downstreamHandler = new IotUdpDownstreamHandler(messageService, sessionManager, protocol);
|
||||
// 注册下游订阅者
|
||||
messageBus.register(this);
|
||||
log.info("[init][UDP 下游订阅者初始化完成,服务器 ID: {},Topic: {}]",
|
||||
protocol.getServerId(), getTopic());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroup() {
|
||||
// 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
return getTopic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
try {
|
||||
downstreamHandler.handle(message);
|
||||
} catch (Exception e) {
|
||||
log.error("[onMessage][处理下行消息失败,设备 ID: {},方法: {},消息 ID: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.router.IotUdpUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.datagram.DatagramSocket;
|
||||
import io.vertx.core.datagram.DatagramSocketOptions;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 网关 UDP 协议:接收设备上行消息
|
||||
* <p>
|
||||
* 采用 Vertx DatagramSocket 实现 UDP 服务器,主要功能:
|
||||
* 1. 监听 UDP 端口,接收设备消息
|
||||
* 2. 定期清理不活跃的设备地址映射
|
||||
* 3. 提供 UDP Socket 用于下行消息发送
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotUdpUpstreamProtocol {
|
||||
|
||||
private final IotGatewayProperties.UdpProperties udpProperties;
|
||||
|
||||
private final IotDeviceService deviceService;
|
||||
|
||||
private final IotDeviceMessageService messageService;
|
||||
|
||||
private final IotUdpSessionManager sessionManager;
|
||||
|
||||
private final Vertx vertx;
|
||||
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
@Getter
|
||||
private DatagramSocket udpSocket;
|
||||
|
||||
/**
|
||||
* 会话清理定时器 ID
|
||||
*/
|
||||
private Long cleanTimerId;
|
||||
|
||||
private IotUdpUpstreamHandler upstreamHandler;
|
||||
|
||||
public IotUdpUpstreamProtocol(IotGatewayProperties.UdpProperties udpProperties,
|
||||
IotDeviceService deviceService,
|
||||
IotDeviceMessageService messageService,
|
||||
IotUdpSessionManager sessionManager,
|
||||
Vertx vertx) {
|
||||
this.udpProperties = udpProperties;
|
||||
this.deviceService = deviceService;
|
||||
this.messageService = messageService;
|
||||
this.sessionManager = sessionManager;
|
||||
this.vertx = vertx;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(udpProperties.getPort());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
// 1. 初始化上行消息处理器
|
||||
this.upstreamHandler = new IotUdpUpstreamHandler(this, messageService, deviceService, sessionManager);
|
||||
|
||||
// 2. 创建 UDP Socket 选项
|
||||
DatagramSocketOptions options = new DatagramSocketOptions()
|
||||
.setReceiveBufferSize(udpProperties.getReceiveBufferSize())
|
||||
.setSendBufferSize(udpProperties.getSendBufferSize())
|
||||
.setReuseAddress(true);
|
||||
|
||||
// 3. 创建 UDP Socket
|
||||
udpSocket = vertx.createDatagramSocket(options);
|
||||
|
||||
// 4. 监听端口
|
||||
udpSocket.listen(udpProperties.getPort(), "0.0.0.0", result -> {
|
||||
if (result.failed()) {
|
||||
log.error("[start][IoT 网关 UDP 协议启动失败]", result.cause());
|
||||
return;
|
||||
}
|
||||
// 设置数据包处理器
|
||||
udpSocket.handler(packet -> upstreamHandler.handle(packet, udpSocket));
|
||||
log.info("[start][IoT 网关 UDP 协议启动成功,端口:{},接收缓冲区:{} 字节,发送缓冲区:{} 字节]",
|
||||
udpProperties.getPort(), udpProperties.getReceiveBufferSize(),
|
||||
udpProperties.getSendBufferSize());
|
||||
|
||||
// 5. 启动会话清理定时器
|
||||
startSessionCleanTimer();
|
||||
});
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
// 1. 取消会话清理定时器
|
||||
if (cleanTimerId != null) {
|
||||
vertx.cancelTimer(cleanTimerId);
|
||||
cleanTimerId = null;
|
||||
log.info("[stop][会话清理定时器已取消]");
|
||||
}
|
||||
|
||||
// 2. 关闭 UDP Socket
|
||||
if (udpSocket != null) {
|
||||
try {
|
||||
udpSocket.close().result();
|
||||
log.info("[stop][IoT 网关 UDP 协议已停止]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT 网关 UDP 协议停止失败]", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动会话清理定时器
|
||||
*/
|
||||
private void startSessionCleanTimer() {
|
||||
cleanTimerId = vertx.setPeriodic(udpProperties.getSessionCleanIntervalMs(), id -> {
|
||||
try {
|
||||
// 1. 清理超时的设备地址映射,并获取离线设备列表
|
||||
List<Long> offlineDeviceIds = sessionManager.cleanExpiredMappings(udpProperties.getSessionTimeoutMs());
|
||||
|
||||
// 2. 为每个离线设备发送离线消息
|
||||
for (Long deviceId : offlineDeviceIds) {
|
||||
sendOfflineMessage(deviceId);
|
||||
}
|
||||
if (CollUtil.isNotEmpty(offlineDeviceIds)) {
|
||||
log.info("[cleanExpiredMappings][本次清理 {} 个超时设备]", offlineDeviceIds.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[cleanExpiredMappings][清理超时会话失败]", e);
|
||||
}
|
||||
});
|
||||
log.info("[startSessionCleanTimer][会话清理定时器启动,间隔:{} ms,超时:{} ms]",
|
||||
udpProperties.getSessionCleanIntervalMs(), udpProperties.getSessionTimeoutMs());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备离线消息
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
*/
|
||||
private void sendOfflineMessage(Long deviceId) {
|
||||
try {
|
||||
// 获取设备信息
|
||||
IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceId);
|
||||
if (device == null) {
|
||||
log.warn("[sendOfflineMessage][设备不存在,设备 ID: {}]", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送离线消息
|
||||
IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline();
|
||||
messageService.sendDeviceMessage(offlineMessage, device.getProductKey(),
|
||||
device.getDeviceName(), serverId);
|
||||
log.info("[sendOfflineMessage][发送离线消息,设备 ID: {},设备名: {}]",
|
||||
deviceId, device.getDeviceName());
|
||||
} catch (Exception e) {
|
||||
log.error("[sendOfflineMessage][发送离线消息失败,设备 ID: {}]", deviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager;
|
||||
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.datagram.DatagramSocket;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* IoT 网关 UDP 会话管理器
|
||||
* <p>
|
||||
* 采用无状态设计,SessionManager 主要用于:
|
||||
* 1. 管理设备地址映射(用于下行消息发送)
|
||||
* 2. 定期清理不活跃的设备地址映射
|
||||
* <p>
|
||||
* 注意:UDP 是无连接协议,上行消息通过 token 验证身份,不依赖会话状态
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class IotUdpSessionManager {
|
||||
|
||||
/**
|
||||
* 设备 ID -> 会话信息(包含地址和 codecType)
|
||||
*/
|
||||
private final Map<Long, SessionInfo> deviceSessionMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 设备地址 Key -> 最后活跃时间(用于清理)
|
||||
*/
|
||||
private final Map<String, LocalDateTime> lastActiveTimeMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 设备地址 Key -> 设备 ID(反向映射,用于清理时同步)
|
||||
*/
|
||||
private final Map<String, Long> addressDeviceMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 更新设备会话(每次收到上行消息时调用)
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @param address 设备地址
|
||||
* @param codecType 消息编解码类型
|
||||
*/
|
||||
public void updateDeviceSession(Long deviceId, InetSocketAddress address, String codecType) {
|
||||
String addressKey = buildAddressKey(address);
|
||||
// 更新设备会话映射
|
||||
deviceSessionMap.put(deviceId, new SessionInfo().setAddress(address).setCodecType(codecType));
|
||||
lastActiveTimeMap.put(addressKey, LocalDateTime.now());
|
||||
addressDeviceMap.put(addressKey, deviceId);
|
||||
log.debug("[updateDeviceSession][更新设备会话,设备 ID: {},地址: {},codecType: {}]", deviceId, addressKey, codecType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备地址(兼容旧接口,默认不更新 codecType)
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @param address 设备地址
|
||||
*/
|
||||
public void updateDeviceAddress(Long deviceId, InetSocketAddress address) {
|
||||
SessionInfo sessionInfo = deviceSessionMap.get(deviceId);
|
||||
String codecType = sessionInfo != null ? sessionInfo.getCodecType() : null;
|
||||
updateDeviceSession(deviceId, address, codecType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备会话信息
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @return 会话信息
|
||||
*/
|
||||
public SessionInfo getSessionInfo(Long deviceId) {
|
||||
return deviceSessionMap.get(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备是否在线(即是否有地址映射)
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @return 是否在线
|
||||
*/
|
||||
public boolean isDeviceOnline(Long deviceId) {
|
||||
return deviceSessionMap.containsKey(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备是否离线
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @return 是否离线
|
||||
*/
|
||||
public boolean isDeviceOffline(Long deviceId) {
|
||||
return !isDeviceOnline(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到设备
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @param data 数据
|
||||
* @param socket UDP Socket
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendToDevice(Long deviceId, byte[] data, DatagramSocket socket) {
|
||||
SessionInfo sessionInfo = deviceSessionMap.get(deviceId);
|
||||
if (sessionInfo == null || sessionInfo.getAddress() == null) {
|
||||
log.warn("[sendToDevice][设备会话不存在,设备 ID: {}]", deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
InetSocketAddress address = sessionInfo.getAddress();
|
||||
try {
|
||||
socket.send(Buffer.buffer(data), address.getPort(), address.getHostString(), result -> {
|
||||
if (result.succeeded()) {
|
||||
log.debug("[sendToDevice][发送消息成功,设备 ID: {},地址: {},数据长度: {} 字节]",
|
||||
deviceId, buildAddressKey(address), data.length);
|
||||
} else {
|
||||
log.error("[sendToDevice][发送消息失败,设备 ID: {},地址: {}]",
|
||||
deviceId, buildAddressKey(address), result.cause());
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("[sendToDevice][发送消息异常,设备 ID: {}]", deviceId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期清理不活跃的设备地址映射
|
||||
*
|
||||
* @param timeoutMs 超时时间(毫秒)
|
||||
* @return 清理的设备 ID 列表(用于发送离线消息)
|
||||
*/
|
||||
public List<Long> cleanExpiredMappings(long timeoutMs) {
|
||||
List<Long> offlineDeviceIds = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime expireTime = now.minusNanos(timeoutMs * 1_000_000);
|
||||
Iterator<Map.Entry<String, LocalDateTime>> iterator = lastActiveTimeMap.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
// 未过期,跳过
|
||||
Map.Entry<String, LocalDateTime> entry = iterator.next();
|
||||
if (entry.getValue().isAfter(expireTime)) {
|
||||
continue;
|
||||
}
|
||||
// 过期处理:记录离线设备 ID
|
||||
String addressKey = entry.getKey();
|
||||
Long deviceId = addressDeviceMap.remove(addressKey);
|
||||
if (deviceId == null) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
SessionInfo sessionInfo = deviceSessionMap.remove(deviceId);
|
||||
if (sessionInfo == null) {
|
||||
iterator.remove();
|
||||
continue;
|
||||
}
|
||||
offlineDeviceIds.add(deviceId);
|
||||
log.debug("[cleanExpiredMappings][清理超时设备,设备 ID: {},地址: {},最后活跃时间: {}]",
|
||||
deviceId, addressKey, entry.getValue());
|
||||
iterator.remove();
|
||||
}
|
||||
return offlineDeviceIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建地址 Key
|
||||
*
|
||||
* @param address 地址
|
||||
* @return 地址 Key
|
||||
*/
|
||||
public String buildAddressKey(InetSocketAddress address) {
|
||||
return address.getHostString() + ":" + address.getPort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话信息
|
||||
*/
|
||||
@Data
|
||||
public static class SessionInfo {
|
||||
|
||||
/**
|
||||
* 设备地址
|
||||
*/
|
||||
private InetSocketAddress address;
|
||||
|
||||
/**
|
||||
* 消息编解码类型
|
||||
*/
|
||||
private String codecType;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* UDP 协议实现包
|
||||
* <p>
|
||||
* 提供基于 Vert.x DatagramSocket 的 IoT 设备连接和消息处理功能
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
|
||||
@@ -0,0 +1,70 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp.router;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.datagram.DatagramSocket;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 UDP 下行消息处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotUdpDownstreamHandler {
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
private final IotUdpSessionManager sessionManager;
|
||||
|
||||
private final IotUdpUpstreamProtocol protocol;
|
||||
|
||||
public IotUdpDownstreamHandler(IotDeviceMessageService deviceMessageService,
|
||||
IotUdpSessionManager sessionManager,
|
||||
IotUdpUpstreamProtocol protocol) {
|
||||
this.deviceMessageService = deviceMessageService;
|
||||
this.sessionManager = sessionManager;
|
||||
this.protocol = protocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理下行消息
|
||||
*
|
||||
* @param message 下行消息
|
||||
*/
|
||||
public void handle(IotDeviceMessage message) {
|
||||
try {
|
||||
log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId());
|
||||
|
||||
// 1. 获取会话信息(包含 codecType)
|
||||
IotUdpSessionManager.SessionInfo sessionInfo = sessionManager.getSessionInfo(message.getDeviceId());
|
||||
if (sessionInfo == null) {
|
||||
log.warn("[handle][设备不在线,设备 ID: {}]", message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 使用会话中的 codecType 编码消息,并发送到设备
|
||||
byte[] bytes = deviceMessageService.encodeDeviceMessage(message, sessionInfo.getCodecType());
|
||||
DatagramSocket socket = protocol.getUdpSocket();
|
||||
if (socket == null) {
|
||||
log.error("[handle][UDP Socket 不可用,设备 ID: {}]", message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
boolean success = sessionManager.sendToDevice(message.getDeviceId(), bytes, socket);
|
||||
if (success) {
|
||||
log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId(), bytes.length);
|
||||
} else {
|
||||
log.error("[handle][下行消息发送失败,设备 ID: {},方法: {},消息 ID: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp.router;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.datagram.DatagramPacket;
|
||||
import io.vertx.core.datagram.DatagramSocket;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* UDP 上行消息处理器
|
||||
* <p>
|
||||
* 采用无状态 Token 机制(每次请求携带 token):
|
||||
* 1. 认证请求:设备发送 auth 消息,携带 clientId、username、password
|
||||
* 2. 返回 Token:服务端验证后返回 JWT token
|
||||
* 3. 后续请求:每次请求在 params 中携带 token
|
||||
* 4. 服务端验证:每次请求通过 IotDeviceTokenService.verifyToken() 验证
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotUdpUpstreamHandler {
|
||||
|
||||
private static final String CODEC_TYPE_JSON = IotTcpJsonDeviceMessageCodec.TYPE;
|
||||
private static final String CODEC_TYPE_BINARY = IotTcpBinaryDeviceMessageCodec.TYPE;
|
||||
|
||||
private static final String AUTH_METHOD = "auth";
|
||||
/**
|
||||
* Token 参数 Key
|
||||
*/
|
||||
private static final String PARAM_KEY_TOKEN = "token";
|
||||
/**
|
||||
* Body 参数 Key(实际请求内容)
|
||||
*/
|
||||
private static final String PARAM_KEY_BODY = "body";
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
private final IotDeviceService deviceService;
|
||||
|
||||
private final IotUdpSessionManager sessionManager;
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
private final String serverId;
|
||||
|
||||
public IotUdpUpstreamHandler(IotUdpUpstreamProtocol protocol,
|
||||
IotDeviceMessageService deviceMessageService,
|
||||
IotDeviceService deviceService,
|
||||
IotUdpSessionManager sessionManager) {
|
||||
this.deviceMessageService = deviceMessageService;
|
||||
this.deviceService = deviceService;
|
||||
this.sessionManager = sessionManager;
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.serverId = protocol.getServerId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 UDP 数据包
|
||||
*
|
||||
* @param packet 数据包
|
||||
* @param socket UDP Socket
|
||||
*/
|
||||
public void handle(DatagramPacket packet, DatagramSocket socket) {
|
||||
InetSocketAddress senderAddress = new InetSocketAddress(packet.sender().host(), packet.sender().port());
|
||||
Buffer data = packet.data();
|
||||
log.debug("[handle][收到 UDP 数据包,来源: {},数据长度: {} 字节]",
|
||||
sessionManager.buildAddressKey(senderAddress), data.length());
|
||||
try {
|
||||
processMessage(data, senderAddress, socket);
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][处理消息失败,来源: {},错误: {}]",
|
||||
sessionManager.buildAddressKey(senderAddress), e.getMessage(), e);
|
||||
// UDP 无连接,不需要断开连接,只记录错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*
|
||||
* @param buffer 消息
|
||||
* @param senderAddress 发送者地址
|
||||
* @param socket UDP Socket
|
||||
*/
|
||||
private void processMessage(Buffer buffer, InetSocketAddress senderAddress, DatagramSocket socket) {
|
||||
// 1. 基础检查
|
||||
if (buffer == null || buffer.length() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取消息格式类型
|
||||
String codecType = getMessageCodecType(buffer);
|
||||
|
||||
// 3. 解码消息
|
||||
IotDeviceMessage message;
|
||||
try {
|
||||
message = deviceMessageService.decodeDeviceMessage(buffer.getBytes(), codecType);
|
||||
if (message == null) {
|
||||
log.warn("[processMessage][消息解码失败,来源: {}]", sessionManager.buildAddressKey(senderAddress));
|
||||
sendErrorResponse(socket, senderAddress, null, "消息解码失败", codecType);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[processMessage][消息解码异常,来源: {}]", sessionManager.buildAddressKey(senderAddress), e);
|
||||
sendErrorResponse(socket, senderAddress, null, "消息解码失败: " + e.getMessage(), codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 根据消息类型路由处理
|
||||
try {
|
||||
if (AUTH_METHOD.equals(message.getMethod())) {
|
||||
// 认证请求
|
||||
handleAuthenticationRequest(message, codecType, senderAddress, socket);
|
||||
} else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) {
|
||||
// 设备动态注册请求
|
||||
handleRegisterRequest(message, codecType, senderAddress, socket);
|
||||
} else {
|
||||
// 业务消息
|
||||
handleBusinessRequest(message, codecType, senderAddress, socket);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[processMessage][处理消息失败,来源: {},消息方法: {}]",
|
||||
sessionManager.buildAddressKey(senderAddress), message.getMethod(), e);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "消息处理失败", codecType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证请求
|
||||
*
|
||||
* @param message 消息信息
|
||||
* @param codecType 消息编解码类型
|
||||
* @param senderAddress 发送者地址
|
||||
* @param socket UDP Socket
|
||||
*/
|
||||
private void handleAuthenticationRequest(IotDeviceMessage message, String codecType,
|
||||
InetSocketAddress senderAddress, DatagramSocket socket) {
|
||||
String addressKey = sessionManager.buildAddressKey(senderAddress);
|
||||
try {
|
||||
// 1.1 解析认证参数
|
||||
IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams());
|
||||
if (authParams == null) {
|
||||
log.warn("[handleAuthenticationRequest][认证参数解析失败,来源: {}]", addressKey);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "认证参数不完整", codecType);
|
||||
return;
|
||||
}
|
||||
// 1.2 执行认证
|
||||
if (!validateDeviceAuth(authParams)) {
|
||||
log.warn("[handleAuthenticationRequest][认证失败,来源: {},username: {}]",
|
||||
addressKey, authParams.getUsername());
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "认证失败", codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 解析设备信息
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername());
|
||||
if (deviceInfo == null) {
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "解析设备信息失败", codecType);
|
||||
return;
|
||||
}
|
||||
// 2.2 获取设备信息
|
||||
IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(),
|
||||
deviceInfo.getDeviceName());
|
||||
if (device == null) {
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "设备不存在", codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3.1 生成 JWT Token(无状态)
|
||||
String token = deviceTokenService.createToken(device.getProductKey(), device.getDeviceName());
|
||||
|
||||
// 3.2 更新设备会话信息(用于下行消息,保存 codecType)
|
||||
sessionManager.updateDeviceSession(device.getId(), senderAddress, codecType);
|
||||
|
||||
// 3.3 发送上线消息
|
||||
sendOnlineMessage(device);
|
||||
|
||||
// 3.4 发送成功响应(包含 token)
|
||||
sendAuthSuccessResponse(socket, senderAddress, message.getRequestId(), token, codecType);
|
||||
log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {},来源: {}]",
|
||||
device.getId(), device.getDeviceName(), addressKey);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleAuthenticationRequest][认证处理异常,来源: {}]", addressKey, e);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "认证处理异常", codecType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备动态注册请求(一型一密,不需要 Token)
|
||||
*
|
||||
* @param message 消息信息
|
||||
* @param codecType 消息编解码类型
|
||||
* @param senderAddress 发送者地址
|
||||
* @param socket UDP Socket
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
*/
|
||||
private void handleRegisterRequest(IotDeviceMessage message, String codecType,
|
||||
InetSocketAddress senderAddress, DatagramSocket socket) {
|
||||
String addressKey = sessionManager.buildAddressKey(senderAddress);
|
||||
try {
|
||||
// 1. 解析注册参数
|
||||
IotDeviceRegisterReqDTO params = parseRegisterParams(message.getParams());
|
||||
if (params == null) {
|
||||
log.warn("[handleRegisterRequest][注册参数解析失败,来源: {}]", addressKey);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "注册参数不完整", codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 调用动态注册
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(params);
|
||||
if (result.isError()) {
|
||||
log.warn("[handleRegisterRequest][注册失败,来源: {},错误: {}]", addressKey, result.getMsg());
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), result.getMsg(), codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 发送成功响应(包含 deviceSecret)
|
||||
sendRegisterSuccessResponse(socket, senderAddress, message.getRequestId(), result.getData(), codecType);
|
||||
log.info("[handleRegisterRequest][注册成功,设备名: {},来源: {}]",
|
||||
params.getDeviceName(), addressKey);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleRegisterRequest][注册处理异常,来源: {}]", addressKey, e);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "注册处理异常", codecType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理业务请求
|
||||
* <p>
|
||||
* 请求参数格式:
|
||||
* - token:JWT 令牌
|
||||
* - body:实际请求内容(可以是 Map、List 或其他类型)
|
||||
*
|
||||
* @param message 消息信息
|
||||
* @param codecType 消息编解码类型
|
||||
* @param senderAddress 发送者地址
|
||||
* @param socket UDP Socket
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleBusinessRequest(IotDeviceMessage message, String codecType,
|
||||
InetSocketAddress senderAddress, DatagramSocket socket) {
|
||||
String addressKey = sessionManager.buildAddressKey(senderAddress);
|
||||
try {
|
||||
// 1.1 从消息中提取 token 和 body(格式:{token: "xxx", body: {...}} 或 {token: "xxx", body: [...]})
|
||||
String token = null;
|
||||
Object body = null;
|
||||
if (message.getParams() instanceof Map) {
|
||||
Map<String, Object> paramsMap = (Map<String, Object>) message.getParams();
|
||||
token = (String) paramsMap.get(PARAM_KEY_TOKEN);
|
||||
body = paramsMap.get(PARAM_KEY_BODY);
|
||||
}
|
||||
if (StrUtil.isBlank(token)) {
|
||||
log.warn("[handleBusinessRequest][缺少 token,来源: {}]", addressKey);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "请先进行认证", codecType);
|
||||
return;
|
||||
}
|
||||
// 1.2 验证 token,获取设备信息
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token);
|
||||
if (deviceInfo == null) {
|
||||
log.warn("[handleBusinessRequest][token 无效或已过期,来源: {}]", addressKey);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "token 无效或已过期", codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取设备详细信息
|
||||
IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(),
|
||||
deviceInfo.getDeviceName());
|
||||
if (device == null) {
|
||||
log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]",
|
||||
addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "设备不存在", codecType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 更新设备会话信息(保持最新,保存 codecType)
|
||||
sessionManager.updateDeviceSession(device.getId(), senderAddress, codecType);
|
||||
|
||||
// 4. 将 body 设置为实际的 params,发送消息到消息总线
|
||||
message.setParams(body);
|
||||
deviceMessageService.sendDeviceMessage(message, device.getProductKey(),
|
||||
device.getDeviceName(), serverId);
|
||||
log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]",
|
||||
device.getId(), message.getMethod(), addressKey);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleBusinessRequest][业务请求处理异常,来源: {}]", addressKey, e);
|
||||
sendErrorResponse(socket, senderAddress, message.getRequestId(), "处理失败", codecType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息编解码类型
|
||||
*
|
||||
* @param buffer 消息
|
||||
* @return 消息编解码类型
|
||||
*/
|
||||
private String getMessageCodecType(Buffer buffer) {
|
||||
// 检测消息格式类型
|
||||
return IotTcpBinaryDeviceMessageCodec.isBinaryFormatQuick(buffer.getBytes()) ? CODEC_TYPE_BINARY
|
||||
: CODEC_TYPE_JSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备上线消息
|
||||
*
|
||||
* @param device 设备信息
|
||||
*/
|
||||
private void sendOnlineMessage(IotDeviceRespDTO device) {
|
||||
try {
|
||||
IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline();
|
||||
deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(),
|
||||
device.getDeviceName(), serverId);
|
||||
} catch (Exception e) {
|
||||
log.error("[sendOnlineMessage][发送上线消息失败,设备: {}]", device.getDeviceName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备认证信息
|
||||
*
|
||||
* @param authParams 认证参数
|
||||
* @return 是否认证成功
|
||||
*/
|
||||
private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) {
|
||||
try {
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(authParams.getClientId()).setUsername(authParams.getUsername())
|
||||
.setPassword(authParams.getPassword()));
|
||||
result.checkError();
|
||||
return BooleanUtil.isTrue(result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送认证成功响应(包含 token)
|
||||
*
|
||||
* @param socket UDP Socket
|
||||
* @param address 目标地址
|
||||
* @param requestId 请求 ID
|
||||
* @param token JWT Token
|
||||
* @param codecType 消息编解码类型
|
||||
*/
|
||||
private void sendAuthSuccessResponse(DatagramSocket socket, InetSocketAddress address,
|
||||
String requestId, String token, String codecType) {
|
||||
try {
|
||||
// 构建响应数据
|
||||
Object responseData = MapUtil.builder()
|
||||
.put("success", true)
|
||||
.put("token", token)
|
||||
.put("message", "认证成功")
|
||||
.build();
|
||||
IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, AUTH_METHOD, responseData, 0, "认证成功");
|
||||
byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType);
|
||||
// 发送响应
|
||||
socket.send(Buffer.buffer(encodedData), address.getPort(), address.getHostString(), result -> {
|
||||
if (result.failed()) {
|
||||
log.error("[sendAuthSuccessResponse][发送认证成功响应失败,地址: {}]",
|
||||
sessionManager.buildAddressKey(address), result.cause());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[sendAuthSuccessResponse][发送认证成功响应异常,地址: {}]",
|
||||
sessionManager.buildAddressKey(address), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送注册成功响应(包含 deviceSecret)
|
||||
*
|
||||
* @param socket UDP Socket
|
||||
* @param address 目标地址
|
||||
* @param requestId 请求 ID
|
||||
* @param registerResp 注册响应
|
||||
* @param codecType 消息编解码类型
|
||||
*/
|
||||
private void sendRegisterSuccessResponse(DatagramSocket socket, InetSocketAddress address,
|
||||
String requestId, IotDeviceRegisterRespDTO registerResp,
|
||||
String codecType) {
|
||||
try {
|
||||
// 1. 构建响应消息(参考 HTTP 返回格式,直接返回 IotDeviceRegisterRespDTO)
|
||||
IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId,
|
||||
IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerResp, 0, null);
|
||||
// 2. 发送响应
|
||||
byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType);
|
||||
socket.send(Buffer.buffer(encodedData), address.getPort(), address.getHostString(), result -> {
|
||||
if (result.failed()) {
|
||||
log.error("[sendRegisterSuccessResponse][发送注册成功响应失败,地址: {}]",
|
||||
sessionManager.buildAddressKey(address), result.cause());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[sendRegisterSuccessResponse][发送注册成功响应异常,地址: {}]",
|
||||
sessionManager.buildAddressKey(address), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*
|
||||
* @param socket UDP Socket
|
||||
* @param address 目标地址
|
||||
* @param requestId 请求 ID
|
||||
* @param errorMessage 错误消息
|
||||
* @param codecType 消息编解码类型
|
||||
*/
|
||||
private void sendErrorResponse(DatagramSocket socket, InetSocketAddress address,
|
||||
String requestId, String errorMessage, String codecType) {
|
||||
sendResponse(socket, address, false, errorMessage, requestId, codecType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送响应消息
|
||||
*
|
||||
* @param socket UDP Socket
|
||||
* @param address 目标地址
|
||||
* @param success 是否成功
|
||||
* @param message 消息
|
||||
* @param requestId 请求 ID
|
||||
* @param codecType 消息编解码类型
|
||||
*/
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private void sendResponse(DatagramSocket socket, InetSocketAddress address, boolean success,
|
||||
String message, String requestId, String codecType) {
|
||||
try {
|
||||
// 构建响应数据
|
||||
Object responseData = MapUtil.builder()
|
||||
.put("success", success)
|
||||
.put("message", message)
|
||||
.build();
|
||||
int code = success ? 0 : 401;
|
||||
IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId,
|
||||
"response", responseData, code, message);
|
||||
|
||||
// 发送响应
|
||||
byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType);
|
||||
socket.send(Buffer.buffer(encodedData), address.getPort(), address.getHostString(), ar -> {
|
||||
if (ar.failed()) {
|
||||
log.error("[sendResponse][发送响应失败,地址: {}]",
|
||||
sessionManager.buildAddressKey(address), ar.cause());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[sendResponse][发送响应异常,地址: {}]",
|
||||
sessionManager.buildAddressKey(address), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析认证参数
|
||||
*
|
||||
* @param params 参数对象(通常为 Map 类型)
|
||||
* @return 认证参数 DTO,解析失败时返回 null
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private IotDeviceAuthReqDTO parseAuthParams(Object params) {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 参数默认为 Map 类型,直接转换
|
||||
if (params instanceof Map) {
|
||||
Map<String, Object> paramMap = (Map<String, Object>) params;
|
||||
return new IotDeviceAuthReqDTO()
|
||||
.setClientId(MapUtil.getStr(paramMap, "clientId"))
|
||||
.setUsername(MapUtil.getStr(paramMap, "username"))
|
||||
.setPassword(MapUtil.getStr(paramMap, "password"));
|
||||
}
|
||||
// 如果已经是目标类型,直接返回
|
||||
if (params instanceof IotDeviceAuthReqDTO) {
|
||||
return (IotDeviceAuthReqDTO) params;
|
||||
}
|
||||
|
||||
// 其他情况尝试 JSON 转换
|
||||
return JsonUtils.convertObject(params, IotDeviceAuthReqDTO.class);
|
||||
} catch (Exception e) {
|
||||
log.error("[parseAuthParams][解析认证参数({})失败]", params, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析注册参数
|
||||
*
|
||||
* @param params 参数对象(通常为 Map 类型)
|
||||
* @return 注册参数 DTO,解析失败时返回 null
|
||||
*/
|
||||
@SuppressWarnings({"unchecked", "DuplicatedCode"})
|
||||
private IotDeviceRegisterReqDTO parseRegisterParams(Object params) {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 参数默认为 Map 类型,直接转换
|
||||
if (params instanceof Map) {
|
||||
Map<String, Object> paramMap = (Map<String, Object>) params;
|
||||
return new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(MapUtil.getStr(paramMap, "productKey"))
|
||||
.setDeviceName(MapUtil.getStr(paramMap, "deviceName"))
|
||||
.setProductSecret(MapUtil.getStr(paramMap, "productSecret"));
|
||||
}
|
||||
// 如果已经是目标类型,直接返回
|
||||
if (params instanceof IotDeviceRegisterReqDTO) {
|
||||
return (IotDeviceRegisterReqDTO) params;
|
||||
}
|
||||
|
||||
// 其他情况尝试 JSON 转换
|
||||
return JsonUtils.convertObject(params, IotDeviceRegisterReqDTO.class);
|
||||
} catch (Exception e) {
|
||||
log.error("[parseRegisterParams][解析注册参数({})失败]", params, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router.IotWebSocketDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* IoT 网关 WebSocket 下游订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class IotWebSocketDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotWebSocketUpstreamProtocol protocol;
|
||||
|
||||
private final IotDeviceMessageService messageService;
|
||||
|
||||
private final IotWebSocketConnectionManager connectionManager;
|
||||
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
private IotWebSocketDownstreamHandler downstreamHandler;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// 初始化下游处理器
|
||||
this.downstreamHandler = new IotWebSocketDownstreamHandler(messageService, connectionManager);
|
||||
// 注册下游订阅者
|
||||
messageBus.register(this);
|
||||
log.info("[init][WebSocket 下游订阅者初始化完成,服务器 ID: {},Topic: {}]",
|
||||
protocol.getServerId(), getTopic());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroup() {
|
||||
// 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
return getTopic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
try {
|
||||
downstreamHandler.handle(message);
|
||||
} catch (Exception e) {
|
||||
log.error("[onMessage][处理下行消息失败,设备 ID: {},方法: {},消息 ID: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket;
|
||||
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router.IotWebSocketUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.net.PemKeyCertOptions;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
|
||||
/**
|
||||
* IoT 网关 WebSocket 协议:接收设备上行消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotWebSocketUpstreamProtocol {
|
||||
|
||||
private final IotGatewayProperties.WebSocketProperties wsProperties;
|
||||
|
||||
private final IotDeviceService deviceService;
|
||||
|
||||
private final IotDeviceMessageService messageService;
|
||||
|
||||
private final IotWebSocketConnectionManager connectionManager;
|
||||
|
||||
private final Vertx vertx;
|
||||
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
private HttpServer httpServer;
|
||||
|
||||
public IotWebSocketUpstreamProtocol(IotGatewayProperties.WebSocketProperties wsProperties,
|
||||
IotDeviceService deviceService,
|
||||
IotDeviceMessageService messageService,
|
||||
IotWebSocketConnectionManager connectionManager,
|
||||
Vertx vertx) {
|
||||
this.wsProperties = wsProperties;
|
||||
this.deviceService = deviceService;
|
||||
this.messageService = messageService;
|
||||
this.connectionManager = connectionManager;
|
||||
this.vertx = vertx;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(wsProperties.getPort());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@SuppressWarnings("deprecation")
|
||||
public void start() {
|
||||
// 1.1 创建服务器选项
|
||||
HttpServerOptions options = new HttpServerOptions()
|
||||
.setPort(wsProperties.getPort())
|
||||
.setIdleTimeout(wsProperties.getIdleTimeoutSeconds())
|
||||
.setMaxWebSocketFrameSize(wsProperties.getMaxFrameSize())
|
||||
.setMaxWebSocketMessageSize(wsProperties.getMaxMessageSize());
|
||||
// 1.2 配置 SSL(如果启用)
|
||||
if (Boolean.TRUE.equals(wsProperties.getSslEnabled())) {
|
||||
PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions()
|
||||
.setKeyPath(wsProperties.getSslKeyPath())
|
||||
.setCertPath(wsProperties.getSslCertPath());
|
||||
options.setSsl(true).setKeyCertOptions(pemKeyCertOptions);
|
||||
}
|
||||
|
||||
// 2. 创建服务器并设置 WebSocket 处理器
|
||||
httpServer = vertx.createHttpServer(options);
|
||||
httpServer.webSocketHandler(socket -> {
|
||||
// 验证路径
|
||||
if (ObjUtil.notEqual(wsProperties.getPath(), socket.path())) {
|
||||
log.warn("[webSocketHandler][WebSocket 路径不匹配,拒绝连接,路径: {},期望: {}]",
|
||||
socket.path(), wsProperties.getPath());
|
||||
socket.reject();
|
||||
return;
|
||||
}
|
||||
// 创建上行处理器
|
||||
IotWebSocketUpstreamHandler handler = new IotWebSocketUpstreamHandler(this,
|
||||
messageService, deviceService, connectionManager);
|
||||
handler.handle(socket);
|
||||
});
|
||||
|
||||
// 3. 启动服务器
|
||||
try {
|
||||
httpServer.listen().result();
|
||||
log.info("[start][IoT 网关 WebSocket 协议启动成功,端口:{},路径:{}]", wsProperties.getPort(), wsProperties.getPath());
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT 网关 WebSocket 协议启动失败]", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (httpServer != null) {
|
||||
try {
|
||||
httpServer.close().result();
|
||||
log.info("[stop][IoT 网关 WebSocket 协议已停止]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT 网关 WebSocket 协议停止失败]", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager;
|
||||
|
||||
import io.vertx.core.http.ServerWebSocket;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* IoT 网关 WebSocket 连接管理器
|
||||
* <p>
|
||||
* 统一管理 WebSocket 连接的认证状态、设备会话和消息发送功能:
|
||||
* 1. 管理 WebSocket 连接的认证状态
|
||||
* 2. 管理设备会话和在线状态
|
||||
* 3. 管理消息发送到设备
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class IotWebSocketConnectionManager {
|
||||
|
||||
/**
|
||||
* 连接信息映射:ServerWebSocket -> 连接信息
|
||||
*/
|
||||
private final Map<ServerWebSocket, ConnectionInfo> connectionMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 设备 ID -> ServerWebSocket 的映射
|
||||
*/
|
||||
private final Map<Long, ServerWebSocket> deviceSocketMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 注册设备连接(包含认证信息)
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
* @param deviceId 设备 ID
|
||||
* @param connectionInfo 连接信息
|
||||
*/
|
||||
public void registerConnection(ServerWebSocket socket, Long deviceId, ConnectionInfo connectionInfo) {
|
||||
// 如果设备已有其他连接,先清理旧连接
|
||||
ServerWebSocket oldSocket = deviceSocketMap.get(deviceId);
|
||||
if (oldSocket != null && oldSocket != socket) {
|
||||
log.info("[registerConnection][设备已有其他连接,断开旧连接,设备 ID: {},旧连接: {}]",
|
||||
deviceId, oldSocket.remoteAddress());
|
||||
oldSocket.close();
|
||||
// 清理旧连接的映射
|
||||
connectionMap.remove(oldSocket);
|
||||
}
|
||||
|
||||
// 注册新连接
|
||||
connectionMap.put(socket, connectionInfo);
|
||||
deviceSocketMap.put(deviceId, socket);
|
||||
log.info("[registerConnection][注册设备连接,设备 ID: {},连接: {},product key: {},device name: {}]",
|
||||
deviceId, socket.remoteAddress(), connectionInfo.getProductKey(), connectionInfo.getDeviceName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销设备连接
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
*/
|
||||
public void unregisterConnection(ServerWebSocket socket) {
|
||||
ConnectionInfo connectionInfo = connectionMap.remove(socket);
|
||||
if (connectionInfo == null) {
|
||||
return;
|
||||
}
|
||||
Long deviceId = connectionInfo.getDeviceId();
|
||||
deviceSocketMap.remove(deviceId);
|
||||
log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]",
|
||||
deviceId, socket.remoteAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接信息
|
||||
*/
|
||||
public ConnectionInfo getConnectionInfo(ServerWebSocket socket) {
|
||||
return connectionMap.get(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备 ID 获取连接信息
|
||||
*/
|
||||
public ConnectionInfo getConnectionInfoByDeviceId(Long deviceId) {
|
||||
ServerWebSocket socket = deviceSocketMap.get(deviceId);
|
||||
return socket != null ? connectionMap.get(socket) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到设备(文本消息)
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @param message JSON 消息
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendToDevice(Long deviceId, String message) {
|
||||
ServerWebSocket socket = deviceSocketMap.get(deviceId);
|
||||
if (socket == null) {
|
||||
log.warn("[sendToDevice][设备未连接,设备 ID: {}]", deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.writeTextMessage(message);
|
||||
log.debug("[sendToDevice][发送消息成功,设备 ID: {},数据长度: {} 字节]", deviceId, message.length());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("[sendToDevice][发送消息失败,设备 ID: {}]", deviceId, e);
|
||||
// 发送失败时清理连接
|
||||
unregisterConnection(socket);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接信息(包含认证信息)
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class ConnectionInfo {
|
||||
|
||||
/**
|
||||
* 设备 ID
|
||||
*/
|
||||
private Long deviceId;
|
||||
/**
|
||||
* 产品 Key
|
||||
*/
|
||||
private String productKey;
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 客户端 ID
|
||||
*/
|
||||
private String clientId;
|
||||
/**
|
||||
* 消息编解码类型(认证后确定)
|
||||
*/
|
||||
private String codecType;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 WebSocket 下行消息处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class IotWebSocketDownstreamHandler {
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
private final IotWebSocketConnectionManager connectionManager;
|
||||
|
||||
/**
|
||||
* 处理下行消息
|
||||
*/
|
||||
public void handle(IotDeviceMessage message) {
|
||||
try {
|
||||
log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId());
|
||||
|
||||
// 1. 获取连接信息
|
||||
IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId(
|
||||
message.getDeviceId());
|
||||
if (connectionInfo == null) {
|
||||
log.error("[handle][连接信息不存在,设备 ID: {}]", message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 编码消息并发送到设备
|
||||
byte[] bytes = deviceMessageService.encodeDeviceMessage(message, connectionInfo.getCodecType());
|
||||
String jsonMessage = StrUtil.utf8Str(bytes);
|
||||
boolean success = connectionManager.sendToDevice(message.getDeviceId(), jsonMessage);
|
||||
if (success) {
|
||||
log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId(), bytes.length);
|
||||
} else {
|
||||
log.error("[handle][下行消息发送失败,设备 ID: {},方法: {},消息 ID: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message.getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]",
|
||||
message.getDeviceId(), message.getMethod(), message, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.router;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Handler;
|
||||
import io.vertx.core.http.ServerWebSocket;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
* WebSocket 上行消息处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotWebSocketUpstreamHandler implements Handler<ServerWebSocket> {
|
||||
|
||||
/**
|
||||
* 默认消息编解码类型
|
||||
*/
|
||||
private static final String CODEC_TYPE = IotAlinkDeviceMessageCodec.TYPE;
|
||||
|
||||
private static final String AUTH_METHOD = "auth";
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
private final IotDeviceService deviceService;
|
||||
|
||||
private final IotWebSocketConnectionManager connectionManager;
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
private final String serverId;
|
||||
|
||||
public IotWebSocketUpstreamHandler(IotWebSocketUpstreamProtocol protocol,
|
||||
IotDeviceMessageService deviceMessageService,
|
||||
IotDeviceService deviceService,
|
||||
IotWebSocketConnectionManager connectionManager) {
|
||||
this.deviceMessageService = deviceMessageService;
|
||||
this.deviceService = deviceService;
|
||||
this.connectionManager = connectionManager;
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.serverId = protocol.getServerId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(ServerWebSocket socket) {
|
||||
String clientId = IdUtil.simpleUUID();
|
||||
log.debug("[handle][设备连接,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress());
|
||||
|
||||
// 1. 设置异常和关闭处理器
|
||||
socket.exceptionHandler(ex -> {
|
||||
log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress());
|
||||
cleanupConnection(socket);
|
||||
});
|
||||
socket.closeHandler(v -> {
|
||||
log.debug("[handle][连接关闭,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress());
|
||||
cleanupConnection(socket);
|
||||
});
|
||||
|
||||
// 2. 设置文本消息处理器
|
||||
socket.textMessageHandler(message -> {
|
||||
try {
|
||||
processMessage(clientId, message, socket);
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]",
|
||||
clientId, socket.remoteAddress(), e.getMessage());
|
||||
cleanupConnection(socket);
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*
|
||||
* @param clientId 客户端 ID
|
||||
* @param message 消息(JSON 字符串)
|
||||
* @param socket WebSocket 连接
|
||||
* @throws Exception 消息解码失败时抛出异常
|
||||
*/
|
||||
private void processMessage(String clientId, String message, ServerWebSocket socket) throws Exception {
|
||||
// 1.1 基础检查
|
||||
if (StrUtil.isBlank(message)) {
|
||||
return;
|
||||
}
|
||||
// 1.2 解码消息(已认证连接使用其 codecType,未认证连接使用默认 CODEC_TYPE)
|
||||
IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
|
||||
String codecType = connectionInfo != null ? connectionInfo.getCodecType() : CODEC_TYPE;
|
||||
IotDeviceMessage deviceMessage;
|
||||
try {
|
||||
deviceMessage = deviceMessageService.decodeDeviceMessage(
|
||||
StrUtil.utf8Bytes(message), codecType);
|
||||
if (deviceMessage == null) {
|
||||
throw new Exception("解码后消息为空");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new Exception("消息解码失败: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 2. 根据消息类型路由处理
|
||||
try {
|
||||
if (AUTH_METHOD.equals(deviceMessage.getMethod())) {
|
||||
// 认证请求
|
||||
handleAuthenticationRequest(clientId, deviceMessage, socket);
|
||||
} else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(deviceMessage.getMethod())) {
|
||||
// 设备动态注册请求
|
||||
handleRegisterRequest(clientId, deviceMessage, socket);
|
||||
} else {
|
||||
// 业务消息
|
||||
handleBusinessRequest(clientId, deviceMessage, socket);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[processMessage][处理消息失败,客户端 ID: {},消息方法: {}]",
|
||||
clientId, deviceMessage.getMethod(), e);
|
||||
// 发送错误响应,避免客户端一直等待
|
||||
try {
|
||||
sendErrorResponse(socket, deviceMessage.getRequestId(), "消息处理失败");
|
||||
} catch (Exception responseEx) {
|
||||
log.error("[processMessage][发送错误响应失败,客户端 ID: {}]", clientId, responseEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证请求
|
||||
*
|
||||
* @param clientId 客户端 ID
|
||||
* @param message 消息信息
|
||||
* @param socket WebSocket 连接
|
||||
*/
|
||||
private void handleAuthenticationRequest(String clientId, IotDeviceMessage message, ServerWebSocket socket) {
|
||||
try {
|
||||
// 1.1 解析认证参数
|
||||
IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams());
|
||||
if (authParams == null) {
|
||||
log.warn("[handleAuthenticationRequest][认证参数解析失败,客户端 ID: {}]", clientId);
|
||||
sendErrorResponse(socket, message.getRequestId(), "认证参数不完整");
|
||||
return;
|
||||
}
|
||||
// 1.2 执行认证
|
||||
if (!validateDeviceAuth(authParams)) {
|
||||
log.warn("[handleAuthenticationRequest][认证失败,客户端 ID: {},username: {}]",
|
||||
clientId, authParams.getUsername());
|
||||
sendErrorResponse(socket, message.getRequestId(), "认证失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 解析设备信息
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername());
|
||||
if (deviceInfo == null) {
|
||||
sendErrorResponse(socket, message.getRequestId(), "解析设备信息失败");
|
||||
return;
|
||||
}
|
||||
// 2.2 获取设备信息
|
||||
IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(),
|
||||
deviceInfo.getDeviceName());
|
||||
if (device == null) {
|
||||
sendErrorResponse(socket, message.getRequestId(), "设备不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3.1 注册连接
|
||||
registerConnection(socket, device, clientId);
|
||||
// 3.2 发送上线消息
|
||||
sendOnlineMessage(device);
|
||||
// 3.3 发送成功响应
|
||||
sendSuccessResponse(socket, message.getRequestId(), "认证成功");
|
||||
log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]",
|
||||
device.getId(), device.getDeviceName());
|
||||
} catch (Exception e) {
|
||||
log.error("[handleAuthenticationRequest][认证处理异常,客户端 ID: {}]", clientId, e);
|
||||
sendErrorResponse(socket, message.getRequestId(), "认证处理异常");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备动态注册请求(一型一密,不需要认证)
|
||||
*
|
||||
* @param clientId 客户端 ID
|
||||
* @param message 消息信息
|
||||
* @param socket WebSocket 连接
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
*/
|
||||
private void handleRegisterRequest(String clientId, IotDeviceMessage message, ServerWebSocket socket) {
|
||||
try {
|
||||
// 1. 解析注册参数
|
||||
IotDeviceRegisterReqDTO params = parseRegisterParams(message.getParams());
|
||||
if (params == null
|
||||
|| StrUtil.hasEmpty(params.getProductKey(), params.getDeviceName(), params.getProductSecret())) {
|
||||
log.warn("[handleRegisterRequest][注册参数解析失败,客户端 ID: {}]", clientId);
|
||||
sendErrorResponse(socket, message.getRequestId(), "注册参数不完整");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 调用动态注册
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(params);
|
||||
if (result.isError()) {
|
||||
log.warn("[handleRegisterRequest][注册失败,客户端 ID: {},错误: {}]", clientId, result.getMsg());
|
||||
sendErrorResponse(socket, message.getRequestId(), result.getMsg());
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 发送成功响应(包含 deviceSecret)
|
||||
sendRegisterSuccessResponse(socket, message.getRequestId(), result.getData());
|
||||
log.info("[handleRegisterRequest][注册成功,客户端 ID: {},设备名: {}]",
|
||||
clientId, params.getDeviceName());
|
||||
} catch (Exception e) {
|
||||
log.error("[handleRegisterRequest][注册处理异常,客户端 ID: {}]", clientId, e);
|
||||
sendErrorResponse(socket, message.getRequestId(), "注册处理异常");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理业务请求
|
||||
*
|
||||
* @param clientId 客户端 ID
|
||||
* @param message 消息信息
|
||||
* @param socket WebSocket 连接
|
||||
*/
|
||||
private void handleBusinessRequest(String clientId, IotDeviceMessage message, ServerWebSocket socket) {
|
||||
try {
|
||||
// 1. 获取认证信息并处理业务消息
|
||||
IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
|
||||
if (connectionInfo == null) {
|
||||
log.warn("[handleBusinessRequest][连接未认证,拒绝处理业务消息,客户端 ID: {}]", clientId);
|
||||
sendErrorResponse(socket, message.getRequestId(), "连接未认证");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 发送消息到消息总线
|
||||
deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(),
|
||||
connectionInfo.getDeviceName(), serverId);
|
||||
log.info("[handleBusinessRequest][发送消息到消息总线,客户端 ID: {},消息: {}",
|
||||
clientId, message.toString());
|
||||
} catch (Exception e) {
|
||||
log.error("[handleBusinessRequest][业务请求处理异常,客户端 ID: {}]", clientId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册连接信息
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
* @param device 设备
|
||||
* @param clientId 客户端 ID
|
||||
*/
|
||||
private void registerConnection(ServerWebSocket socket, IotDeviceRespDTO device, String clientId) {
|
||||
IotWebSocketConnectionManager.ConnectionInfo connectionInfo = new IotWebSocketConnectionManager.ConnectionInfo()
|
||||
.setDeviceId(device.getId())
|
||||
.setProductKey(device.getProductKey())
|
||||
.setDeviceName(device.getDeviceName())
|
||||
.setClientId(clientId)
|
||||
.setCodecType(CODEC_TYPE);
|
||||
// 注册连接
|
||||
connectionManager.registerConnection(socket, device.getId(), connectionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备上线消息
|
||||
*
|
||||
* @param device 设备信息
|
||||
*/
|
||||
private void sendOnlineMessage(IotDeviceRespDTO device) {
|
||||
try {
|
||||
IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline();
|
||||
deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(),
|
||||
device.getDeviceName(), serverId);
|
||||
} catch (Exception e) {
|
||||
log.error("[sendOnlineMessage][发送上线消息失败,设备: {}]", device.getDeviceName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理连接
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
*/
|
||||
private void cleanupConnection(ServerWebSocket socket) {
|
||||
try {
|
||||
// 1. 发送离线消息(如果已认证)
|
||||
IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
|
||||
if (connectionInfo != null) {
|
||||
IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline();
|
||||
deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(),
|
||||
connectionInfo.getDeviceName(), serverId);
|
||||
}
|
||||
|
||||
// 2. 注销连接
|
||||
connectionManager.unregisterConnection(socket);
|
||||
} catch (Exception e) {
|
||||
log.error("[cleanupConnection][清理连接失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送响应消息
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
* @param success 是否成功
|
||||
* @param message 消息
|
||||
* @param requestId 请求 ID
|
||||
*/
|
||||
private void sendResponse(ServerWebSocket socket, boolean success, String message, String requestId) {
|
||||
try {
|
||||
Object responseData = MapUtil.builder()
|
||||
.put("success", success)
|
||||
.put("message", message)
|
||||
.build();
|
||||
|
||||
int code = success ? 0 : 401;
|
||||
IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, AUTH_METHOD, responseData, code, message);
|
||||
|
||||
byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, CODEC_TYPE);
|
||||
socket.writeTextMessage(StrUtil.utf8Str(encodedData));
|
||||
} catch (Exception e) {
|
||||
log.error("[sendResponse][发送响应失败,requestId: {}]", requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备认证信息
|
||||
*
|
||||
* @param authParams 认证参数
|
||||
* @return 是否认证成功
|
||||
*/
|
||||
private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) {
|
||||
try {
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(authParams.getClientId()).setUsername(authParams.getUsername())
|
||||
.setPassword(authParams.getPassword()));
|
||||
result.checkError();
|
||||
return BooleanUtil.isTrue(result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("[validateDeviceAuth][设备认证异常,username: {}]", authParams.getUsername(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送错误响应
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
* @param requestId 请求 ID
|
||||
* @param errorMessage 错误消息
|
||||
*/
|
||||
private void sendErrorResponse(ServerWebSocket socket, String requestId, String errorMessage) {
|
||||
sendResponse(socket, false, errorMessage, requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送成功响应
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
* @param requestId 请求 ID
|
||||
* @param message 消息
|
||||
*/
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private void sendSuccessResponse(ServerWebSocket socket, String requestId, String message) {
|
||||
sendResponse(socket, true, message, requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析认证参数
|
||||
*
|
||||
* @param params 参数对象(通常为 Map 类型)
|
||||
* @return 认证参数 DTO,解析失败时返回 null
|
||||
*/
|
||||
@SuppressWarnings({"unchecked", "DuplicatedCode"})
|
||||
private IotDeviceAuthReqDTO parseAuthParams(Object params) {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 参数默认为 Map 类型,直接转换
|
||||
if (params instanceof Map) {
|
||||
Map<String, Object> paramMap = (Map<String, Object>) params;
|
||||
return new IotDeviceAuthReqDTO()
|
||||
.setClientId(MapUtil.getStr(paramMap, "clientId"))
|
||||
.setUsername(MapUtil.getStr(paramMap, "username"))
|
||||
.setPassword(MapUtil.getStr(paramMap, "password"));
|
||||
}
|
||||
// 如果已经是目标类型,直接返回
|
||||
if (params instanceof IotDeviceAuthReqDTO) {
|
||||
return (IotDeviceAuthReqDTO) params;
|
||||
}
|
||||
|
||||
// 其他情况尝试 JSON 转换
|
||||
return JsonUtils.convertObject(params, IotDeviceAuthReqDTO.class);
|
||||
} catch (Exception e) {
|
||||
log.error("[parseAuthParams][解析认证参数({})失败]", params, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析注册参数
|
||||
*
|
||||
* @param params 参数对象(通常为 Map 类型)
|
||||
* @return 注册参数 DTO,解析失败时返回 null
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private IotDeviceRegisterReqDTO parseRegisterParams(Object params) {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 参数默认为 Map 类型,直接转换
|
||||
if (params instanceof Map) {
|
||||
Map<String, Object> paramMap = (Map<String, Object>) params;
|
||||
return new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(MapUtil.getStr(paramMap, "productKey"))
|
||||
.setDeviceName(MapUtil.getStr(paramMap, "deviceName"))
|
||||
.setProductSecret(MapUtil.getStr(paramMap, "productSecret"));
|
||||
}
|
||||
// 如果已经是目标类型,直接返回
|
||||
if (params instanceof IotDeviceRegisterReqDTO) {
|
||||
return (IotDeviceRegisterReqDTO) params;
|
||||
}
|
||||
|
||||
// 其他情况尝试 JSON 转换
|
||||
return JsonUtils.convertObject(params, IotDeviceRegisterReqDTO.class);
|
||||
} catch (Exception e) {
|
||||
log.error("[parseRegisterParams][解析注册参数({})失败]", params, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送注册成功响应(包含 deviceSecret)
|
||||
*
|
||||
* @param socket WebSocket 连接
|
||||
* @param requestId 请求 ID
|
||||
* @param registerResp 注册响应
|
||||
*/
|
||||
private void sendRegisterSuccessResponse(ServerWebSocket socket, String requestId,
|
||||
IotDeviceRegisterRespDTO registerResp) {
|
||||
try {
|
||||
// 1. 构建响应消息(参考 HTTP 返回格式,直接返回 IotDeviceRegisterRespDTO)
|
||||
IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId,
|
||||
IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerResp, 0, null);
|
||||
// 2. 发送响应
|
||||
byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, CODEC_TYPE);
|
||||
socket.writeTextMessage(StrUtil.utf8Str(encodedData));
|
||||
} catch (Exception e) {
|
||||
log.error("[sendRegisterSuccessResponse][发送注册成功响应失败,requestId: {}]", requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapClient;
|
||||
import org.eclipse.californium.core.CoapResponse;
|
||||
import org.eclipse.californium.core.coap.MediaTypeRegistry;
|
||||
import org.eclipse.californium.core.coap.Option;
|
||||
import org.eclipse.californium.core.coap.Request;
|
||||
import org.eclipse.californium.core.config.CoapConfig;
|
||||
import org.eclipse.californium.elements.config.Configuration;
|
||||
import org.eclipse.californium.elements.config.UdpConfig;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* IoT 直连设备 CoAP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 CoAP 协议直接连接平台
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(CoAP 端口 5683)</li>
|
||||
* <li>运行 {@link #testDeviceRegister()} 测试直连设备动态注册(一型一密)</li>
|
||||
* <li>运行 {@link #testAuth()} 获取设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testPropertyPost()} - 设备属性上报</li>
|
||||
* <li>{@link #testEventPost()} - 设备事件上报</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotDirectDeviceCoapProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 5683;
|
||||
|
||||
// ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
/**
|
||||
* 直连设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc2OTk5MjgxOSwiZGV2aWNlTmFtZSI6InNtYWxsIn0.UHLCXsoGNsKbtJcbTV3n1psp03G75hVcVpV4wwd39r4";
|
||||
|
||||
@BeforeAll
|
||||
public static void initCaliforniumConfig() {
|
||||
// 注册 Californium 配置定义
|
||||
CoapConfig.register();
|
||||
UdpConfig.register();
|
||||
// 创建默认配置
|
||||
Configuration.setStandard(Configuration.createStandardWithoutFile());
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:获取设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/auth", SERVER_HOST, SERVER_PORT);
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
String payload = JsonUtils.toJsonString(authReqDTO);
|
||||
// 1.2 输出请求
|
||||
log.info("[testAuth][请求 URI: {}]", uri);
|
||||
log.info("[testAuth][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
CoapResponse response = client.post(payload, MediaTypeRegistry.APPLICATION_JSON);
|
||||
// 2.2 输出结果
|
||||
log.info("[testAuth][响应码: {}]", response.getCode());
|
||||
log.info("[testAuth][响应体: {}]", response.getResponseText());
|
||||
log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]");
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 属性上报测试
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/property/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("width", 1)
|
||||
.put("height", "2")
|
||||
.build())
|
||||
)
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testPropertyPost][请求 URI: {}]", uri);
|
||||
log.info("[testPropertyPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testPropertyPost][响应码: {}]", response.getCode());
|
||||
log.info("[testPropertyPost][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 事件上报测试
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testEventPost() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/event/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDeviceEventPostReqDTO.of(
|
||||
"eat",
|
||||
MapUtil.<String, Object>builder().put("rice", 3).build(),
|
||||
System.currentTimeMillis())
|
||||
)
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testEventPost][请求 URI: {}]", uri);
|
||||
log.info("[testEventPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testEventPost][响应码: {}]", response.getCode());
|
||||
log.info("[testEventPost][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 动态注册测试 =====================
|
||||
|
||||
/**
|
||||
* 直连设备动态注册测试(一型一密)
|
||||
* <p>
|
||||
* 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret)
|
||||
* <p>
|
||||
* 注意:此接口不需要 Token 认证
|
||||
*/
|
||||
@Test
|
||||
public void testDeviceRegister() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/auth/register/device", SERVER_HOST, SERVER_PORT);
|
||||
// 1.2 构建请求参数
|
||||
IotDeviceRegisterReqDTO reqDTO = new IotDeviceRegisterReqDTO();
|
||||
reqDTO.setProductKey(PRODUCT_KEY);
|
||||
reqDTO.setDeviceName("test-" + System.currentTimeMillis());
|
||||
reqDTO.setProductSecret("test-product-secret");
|
||||
String payload = JsonUtils.toJsonString(reqDTO);
|
||||
// 1.3 输出请求
|
||||
log.info("[testDeviceRegister][请求 URI: {}]", uri);
|
||||
log.info("[testDeviceRegister][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
CoapResponse response = client.post(payload, MediaTypeRegistry.APPLICATION_JSON);
|
||||
// 2.2 输出结果
|
||||
log.info("[testDeviceRegister][响应码: {}]", response.getCode());
|
||||
log.info("[testDeviceRegister][响应体: {}]", response.getResponseText());
|
||||
log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]");
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapClient;
|
||||
import org.eclipse.californium.core.CoapResponse;
|
||||
import org.eclipse.californium.core.coap.MediaTypeRegistry;
|
||||
import org.eclipse.californium.core.coap.Option;
|
||||
import org.eclipse.californium.core.coap.Request;
|
||||
import org.eclipse.californium.core.config.CoapConfig;
|
||||
import org.eclipse.californium.elements.config.Configuration;
|
||||
import org.eclipse.californium.elements.config.UdpConfig;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 网关设备 CoAP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 CoAP 协议管理子设备拓扑关系
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(CoAP 端口 5683)</li>
|
||||
* <li>运行 {@link #testAuth()} 获取网关设备 token,将返回的 token 粘贴到 {@link #GATEWAY_TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li>
|
||||
* <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li>
|
||||
* <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewayDeviceCoapProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 5683;
|
||||
|
||||
// ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) =====================
|
||||
|
||||
private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v";
|
||||
private static final String GATEWAY_DEVICE_NAME = "sub-ddd";
|
||||
private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3";
|
||||
|
||||
/**
|
||||
* 网关设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String GATEWAY_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoibTZYY1MxWkozVFc4ZUMwdiIsImV4cCI6MTc2OTg2NjY3OCwiZGV2aWNlTmFtZSI6InN1Yi1kZGQifQ.nCLSAfHEjXLtTDRXARjOoFqpuo5WfArjFWweUAzrjKU";
|
||||
|
||||
// ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String SUB_DEVICE_NAME = "chazuo-it";
|
||||
private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
@BeforeAll
|
||||
public static void initCaliforniumConfig() {
|
||||
// 注册 Californium 配置定义
|
||||
CoapConfig.register();
|
||||
UdpConfig.register();
|
||||
// 创建默认配置
|
||||
Configuration.setStandard(Configuration.createStandardWithoutFile());
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 网关设备认证测试:获取网关设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/auth", SERVER_HOST, SERVER_PORT);
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
String payload = JsonUtils.toJsonString(authReqDTO);
|
||||
// 1.2 输出请求
|
||||
log.info("[testAuth][请求 URI: {}]", uri);
|
||||
log.info("[testAuth][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
CoapResponse response = client.post(payload, MediaTypeRegistry.APPLICATION_JSON);
|
||||
// 2.2 输出结果
|
||||
log.info("[testAuth][响应码: {}]", response.getCode());
|
||||
log.info("[testAuth][响应体: {}]", response.getResponseText());
|
||||
log.info("[testAuth][请将返回的 token 复制到 GATEWAY_TOKEN 常量中]");
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 拓扑管理测试 =====================
|
||||
|
||||
/**
|
||||
* 添加子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要绑定的子设备信息
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testTopoAdd() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/topo/add",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建子设备认证信息
|
||||
IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO()
|
||||
.setClientId(subAuthInfo.getClientId())
|
||||
.setUsername(subAuthInfo.getUsername())
|
||||
.setPassword(subAuthInfo.getPassword());
|
||||
// 1.3 构建请求参数
|
||||
IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(subDeviceAuth));
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.4 输出请求
|
||||
log.info("[testTopoAdd][请求 URI: {}]", uri);
|
||||
log.info("[testTopoAdd][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, GATEWAY_TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testTopoAdd][响应码: {}]", response.getCode());
|
||||
log.info("[testTopoAdd][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要解绑的子设备信息
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testTopoDelete() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/topo/delete",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建请求参数
|
||||
IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(
|
||||
new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)));
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.3 输出请求
|
||||
log.info("[testTopoDelete][请求 URI: {}]", uri);
|
||||
log.info("[testTopoDelete][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, GATEWAY_TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testTopoDelete][响应码: {}]", response.getCode());
|
||||
log.info("[testTopoDelete][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台查询已绑定的子设备列表
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testTopoGet() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/topo/get",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建请求参数(目前为空,预留扩展)
|
||||
IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO();
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.TOPO_GET.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.3 输出请求
|
||||
log.info("[testTopoGet][请求 URI: {}]", uri);
|
||||
log.info("[testTopoGet][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, GATEWAY_TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testTopoGet][响应码: {}]", response.getCode());
|
||||
log.info("[testTopoGet][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备注册测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备动态注册测试
|
||||
* <p>
|
||||
* 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret
|
||||
* <p>
|
||||
* 注意:此接口需要网关 Token 认证
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testSubDeviceRegister() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/auth/register/sub-device/%s/%s",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建请求参数
|
||||
IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO();
|
||||
subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY);
|
||||
subDevice.setDeviceName("mougezishebei");
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", Collections.singletonList(subDevice))
|
||||
.build());
|
||||
// 1.3 输出请求
|
||||
log.info("[testSubDeviceRegister][请求 URI: {}]", uri);
|
||||
log.info("[testSubDeviceRegister][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, GATEWAY_TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testSubDeviceRegister][响应码: {}]", response.getCode());
|
||||
log.info("[testSubDeviceRegister][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 批量上报测试 =====================
|
||||
|
||||
/**
|
||||
* 批量上报属性测试(网关 + 子设备)
|
||||
* <p>
|
||||
* 网关设备批量上报自身属性、事件,以及子设备的属性、事件
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testPropertyPackPost() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/event/property/pack/post",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建【网关设备】自身属性
|
||||
Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder()
|
||||
.put("temperature", 25.5)
|
||||
.build();
|
||||
// 1.3 构建【网关设备】自身事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build());
|
||||
gatewayEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("statusReport", gatewayEvent)
|
||||
.build();
|
||||
// 1.4 构建【网关子设备】属性
|
||||
Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.build();
|
||||
// 1.5 构建【网关子设备】事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build());
|
||||
subDeviceEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("healthCheck", subDeviceEvent)
|
||||
.build();
|
||||
// 1.6 构建子设备数据
|
||||
IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData();
|
||||
subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME));
|
||||
subDeviceData.setProperties(subDeviceProperties);
|
||||
subDeviceData.setEvents(subDeviceEvents);
|
||||
// 1.7 构建请求参数
|
||||
IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO();
|
||||
params.setProperties(gatewayProperties);
|
||||
params.setEvents(gatewayEvents);
|
||||
params.setSubDevices(ListUtil.of(subDeviceData));
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.8 输出请求
|
||||
log.info("[testPropertyPackPost][请求 URI: {}]", uri);
|
||||
log.info("[testPropertyPackPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, GATEWAY_TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testPropertyPackPost][响应码: {}]", response.getCode());
|
||||
log.info("[testPropertyPackPost][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapClient;
|
||||
import org.eclipse.californium.core.CoapResponse;
|
||||
import org.eclipse.californium.core.coap.MediaTypeRegistry;
|
||||
import org.eclipse.californium.core.coap.Option;
|
||||
import org.eclipse.californium.core.coap.Request;
|
||||
import org.eclipse.californium.core.config.CoapConfig;
|
||||
import org.eclipse.californium.elements.config.Configuration;
|
||||
import org.eclipse.californium.elements.config.UdpConfig;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* IoT 网关子设备 CoAP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据
|
||||
*
|
||||
* <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b>
|
||||
* <p>网关设备转发子设备请求时,Token 使用子设备自己的信息。
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(CoAP 端口 5683)</li>
|
||||
* <li>确保子设备已通过 {@link IotGatewayDeviceCoapProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li>
|
||||
* <li>运行 {@link #testAuth()} 获取子设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li>
|
||||
* <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewaySubDeviceCoapProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 5683;
|
||||
|
||||
// ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String DEVICE_NAME = "chazuo-it";
|
||||
private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
/**
|
||||
* 网关子设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiakF1ZkVNVEYxVzZ3blBobiIsImV4cCI6MTc2OTk1NDY3OSwiZGV2aWNlTmFtZSI6ImNoYXp1by1pdCJ9.jfbUAoU0xkJl4UvO-NUvcJ6yITPRgUjQ4MKATPuwneg";
|
||||
|
||||
@BeforeAll
|
||||
public static void initCaliforniumConfig() {
|
||||
// 注册 Californium 配置定义
|
||||
CoapConfig.register();
|
||||
UdpConfig.register();
|
||||
// 创建默认配置
|
||||
Configuration.setStandard(Configuration.createStandardWithoutFile());
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备认证测试:获取子设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/auth", SERVER_HOST, SERVER_PORT);
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
String payload = JsonUtils.toJsonString(authReqDTO);
|
||||
// 1.2 输出请求
|
||||
log.info("[testAuth][请求 URI: {}]", uri);
|
||||
log.info("[testAuth][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
CoapResponse response = client.post(payload, MediaTypeRegistry.APPLICATION_JSON);
|
||||
// 2.2 输出结果
|
||||
log.info("[testAuth][响应码: {}]", response.getCode());
|
||||
log.info("[testAuth][响应体: {}]", response.getResponseText());
|
||||
log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]");
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备属性上报测试
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/property/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.put("status", "online")
|
||||
.put("temperature", 36.5)
|
||||
.build()))
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testPropertyPost][请求 URI: {}]", uri);
|
||||
log.info("[testPropertyPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testPropertyPost][响应码: {}]", response.getCode());
|
||||
log.info("[testPropertyPost][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备事件上报测试
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
public void testEventPost() throws Exception {
|
||||
// 1.1 构建请求
|
||||
String uri = String.format("coap://%s:%d/topic/sys/%s/%s/thing/event/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDeviceEventPostReqDTO.of(
|
||||
"alarm",
|
||||
MapUtil.<String, Object>builder()
|
||||
.put("level", "warning")
|
||||
.put("message", "temperature too high")
|
||||
.put("threshold", 40)
|
||||
.put("current", 42)
|
||||
.build(),
|
||||
System.currentTimeMillis()))
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testEventPost][请求 URI: {}]", uri);
|
||||
log.info("[testEventPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
CoapClient client = new CoapClient(uri);
|
||||
try {
|
||||
Request request = Request.newPost();
|
||||
request.setURI(uri);
|
||||
request.setPayload(payload);
|
||||
request.getOptions().setContentFormat(MediaTypeRegistry.APPLICATION_JSON);
|
||||
request.getOptions().addOption(new Option(IotCoapUtils.OPTION_TOKEN, TOKEN));
|
||||
|
||||
CoapResponse response = client.advanced(request);
|
||||
// 2.2 输出结果
|
||||
log.info("[testEventPost][响应码: {}]", response.getCode());
|
||||
log.info("[testEventPost][响应体: {}]", response.getResponseText());
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
/**
|
||||
* IoT 直连设备 HTTP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 HTTP 协议直接连接平台
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(HTTP 端口 8092)</li>
|
||||
* <li>运行 {@link #testDeviceRegister()} 测试直连设备动态注册(一型一密)</li>
|
||||
* <li>运行 {@link #testAuth()} 获取设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testPropertyPost()} - 设备属性上报</li>
|
||||
* <li>{@link #testEventPost()} - 设备事件上报</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
@SuppressWarnings("HttpUrlsUsage")
|
||||
public class IotDirectDeviceHttpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8092;
|
||||
|
||||
// ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
/**
|
||||
* 直连设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc2OTMwNTA1NSwiZGV2aWNlTmFtZSI6InNtYWxsIn0.mf3MEATCn5bp6cXgULunZjs8d00RGUxj96JEz0hMS7k";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:获取设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/auth", SERVER_HOST, SERVER_PORT);
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
String payload = JsonUtils.toJsonString(authReqDTO);
|
||||
// 1.2 输出请求
|
||||
log.info("[testAuth][请求 URL: {}]", url);
|
||||
log.info("[testAuth][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
String response = HttpUtil.post(url, payload);
|
||||
// 2.2 输出结果
|
||||
log.info("[testAuth][响应体: {}]", response);
|
||||
log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]");
|
||||
}
|
||||
|
||||
// ===================== 直连设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/property/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("width", 1)
|
||||
.put("height", "2")
|
||||
.build())
|
||||
)
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testPropertyPost][请求 URL: {}]", url);
|
||||
log.info("[testPropertyPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testPropertyPost][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/event/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDeviceEventPostReqDTO.of(
|
||||
"eat",
|
||||
MapUtil.<String, Object>builder().put("rice", 3).build(),
|
||||
System.currentTimeMillis())
|
||||
)
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testEventPost][请求 URL: {}]", url);
|
||||
log.info("[testEventPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testEventPost][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 动态注册测试 =====================
|
||||
|
||||
/**
|
||||
* 直连设备动态注册测试(一型一密)
|
||||
* <p>
|
||||
* 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret)
|
||||
* <p>
|
||||
* 注意:此接口不需要 Token 认证
|
||||
*/
|
||||
@Test
|
||||
public void testDeviceRegister() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/auth/register/device", SERVER_HOST, SERVER_PORT);
|
||||
// 1.2 构建请求参数
|
||||
IotDeviceRegisterReqDTO reqDTO = new IotDeviceRegisterReqDTO();
|
||||
reqDTO.setProductKey(PRODUCT_KEY);
|
||||
reqDTO.setDeviceName("test-" + System.currentTimeMillis());
|
||||
reqDTO.setProductSecret("test-product-secret");
|
||||
String payload = JsonUtils.toJsonString(reqDTO);
|
||||
// 1.3 输出请求
|
||||
log.info("[testDeviceRegister][请求 URL: {}]", url);
|
||||
log.info("[testDeviceRegister][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
String response = HttpUtil.post(url, payload);
|
||||
// 2.2 输出结果
|
||||
log.info("[testDeviceRegister][响应体: {}]", response);
|
||||
log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
* IoT 网关设备 HTTP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 HTTP 协议管理子设备拓扑关系
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(HTTP 端口 8092)</li>
|
||||
* <li>运行 {@link #testAuth()} 获取网关设备 token,将返回的 token 粘贴到 {@link #GATEWAY_TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li>
|
||||
* <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li>
|
||||
* <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
@SuppressWarnings("HttpUrlsUsage")
|
||||
public class IotGatewayDeviceHttpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8092;
|
||||
|
||||
// ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) =====================
|
||||
|
||||
private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v";
|
||||
private static final String GATEWAY_DEVICE_NAME = "sub-ddd";
|
||||
private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3";
|
||||
|
||||
/**
|
||||
* 网关设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String GATEWAY_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoibTZYY1MxWkozVFc4ZUMwdiIsImV4cCI6MTc2OTg2NjY3OCwiZGV2aWNlTmFtZSI6InN1Yi1kZGQifQ.nCLSAfHEjXLtTDRXARjOoFqpuo5WfArjFWweUAzrjKU";
|
||||
|
||||
// ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String SUB_DEVICE_NAME = "chazuo-it";
|
||||
private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 网关设备认证测试:获取网关设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/auth", SERVER_HOST, SERVER_PORT);
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
String payload = JsonUtils.toJsonString(authReqDTO);
|
||||
// 1.2 输出请求
|
||||
log.info("[testAuth][请求 URL: {}]", url);
|
||||
log.info("[testAuth][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
String response = HttpUtil.post(url, payload);
|
||||
// 2.2 输出结果
|
||||
log.info("[testAuth][响应体: {}]", response);
|
||||
log.info("[testAuth][请将返回的 token 复制到 GATEWAY_TOKEN 常量中]");
|
||||
}
|
||||
|
||||
// ===================== 拓扑管理测试 =====================
|
||||
|
||||
/**
|
||||
* 添加子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要绑定的子设备信息
|
||||
*/
|
||||
@Test
|
||||
public void testTopoAdd() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/topo/add",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建子设备认证信息
|
||||
IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO()
|
||||
.setClientId(subAuthInfo.getClientId())
|
||||
.setUsername(subAuthInfo.getUsername())
|
||||
.setPassword(subAuthInfo.getPassword());
|
||||
// 1.3 构建请求参数
|
||||
IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(subDeviceAuth));
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.4 输出请求
|
||||
log.info("[testTopoAdd][请求 URL: {}]", url);
|
||||
log.info("[testTopoAdd][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", GATEWAY_TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testTopoAdd][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要解绑的子设备信息
|
||||
*/
|
||||
@Test
|
||||
public void testTopoDelete() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/topo/delete",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建请求参数
|
||||
IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(
|
||||
new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)));
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.3 输出请求
|
||||
log.info("[testTopoDelete][请求 URL: {}]", url);
|
||||
log.info("[testTopoDelete][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", GATEWAY_TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testTopoDelete][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台查询已绑定的子设备列表
|
||||
*/
|
||||
@Test
|
||||
public void testTopoGet() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/topo/get",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建请求参数(目前为空,预留扩展)
|
||||
IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO();
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.TOPO_GET.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.3 输出请求
|
||||
log.info("[testTopoGet][请求 URL: {}]", url);
|
||||
log.info("[testTopoGet][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", GATEWAY_TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testTopoGet][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备注册测试 =====================
|
||||
|
||||
// TODO @芋艿:待测试
|
||||
|
||||
/**
|
||||
* 子设备动态注册测试
|
||||
* <p>
|
||||
* 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret
|
||||
* <p>
|
||||
* 注意:此接口需要网关 Token 认证
|
||||
*/
|
||||
@Test
|
||||
public void testSubDeviceRegister() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/auth/register/sub-device/%s/%s",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建请求参数
|
||||
IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO();
|
||||
subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY);
|
||||
subDevice.setDeviceName("mougezishebei");
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", Collections.singletonList(subDevice))
|
||||
.build());
|
||||
// 1.3 输出请求
|
||||
log.info("[testSubDeviceRegister][请求 URL: {}]", url);
|
||||
log.info("[testSubDeviceRegister][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", GATEWAY_TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testSubDeviceRegister][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 批量上报测试 =====================
|
||||
|
||||
/**
|
||||
* 批量上报属性测试(网关 + 子设备)
|
||||
* <p>
|
||||
* 网关设备批量上报自身属性、事件,以及子设备的属性、事件
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPackPost() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/event/property/pack/post",
|
||||
SERVER_HOST, SERVER_PORT, GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
// 1.2 构建【网关设备】自身属性
|
||||
Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder()
|
||||
.put("temperature", 25.5)
|
||||
.build();
|
||||
// 1.3 构建【网关设备】自身事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build());
|
||||
gatewayEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("statusReport", gatewayEvent)
|
||||
.build();
|
||||
// 1.4 构建【网关子设备】属性
|
||||
Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.build();
|
||||
// 1.5 构建【网关子设备】事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build());
|
||||
subDeviceEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("healthCheck", subDeviceEvent)
|
||||
.build();
|
||||
// 1.6 构建子设备数据
|
||||
IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData();
|
||||
subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME));
|
||||
subDeviceData.setProperties(subDeviceProperties);
|
||||
subDeviceData.setEvents(subDeviceEvents);
|
||||
// 1.7 构建请求参数
|
||||
IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO();
|
||||
params.setProperties(gatewayProperties);
|
||||
params.setEvents(gatewayEvents);
|
||||
params.setSubDevices(ListUtil.of(subDeviceData));
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", params)
|
||||
.build());
|
||||
// 1.8 输出请求
|
||||
log.info("[testPropertyPackPost][请求 URL: {}]", url);
|
||||
log.info("[testPropertyPackPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", GATEWAY_TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testPropertyPackPost][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
|
||||
/**
|
||||
* IoT 网关子设备 HTTP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据
|
||||
*
|
||||
* <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b>
|
||||
* <p>网关设备转发子设备请求时,URL 和 Token 都使用子设备自己的信息。
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(HTTP 端口 8092)</li>
|
||||
* <li>确保子设备已通过 {@link IotGatewayDeviceHttpProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li>
|
||||
* <li>运行 {@link #testAuth()} 获取子设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li>
|
||||
* <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
@SuppressWarnings("HttpUrlsUsage")
|
||||
public class IotGatewaySubDeviceHttpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8092;
|
||||
|
||||
// ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String DEVICE_NAME = "chazuo-it";
|
||||
private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
/**
|
||||
* 网关子设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiakF1ZkVNVEYxVzZ3blBobiIsImV4cCI6MTc2OTg3MTI3NCwiZGV2aWNlTmFtZSI6ImNoYXp1by1pdCJ9.99sAlRalzMU3CqRlGStDzCwWSBJq6u3PJw48JQ3NpzQ";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备认证测试:获取子设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/auth", SERVER_HOST, SERVER_PORT);
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
String payload = JsonUtils.toJsonString(authReqDTO);
|
||||
// 1.2 输出请求
|
||||
log.info("[testAuth][请求 URL: {}]", url);
|
||||
log.info("[testAuth][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
String response = HttpUtil.post(url, payload);
|
||||
// 2.2 输出结果
|
||||
log.info("[testAuth][响应体: {}]", response);
|
||||
log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]");
|
||||
}
|
||||
|
||||
// ===================== 子设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/property/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.put("status", "online")
|
||||
.put("temperature", 36.5)
|
||||
.build())
|
||||
)
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testPropertyPost][请求 URL: {}]", url);
|
||||
log.info("[testPropertyPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testPropertyPost][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() {
|
||||
// 1.1 构建请求
|
||||
String url = String.format("http://%s:%d/topic/sys/%s/%s/thing/event/post",
|
||||
SERVER_HOST, SERVER_PORT, PRODUCT_KEY, DEVICE_NAME);
|
||||
String payload = JsonUtils.toJsonString(MapUtil.builder()
|
||||
.put("id", IdUtil.fastSimpleUUID())
|
||||
.put("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod())
|
||||
.put("version", "1.0")
|
||||
.put("params", IotDeviceEventPostReqDTO.of(
|
||||
"alarm",
|
||||
MapUtil.<String, Object>builder()
|
||||
.put("level", "warning")
|
||||
.put("message", "temperature too high")
|
||||
.put("threshold", 40)
|
||||
.put("current", 42)
|
||||
.build(),
|
||||
System.currentTimeMillis())
|
||||
)
|
||||
.build());
|
||||
// 1.2 输出请求
|
||||
log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testEventPost][请求 URL: {}]", url);
|
||||
log.info("[testEventPost][请求体: {}]", payload);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (HttpResponse httpResponse = HttpUtil.createPost(url)
|
||||
.header("Authorization", TOKEN)
|
||||
.body(payload)
|
||||
.execute()) {
|
||||
// 2.2 输出结果
|
||||
log.info("[testEventPost][响应体: {}]", httpResponse.body());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import io.netty.handler.codec.mqtt.MqttQoS;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.mqtt.MqttClient;
|
||||
import io.vertx.mqtt.MqttClientOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 直连设备 MQTT 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 MQTT 协议直接连接平台
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(MQTT 端口 1883)</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 设备连接认证</li>
|
||||
* <li>{@link #testPropertyPost()} - 设备属性上报</li>
|
||||
* <li>{@link #testEventPost()} - 设备事件上报</li>
|
||||
* <li>{@link #testSubscribe()} - 订阅下行消息</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:MQTT 协议是有状态的长连接,认证在连接时通过 username/password 完成,
|
||||
* 认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotDirectDeviceMqttProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 1883;
|
||||
private static final int TIMEOUT_SECONDS = 10;
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
// ===================== 编解码器(MQTT 使用 Alink 协议) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();
|
||||
|
||||
// ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 连接认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:获取设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 构建认证信息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
log.info("[testAuth][认证信息: clientId={}, username={}, password={}]",
|
||||
authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());
|
||||
|
||||
// 2. 创建客户端并连接
|
||||
MqttClient client = connect(authInfo);
|
||||
client.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[testAuth][连接成功,客户端 ID: {}]", client.clientId());
|
||||
// 断开连接
|
||||
client.disconnect()
|
||||
.onComplete(disconnectAr -> {
|
||||
if (disconnectAr.succeeded()) {
|
||||
log.info("[testAuth][断开连接成功]");
|
||||
} else {
|
||||
log.error("[testAuth][断开连接失败]", disconnectAr.cause());
|
||||
}
|
||||
latch.countDown();
|
||||
});
|
||||
} else {
|
||||
log.error("[testAuth][连接失败]", ar.cause());
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 等待测试完成
|
||||
boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[testAuth][测试超时]");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testPropertyPost][连接认证成功]");
|
||||
|
||||
// 2. 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/property/post_reply", PRODUCT_KEY, DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 3. 构建属性上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("width", 1)
|
||||
.put("height", "2")
|
||||
.build()),
|
||||
null, null, null);
|
||||
|
||||
// 4. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/property/post", PRODUCT_KEY, DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testPropertyPost][响应消息: {}]", response);
|
||||
|
||||
// 5. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 直连设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testEventPost][连接认证成功]");
|
||||
|
||||
// 2. 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/event/post_reply", PRODUCT_KEY, DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 3. 构建事件上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(
|
||||
"eat",
|
||||
MapUtil.<String, Object>builder().put("rice", 3).build(),
|
||||
System.currentTimeMillis()),
|
||||
null, null, null);
|
||||
|
||||
// 4. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/event/post", PRODUCT_KEY, DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testEventPost][响应消息: {}]", response);
|
||||
|
||||
// 5. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 设备动态注册测试(一型一密) =====================
|
||||
|
||||
/**
|
||||
* 直连设备动态注册测试(一型一密)
|
||||
* <p>
|
||||
* 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret)
|
||||
* <p>
|
||||
* 注意:此接口不需要认证
|
||||
*/
|
||||
@Test
|
||||
public void testDeviceRegister() throws Exception {
|
||||
// 1. 连接并认证(使用已有设备连接)
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testDeviceRegister][连接认证成功]");
|
||||
|
||||
// 2.1 构建注册消息
|
||||
IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO();
|
||||
registerReqDTO.setProductKey(PRODUCT_KEY);
|
||||
registerReqDTO.setDeviceName("test-mqtt-" + System.currentTimeMillis());
|
||||
registerReqDTO.setProductSecret("test-product-secret");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO, null, null, null);
|
||||
// 2.2 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/auth/register_reply",
|
||||
registerReqDTO.getProductKey(), registerReqDTO.getDeviceName());
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 3. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/auth/register",
|
||||
registerReqDTO.getProductKey(), registerReqDTO.getDeviceName());
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testDeviceRegister][响应消息: {}]", response);
|
||||
log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]");
|
||||
|
||||
// 4. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 订阅下行消息测试 =====================
|
||||
|
||||
/**
|
||||
* 订阅下行消息测试:订阅服务端下发的消息
|
||||
*/
|
||||
@Test
|
||||
public void testSubscribe() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testSubscribe][连接认证成功]");
|
||||
|
||||
// 2. 设置消息处理器
|
||||
client.publishHandler(message -> {
|
||||
log.info("[testSubscribe][收到消息: topic={}, payload={}]",
|
||||
message.topicName(), message.payload().toString());
|
||||
});
|
||||
|
||||
// 3. 订阅下行主题
|
||||
String topic = String.format("/sys/%s/%s/thing/service/#", PRODUCT_KEY, DEVICE_NAME);
|
||||
log.info("[testSubscribe][订阅主题: {}]", topic);
|
||||
|
||||
client.subscribe(topic, MqttQoS.AT_LEAST_ONCE.value())
|
||||
.onComplete(subscribeAr -> {
|
||||
if (subscribeAr.succeeded()) {
|
||||
log.info("[testSubscribe][订阅成功,等待下行消息... (30秒后自动断开)]");
|
||||
// 保持连接 30 秒等待消息
|
||||
vertx.setTimer(30000, id -> {
|
||||
client.disconnect()
|
||||
.onComplete(disconnectAr -> {
|
||||
log.info("[testSubscribe][断开连接]");
|
||||
latch.countDown();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
log.error("[testSubscribe][订阅失败]", subscribeAr.cause());
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 等待测试完成
|
||||
boolean completed = latch.await(60, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[testSubscribe][测试超时]");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建 MQTT 客户端
|
||||
*
|
||||
* @param authInfo 认证信息
|
||||
* @return MQTT 客户端
|
||||
*/
|
||||
private MqttClient connect(IotDeviceAuthReqDTO authInfo) {
|
||||
MqttClientOptions options = new MqttClientOptions()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword())
|
||||
.setCleanSession(true)
|
||||
.setKeepAliveInterval(60);
|
||||
return MqttClient.create(vertx, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接并认证设备
|
||||
*
|
||||
* @return 已认证的 MQTT 客户端
|
||||
*/
|
||||
private MqttClient connectAndAuth() throws Exception {
|
||||
// 1. 创建客户端并连接
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
MqttClient client = connect(authInfo);
|
||||
|
||||
// 2.1 连接
|
||||
CompletableFuture<MqttClient> future = new CompletableFuture<>();
|
||||
client.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
future.complete(client);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2.2 等待连接结果
|
||||
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅响应主题
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @param replyTopic 响应主题
|
||||
*/
|
||||
private void subscribeReply(MqttClient client, String replyTopic) throws Exception {
|
||||
// 1. 订阅响应主题
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
client.subscribe(replyTopic, MqttQoS.AT_LEAST_ONCE.value())
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[subscribeReply][订阅响应主题成功: {}]", replyTopic);
|
||||
future.complete(null);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2. 等待订阅结果
|
||||
future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息并等待响应
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @param topic 发布主题
|
||||
* @param request 请求消息
|
||||
* @return 响应消息
|
||||
*/
|
||||
private IotDeviceMessage publishAndWaitReply(MqttClient client, String topic, IotDeviceMessage request) {
|
||||
// 1. 设置消息处理器,接收响应
|
||||
CompletableFuture<IotDeviceMessage> future = new CompletableFuture<>();
|
||||
client.publishHandler(message -> {
|
||||
log.info("[publishAndWaitReply][收到响应: topic={}, payload={}]",
|
||||
message.topicName(), message.payload().toString());
|
||||
IotDeviceMessage response = CODEC.decode(message.payload().getBytes());
|
||||
future.complete(response);
|
||||
});
|
||||
|
||||
// 2. 编码并发布消息
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[publishAndWaitReply][Codec: {}, 发送消息: topic={}, payload={}]",
|
||||
CODEC.type(), topic, new String(payload));
|
||||
|
||||
client.publish(topic, Buffer.buffer(payload), MqttQoS.AT_LEAST_ONCE, false, false)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[publishAndWaitReply][消息发布成功,messageId={}]", ar.result());
|
||||
} else {
|
||||
log.error("[publishAndWaitReply][消息发布失败]", ar.cause());
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 等待响应(超时返回 null)
|
||||
try {
|
||||
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("[publishAndWaitReply][等待响应超时或失败]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
*/
|
||||
private void disconnect(MqttClient client) throws Exception {
|
||||
// 1. 断开连接
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
client.disconnect()
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[disconnect][断开连接成功]");
|
||||
future.complete(null);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2. 等待断开结果
|
||||
future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import io.netty.handler.codec.mqtt.MqttQoS;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.mqtt.MqttClient;
|
||||
import io.vertx.mqtt.MqttClientOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 网关设备 MQTT 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 MQTT 协议管理子设备拓扑关系
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(MQTT 端口 1883)</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 网关设备连接认证</li>
|
||||
* <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li>
|
||||
* <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li>
|
||||
* <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:MQTT 协议是有状态的长连接,认证在连接时通过 username/password 完成,
|
||||
* 认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewayDeviceMqttProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 1883;
|
||||
private static final int TIMEOUT_SECONDS = 10;
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
// ===================== 编解码器(MQTT 使用 Alink 协议) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) =====================
|
||||
|
||||
private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v";
|
||||
private static final String GATEWAY_DEVICE_NAME = "sub-ddd";
|
||||
private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3";
|
||||
|
||||
// ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String SUB_DEVICE_NAME = "chazuo-it";
|
||||
private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 连接认证测试 =====================
|
||||
|
||||
/**
|
||||
* 网关设备认证测试:获取网关设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 构建认证信息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
log.info("[testAuth][认证信息: clientId={}, username={}, password={}]",
|
||||
authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());
|
||||
|
||||
// 2. 创建客户端并连接
|
||||
MqttClient client = connect(authInfo);
|
||||
client.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[testAuth][连接成功,客户端 ID: {}]", client.clientId());
|
||||
// 断开连接
|
||||
client.disconnect()
|
||||
.onComplete(disconnectAr -> {
|
||||
if (disconnectAr.succeeded()) {
|
||||
log.info("[testAuth][断开连接成功]");
|
||||
} else {
|
||||
log.error("[testAuth][断开连接失败]", disconnectAr.cause());
|
||||
}
|
||||
latch.countDown();
|
||||
});
|
||||
} else {
|
||||
log.error("[testAuth][连接失败]", ar.cause());
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 等待测试完成
|
||||
boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[testAuth][测试超时]");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 拓扑管理测试 =====================
|
||||
|
||||
/**
|
||||
* 添加子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要绑定的子设备信息
|
||||
*/
|
||||
@Test
|
||||
public void testTopoAdd() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testTopoAdd][连接认证成功]");
|
||||
|
||||
// 2.1 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/topo/add_reply",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 2.2 构建子设备认证信息
|
||||
IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO()
|
||||
.setClientId(subAuthInfo.getClientId())
|
||||
.setUsername(subAuthInfo.getUsername())
|
||||
.setPassword(subAuthInfo.getPassword());
|
||||
|
||||
// 2.3 构建请求消息
|
||||
IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(subDeviceAuth));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
|
||||
// 3. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/topo/add",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testTopoAdd][响应消息: {}]", response);
|
||||
|
||||
// 4. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要解绑的子设备信息
|
||||
*/
|
||||
@Test
|
||||
public void testTopoDelete() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testTopoDelete][连接认证成功]");
|
||||
|
||||
// 2.1 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/topo/delete_reply",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 2.2 构建请求消息
|
||||
IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(
|
||||
new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
|
||||
// 3. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/topo/delete",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testTopoDelete][响应消息: {}]", response);
|
||||
|
||||
// 4. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台查询已绑定的子设备列表
|
||||
*/
|
||||
@Test
|
||||
public void testTopoGet() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testTopoGet][连接认证成功]");
|
||||
|
||||
// 2.1 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/topo/get_reply",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 2.2 构建请求消息
|
||||
IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO();
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_GET.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
|
||||
// 3. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/topo/get",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testTopoGet][响应消息: {}]", response);
|
||||
|
||||
// 4. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 子设备注册测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备动态注册测试
|
||||
* <p>
|
||||
* 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret
|
||||
* <p>
|
||||
* 注意:此接口需要网关认证
|
||||
*/
|
||||
@Test
|
||||
public void testSubDeviceRegister() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testSubDeviceRegister][连接认证成功]");
|
||||
|
||||
// 2.1 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/auth/sub-device/register_reply",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 2.2 构建请求消息
|
||||
IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO();
|
||||
subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY);
|
||||
subDevice.setDeviceName("mougezishebei-mqtt");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(),
|
||||
Collections.singletonList(subDevice),
|
||||
null, null, null);
|
||||
|
||||
// 3. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/auth/sub-device/register",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testSubDeviceRegister][响应消息: {}]", response);
|
||||
|
||||
// 4. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 批量上报测试 =====================
|
||||
|
||||
/**
|
||||
* 批量上报属性测试(网关 + 子设备)
|
||||
* <p>
|
||||
* 网关设备批量上报自身属性、事件,以及子设备的属性、事件
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPackPost() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testPropertyPackPost][连接认证成功]");
|
||||
|
||||
// 2.1 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/event/property/pack/post_reply",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 2.2 构建【网关设备】自身属性
|
||||
Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder()
|
||||
.put("temperature", 25.5)
|
||||
.build();
|
||||
|
||||
// 2.3 构建【网关设备】自身事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build());
|
||||
gatewayEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil
|
||||
.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("statusReport", gatewayEvent)
|
||||
.build();
|
||||
|
||||
// 2.4 构建【网关子设备】属性
|
||||
Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.build();
|
||||
|
||||
// 2.5 构建【网关子设备】事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build());
|
||||
subDeviceEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil
|
||||
.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("healthCheck", subDeviceEvent)
|
||||
.build();
|
||||
|
||||
// 2.6 构建子设备数据
|
||||
IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData();
|
||||
subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME));
|
||||
subDeviceData.setProperties(subDeviceProperties);
|
||||
subDeviceData.setEvents(subDeviceEvents);
|
||||
|
||||
// 2.7 构建请求消息
|
||||
IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO();
|
||||
params.setProperties(gatewayProperties);
|
||||
params.setEvents(gatewayEvents);
|
||||
params.setSubDevices(ListUtil.of(subDeviceData));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
|
||||
// 3. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/event/property/pack/post",
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testPropertyPackPost][响应消息: {}]", response);
|
||||
|
||||
// 4. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建 MQTT 客户端
|
||||
*
|
||||
* @param authInfo 认证信息
|
||||
* @return MQTT 客户端
|
||||
*/
|
||||
private MqttClient connect(IotDeviceAuthReqDTO authInfo) {
|
||||
MqttClientOptions options = new MqttClientOptions()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword())
|
||||
.setCleanSession(true)
|
||||
.setKeepAliveInterval(60);
|
||||
return MqttClient.create(vertx, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接并认证网关设备
|
||||
*
|
||||
* @return 已认证的 MQTT 客户端
|
||||
*/
|
||||
private MqttClient connectAndAuth() throws Exception {
|
||||
// 1. 创建客户端并连接
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
MqttClient client = connect(authInfo);
|
||||
|
||||
// 2.1 连接
|
||||
CompletableFuture<MqttClient> future = new CompletableFuture<>();
|
||||
client.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
future.complete(client);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2.2 等待连接结果
|
||||
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅响应主题
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @param replyTopic 响应主题
|
||||
*/
|
||||
private void subscribeReply(MqttClient client, String replyTopic) throws Exception {
|
||||
// 1. 订阅响应主题
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
client.subscribe(replyTopic, MqttQoS.AT_LEAST_ONCE.value())
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[subscribeReply][订阅响应主题成功: {}]", replyTopic);
|
||||
future.complete(null);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2. 等待订阅结果
|
||||
future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息并等待响应
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @param topic 发布主题
|
||||
* @param request 请求消息
|
||||
* @return 响应消息
|
||||
*/
|
||||
private IotDeviceMessage publishAndWaitReply(MqttClient client, String topic, IotDeviceMessage request) {
|
||||
// 1. 设置消息处理器,接收响应
|
||||
CompletableFuture<IotDeviceMessage> future = new CompletableFuture<>();
|
||||
client.publishHandler(message -> {
|
||||
log.info("[publishAndWaitReply][收到响应: topic={}, payload={}]",
|
||||
message.topicName(), message.payload().toString());
|
||||
IotDeviceMessage response = CODEC.decode(message.payload().getBytes());
|
||||
future.complete(response);
|
||||
});
|
||||
|
||||
// 2. 编码并发布消息
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[publishAndWaitReply][Codec: {}, 发送消息: topic={}, payload={}]",
|
||||
CODEC.type(), topic, new String(payload));
|
||||
|
||||
client.publish(topic, Buffer.buffer(payload), MqttQoS.AT_LEAST_ONCE, false, false)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[publishAndWaitReply][消息发布成功,messageId={}]", ar.result());
|
||||
} else {
|
||||
log.error("[publishAndWaitReply][消息发布失败]", ar.cause());
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 等待响应(超时返回 null)
|
||||
try {
|
||||
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("[publishAndWaitReply][等待响应超时或失败]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
*/
|
||||
private void disconnect(MqttClient client) throws Exception {
|
||||
// 1. 断开连接
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
client.disconnect()
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[disconnect][断开连接成功]");
|
||||
future.complete(null);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2. 等待断开结果
|
||||
future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import io.netty.handler.codec.mqtt.MqttQoS;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.mqtt.MqttClient;
|
||||
import io.vertx.mqtt.MqttClientOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 网关子设备 MQTT 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据
|
||||
*
|
||||
* <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b>
|
||||
* <p>网关设备转发子设备请求时,使用子设备自己的认证信息连接。
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(MQTT 端口 1883)</li>
|
||||
* <li>确保子设备已通过 {@link IotGatewayDeviceMqttProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 子设备连接认证</li>
|
||||
* <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li>
|
||||
* <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:MQTT 协议是有状态的长连接,认证在连接时通过 username/password 完成,
|
||||
* 认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewaySubDeviceMqttProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 1883;
|
||||
private static final int TIMEOUT_SECONDS = 10;
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
// ===================== 编解码器(MQTT 使用 Alink 协议) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String DEVICE_NAME = "chazuo-it";
|
||||
private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 连接认证测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备认证测试:获取子设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
// 1. 构建认证信息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
log.info("[testAuth][认证信息: clientId={}, username={}, password={}]",
|
||||
authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());
|
||||
|
||||
// 2. 创建客户端并连接
|
||||
MqttClient client = connect(authInfo);
|
||||
client.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[testAuth][连接成功,客户端 ID: {}]", client.clientId());
|
||||
// 断开连接
|
||||
client.disconnect()
|
||||
.onComplete(disconnectAr -> {
|
||||
if (disconnectAr.succeeded()) {
|
||||
log.info("[testAuth][断开连接成功]");
|
||||
} else {
|
||||
log.error("[testAuth][断开连接失败]", disconnectAr.cause());
|
||||
}
|
||||
latch.countDown();
|
||||
});
|
||||
} else {
|
||||
log.error("[testAuth][连接失败]", ar.cause());
|
||||
latch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 等待测试完成
|
||||
boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[testAuth][测试超时]");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testPropertyPost][连接认证成功]");
|
||||
log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]");
|
||||
|
||||
// 2. 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/property/post_reply", PRODUCT_KEY, DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 3. 构建属性上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.put("status", "online")
|
||||
.put("temperature", 36.5)
|
||||
.build()),
|
||||
null, null, null);
|
||||
|
||||
// 4. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/property/post", PRODUCT_KEY, DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testPropertyPost][响应消息: {}]", response);
|
||||
|
||||
// 5. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 子设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
// 1. 连接并认证
|
||||
MqttClient client = connectAndAuth();
|
||||
log.info("[testEventPost][连接认证成功]");
|
||||
log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]");
|
||||
|
||||
// 2. 订阅 _reply 主题
|
||||
String replyTopic = String.format("/sys/%s/%s/thing/event/post_reply", PRODUCT_KEY, DEVICE_NAME);
|
||||
subscribeReply(client, replyTopic);
|
||||
|
||||
// 3. 构建事件上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(
|
||||
"alarm",
|
||||
MapUtil.<String, Object>builder()
|
||||
.put("level", "warning")
|
||||
.put("message", "temperature too high")
|
||||
.put("threshold", 40)
|
||||
.put("current", 42)
|
||||
.build(),
|
||||
System.currentTimeMillis()),
|
||||
null, null, null);
|
||||
|
||||
// 4. 发布消息并等待响应
|
||||
String topic = String.format("/sys/%s/%s/thing/event/post", PRODUCT_KEY, DEVICE_NAME);
|
||||
IotDeviceMessage response = publishAndWaitReply(client, topic, request);
|
||||
log.info("[testEventPost][响应消息: {}]", response);
|
||||
|
||||
// 5. 断开连接
|
||||
disconnect(client);
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建 MQTT 客户端
|
||||
*
|
||||
* @param authInfo 认证信息
|
||||
* @return MQTT 客户端
|
||||
*/
|
||||
private MqttClient connect(IotDeviceAuthReqDTO authInfo) {
|
||||
MqttClientOptions options = new MqttClientOptions()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword())
|
||||
.setCleanSession(true)
|
||||
.setKeepAliveInterval(60);
|
||||
return MqttClient.create(vertx, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接并认证子设备
|
||||
*
|
||||
* @return 已认证的 MQTT 客户端
|
||||
*/
|
||||
private MqttClient connectAndAuth() throws Exception {
|
||||
// 1. 创建客户端并连接
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
MqttClient client = connect(authInfo);
|
||||
|
||||
// 2.1 连接
|
||||
CompletableFuture<MqttClient> future = new CompletableFuture<>();
|
||||
client.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
future.complete(client);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2.2 等待连接结果
|
||||
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅响应主题
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @param replyTopic 响应主题
|
||||
*/
|
||||
private void subscribeReply(MqttClient client, String replyTopic) throws Exception {
|
||||
// 1. 订阅响应主题
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
client.subscribe(replyTopic, MqttQoS.AT_LEAST_ONCE.value())
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[subscribeReply][订阅响应主题成功: {}]", replyTopic);
|
||||
future.complete(null);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2. 等待订阅结果
|
||||
future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息并等待响应
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @param topic 发布主题
|
||||
* @param request 请求消息
|
||||
* @return 响应消息
|
||||
*/
|
||||
private IotDeviceMessage publishAndWaitReply(MqttClient client, String topic, IotDeviceMessage request) {
|
||||
// 1. 设置消息处理器,接收响应
|
||||
CompletableFuture<IotDeviceMessage> future = new CompletableFuture<>();
|
||||
client.publishHandler(message -> {
|
||||
log.info("[publishAndWaitReply][收到响应: topic={}, payload={}]",
|
||||
message.topicName(), message.payload().toString());
|
||||
IotDeviceMessage response = CODEC.decode(message.payload().getBytes());
|
||||
future.complete(response);
|
||||
});
|
||||
|
||||
// 2. 编码并发布消息
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[publishAndWaitReply][Codec: {}, 发送消息: topic={}, payload={}]",
|
||||
CODEC.type(), topic, new String(payload));
|
||||
|
||||
client.publish(topic, Buffer.buffer(payload), MqttQoS.AT_LEAST_ONCE, false, false)
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[publishAndWaitReply][消息发布成功,messageId={}]", ar.result());
|
||||
} else {
|
||||
log.error("[publishAndWaitReply][消息发布失败]", ar.cause());
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 等待响应(超时返回 null)
|
||||
try {
|
||||
return future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("[publishAndWaitReply][等待响应超时或失败]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
*/
|
||||
private void disconnect(MqttClient client) throws Exception {
|
||||
// 1. 断开连接
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
client.disconnect()
|
||||
.onComplete(ar -> {
|
||||
if (ar.succeeded()) {
|
||||
log.info("[disconnect][断开连接成功]");
|
||||
future.complete(null);
|
||||
} else {
|
||||
future.completeExceptionally(ar.cause());
|
||||
}
|
||||
});
|
||||
// 2. 等待断开结果
|
||||
future.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.tcp;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.HexUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* IoT 直连设备 TCP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 TCP 协议直接连接平台
|
||||
*
|
||||
* <p>支持两种编解码格式:
|
||||
* <ul>
|
||||
* <li>{@link IotTcpJsonDeviceMessageCodec} - JSON 格式</li>
|
||||
* <li>{@link IotTcpBinaryDeviceMessageCodec} - 二进制格式</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(TCP 端口 8091)</li>
|
||||
* <li>修改 {@link #CODEC} 选择测试的编解码格式</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 设备认证</li>
|
||||
* <li>{@link #testDeviceRegister()} - 设备动态注册(一型一密)</li>
|
||||
* <li>{@link #testPropertyPost()} - 设备属性上报</li>
|
||||
* <li>{@link #testEventPost()} - 设备事件上报</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:TCP 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotDirectDeviceTcpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8091;
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
// ===================== 编解码器选择(修改此处切换 JSON / Binary) =====================
|
||||
|
||||
// private static final IotDeviceMessageCodec CODEC = new IotTcpJsonDeviceMessageCodec();
|
||||
private static final IotDeviceMessageCodec CODEC = new IotTcpBinaryDeviceMessageCodec();
|
||||
|
||||
// ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:获取设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testAuth][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 动态注册测试 =====================
|
||||
|
||||
/**
|
||||
* 直连设备动态注册测试(一型一密)
|
||||
* <p>
|
||||
* 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret)
|
||||
* <p>
|
||||
* 注意:此接口不需要认证
|
||||
*/
|
||||
@Test
|
||||
public void testDeviceRegister() throws Exception {
|
||||
// 1.1 构建注册消息
|
||||
IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO();
|
||||
registerReqDTO.setProductKey(PRODUCT_KEY);
|
||||
registerReqDTO.setDeviceName("test-tcp-" + System.currentTimeMillis());
|
||||
registerReqDTO.setProductSecret("test-product-secret");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testDeviceRegister][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testDeviceRegister][响应消息: {}]", response);
|
||||
log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]");
|
||||
} else {
|
||||
log.warn("[testDeviceRegister][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testPropertyPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建属性上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("width", 1)
|
||||
.put("height", "2")
|
||||
.build()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testPropertyPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testPropertyPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testPropertyPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testEventPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建事件上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(
|
||||
"eat",
|
||||
MapUtil.<String, Object>builder().put("rice", 3).build(),
|
||||
System.currentTimeMillis()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testEventPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testEventPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testEventPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 执行设备认证
|
||||
*
|
||||
* @param socket TCP 连接
|
||||
* @return 认证响应消息
|
||||
*/
|
||||
private IotDeviceMessage authenticate(Socket socket) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
byte[] payload = CODEC.encode(request);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
if (responseBytes != null) {
|
||||
log.info("[authenticate][响应数据长度: {} 字节,首字节: 0x{}, HEX: {}]",
|
||||
responseBytes.length,
|
||||
String.format("%02X", responseBytes[0]),
|
||||
HexUtil.encodeHexStr(responseBytes));
|
||||
return CODEC.decode(responseBytes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 TCP 请求并接收响应
|
||||
*
|
||||
* @param socket TCP Socket
|
||||
* @param payload 请求数据
|
||||
* @return 响应数据
|
||||
*/
|
||||
private byte[] sendAndReceive(Socket socket, byte[] payload) throws Exception {
|
||||
// 1. 发送请求
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream();
|
||||
out.write(payload);
|
||||
out.flush();
|
||||
|
||||
// 2.1 等待一小段时间让服务器处理
|
||||
Thread.sleep(100);
|
||||
// 2.2 接收响应
|
||||
byte[] buffer = new byte[4096];
|
||||
try {
|
||||
int length = in.read(buffer);
|
||||
if (length > 0) {
|
||||
byte[] response = new byte[length];
|
||||
System.arraycopy(buffer, 0, response, 0, length);
|
||||
return response;
|
||||
}
|
||||
return null;
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
log.warn("[sendAndReceive][接收响应超时]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.tcp;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 网关设备 TCP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 TCP 协议管理子设备拓扑关系
|
||||
*
|
||||
* <p>支持两种编解码格式:
|
||||
* <ul>
|
||||
* <li>{@link IotTcpJsonDeviceMessageCodec} - JSON 格式</li>
|
||||
* <li>{@link IotTcpBinaryDeviceMessageCodec} - 二进制格式</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(TCP 端口 8091)</li>
|
||||
* <li>修改 {@link #CODEC} 选择测试的编解码格式</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 网关设备认证</li>
|
||||
* <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li>
|
||||
* <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li>
|
||||
* <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:TCP 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewayDeviceTcpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8091;
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
// ===================== 编解码器选择(修改此处切换 JSON / Binary) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotTcpJsonDeviceMessageCodec();
|
||||
// private static final IotDeviceMessageCodec CODEC = new IotTcpBinaryDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) =====================
|
||||
|
||||
private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v";
|
||||
private static final String GATEWAY_DEVICE_NAME = "sub-ddd";
|
||||
private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3";
|
||||
|
||||
// ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String SUB_DEVICE_NAME = "chazuo-it";
|
||||
private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 网关设备认证测试:获取网关设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testAuth][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 拓扑管理测试 =====================
|
||||
|
||||
/**
|
||||
* 添加子设备拓扑关系测试
|
||||
*/
|
||||
@Test
|
||||
public void testTopoAdd() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testTopoAdd][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建子设备认证信息
|
||||
IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO()
|
||||
.setClientId(subAuthInfo.getClientId())
|
||||
.setUsername(subAuthInfo.getUsername())
|
||||
.setPassword(subAuthInfo.getPassword());
|
||||
// 2.2 构建请求参数
|
||||
IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(subDeviceAuth));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.3 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testTopoAdd][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testTopoAdd][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testTopoAdd][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子设备拓扑关系测试
|
||||
*/
|
||||
@Test
|
||||
public void testTopoDelete() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testTopoDelete][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建请求参数
|
||||
IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(
|
||||
new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testTopoDelete][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testTopoDelete][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testTopoDelete][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子设备拓扑关系测试
|
||||
*/
|
||||
@Test
|
||||
public void testTopoGet() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testTopoGet][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建请求参数
|
||||
IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO();
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_GET.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testTopoGet][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testTopoGet][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testTopoGet][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备注册测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备动态注册测试
|
||||
*/
|
||||
@Test
|
||||
public void testSubDeviceRegister() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testSubDeviceRegister][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建请求参数
|
||||
IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO();
|
||||
subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY);
|
||||
subDevice.setDeviceName("mougezishebei");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(),
|
||||
Collections.singletonList(subDevice),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testSubDeviceRegister][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testSubDeviceRegister][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testSubDeviceRegister][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 批量上报测试 =====================
|
||||
|
||||
/**
|
||||
* 批量上报属性测试(网关 + 子设备)
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPackPost() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testPropertyPackPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建【网关设备】自身属性
|
||||
Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder()
|
||||
.put("temperature", 25.5)
|
||||
.build();
|
||||
// 2.2 构建【网关设备】自身事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build());
|
||||
gatewayEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("statusReport", gatewayEvent)
|
||||
.build();
|
||||
// 2.3 构建【网关子设备】属性
|
||||
Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.build();
|
||||
// 2.4 构建【网关子设备】事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build());
|
||||
subDeviceEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("healthCheck", subDeviceEvent)
|
||||
.build();
|
||||
// 2.5 构建子设备数据
|
||||
IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData();
|
||||
subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME));
|
||||
subDeviceData.setProperties(subDeviceProperties);
|
||||
subDeviceData.setEvents(subDeviceEvents);
|
||||
// 2.6 构建请求参数
|
||||
IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO();
|
||||
params.setProperties(gatewayProperties);
|
||||
params.setEvents(gatewayEvents);
|
||||
params.setSubDevices(ListUtil.of(subDeviceData));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.7 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testPropertyPackPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testPropertyPackPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testPropertyPackPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 执行网关设备认证
|
||||
*
|
||||
* @param socket TCP 连接
|
||||
* @return 认证响应消息
|
||||
*/
|
||||
private IotDeviceMessage authenticate(Socket socket) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
byte[] payload = CODEC.encode(request);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
if (responseBytes != null) {
|
||||
return CODEC.decode(responseBytes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 TCP 请求并接收响应
|
||||
*
|
||||
* @param socket TCP Socket
|
||||
* @param payload 请求数据
|
||||
* @return 响应数据
|
||||
*/
|
||||
private byte[] sendAndReceive(Socket socket, byte[] payload) throws Exception {
|
||||
// 1. 发送请求
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream();
|
||||
out.write(payload);
|
||||
out.flush();
|
||||
|
||||
// 2.1 等待一小段时间让服务器处理
|
||||
Thread.sleep(100);
|
||||
// 2.2 接收响应
|
||||
byte[] buffer = new byte[4096];
|
||||
try {
|
||||
int length = in.read(buffer);
|
||||
if (length > 0) {
|
||||
byte[] response = new byte[length];
|
||||
System.arraycopy(buffer, 0, response, 0, length);
|
||||
return response;
|
||||
}
|
||||
return null;
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
log.warn("[sendAndReceive][接收响应超时]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.tcp;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* IoT 网关子设备 TCP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据
|
||||
*
|
||||
* <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b>
|
||||
*
|
||||
* <p>支持两种编解码格式:
|
||||
* <ul>
|
||||
* <li>{@link IotTcpJsonDeviceMessageCodec} - JSON 格式</li>
|
||||
* <li>{@link IotTcpBinaryDeviceMessageCodec} - 二进制格式</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(TCP 端口 8091)</li>
|
||||
* <li>确保子设备已通过 {@link IotGatewayDeviceTcpProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li>
|
||||
* <li>修改 {@link #CODEC} 选择测试的编解码格式</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 子设备认证</li>
|
||||
* <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li>
|
||||
* <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:TCP 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewaySubDeviceTcpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8091;
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
// ===================== 编解码器选择(修改此处切换 JSON / Binary) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotTcpJsonDeviceMessageCodec();
|
||||
// private static final IotDeviceMessageCodec CODEC = new IotTcpBinaryDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String DEVICE_NAME = "chazuo-it";
|
||||
private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备认证测试:获取子设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testAuth][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testPropertyPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建属性上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.put("status", "online")
|
||||
.put("temperature", 36.5)
|
||||
.build()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testPropertyPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testPropertyPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testPropertyPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
try (Socket socket = new Socket(SERVER_HOST, SERVER_PORT)) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
|
||||
// 1. 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(socket);
|
||||
log.info("[testEventPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建事件上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(
|
||||
"alarm",
|
||||
MapUtil.<String, Object>builder()
|
||||
.put("level", "warning")
|
||||
.put("message", "temperature too high")
|
||||
.put("threshold", 40)
|
||||
.put("current", 42)
|
||||
.build(),
|
||||
System.currentTimeMillis()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testEventPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送请求
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 3.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testEventPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testEventPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 执行子设备认证
|
||||
*
|
||||
* @param socket TCP 连接
|
||||
* @return 认证响应消息
|
||||
*/
|
||||
private IotDeviceMessage authenticate(Socket socket) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
byte[] payload = CODEC.encode(request);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
if (responseBytes != null) {
|
||||
return CODEC.decode(responseBytes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 TCP 请求并接收响应
|
||||
*
|
||||
* @param socket TCP Socket
|
||||
* @param payload 请求数据
|
||||
* @return 响应数据
|
||||
*/
|
||||
private byte[] sendAndReceive(Socket socket, byte[] payload) throws Exception {
|
||||
// 1. 发送请求
|
||||
OutputStream out = socket.getOutputStream();
|
||||
InputStream in = socket.getInputStream();
|
||||
out.write(payload);
|
||||
out.flush();
|
||||
|
||||
// 2.1 等待一小段时间让服务器处理
|
||||
Thread.sleep(100);
|
||||
// 2.2 接收响应
|
||||
byte[] buffer = new byte[4096];
|
||||
try {
|
||||
int length = in.read(buffer);
|
||||
if (length > 0) {
|
||||
byte[] response = new byte[length];
|
||||
System.arraycopy(buffer, 0, response, 0, length);
|
||||
return response;
|
||||
}
|
||||
return null;
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
log.warn("[sendAndReceive][接收响应超时]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 直连设备 UDP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 UDP 协议直接连接平台
|
||||
*
|
||||
* <p>支持两种编解码格式:
|
||||
* <ul>
|
||||
* <li>{@link IotTcpJsonDeviceMessageCodec} - JSON 格式</li>
|
||||
* <li>{@link IotTcpBinaryDeviceMessageCodec} - 二进制格式</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(UDP 端口 8093)</li>
|
||||
* <li>修改 {@link #CODEC} 选择测试的编解码格式</li>
|
||||
* <li>运行 {@link #testAuth()} 获取设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testPropertyPost()} - 设备属性上报</li>
|
||||
* <li>{@link #testEventPost()} - 设备事件上报</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:UDP 协议是无状态的,每次请求需要在 params 中携带 token(与 HTTP 通过 Header 传递不同)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotDirectDeviceUdpProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8093;
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
// ===================== 编解码器选择(修改此处切换 JSON / Binary) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotTcpJsonDeviceMessageCodec();
|
||||
// private static final IotDeviceMessageCodec CODEC = new IotTcpBinaryDeviceMessageCodec();
|
||||
|
||||
// ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
/**
|
||||
* 直连设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc2OTk0ODYzOCwiZGV2aWNlTmFtZSI6InNtYWxsIn0.TrOJisXhloZ3quLBOAIyowmpq6Syp9PHiEpfj-nQ9xo";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:获取设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testAuth][响应消息: {}]", response);
|
||||
log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]");
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 动态注册测试 =====================
|
||||
|
||||
/**
|
||||
* 直连设备动态注册测试(一型一密)
|
||||
* <p>
|
||||
* 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret)
|
||||
* <p>
|
||||
* 注意:此接口不需要认证
|
||||
*/
|
||||
@Test
|
||||
public void testDeviceRegister() throws Exception {
|
||||
// 1.1 构建注册消息
|
||||
IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO();
|
||||
registerReqDTO.setProductKey(PRODUCT_KEY);
|
||||
registerReqDTO.setDeviceName("test-udp-" + System.currentTimeMillis());
|
||||
registerReqDTO.setProductSecret("test-product-secret");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testDeviceRegister][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testDeviceRegister][响应消息: {}]", response);
|
||||
log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]");
|
||||
} else {
|
||||
log.warn("[testDeviceRegister][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1.1 构建属性上报消息(UDP 协议:token 放在 params 中)
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
withToken(IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("width", 1)
|
||||
.put("height", "2")
|
||||
.build())),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testPropertyPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testPropertyPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testPropertyPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 直连设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
// 1.1 构建事件上报消息(UDP 协议:token 放在 params 中)
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
withToken(IotDeviceEventPostReqDTO.of(
|
||||
"eat",
|
||||
MapUtil.<String, Object>builder().put("rice", 3).build(),
|
||||
System.currentTimeMillis())),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testEventPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testEventPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testEventPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 构建带 token 的 params
|
||||
* <p>
|
||||
* 返回格式:{token: "xxx", body: params}
|
||||
* - token:JWT 令牌
|
||||
* - body:实际请求内容(可以是 Map、List 或其他类型)
|
||||
*
|
||||
* @param params 原始参数(Map、List 或对象)
|
||||
* @return 包含 token 和 body 的 Map
|
||||
*/
|
||||
private Map<String, Object> withToken(Object params) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", TOKEN);
|
||||
result.put("body", params);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 UDP 请求并接收响应
|
||||
*
|
||||
* @param socket UDP Socket
|
||||
* @param payload 请求数据
|
||||
* @return 响应数据
|
||||
*/
|
||||
public static byte[] sendAndReceive(DatagramSocket socket, byte[] payload) throws Exception {
|
||||
InetAddress address = InetAddress.getByName(SERVER_HOST);
|
||||
|
||||
// 发送请求
|
||||
DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, address, SERVER_PORT);
|
||||
socket.send(sendPacket);
|
||||
|
||||
// 接收响应
|
||||
byte[] receiveData = new byte[4096];
|
||||
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
|
||||
try {
|
||||
socket.receive(receivePacket);
|
||||
byte[] response = new byte[receivePacket.getLength()];
|
||||
System.arraycopy(receivePacket.getData(), 0, response, 0, receivePacket.getLength());
|
||||
return response;
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
log.warn("[sendAndReceive][接收响应超时]");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.DatagramSocket;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotDirectDeviceUdpProtocolIntegrationTest.sendAndReceive;
|
||||
|
||||
/**
|
||||
* IoT 网关设备 UDP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 UDP 协议管理子设备拓扑关系
|
||||
*
|
||||
* <p>支持两种编解码格式:
|
||||
* <ul>
|
||||
* <li>{@link IotTcpJsonDeviceMessageCodec} - JSON 格式</li>
|
||||
* <li>{@link IotTcpBinaryDeviceMessageCodec} - 二进制格式</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(UDP 端口 8093)</li>
|
||||
* <li>修改 {@link #CODEC} 选择测试的编解码格式</li>
|
||||
* <li>运行 {@link #testAuth()} 获取网关设备 token,将返回的 token 粘贴到 {@link #GATEWAY_TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li>
|
||||
* <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li>
|
||||
* <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:UDP 协议是无状态的,每次请求需要在 params 中携带 token(与 HTTP 通过 Header 传递不同)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewayDeviceUdpProtocolIntegrationTest {
|
||||
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
// ===================== 编解码器选择(修改此处切换 JSON / Binary) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotTcpJsonDeviceMessageCodec();
|
||||
// private static final IotDeviceMessageCodec CODEC = new IotTcpBinaryDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) =====================
|
||||
|
||||
private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v";
|
||||
private static final String GATEWAY_DEVICE_NAME = "sub-ddd";
|
||||
private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3";
|
||||
|
||||
/**
|
||||
* 网关设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String GATEWAY_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoibTZYY1MxWkozVFc4ZUMwdiIsImV4cCI6MTc2OTk1NDcxNSwiZGV2aWNlTmFtZSI6InN1Yi1kZGQifQ.Vg5iateNrpg0FVQI2eJomggxrYXGpwug8wsz9BsVr5w";
|
||||
|
||||
// ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String SUB_DEVICE_NAME = "chazuo-it";
|
||||
private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 网关设备认证测试:获取网关设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testAuth][响应消息: {}]", response);
|
||||
log.info("[testAuth][请将返回的 token 复制到 GATEWAY_TOKEN 常量中]");
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 拓扑管理测试 =====================
|
||||
|
||||
/**
|
||||
* 添加子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要绑定的子设备信息
|
||||
*/
|
||||
@Test
|
||||
public void testTopoAdd() throws Exception {
|
||||
// 1.1 构建子设备认证信息
|
||||
IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO()
|
||||
.setClientId(subAuthInfo.getClientId())
|
||||
.setUsername(subAuthInfo.getUsername())
|
||||
.setPassword(subAuthInfo.getPassword());
|
||||
// 1.2 构建请求参数
|
||||
IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(subDeviceAuth));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(),
|
||||
withToken(params),
|
||||
null, null, null);
|
||||
// 1.3 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testTopoAdd][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testTopoAdd][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testTopoAdd][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台上报需要解绑的子设备信息
|
||||
*/
|
||||
@Test
|
||||
public void testTopoDelete() throws Exception {
|
||||
// 1.1 构建请求参数
|
||||
IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(
|
||||
new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(),
|
||||
withToken(params),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testTopoDelete][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testTopoDelete][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testTopoDelete][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子设备拓扑关系测试
|
||||
* <p>
|
||||
* 网关设备向平台查询已绑定的子设备列表
|
||||
*/
|
||||
@Test
|
||||
public void testTopoGet() throws Exception {
|
||||
// 1.1 构建请求参数(目前为空,预留扩展)
|
||||
IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO();
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_GET.getMethod(),
|
||||
withToken(params),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testTopoGet][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testTopoGet][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testTopoGet][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备注册测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备动态注册测试
|
||||
* <p>
|
||||
* 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret
|
||||
* <p>
|
||||
* 注意:此接口需要网关 Token 认证
|
||||
*/
|
||||
@Test
|
||||
public void testSubDeviceRegister() throws Exception {
|
||||
// 1.1 构建请求参数
|
||||
IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO();
|
||||
subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY);
|
||||
subDevice.setDeviceName("mougezishebei");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(),
|
||||
withToken(Collections.singletonList(subDevice)),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testSubDeviceRegister][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testSubDeviceRegister][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testSubDeviceRegister][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 批量上报测试 =====================
|
||||
|
||||
/**
|
||||
* 批量上报属性测试(网关 + 子设备)
|
||||
* <p>
|
||||
* 网关设备批量上报自身属性、事件,以及子设备的属性、事件
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPackPost() throws Exception {
|
||||
// 1.1 构建【网关设备】自身属性
|
||||
Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder()
|
||||
.put("temperature", 25.5)
|
||||
.build();
|
||||
// 1.2 构建【网关设备】自身事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build());
|
||||
gatewayEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("statusReport", gatewayEvent)
|
||||
.build();
|
||||
// 1.3 构建【网关子设备】属性
|
||||
Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.build();
|
||||
// 1.4 构建【网关子设备】事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build());
|
||||
subDeviceEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("healthCheck", subDeviceEvent)
|
||||
.build();
|
||||
// 1.5 构建子设备数据
|
||||
IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData();
|
||||
subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME));
|
||||
subDeviceData.setProperties(subDeviceProperties);
|
||||
subDeviceData.setEvents(subDeviceEvents);
|
||||
// 1.6 构建请求参数
|
||||
IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO();
|
||||
params.setProperties(gatewayProperties);
|
||||
params.setEvents(gatewayEvents);
|
||||
params.setSubDevices(ListUtil.of(subDeviceData));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(),
|
||||
withToken(params),
|
||||
null, null, null);
|
||||
// 1.7 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testPropertyPackPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testPropertyPackPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testPropertyPackPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 构建带 token 的 params
|
||||
* <p>
|
||||
* 返回格式:{token: "xxx", body: params}
|
||||
* - token:JWT 令牌
|
||||
* - body:实际请求内容(可以是 Map、List 或其他类型)
|
||||
*
|
||||
* @param params 原始参数(Map、List 或对象)
|
||||
* @return 包含 token 和 body 的 Map
|
||||
*/
|
||||
private Map<String, Object> withToken(Object params) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", GATEWAY_TOKEN);
|
||||
result.put("body", params);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.udp;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpBinaryDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.tcp.IotTcpJsonDeviceMessageCodec;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.DatagramSocket;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotDirectDeviceUdpProtocolIntegrationTest.sendAndReceive;
|
||||
|
||||
/**
|
||||
* IoT 网关子设备 UDP 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据
|
||||
*
|
||||
* <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b>
|
||||
* <p>网关设备转发子设备请求时,Token 使用子设备自己的信息。
|
||||
*
|
||||
* <p>支持两种编解码格式:
|
||||
* <ul>
|
||||
* <li>{@link IotTcpJsonDeviceMessageCodec} - JSON 格式</li>
|
||||
* <li>{@link IotTcpBinaryDeviceMessageCodec} - 二进制格式</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(UDP 端口 8093)</li>
|
||||
* <li>确保子设备已通过 {@link IotGatewayDeviceUdpProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li>
|
||||
* <li>修改 {@link #CODEC} 选择测试的编解码格式</li>
|
||||
* <li>运行 {@link #testAuth()} 获取子设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li>
|
||||
* <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:UDP 协议是无状态的,每次请求需要在 params 中携带 token(与 HTTP 通过 Header 传递不同)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewaySubDeviceUdpProtocolIntegrationTest {
|
||||
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
// ===================== 编解码器选择(修改此处切换 JSON / Binary) =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotTcpJsonDeviceMessageCodec();
|
||||
// private static final IotDeviceMessageCodec CODEC = new IotTcpBinaryDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String DEVICE_NAME = "chazuo-it";
|
||||
private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
/**
|
||||
* 网关子设备 Token:从 {@link #testAuth()} 方法获取后,粘贴到这里
|
||||
*/
|
||||
private static final String TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcm9kdWN0S2V5IjoiakF1ZkVNVEYxVzZ3blBobiIsImV4cCI6MTc2OTk1NDY3OSwiZGV2aWNlTmFtZSI6ImNoYXp1by1pdCJ9.jfbUAoU0xkJl4UvO-NUvcJ6yITPRgUjQ4MKATPuwneg";
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备认证测试:获取子设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}, 数据包长度: {} 字节]", CODEC.type(), request, payload.length);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testAuth][响应消息: {}]", response);
|
||||
log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]");
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1.1 构建属性上报消息(UDP 协议:token 放在 params 中)
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
withToken(IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.put("status", "online")
|
||||
.put("temperature", 36.5)
|
||||
.build())),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testPropertyPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testPropertyPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testPropertyPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 子设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
// 1.1 构建事件上报消息(UDP 协议:token 放在 params 中)
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
withToken(IotDeviceEventPostReqDTO.of(
|
||||
"alarm",
|
||||
MapUtil.<String, Object>builder()
|
||||
.put("level", "warning")
|
||||
.put("message", "temperature too high")
|
||||
.put("threshold", 40)
|
||||
.put("current", 42)
|
||||
.build(),
|
||||
System.currentTimeMillis())),
|
||||
null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]");
|
||||
log.info("[testEventPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 发送请求
|
||||
try (DatagramSocket socket = new DatagramSocket()) {
|
||||
socket.setSoTimeout(TIMEOUT_MS);
|
||||
byte[] responseBytes = sendAndReceive(socket, payload);
|
||||
// 2.2 解码响应
|
||||
if (responseBytes != null) {
|
||||
IotDeviceMessage response = CODEC.decode(responseBytes);
|
||||
log.info("[testEventPost][响应消息: {}]", response);
|
||||
} else {
|
||||
log.warn("[testEventPost][未收到响应]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 构建带 token 的 params
|
||||
* <p>
|
||||
* 返回格式:{token: "xxx", body: params}
|
||||
* - token:JWT 令牌
|
||||
* - body:实际请求内容(可以是 Map、List 或其他类型)
|
||||
*
|
||||
* @param params 原始参数(Map、List 或对象)
|
||||
* @return 包含 token 和 body 的 Map
|
||||
*/
|
||||
private Map<String, Object> withToken(Object params) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("token", TOKEN);
|
||||
result.put("body", params);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.WebSocket;
|
||||
import io.vertx.core.http.WebSocketClient;
|
||||
import io.vertx.core.http.WebSocketConnectOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* IoT 直连设备 WebSocket 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 WebSocket 协议直接连接平台
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 设备认证</li>
|
||||
* <li>{@link #testDeviceRegister()} - 设备动态注册(一型一密)</li>
|
||||
* <li>{@link #testPropertyPost()} - 设备属性上报</li>
|
||||
* <li>{@link #testEventPost()} - 设备事件上报</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:WebSocket 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotDirectDeviceWebSocketProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8094;
|
||||
private static final String WS_PATH = "/ws";
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
// ===================== 编解码器选择 =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();
|
||||
|
||||
// ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:获取设备 Token
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testAuth][WebSocket 连接成功]");
|
||||
|
||||
// 2.2 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
|
||||
// 3. 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testAuth][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 动态注册测试 =====================
|
||||
|
||||
/**
|
||||
* 直连设备动态注册测试(一型一密)
|
||||
* <p>
|
||||
* 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret)
|
||||
* <p>
|
||||
* 注意:此接口不需要认证
|
||||
*/
|
||||
@Test
|
||||
public void testDeviceRegister() throws Exception {
|
||||
// 1.1 构建注册消息
|
||||
IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO();
|
||||
registerReqDTO.setProductKey(PRODUCT_KEY);
|
||||
registerReqDTO.setDeviceName("test-ws-" + System.currentTimeMillis());
|
||||
registerReqDTO.setProductSecret("test-product-secret");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testDeviceRegister][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testDeviceRegister][WebSocket 连接成功]");
|
||||
|
||||
// 2.2 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
|
||||
// 3. 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testDeviceRegister][响应消息: {}]", responseMessage);
|
||||
log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]");
|
||||
} else {
|
||||
log.warn("[testDeviceRegister][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 直连设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testPropertyPost][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testPropertyPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建属性上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("width", 1)
|
||||
.put("height", "2")
|
||||
.build()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testPropertyPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testPropertyPost][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testPropertyPost][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 直连设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testEventPost][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testEventPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建事件上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(
|
||||
"eat",
|
||||
MapUtil.<String, Object>builder().put("rice", 3).build(),
|
||||
System.currentTimeMillis()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testEventPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testEventPost][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testEventPost][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 连接(同步)
|
||||
*
|
||||
* @return WebSocket 连接
|
||||
*/
|
||||
private WebSocket createWebSocketConnection() throws Exception {
|
||||
WebSocketClient wsClient = vertx.createWebSocketClient();
|
||||
WebSocketConnectOptions options = new WebSocketConnectOptions()
|
||||
.setHost(SERVER_HOST)
|
||||
.setPort(SERVER_PORT)
|
||||
.setURI(WS_PATH);
|
||||
return wsClient.connect(options).toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息并等待响应(同步)
|
||||
*
|
||||
* @param ws WebSocket 连接
|
||||
* @param message 请求消息
|
||||
* @return 响应消息
|
||||
*/
|
||||
public static String sendAndReceive(WebSocket ws, String message) throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<String> responseRef = new AtomicReference<>();
|
||||
|
||||
// 设置消息处理器
|
||||
ws.textMessageHandler(response -> {
|
||||
log.info("[sendAndReceive][收到响应: {}]", response);
|
||||
responseRef.set(response);
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
log.info("[sendAndReceive][发送请求: {}]", message);
|
||||
ws.writeTextMessage(message);
|
||||
|
||||
// 等待响应
|
||||
boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[sendAndReceive][等待响应超时]");
|
||||
}
|
||||
return responseRef.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行设备认证(同步)
|
||||
*
|
||||
* @param ws WebSocket 连接
|
||||
* @return 认证响应消息
|
||||
*/
|
||||
private IotDeviceMessage authenticate(WebSocket ws) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[authenticate][发送认证请求: {}]", jsonMessage);
|
||||
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
if (response != null) {
|
||||
return CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.WebSocket;
|
||||
import io.vertx.core.http.WebSocketClient;
|
||||
import io.vertx.core.http.WebSocketConnectOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* IoT 网关设备 WebSocket 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 WebSocket 协议管理子设备拓扑关系
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 网关设备认证</li>
|
||||
* <li>{@link #testTopoAdd()} - 添加子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoDelete()} - 删除子设备拓扑关系</li>
|
||||
* <li>{@link #testTopoGet()} - 获取子设备拓扑关系</li>
|
||||
* <li>{@link #testSubDeviceRegister()} - 子设备动态注册</li>
|
||||
* <li>{@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:WebSocket 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewayDeviceWebSocketProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8094;
|
||||
private static final String WS_PATH = "/ws";
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
// ===================== 编解码器选择 =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关设备信息(根据实际情况修改,从 iot_device 表查询网关设备) =====================
|
||||
|
||||
private static final String GATEWAY_PRODUCT_KEY = "m6XcS1ZJ3TW8eC0v";
|
||||
private static final String GATEWAY_DEVICE_NAME = "sub-ddd";
|
||||
private static final String GATEWAY_DEVICE_SECRET = "b3d62c70f8a4495487ed1d35d61ac2b3";
|
||||
|
||||
// ===================== 子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String SUB_DEVICE_PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String SUB_DEVICE_NAME = "chazuo-it";
|
||||
private static final String SUB_DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 网关设备认证测试
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testAuth][WebSocket 连接成功]");
|
||||
|
||||
// 2.2 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
|
||||
// 3. 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testAuth][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 拓扑管理测试 =====================
|
||||
|
||||
/**
|
||||
* 添加子设备拓扑关系测试
|
||||
*/
|
||||
@Test
|
||||
public void testTopoAdd() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testTopoAdd][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testTopoAdd][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建子设备认证信息
|
||||
IotDeviceAuthReqDTO subAuthInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME, SUB_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO subDeviceAuth = new IotDeviceAuthReqDTO()
|
||||
.setClientId(subAuthInfo.getClientId())
|
||||
.setUsername(subAuthInfo.getUsername())
|
||||
.setPassword(subAuthInfo.getPassword());
|
||||
// 2.2 构建请求参数
|
||||
IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(subDeviceAuth));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.3 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testTopoAdd][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testTopoAdd][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testTopoAdd][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子设备拓扑关系测试
|
||||
*/
|
||||
@Test
|
||||
public void testTopoDelete() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testTopoDelete][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testTopoDelete][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建请求参数
|
||||
IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO();
|
||||
params.setSubDevices(Collections.singletonList(
|
||||
new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testTopoDelete][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testTopoDelete][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testTopoDelete][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取子设备拓扑关系测试
|
||||
*/
|
||||
@Test
|
||||
public void testTopoGet() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testTopoGet][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testTopoGet][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建请求参数
|
||||
IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO();
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.TOPO_GET.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testTopoGet][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testTopoGet][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testTopoGet][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 子设备注册测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备动态注册测试
|
||||
*/
|
||||
@Test
|
||||
public void testSubDeviceRegister() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testSubDeviceRegister][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testSubDeviceRegister][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建请求参数
|
||||
IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO();
|
||||
subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY);
|
||||
subDevice.setDeviceName("mougezishebei-ws");
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(),
|
||||
Collections.singletonList(subDevice),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testSubDeviceRegister][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testSubDeviceRegister][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testSubDeviceRegister][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 批量上报测试 =====================
|
||||
|
||||
/**
|
||||
* 批量上报属性测试(网关 + 子设备)
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPackPost() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testPropertyPackPost][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testPropertyPackPost][认证响应: {}]", authResponse);
|
||||
|
||||
// 2.1 构建【网关设备】自身属性
|
||||
Map<String, Object> gatewayProperties = MapUtil.<String, Object>builder()
|
||||
.put("temperature", 25.5)
|
||||
.build();
|
||||
// 2.2 构建【网关设备】自身事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
gatewayEvent.setValue(MapUtil.builder().put("message", "gateway started").build());
|
||||
gatewayEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> gatewayEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("statusReport", gatewayEvent)
|
||||
.build();
|
||||
// 2.3 构建【网关子设备】属性
|
||||
Map<String, Object> subDeviceProperties = MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.build();
|
||||
// 2.4 构建【网关子设备】事件
|
||||
IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue();
|
||||
subDeviceEvent.setValue(MapUtil.builder().put("errorCode", 0).build());
|
||||
subDeviceEvent.setTime(System.currentTimeMillis());
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> subDeviceEvents = MapUtil.<String, IotDevicePropertyPackPostReqDTO.EventValue>builder()
|
||||
.put("healthCheck", subDeviceEvent)
|
||||
.build();
|
||||
// 2.5 构建子设备数据
|
||||
IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData();
|
||||
subDeviceData.setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME));
|
||||
subDeviceData.setProperties(subDeviceProperties);
|
||||
subDeviceData.setEvents(subDeviceEvents);
|
||||
// 2.6 构建请求参数
|
||||
IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO();
|
||||
params.setProperties(gatewayProperties);
|
||||
params.setEvents(gatewayEvents);
|
||||
params.setSubDevices(ListUtil.of(subDeviceData));
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(),
|
||||
params,
|
||||
null, null, null);
|
||||
// 2.7 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testPropertyPackPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testPropertyPackPost][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testPropertyPackPost][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 连接(同步)
|
||||
*
|
||||
* @return WebSocket 连接
|
||||
*/
|
||||
private WebSocket createWebSocketConnection() throws Exception {
|
||||
WebSocketClient wsClient = vertx.createWebSocketClient();
|
||||
WebSocketConnectOptions options = new WebSocketConnectOptions()
|
||||
.setHost(SERVER_HOST)
|
||||
.setPort(SERVER_PORT)
|
||||
.setURI(WS_PATH);
|
||||
return wsClient.connect(options).toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息并等待响应(同步)
|
||||
*
|
||||
* @param ws WebSocket 连接
|
||||
* @param message 请求消息
|
||||
* @return 响应消息
|
||||
*/
|
||||
private String sendAndReceive(WebSocket ws, String message) throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<String> responseRef = new AtomicReference<>();
|
||||
|
||||
// 设置消息处理器
|
||||
ws.textMessageHandler(response -> {
|
||||
log.info("[sendAndReceive][收到响应: {}]", response);
|
||||
responseRef.set(response);
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
log.info("[sendAndReceive][发送请求: {}]", message);
|
||||
ws.writeTextMessage(message);
|
||||
|
||||
// 等待响应
|
||||
boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[sendAndReceive][等待响应超时]");
|
||||
}
|
||||
return responseRef.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行网关设备认证(同步)
|
||||
*
|
||||
* @param ws WebSocket 连接
|
||||
* @return 认证响应消息
|
||||
*/
|
||||
private IotDeviceMessage authenticate(WebSocket ws) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[authenticate][发送认证请求: {}]", jsonMessage);
|
||||
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
if (response != null) {
|
||||
return CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.websocket;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.alink.IotAlinkDeviceMessageCodec;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.WebSocket;
|
||||
import io.vertx.core.http.WebSocketClient;
|
||||
import io.vertx.core.http.WebSocketConnectOptions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* IoT 网关子设备 WebSocket 协议集成测试(手动测试)
|
||||
*
|
||||
* <p>测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据
|
||||
*
|
||||
* <p><b>重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。</b>
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)</li>
|
||||
* <li>确保子设备已通过 {@link IotGatewayDeviceWebSocketProtocolIntegrationTest#testTopoAdd()} 绑定到网关</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 子设备认证</li>
|
||||
* <li>{@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)</li>
|
||||
* <li>{@link #testEventPost()} - 子设备事件上报(由网关代理转发)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>注意:WebSocket 协议是有状态的长连接,认证成功后同一连接上的后续请求无需再携带认证信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotGatewaySubDeviceWebSocketProtocolIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 8094;
|
||||
private static final String WS_PATH = "/ws";
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private static Vertx vertx;
|
||||
|
||||
// ===================== 编解码器选择 =====================
|
||||
|
||||
private static final IotDeviceMessageCodec CODEC = new IotAlinkDeviceMessageCodec();
|
||||
|
||||
// ===================== 网关子设备信息(根据实际情况修改,从 iot_device 表查询子设备) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "jAufEMTF1W6wnPhn";
|
||||
private static final String DEVICE_NAME = "chazuo-it";
|
||||
private static final String DEVICE_SECRET = "d46ef9b28ab14238b9c00a3a668032af";
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备认证测试
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
// 1.1 构建认证消息
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
// 1.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testAuth][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 2.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testAuth][WebSocket 连接成功]");
|
||||
|
||||
// 2.2 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
|
||||
// 3. 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testAuth][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testAuth][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 子设备属性上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备属性上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testPropertyPost() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testPropertyPost][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testPropertyPost][认证响应: {}]", authResponse);
|
||||
log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]");
|
||||
|
||||
// 2.1 构建属性上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(MapUtil.<String, Object>builder()
|
||||
.put("power", 100)
|
||||
.put("status", "online")
|
||||
.put("temperature", 36.5)
|
||||
.build()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testPropertyPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testPropertyPost][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testPropertyPost][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 子设备事件上报测试 =====================
|
||||
|
||||
/**
|
||||
* 子设备事件上报测试
|
||||
*/
|
||||
@Test
|
||||
public void testEventPost() throws Exception {
|
||||
// 1.1 创建 WebSocket 连接(同步)
|
||||
WebSocket ws = createWebSocketConnection();
|
||||
log.info("[testEventPost][WebSocket 连接成功]");
|
||||
|
||||
// 1.2 先进行认证
|
||||
IotDeviceMessage authResponse = authenticate(ws);
|
||||
log.info("[testEventPost][认证响应: {}]", authResponse);
|
||||
log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]");
|
||||
|
||||
// 2.1 构建事件上报消息
|
||||
IotDeviceMessage request = IotDeviceMessage.of(
|
||||
IdUtil.fastSimpleUUID(),
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(
|
||||
"alarm",
|
||||
MapUtil.<String, Object>builder()
|
||||
.put("level", "warning")
|
||||
.put("message", "temperature too high")
|
||||
.put("threshold", 40)
|
||||
.put("current", 42)
|
||||
.build(),
|
||||
System.currentTimeMillis()),
|
||||
null, null, null);
|
||||
// 2.2 编码
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[testEventPost][Codec: {}, 请求消息: {}]", CODEC.type(), request);
|
||||
|
||||
// 3.1 发送并等待响应
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
// 3.2 解码响应
|
||||
if (response != null) {
|
||||
IotDeviceMessage responseMessage = CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
log.info("[testEventPost][响应消息: {}]", responseMessage);
|
||||
} else {
|
||||
log.warn("[testEventPost][未收到响应]");
|
||||
}
|
||||
|
||||
// 4. 关闭连接
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 连接(同步)
|
||||
*
|
||||
* @return WebSocket 连接
|
||||
*/
|
||||
private WebSocket createWebSocketConnection() throws Exception {
|
||||
WebSocketClient wsClient = vertx.createWebSocketClient();
|
||||
WebSocketConnectOptions options = new WebSocketConnectOptions()
|
||||
.setHost(SERVER_HOST)
|
||||
.setPort(SERVER_PORT)
|
||||
.setURI(WS_PATH);
|
||||
return wsClient.connect(options).toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息并等待响应(同步)
|
||||
*
|
||||
* @param ws WebSocket 连接
|
||||
* @param message 请求消息
|
||||
* @return 响应消息
|
||||
*/
|
||||
private String sendAndReceive(WebSocket ws, String message) throws Exception {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<String> responseRef = new AtomicReference<>();
|
||||
|
||||
// 设置消息处理器
|
||||
ws.textMessageHandler(response -> {
|
||||
log.info("[sendAndReceive][收到响应: {}]", response);
|
||||
responseRef.set(response);
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
// 发送请求
|
||||
log.info("[sendAndReceive][发送请求: {}]", message);
|
||||
ws.writeTextMessage(message);
|
||||
|
||||
// 等待响应
|
||||
boolean completed = latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (!completed) {
|
||||
log.warn("[sendAndReceive][等待响应超时]");
|
||||
}
|
||||
return responseRef.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行子设备认证(同步)
|
||||
*
|
||||
* @param ws WebSocket 连接
|
||||
* @return 认证响应消息
|
||||
*/
|
||||
private IotDeviceMessage authenticate(WebSocket ws) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
IotDeviceAuthReqDTO authReqDTO = new IotDeviceAuthReqDTO()
|
||||
.setClientId(authInfo.getClientId())
|
||||
.setUsername(authInfo.getUsername())
|
||||
.setPassword(authInfo.getPassword());
|
||||
IotDeviceMessage request = IotDeviceMessage.of(IdUtil.fastSimpleUUID(), "auth", authReqDTO, null, null, null);
|
||||
|
||||
byte[] payload = CODEC.encode(request);
|
||||
String jsonMessage = StrUtil.utf8Str(payload);
|
||||
log.info("[authenticate][发送认证请求: {}]", jsonMessage);
|
||||
|
||||
String response = sendAndReceive(ws, jsonMessage);
|
||||
if (response != null) {
|
||||
return CODEC.decode(StrUtil.utf8Bytes(response));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,14 +23,14 @@ public class MailSendApiImpl implements MailSendApi {
|
||||
public Long sendSingleMailToAdmin(MailSendSingleToUserReqDTO reqDTO) {
|
||||
return mailSendService.sendSingleMailToAdmin(reqDTO.getUserId(),
|
||||
reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
|
||||
reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
|
||||
reqDTO.getTemplateCode(), reqDTO.getTemplateParams(), reqDTO.getAttachments());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long sendSingleMailToMember(MailSendSingleToUserReqDTO reqDTO) {
|
||||
return mailSendService.sendSingleMailToMember(reqDTO.getUserId(),
|
||||
reqDTO.getToMails(), reqDTO.getCcMails(), reqDTO.getBccMails(),
|
||||
reqDTO.getTemplateCode(), reqDTO.getTemplateParams());
|
||||
reqDTO.getTemplateCode(), reqDTO.getTemplateParams(), reqDTO.getAttachments());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Email;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -46,4 +47,9 @@ public class MailSendSingleToUserReqDTO {
|
||||
*/
|
||||
private Map<String, Object> templateParams;
|
||||
|
||||
/**
|
||||
* 附件
|
||||
*/
|
||||
private File[] attachments;
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.system.api.user;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
|
||||
import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils;
|
||||
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
|
||||
@@ -33,6 +34,7 @@ public class AdminUserApiImpl implements AdminUserApi {
|
||||
private DeptService deptService;
|
||||
|
||||
@Override
|
||||
@DataPermission(enable = false) // 忽略数据权限,避免因为过滤,导致无法查询用户。类似:https://github.com/YunaiV/ruoyi-vue-pro/issues/1051
|
||||
public AdminUserRespDTO getUser(Long id) {
|
||||
AdminUserDO user = userService.getUser(id);
|
||||
return BeanUtils.toBean(user, AdminUserRespDTO.class);
|
||||
|
||||
@@ -23,8 +23,8 @@ public class AuthLoginReqVO extends CaptchaVerificationReqVO {
|
||||
|
||||
@Schema(description = "账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudaoyuanma")
|
||||
@NotEmpty(message = "登录账号不能为空")
|
||||
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
|
||||
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
|
||||
@Length(min = 4, max = 30, message = "账号长度为 4-30 位")
|
||||
@Pattern(regexp = "^[a-zA-Z0-9]{4,30}$", message = "账号格式为数字以及字母")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "buzhidao")
|
||||
|
||||
@@ -23,7 +23,7 @@ public class UserSaveReqVO {
|
||||
|
||||
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao")
|
||||
@NotBlank(message = "用户账号不能为空")
|
||||
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "用户账号由 数字、字母 组成")
|
||||
@Pattern(regexp = "^[a-zA-Z0-9]{4,30}$", message = "用户账号由 数字、字母 组成")
|
||||
@Size(min = 4, max = 30, message = "用户账号长度为 4-30 个字符")
|
||||
@DiffLogField(name = "用户账号")
|
||||
private String username;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.iocoder.yudao.module.system.convert.auth;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeSendReqDTO;
|
||||
import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO;
|
||||
@@ -39,8 +40,6 @@ public interface AuthConvert {
|
||||
.build();
|
||||
}
|
||||
|
||||
AuthPermissionInfoRespVO.MenuVO convertTreeNode(MenuDO menu);
|
||||
|
||||
/**
|
||||
* 将菜单列表,构建成菜单树
|
||||
*
|
||||
@@ -59,9 +58,10 @@ public interface AuthConvert {
|
||||
// 构建菜单树
|
||||
// 使用 LinkedHashMap 的原因,是为了排序 。实际也可以用 Stream API ,就是太丑了。
|
||||
Map<Long, AuthPermissionInfoRespVO.MenuVO> treeNodeMap = new LinkedHashMap<>();
|
||||
menuList.forEach(menu -> treeNodeMap.put(menu.getId(), AuthConvert.INSTANCE.convertTreeNode(menu)));
|
||||
menuList.forEach(menu -> treeNodeMap.put(menu.getId(),
|
||||
BeanUtils.toBean(menu, AuthPermissionInfoRespVO.MenuVO.class)));
|
||||
// 处理父子关系
|
||||
treeNodeMap.values().stream().filter(node -> !node.getParentId().equals(ID_ROOT)).forEach(childNode -> {
|
||||
treeNodeMap.values().stream().filter(node -> ObjUtil.notEqual(node.getParentId(), ID_ROOT)).forEach(childNode -> {
|
||||
// 获得父节点
|
||||
AuthPermissionInfoRespVO.MenuVO parentNode = treeNodeMap.get(childNode.getParentId());
|
||||
if (parentNode == null) {
|
||||
|
||||
@@ -4,9 +4,8 @@ import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 邮箱发送消息
|
||||
@@ -55,4 +54,9 @@ public class MailSendMessage {
|
||||
@NotEmpty(message = "邮件内容不能为空")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 附件
|
||||
*/
|
||||
private File[] attachments;
|
||||
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static java.util.Collections.singletonList;
|
||||
|
||||
/**
|
||||
* Mail 邮件相关消息的 Producer
|
||||
@@ -28,23 +25,25 @@ public class MailProducer {
|
||||
/**
|
||||
* 发送 {@link MailSendMessage} 消息
|
||||
*
|
||||
* @param sendLogId 发送日志编码
|
||||
* @param toMails 接收邮件地址
|
||||
* @param ccMails 抄送邮件地址
|
||||
* @param bccMails 密送邮件地址
|
||||
* @param accountId 邮件账号编号
|
||||
* @param nickname 邮件发件人
|
||||
* @param title 邮件标题
|
||||
* @param content 邮件内容
|
||||
* @param sendLogId 发送日志编码
|
||||
* @param toMails 接收邮件地址
|
||||
* @param ccMails 抄送邮件地址
|
||||
* @param bccMails 密送邮件地址
|
||||
* @param accountId 邮件账号编号
|
||||
* @param nickname 邮件发件人
|
||||
* @param title 邮件标题
|
||||
* @param content 邮件内容
|
||||
* @param attachments 附件
|
||||
*/
|
||||
public void sendMailSendMessage(Long sendLogId,
|
||||
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
|
||||
Long accountId, String nickname, String title, String content) {
|
||||
Long accountId, String nickname, String title, String content,
|
||||
File[] attachments) {
|
||||
MailSendMessage message = new MailSendMessage()
|
||||
.setLogId(sendLogId)
|
||||
.setToMails(toMails).setCcMails(ccMails).setBccMails(bccMails)
|
||||
.setAccountId(accountId).setNickname(nickname)
|
||||
.setTitle(title).setContent(content);
|
||||
.setTitle(title).setContent(content).setAttachments(attachments);
|
||||
applicationContext.publishEvent(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.system.service.mail;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.module.system.mq.message.mail.MailSendMessage;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -23,13 +24,15 @@ public interface MailSendService {
|
||||
* @param bccMails 密送邮箱
|
||||
* @param templateCode 邮件模版编码
|
||||
* @param templateParams 邮件模版参数
|
||||
* @param attachments 附件
|
||||
* @return 发送日志编号
|
||||
*/
|
||||
default Long sendSingleMailToAdmin(Long userId,
|
||||
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
|
||||
String templateCode, Map<String, Object> templateParams) {
|
||||
String templateCode, Map<String, Object> templateParams,
|
||||
File... attachments) {
|
||||
return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.ADMIN.getValue(),
|
||||
templateCode, templateParams);
|
||||
templateCode, templateParams, attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,13 +44,15 @@ public interface MailSendService {
|
||||
* @param bccMails 密送邮箱
|
||||
* @param templateCode 邮件模版编码
|
||||
* @param templateParams 邮件模版参数
|
||||
* @param attachments 附件
|
||||
* @return 发送日志编号
|
||||
*/
|
||||
default Long sendSingleMailToMember(Long userId,
|
||||
Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
|
||||
String templateCode, Map<String, Object> templateParams) {
|
||||
String templateCode, Map<String, Object> templateParams,
|
||||
File... attachments) {
|
||||
return sendSingleMail(toMails, ccMails, bccMails, userId, UserTypeEnum.MEMBER.getValue(),
|
||||
templateCode, templateParams);
|
||||
templateCode, templateParams, attachments);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,11 +65,13 @@ public interface MailSendService {
|
||||
* @param userType 用户类型
|
||||
* @param templateCode 邮件模版编码
|
||||
* @param templateParams 邮件模版参数
|
||||
* @param attachments 附件
|
||||
* @return 发送日志编号
|
||||
*/
|
||||
Long sendSingleMail(Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
|
||||
Long userId, Integer userType,
|
||||
String templateCode, Map<String, Object> templateParams);
|
||||
String templateCode, Map<String, Object> templateParams,
|
||||
File... attachments);
|
||||
|
||||
/**
|
||||
* 执行真正的邮件发送
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.File;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
@@ -56,7 +57,8 @@ public class MailSendServiceImpl implements MailSendService {
|
||||
@Override
|
||||
public Long sendSingleMail(Collection<String> toMails, Collection<String> ccMails, Collection<String> bccMails,
|
||||
Long userId, Integer userType,
|
||||
String templateCode, Map<String, Object> templateParams) {
|
||||
String templateCode, Map<String, Object> templateParams,
|
||||
File... attachments) {
|
||||
// 1.1 校验邮箱模版是否合法
|
||||
MailTemplateDO template = validateMailTemplate(templateCode);
|
||||
// 1.2 校验邮箱账号是否合法
|
||||
@@ -94,7 +96,7 @@ public class MailSendServiceImpl implements MailSendService {
|
||||
// 发送 MQ 消息,异步执行发送短信
|
||||
if (isSend) {
|
||||
mailProducer.sendMailSendMessage(sendLogId, toMailSet, ccMailSet, bccMailSet,
|
||||
account.getId(), template.getNickname(), title, content);
|
||||
account.getId(), template.getNickname(), title, content, attachments);
|
||||
}
|
||||
return sendLogId;
|
||||
}
|
||||
@@ -123,7 +125,7 @@ public class MailSendServiceImpl implements MailSendService {
|
||||
// 2. 发送邮件
|
||||
try {
|
||||
String messageId = MailUtil.send(mailAccount, message.getToMails(), message.getCcMails(), message.getBccMails(),
|
||||
message.getTitle(), message.getContent(), true);
|
||||
message.getTitle(), message.getContent(), true, message.getAttachments());
|
||||
// 3. 更新结果(成功)
|
||||
mailLogService.updateMailSendResult(message.getLogId(), messageId, null);
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
|
||||
@@ -68,7 +69,7 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@Transactional(noRollbackFor = ServiceException.class)
|
||||
public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
|
||||
// 查询访问令牌
|
||||
OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
|
||||
|
||||
@@ -102,6 +102,19 @@ public class SocialClientServiceImpl implements SocialClientService {
|
||||
@Value("${yudao.wxa-subscribe-message.miniprogram-state:formal}")
|
||||
public String miniprogramState;
|
||||
|
||||
/**
|
||||
* 上传发货信息重试次数
|
||||
*/
|
||||
private static final int UPLOAD_SHIPPING_INFO_MAX_RETRIES = 5;
|
||||
/**
|
||||
* 上传发货信息重试间隔
|
||||
*/
|
||||
private static final Duration UPLOAD_SHIPPING_INFO_RETRY_INTERVAL = Duration.ofMillis(500L);
|
||||
/**
|
||||
* 微信错误码:支付单不存在
|
||||
*/
|
||||
private static final int WX_ERR_CODE_PAY_ORDER_NOT_EXIST = 10060001;
|
||||
|
||||
@SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection")
|
||||
@Autowired(required = false) // 由于 justauth.enable 配置项,可以关闭 AuthRequestFactory 的功能,所以这里只能不强制注入
|
||||
private AuthRequestFactory authRequestFactory;
|
||||
@@ -372,16 +385,34 @@ public class SocialClientServiceImpl implements SocialClientService {
|
||||
.payer(PayerBean.builder().openid(reqDTO.getOpenid()).build())
|
||||
.uploadTime(ZonedDateTime.now().format(UTC_MS_WITH_XXX_OFFSET_FORMATTER))
|
||||
.build();
|
||||
try {
|
||||
WxMaOrderShippingInfoBaseResponse response = service.getWxMaOrderShippingService().upload(request);
|
||||
if (response.getErrCode() != 0) {
|
||||
// 重试机制:解决支付回调与订单信息上传之间的时间差导致的 10060001 错误
|
||||
// 对应 ISSUE:https://gitee.com/zhijiantianya/yudao-cloud/pulls/230
|
||||
for (int attempt = 1; attempt <= UPLOAD_SHIPPING_INFO_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
WxMaOrderShippingInfoBaseResponse response = service.getWxMaOrderShippingService().upload(request);
|
||||
// 成功,直接返回
|
||||
if (response.getErrCode() == 0) {
|
||||
log.info("[uploadWxaOrderShippingInfo][上传微信小程序发货信息成功:request({}) response({})]", request, response);
|
||||
return;
|
||||
}
|
||||
// 如果是 10060001 错误(支付单不存在)且还有重试次数,则等待后重试
|
||||
if (response.getErrCode() == WX_ERR_CODE_PAY_ORDER_NOT_EXIST && attempt < UPLOAD_SHIPPING_INFO_MAX_RETRIES) {
|
||||
log.warn("[uploadWxaOrderShippingInfo][第 {} 次尝试失败,支付单不存在,{} 后重试:request({}) response({})]",
|
||||
attempt, UPLOAD_SHIPPING_INFO_RETRY_INTERVAL, request, response);
|
||||
Thread.sleep(UPLOAD_SHIPPING_INFO_RETRY_INTERVAL.toMillis());
|
||||
continue;
|
||||
}
|
||||
// 其他错误或重试次数用尽,抛出异常
|
||||
log.error("[uploadWxaOrderShippingInfo][上传微信小程序发货信息失败:request({}) response({})]", request, response);
|
||||
throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR, response.getErrMsg());
|
||||
} catch (WxErrorException ex) {
|
||||
log.error("[uploadWxaOrderShippingInfo][上传微信小程序发货信息失败:request({})]", request, ex);
|
||||
throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR, ex.getError().getErrorMsg());
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("[uploadWxaOrderShippingInfo][重试等待被中断:request({})]", request, ex);
|
||||
throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR, "重试等待被中断");
|
||||
}
|
||||
log.info("[uploadWxaOrderShippingInfo][上传微信小程序发货信息成功:request({}) response({})]", request, response);
|
||||
} catch (WxErrorException ex) {
|
||||
log.error("[uploadWxaOrderShippingInfo][上传微信小程序发货信息失败:request({})]", request, ex);
|
||||
throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_ORDER_UPLOAD_SHIPPING_INFO_ERROR, ex.getError().getErrorMsg());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import javax.annotation.Resource;
|
||||
import javax.validation.ConstraintViolationException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
|
||||
@@ -284,11 +285,14 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
@Override
|
||||
public PageResult<AdminUserDO> getUserPage(UserPageReqVO reqVO) {
|
||||
// 如果有角色编号,查询角色对应的用户编号
|
||||
Set<Long> userIds = reqVO.getRoleId() != null ?
|
||||
permissionService.getUserRoleIdListByRoleId(singleton(reqVO.getRoleId())) : null;
|
||||
if (userIds != null && userIds.isEmpty()) {
|
||||
return PageResult.empty();
|
||||
Set<Long> userIds = null;
|
||||
if (reqVO.getRoleId() != null) {
|
||||
userIds = permissionService.getUserRoleIdListByRoleId(singleton(reqVO.getRoleId()));
|
||||
if (CollUtil.isEmpty(userIds)) {
|
||||
return PageResult.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
return userMapper.selectPage(reqVO, getDeptCondition(reqVO.getDeptId()), userIds);
|
||||
}
|
||||
@@ -484,12 +488,15 @@ public class AdminUserServiceImpl implements AdminUserService {
|
||||
// 2. 遍历,逐个创建 or 更新
|
||||
UserImportRespVO respVO = UserImportRespVO.builder().createUsernames(new ArrayList<>())
|
||||
.updateUsernames(new ArrayList<>()).failureUsernames(new LinkedHashMap<>()).build();
|
||||
AtomicInteger index = new AtomicInteger(1);
|
||||
importUsers.forEach(importUser -> {
|
||||
int currentIndex = index.getAndIncrement();
|
||||
// 2.1.1 校验字段是否符合要求
|
||||
try {
|
||||
ValidationUtils.validate(BeanUtils.toBean(importUser, UserSaveReqVO.class).setPassword(initPassword));
|
||||
} catch (ConstraintViolationException ex){
|
||||
respVO.getFailureUsernames().put(importUser.getUsername(), ex.getMessage());
|
||||
} catch (ConstraintViolationException ex) {
|
||||
String key = StrUtil.blankToDefault(importUser.getUsername(), "第 " + currentIndex + " 行");
|
||||
respVO.getFailureUsernames().put(key, ex.getMessage());
|
||||
return;
|
||||
}
|
||||
// 2.1.2 校验,判断是否有不符合的原因
|
||||
|
||||
@@ -70,7 +70,8 @@ spring:
|
||||
username: root
|
||||
password: 123456
|
||||
# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!)
|
||||
# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro
|
||||
# lazy: true # 开启懒加载,保证启动速度
|
||||
# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true
|
||||
# driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver
|
||||
# username: root
|
||||
# password: taosdata
|
||||
|
||||
Reference in New Issue
Block a user