diff --git a/pom.xml b/pom.xml index 562ae24415..b4016c70ce 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ https://github.com/YunaiV/ruoyi-vue-pro - 2025.12-SNAPSHOT + 2026.01-SNAPSHOT 17 ${java.version} diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index 0257eb3109..2e8c26e2b2 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -14,7 +14,7 @@ https://github.com/YunaiV/ruoyi-vue-pro - 2025.12-SNAPSHOT + 2026.01-SNAPSHOT 1.7.2 3.5.9 @@ -68,6 +68,8 @@ 4.2.9.Final 1.2.5 4.5.22 + 4.12.0 + 3.12.0 2.40.15 1.16.7 @@ -555,6 +557,50 @@ ${jsoup.version} + + + io.vertx + vertx-core + ${vertx.version} + + + io.vertx + vertx-web + ${vertx.version} + + + io.vertx + vertx-mqtt + ${vertx.version} + + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + ${mqtt.version} + + + + + com.squareup.okhttp3 + okhttp + ${okhttp.version} + + + com.squareup.okhttp3 + mockwebserver + ${okhttp.version} + test + + + + + org.eclipse.californium + californium-core + ${californium.version} + + software.amazon.awssdk @@ -629,30 +675,6 @@ - - - - io.vertx - vertx-core - ${vertx.version} - - - io.vertx - vertx-web - ${vertx.version} - - - io.vertx - vertx-mqtt - ${vertx.version} - - - - - org.eclipse.paho - org.eclipse.paho.client.mqttv3 - ${mqtt.version} - diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/MapUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/MapUtils.java index a59b53fd4b..7ba36710ec 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/MapUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/MapUtils.java @@ -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 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 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; + } + } diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java index e35cd9b437..7711ae0d88 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java @@ -229,4 +229,53 @@ public class JsonUtils { return JSONUtil.isTypeJSONObject(str); } + /** + * 将 Object 转换为目标类型 + *

+ * 避免先转 jsonString 再 parseObject 的性能损耗 + * + * @param obj 源对象(可以是 Map、POJO 等) + * @param clazz 目标类型 + * @return 转换后的对象 + */ + public static T convertObject(Object obj, Class 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 convertObject(Object obj, TypeReference typeReference) { + if (obj == null) { + return null; + } + return objectMapper.convertValue(obj, typeReference); + } + + /** + * 将 Object 转换为 List 类型 + *

+ * 避免先转 jsonString 再 parseArray 的性能损耗 + * + * @param obj 源对象(可以是 List、数组等) + * @param clazz 目标元素类型 + * @return 转换后的 List + */ + public static List convertList(Object obj, Class clazz) { + if (obj == null) { + return new ArrayList<>(); + } + return objectMapper.convertValue(obj, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)); + } + } diff --git a/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java b/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java index 8cfad28697..2e209e6ad7 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java @@ -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; /** diff --git a/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java b/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java index 7faae00877..3c3db4e1f9 100644 --- a/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java +++ b/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/test/java/cn/iocoder/yudao/framework/datapermission/core/rule/dept/DeptDataPermissionRuleTest.java @@ -19,7 +19,6 @@ import org.mockito.MockedStatic; import java.util.Map; -import static cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRule.EXPRESSION_NULL; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; import static org.junit.jupiter.api.Assertions.*; @@ -150,7 +149,7 @@ class DeptDataPermissionRuleTest extends BaseMockitoUnitTest { // 调用 Expression expression = rule.getExpression(tableName, tableAlias); // 断言 - assertSame(EXPRESSION_NULL, expression); + assertEquals("null = null", expression.toString()); assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class)); } } diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/MPJLambdaWrapperX.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/MPJLambdaWrapperX.java index 8b5a0fcfc8..aed2f02df3 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/MPJLambdaWrapperX.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/mybatis/core/query/MPJLambdaWrapperX.java @@ -15,6 +15,7 @@ import java.util.function.Consumer; *

* 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 * 2. SFunction column + 泛型:支持任意类字段(主表、子表、三表),推荐写法, 让编译器自动推断 S 类型 + * * @param 数据类型 */ public class MPJLambdaWrapperX extends MPJLambdaWrapper { @@ -122,6 +123,12 @@ public class MPJLambdaWrapperX extends MPJLambdaWrapper { return this; } + @Override + public MPJLambdaWrapperX orderByAsc(SFunction column) { + super.orderByAsc(true, column); + return this; + } + @Override public MPJLambdaWrapperX last(String lastSql) { super.last(lastSql); diff --git a/yudao-module-ai/pom.xml b/yudao-module-ai/pom.xml index 51e6825966..dc78d42049 100644 --- a/yudao-module-ai/pom.xml +++ b/yudao-module-ai/pom.xml @@ -204,6 +204,12 @@ org.springframework.ai spring-ai-starter-mcp-server-webmvc ${spring-ai.version} + + + io.swagger.core.v3 + swagger-annotations-jakarta + + diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java index e79437b436..8297dbce17 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/config/BpmFlowableConfiguration.java @@ -15,7 +15,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.AsyncListenableTaskExecutor; +import org.springframework.core.task.AsyncTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.List; @@ -30,12 +30,12 @@ public class BpmFlowableConfiguration { /** * 参考 {@link org.flowable.spring.boot.FlowableJobConfiguration} 类,创建对应的 AsyncListenableTaskExecutor Bean - * + *

* 如果不创建,会导致项目启动时,Flowable 报错的问题 */ @Bean(name = "applicationTaskExecutor") @ConditionalOnMissingBean(name = "applicationTaskExecutor") - public AsyncListenableTaskExecutor taskExecutor() { + public AsyncTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(8); diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java index 20fcf54753..b35f18e563 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java @@ -14,6 +14,7 @@ import org.flowable.bpmn.model.UserTask; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.impl.bpmn.behavior.AbstractBpmnActivityBehavior; import org.flowable.engine.impl.bpmn.behavior.ParallelMultiInstanceBehavior; +import org.flowable.common.engine.api.delegate.Expression; import java.util.List; import java.util.Set; @@ -56,14 +57,7 @@ public class BpmParallelMultiInstanceBehavior extends ParallelMultiInstanceBehav protected int resolveNrOfInstances(DelegateExecution execution) { // 情况一:UserTask 节点 if (execution.getCurrentFlowElement() instanceof UserTask) { - // 第一步,设置 collectionVariable 和 CollectionVariable - // 从 execution.getVariable() 读取所有任务处理人的 key - super.collectionExpression = null; // collectionExpression 和 collectionVariable 是互斥的 - super.collectionVariable = FlowableUtils.formatExecutionCollectionVariable(execution.getCurrentActivityId()); - // 从 execution.getVariable() 读取当前所有任务处理的人的 key - super.collectionElementVariable = FlowableUtils.formatExecutionCollectionElementVariable(execution.getCurrentActivityId()); - - // 第二步,获取任务的所有处理人 + // 获取任务的所有处理人 @SuppressWarnings("unchecked") Set assigneeUserIds = (Set) execution.getVariable(super.collectionVariable, Set.class); if (assigneeUserIds == null) { @@ -94,4 +88,21 @@ public class BpmParallelMultiInstanceBehavior extends ParallelMultiInstanceBehav return super.resolveNrOfInstances(execution); } + // ========== 屏蔽解析器覆写 ========== + + @Override + public void setCollectionExpression(Expression collectionExpression) { + // 保持自定义变量名,忽略解析器写入的 collection 表达式 + } + + @Override + public void setCollectionVariable(String collectionVariable) { + // 保持自定义变量名,忽略解析器写入的 collection 变量名 + } + + @Override + public void setCollectionElementVariable(String collectionElementVariable) { + // 保持自定义变量名,忽略解析器写入的单元素变量名 + } + } diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java index ebf67a46bb..8848f81836 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java @@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils; import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; import lombok.Setter; import org.flowable.bpmn.model.*; +import org.flowable.common.engine.api.delegate.Expression; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.impl.bpmn.behavior.AbstractBpmnActivityBehavior; import org.flowable.engine.impl.bpmn.behavior.SequentialMultiInstanceBehavior; @@ -47,14 +48,7 @@ public class BpmSequentialMultiInstanceBehavior extends SequentialMultiInstanceB protected int resolveNrOfInstances(DelegateExecution execution) { // 情况一:UserTask 节点 if (execution.getCurrentFlowElement() instanceof UserTask) { - // 第一步,设置 collectionVariable 和 CollectionVariable - // 从 execution.getVariable() 读取所有任务处理人的 key - super.collectionExpression = null; // collectionExpression 和 collectionVariable 是互斥的 - super.collectionVariable = FlowableUtils.formatExecutionCollectionVariable(execution.getCurrentActivityId()); - // 从 execution.getVariable() 读取当前所有任务处理的人的 key - super.collectionElementVariable = FlowableUtils.formatExecutionCollectionElementVariable(execution.getCurrentActivityId()); - - // 第二步,获取任务的所有处理人 + // 获取任务的所有处理人 // 不使用 execution.getVariable 原因:目前依次审批任务回退后 collectionVariable 变量没有清理, 如果重新进入该任务不会重新分配审批人 @SuppressWarnings("unchecked") Set assigneeUserIds = (Set) execution.getVariableLocal(super.collectionVariable, Set.class); @@ -97,4 +91,21 @@ public class BpmSequentialMultiInstanceBehavior extends SequentialMultiInstanceB super.executeOriginalBehavior(execution, multiInstanceRootExecution, loopCounter); } + // ========== 屏蔽解析器覆写 ========== + + @Override + public void setCollectionExpression(Expression collectionExpression) { + // 保持自定义变量名,忽略解析器写入的 collection 表达式 + } + + @Override + public void setCollectionVariable(String collectionVariable) { + // 保持自定义变量名,忽略解析器写入的 collection 变量名 + } + + @Override + public void setCollectionElementVariable(String collectionElementVariable) { + // 保持自定义变量名,忽略解析器写入的单元素变量名 + } + } diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentItemMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentItemMapper.java index 7787e8d709..7a5bb6e63c 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentItemMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinancePaymentItemMapper.java @@ -31,14 +31,14 @@ public interface ErpFinancePaymentItemMapper extends BaseMapperX> result = selectMaps(new QueryWrapper() - .select("SUM(payment_price) AS paymentPriceSum") + .select("SUM(payment_price) AS payment_price_sum") .eq("biz_id", bizId) .eq("biz_type", bizType)); // 获得数量 if (CollUtil.isEmpty(result)) { return BigDecimal.ZERO; } - return BigDecimal.valueOf(MapUtil.getDouble(result.get(0), "paymentPriceSum", 0D)); + return BigDecimal.valueOf(MapUtil.getDouble(result.get(0), "payment_price_sum", 0D)); } } \ No newline at end of file diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinanceReceiptItemMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinanceReceiptItemMapper.java index cb6082b0e4..40ac887582 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinanceReceiptItemMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/finance/ErpFinanceReceiptItemMapper.java @@ -31,14 +31,14 @@ public interface ErpFinanceReceiptItemMapper extends BaseMapperX> result = selectMaps(new QueryWrapper() - .select("SUM(receipt_price) AS receiptPriceSum") + .select("SUM(receipt_price) AS receipt_price_sum") .eq("biz_id", bizId) .eq("biz_type", bizType)); // 获得数量 if (CollUtil.isEmpty(result)) { return BigDecimal.ZERO; } - return BigDecimal.valueOf(MapUtil.getDouble(result.get(0), "receiptPriceSum", 0D)); + return BigDecimal.valueOf(MapUtil.getDouble(result.get(0), "receipt_price_sum", 0D)); } } \ No newline at end of file diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInItemMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInItemMapper.java index 9140f9548f..5a14317806 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInItemMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseInItemMapper.java @@ -46,11 +46,11 @@ public interface ErpPurchaseInItemMapper extends BaseMapperX> result = selectMaps(new QueryWrapper() - .select("order_item_id, SUM(count) AS sumCount") + .select("order_item_id, SUM(count) AS sum_count") .groupBy("order_item_id") .in("in_id", inIds)); // 获得数量 - return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sumCount")); + return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sum_count")); } } \ No newline at end of file diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseReturnItemMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseReturnItemMapper.java index 2a8011900c..30527d2321 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseReturnItemMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/purchase/ErpPurchaseReturnItemMapper.java @@ -46,11 +46,11 @@ public interface ErpPurchaseReturnItemMapper extends BaseMapperX> result = selectMaps(new QueryWrapper() - .select("order_item_id, SUM(count) AS sumCount") + .select("order_item_id, SUM(count) AS sum_count") .groupBy("order_item_id") .in("return_id", returnIds)); // 获得数量 - return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sumCount")); + return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sum_count")); } } \ No newline at end of file diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleOutItemMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleOutItemMapper.java index 9cd5dede0d..7872bb2b9d 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleOutItemMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleOutItemMapper.java @@ -46,11 +46,11 @@ public interface ErpSaleOutItemMapper extends BaseMapperX { } // SQL sum 查询 List> result = selectMaps(new QueryWrapper() - .select("order_item_id, SUM(count) AS sumCount") + .select("order_item_id, SUM(count) AS sum_count") .groupBy("order_item_id") .in("out_id", outIds)); // 获得数量 - return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sumCount")); + return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sum_count")); } } \ No newline at end of file diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleReturnItemMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleReturnItemMapper.java index fdc5729643..609ee2445e 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleReturnItemMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/sale/ErpSaleReturnItemMapper.java @@ -46,11 +46,11 @@ public interface ErpSaleReturnItemMapper extends BaseMapperX> result = selectMaps(new QueryWrapper() - .select("order_item_id, SUM(count) AS sumCount") + .select("order_item_id, SUM(count) AS sum_count") .groupBy("order_item_id") .in("return_id", returnIds)); // 获得数量 - return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sumCount")); + return convertMap(result, obj -> (Long) obj.get("order_item_id"), obj -> (BigDecimal) obj.get("sum_count")); } } \ No newline at end of file diff --git a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/stock/ErpStockMapper.java b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/stock/ErpStockMapper.java index 0ebc985979..63ff1ca5f4 100644 --- a/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/stock/ErpStockMapper.java +++ b/yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/dal/mysql/stock/ErpStockMapper.java @@ -52,13 +52,13 @@ public interface ErpStockMapper extends BaseMapperX { default BigDecimal selectSumByProductId(Long productId) { // SQL sum 查询 List> result = selectMaps(new QueryWrapper() - .select("SUM(count) AS sumCount") + .select("SUM(count) AS sum_count") .eq("product_id", productId)); // 获得数量 if (CollUtil.isEmpty(result)) { return BigDecimal.ZERO; } - return BigDecimal.valueOf(MapUtil.getDouble(result.get(0), "sumCount", 0D)); + return BigDecimal.valueOf(MapUtil.getDouble(result.get(0), "sum_count", 0D)); } } \ No newline at end of file diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/logger/vo/apiaccesslog/ApiAccessLogRespVO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/logger/vo/apiaccesslog/ApiAccessLogRespVO.java index 45fc4df130..1b3dc0f96a 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/logger/vo/apiaccesslog/ApiAccessLogRespVO.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/logger/vo/apiaccesslog/ApiAccessLogRespVO.java @@ -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) diff --git a/yudao-module-iot/yudao-module-iot-biz/pom.xml b/yudao-module-iot/yudao-module-iot-biz/pom.xml index 1f83a7acb2..02644cb02f 100644 --- a/yudao-module-iot/yudao-module-iot-biz/pom.xml +++ b/yudao-module-iot/yudao-module-iot-biz/pom.xml @@ -73,6 +73,17 @@ yudao-spring-boot-starter-excel + + + com.squareup.okhttp3 + okhttp + + + com.squareup.okhttp3 + mockwebserver + test + + @@ -90,18 +101,6 @@ spring-boot-starter-amqp true - - - - - - - - - - - - diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java index 4a4d194db9..d7531ea390 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java @@ -7,6 +7,13 @@ import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.core.biz.dto.*; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; +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.topic.auth.IotSubDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusPointDO; @@ -28,6 +35,8 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.List; + import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; @@ -107,4 +116,18 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { return success(result); } + @Override + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/register") + @PermitAll + public CommonResult registerDevice(@RequestBody IotDeviceRegisterReqDTO reqDTO) { + return success(deviceService.registerDevice(reqDTO)); + } + + @Override + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/register-sub") + @PermitAll + public CommonResult> registerSubDevices(@RequestBody IotSubDeviceRegisterFullReqDTO reqDTO) { + return success(deviceService.registerSubDevices(reqDTO)); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceController.java index f8f78aa63d..18553a7359 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceController.java @@ -1,15 +1,18 @@ package cn.iocoder.yudao.module.iot.controller.admin.device; +import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; -import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; @@ -23,13 +26,12 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.Arrays; -import java.util.Collection; -import java.util.List; +import java.util.*; import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; @Tag(name = "管理后台 - IoT 设备") @RestController @@ -39,6 +41,8 @@ public class IotDeviceController { @Resource private IotDeviceService deviceService; + @Resource + private IotProductService productService; @PostMapping("/create") @Operation(summary = "创建设备") @@ -47,6 +51,7 @@ public class IotDeviceController { return success(deviceService.createDevice(createReqVO)); } + @PutMapping("/update") @Operation(summary = "更新设备") @PreAuthorize("@ss.hasPermission('iot:device:update')") @@ -55,7 +60,57 @@ public class IotDeviceController { return success(true); } - // TODO @芋艿:参考阿里云:1)绑定网关;2)解绑网关 + @PutMapping("/bind-gateway") + @Operation(summary = "绑定子设备到网关") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult bindDeviceGateway(@Valid @RequestBody IotDeviceBindGatewayReqVO reqVO) { + deviceService.bindDeviceGateway(reqVO.getSubIds(), reqVO.getGatewayId()); + return success(true); + } + + @PutMapping("/unbind-gateway") + @Operation(summary = "解绑子设备与网关") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult unbindDeviceGateway(@Valid @RequestBody IotDeviceUnbindGatewayReqVO reqVO) { + deviceService.unbindDeviceGateway(reqVO.getSubIds(), reqVO.getGatewayId()); + return success(true); + } + + @GetMapping("/sub-device-list") + @Operation(summary = "获取网关的子设备列表") + @Parameter(name = "gatewayId", description = "网关设备编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult> getSubDeviceList(@RequestParam("gatewayId") Long gatewayId) { + List list = deviceService.getDeviceListByGatewayId(gatewayId); + if (CollUtil.isEmpty(list)) { + return success(Collections.emptyList()); + } + + // 补充产品名称 + Map productMap = convertMap(productService.getProductList(), IotProductDO::getId); + return success(convertList(list, device -> { + IotDeviceRespVO respVO = BeanUtils.toBean(device, IotDeviceRespVO.class); + MapUtils.findAndThen(productMap, device.getProductId(), + product -> respVO.setProductName(product.getName())); + return respVO; + })); + } + + @GetMapping("/unbound-sub-device-page") + @Operation(summary = "获取未绑定网关的子设备分页") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult> getUnboundSubDevicePage(@Valid IotDevicePageReqVO pageReqVO) { + PageResult pageResult = deviceService.getUnboundSubDevicePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty()); + } + + // 补充产品名称 + Map productMap = convertMap(productService.getProductList(), IotProductDO::getId); + PageResult result = BeanUtils.toBean(pageResult, IotDeviceRespVO.class, device -> + MapUtils.findAndThen(productMap, device.getProductId(), product -> device.setProductName(product.getName()))); + return success(result); + } @PutMapping("/update-group") @Operation(summary = "更新设备分组") @@ -136,6 +191,26 @@ public class IotDeviceController { .setProductId(device.getProductId()).setState(device.getState()))); } + @GetMapping("/location-list") + @Operation(summary = "获取设备位置列表", description = "获取有经纬度信息的设备列表,用于地图展示") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult> getDeviceLocationList() { + // 1. 获取有位置信息的设备列表 + List devices = deviceService.getDeviceListByHasLocation(); + if (CollUtil.isEmpty(devices)) { + return success(Collections.emptyList()); + } + + // 2. 转换并返回 + Map productMap = convertMap(productService.getProductList(), IotProductDO::getId); + return success(convertList(devices, device -> { + IotDeviceRespVO respVO = BeanUtils.toBean(device, IotDeviceRespVO.class); + MapUtils.findAndThen(productMap, device.getProductId(), + product -> respVO.setProductName(product.getName())); + return respVO; + })); + } + @PostMapping("/import") @Operation(summary = "导入设备") @PreAuthorize("@ss.hasPermission('iot:device:import')") @@ -153,10 +228,9 @@ public class IotDeviceController { // 手动创建导出 demo List list = Arrays.asList( IotDeviceImportExcelVO.builder().deviceName("温度传感器001").parentDeviceName("gateway110") - .productKey("1de24640dfe").groupNames("灰度分组,生产分组") - .locationType(IotLocationTypeEnum.IP.getType()).build(), + .productKey("1de24640dfe").groupNames("灰度分组,生产分组").build(), IotDeviceImportExcelVO.builder().deviceName("biubiu").productKey("YzvHxd4r67sT4s2B") - .groupNames("").locationType(IotLocationTypeEnum.MANUAL.getType()).build()); + .groupNames("").build()); // 输出 ExcelUtils.write(response, "设备导入模板.xls", "数据", IotDeviceImportExcelVO.class, list); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceBindGatewayReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceBindGatewayReqVO.java new file mode 100644 index 0000000000..dbfa523b9c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceBindGatewayReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +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 subIds; + + @Schema(description = "网关设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "网关设备编号不能为空") + private Long gatewayId; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceImportExcelVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceImportExcelVO.java index ba03a8415f..6ea15a16a7 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceImportExcelVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceImportExcelVO.java @@ -1,11 +1,8 @@ package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; import cn.idev.excel.annotation.ExcelProperty; -import cn.iocoder.yudao.framework.common.validation.InEnum; -import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -35,9 +32,4 @@ public class IotDeviceImportExcelVO { @ExcelProperty("设备分组") private String groupNames; - @ExcelProperty("上报方式(1:IP 定位, 2:设备上报,3:手动定位)") - @NotNull(message = "上报方式不能为空") - @InEnum(IotLocationTypeEnum.class) - private Integer locationType; - } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java index ecb8f81c45..0d4a9d8b5b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceRespVO.java @@ -4,7 +4,6 @@ import cn.idev.excel.annotation.ExcelIgnoreUnannotated; import cn.idev.excel.annotation.ExcelProperty; import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; -import cn.iocoder.yudao.module.iot.enums.DictTypeConstants; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -45,6 +44,9 @@ public class IotDeviceRespVO { @ExcelProperty("产品编号") private Long productId; + @Schema(description = "产品名称", example = "温湿度传感器") + private String productName; // 只有部分接口返回,例如 getDeviceLocationList + @Schema(description = "产品标识", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("产品 Key") private String productKey; @@ -77,18 +79,9 @@ public class IotDeviceRespVO { @ExcelProperty("设备密钥") private String deviceSecret; - @Schema(description = "认证类型(如一机一密、动态注册)", example = "2") - @ExcelProperty("认证类型(如一机一密、动态注册)") - private String authType; - @Schema(description = "设备配置", example = "{\"abc\": \"efg\"}") private String config; - @Schema(description = "定位方式", example = "2") - @ExcelProperty(value = "定位方式", converter = DictConvert.class) - @DictFormat(DictTypeConstants.LOCATION_TYPE) - private Integer locationType; - @Schema(description = "设备位置的纬度", example = "45.000000") private BigDecimal latitude; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java index 7c8ecadb11..637ebfefbd 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceSaveReqVO.java @@ -1,8 +1,8 @@ package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; -import cn.iocoder.yudao.framework.common.validation.InEnum; -import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; import lombok.Data; import java.math.BigDecimal; @@ -39,14 +39,14 @@ public class IotDeviceSaveReqVO { @Schema(description = "设备配置", example = "{\"abc\": \"efg\"}") private String config; - @Schema(description = "定位类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") - @InEnum(value = IotLocationTypeEnum.class, message = "定位方式必须是 {value}") - private Integer locationType; - - @Schema(description = "设备位置的纬度", example = "16380") + @Schema(description = "设备位置的纬度", example = "39.915") + @DecimalMin(value = "-90", message = "纬度范围为 -90 到 90") + @DecimalMax(value = "90", message = "纬度范围为 -90 到 90") private BigDecimal latitude; - @Schema(description = "设备位置的经度", example = "16380") + @Schema(description = "设备位置的经度", example = "116.404") + @DecimalMin(value = "-180", message = "经度范围为 -180 到 180") + @DecimalMax(value = "180", message = "经度范围为 -180 到 180") private BigDecimal longitude; } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceUnbindGatewayReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceUnbindGatewayReqVO.java new file mode 100644 index 0000000000..f51d6599ea --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDeviceUnbindGatewayReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +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 subIds; + + @Schema(description = "网关设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "网关设备编号不能为空") + private Long gatewayId; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java index 3acf928245..043f48772b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java @@ -143,11 +143,13 @@ public class IotProductController { @GetMapping("/simple-list") @Operation(summary = "获取产品的精简信息列表", description = "主要用于前端的下拉选项") - public CommonResult> getProductSimpleList() { - List list = productService.getProductList(); - return success(convertList(list, product -> // 只返回 id、name 字段 + @Parameter(name = "deviceType", description = "设备类型", example = "1") + public CommonResult> getProductSimpleList( + @RequestParam(value = "deviceType", required = false) Integer deviceType) { + List list = productService.getProductList(deviceType); + return success(convertList(list, product -> // 只返回 id、name、productKey 字段 new IotProductRespVO().setId(product.getId()).setName(product.getName()).setStatus(product.getStatus()) - .setDeviceType(product.getDeviceType()).setLocationType(product.getLocationType()))); + .setDeviceType(product.getDeviceType()).setProductKey(product.getProductKey()))); } } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java index 99effda1d1..ffc92a2132 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java @@ -27,6 +27,12 @@ public class IotProductRespVO { @ExcelProperty("产品标识") private String productKey; + @Schema(description = "产品密钥", requiredMode = Schema.RequiredMode.REQUIRED) + private String productSecret; + + @Schema(description = "是否开启动态注册", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean registerEnabled; + @Schema(description = "产品分类编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") private Long categoryId; @@ -61,11 +67,6 @@ public class IotProductRespVO { @DictFormat(DictTypeConstants.NET_TYPE) private Integer netType; - @Schema(description = "定位方式", example = "2") - @ExcelProperty(value = "定位方式", converter = DictConvert.class) - @DictFormat(DictTypeConstants.LOCATION_TYPE) - private Integer locationType; - @Schema(description = "数据格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") @ExcelProperty(value = "数据格式", converter = DictConvert.class) @DictFormat(DictTypeConstants.CODEC_TYPE) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductSaveReqVO.java index 5f8cb00530..08c636f7f2 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductSaveReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductSaveReqVO.java @@ -1,7 +1,6 @@ package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; import cn.iocoder.yudao.framework.common.validation.InEnum; -import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum; import cn.iocoder.yudao.module.iot.enums.product.IotNetTypeEnum; import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; @@ -45,12 +44,12 @@ public class IotProductSaveReqVO { @InEnum(value = IotNetTypeEnum.class, message = "联网方式必须是 {value}") private Integer netType; - @Schema(description = "定位类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") - @InEnum(value = IotLocationTypeEnum.class, message = "定位方式必须是 {value}") - private Integer locationType; - @Schema(description = "数据格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") @NotEmpty(message = "数据格式不能为空") private String codecType; + @Schema(description = "是否开启动态注册", example = "false") + @NotNull(message = "是否开启动态注册不能为空") + private Boolean registerEnabled; + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java index 06bbecc894..8a8fcdef3d 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.springframework.format.annotation.DateTimeFormat; @@ -22,6 +23,10 @@ public class IotDataSinkPageReqVO extends PageParam { @InEnum(CommonStatusEnum.class) private Integer status; + @Schema(description = "数据目的类型", example = "1") + @InEnum(IotDataSinkTypeEnum.class) + private Integer type; + @Schema(description = "创建时间") @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) private LocalDateTime[] createTime; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java index 46563b9229..7b7d021c3b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java @@ -123,18 +123,7 @@ public class IotDeviceDO extends TenantBaseDO { * 设备密钥,用于设备认证 */ private String deviceSecret; - /** - * 认证类型(如一机一密、动态注册) - */ - // TODO @haohao:是不是要枚举哈 - private String authType; - /** - * 定位方式 - *

- * 枚举 {@link cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum} - */ - private Integer locationType; /** * 设备位置的纬度 */ @@ -143,16 +132,6 @@ public class IotDeviceDO extends TenantBaseDO { * 设备位置的经度 */ private BigDecimal longitude; - /** - * 地区编码 - *

- * 关联 Area 的 id - */ - private Integer areaId; - /** - * 设备详细地址 - */ - private String address; /** * 设备配置 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java index fc34231418..e296b35017 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java @@ -32,6 +32,14 @@ public class IotProductDO extends TenantBaseDO { * 产品标识 */ private String productKey; + /** + * 产品密钥,用于一型一密动态注册 + */ + private String productSecret; + /** + * 是否开启动态注册 + */ + private Boolean registerEnabled; /** * 产品分类编号 *

@@ -69,12 +77,6 @@ public class IotProductDO extends TenantBaseDO { * 枚举 {@link cn.iocoder.yudao.module.iot.enums.product.IotNetTypeEnum} */ private Integer netType; - /** - * 定位方式 - *

- * 枚举 {@link cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum} - */ - private Integer locationType; /** * 数据格式(编解码器类型) *

diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java index 7423f943ce..1e3fb2e576 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java @@ -6,7 +6,9 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import jakarta.annotation.Nullable; import org.apache.ibatis.annotations.Mapper; @@ -118,4 +120,56 @@ public interface IotDeviceMapper extends BaseMapperX { )); } + /** + * 查询有位置信息的设备列表 + * + * @return 设备列表 + */ + default List selectListByHasLocation() { + return selectList(new LambdaQueryWrapperX() + .isNotNull(IotDeviceDO::getLatitude) + .isNotNull(IotDeviceDO::getLongitude)); + } + + // ========== 网关-子设备绑定相关 ========== + + /** + * 根据网关编号查询子设备列表 + * + * @param gatewayId 网关设备编号 + * @return 子设备列表 + */ + default List selectListByGatewayId(Long gatewayId) { + return selectList(IotDeviceDO::getGatewayId, gatewayId); + } + + /** + * 分页查询未绑定网关的子设备 + * + * @param reqVO 分页查询参数 + * @return 子设备分页 + */ + default PageResult selectUnboundSubDevicePage(IotDevicePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(IotDeviceDO::getDeviceName, reqVO.getDeviceName()) + .likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname()) + .eqIfPresent(IotDeviceDO::getProductId, reqVO.getProductId()) + // 仅查询子设备 + 未绑定网关 + .eq(IotDeviceDO::getDeviceType, IotProductDeviceTypeEnum.GATEWAY_SUB.getType()) + .isNull(IotDeviceDO::getGatewayId) + .orderByDesc(IotDeviceDO::getId)); + } + + /** + * 批量更新设备的网关编号 + * + * @param ids 设备编号列表 + * @param gatewayId 网关设备编号(可以为 null,表示解绑) + */ + default void updateGatewayIdBatch(Collection ids, Long gatewayId) { + update(null, new LambdaUpdateWrapper() + .set(IotDeviceDO::getGatewayId, gatewayId) + .in(IotDeviceDO::getId, ids)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java index 5ba4a81772..2ed27dbb67 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java @@ -27,6 +27,12 @@ public interface IotProductMapper extends BaseMapperX { .orderByDesc(IotProductDO::getId)); } + default List selectList(Integer deviceType) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(IotProductDO::getDeviceType, deviceType) + .orderByDesc(IotProductDO::getId)); + } + default IotProductDO selectByProductKey(String productKey) { return selectOne(new LambdaQueryWrapper() .apply("LOWER(product_key) = {0}", productKey.toLowerCase())); @@ -37,5 +43,4 @@ public interface IotProductMapper extends BaseMapperX { .geIfPresent(IotProductDO::getCreateTime, createTime)); } - } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java index 7c0c17d3bc..ce2eeb04bc 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java @@ -35,4 +35,8 @@ public interface IotDataRuleMapper extends BaseMapperX { return selectList(IotDataRuleDO::getStatus, status); } + default IotDataRuleDO selectByName(String name) { + return selectOne(IotDataRuleDO::getName, name); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java index e65001db86..57e2a84595 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java @@ -21,6 +21,7 @@ public interface IotDataSinkMapper extends BaseMapperX { return selectPage(reqVO, new LambdaQueryWrapperX() .likeIfPresent(IotDataSinkDO::getName, reqVO.getName()) .eqIfPresent(IotDataSinkDO::getStatus, reqVO.getStatus()) + .eqIfPresent(IotDataSinkDO::getType, reqVO.getType()) .betweenIfPresent(IotDataSinkDO::getCreateTime, reqVO.getCreateTime()) .orderByDesc(IotDataSinkDO::getId)); } @@ -29,4 +30,8 @@ public interface IotDataSinkMapper extends BaseMapperX { return selectList(IotDataSinkDO::getStatus, status); } + default IotDataSinkDO selectByName(String name) { + return selectOne(IotDataSinkDO::getName, name); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java index c8041a673c..95d210252f 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/redis/RedisKeyConstants.java @@ -84,4 +84,12 @@ public interface RedisKeyConstants { */ String SCENE_RULE_LIST = "iot:scene_rule_list"; + /** + * WebSocket 连接分布式锁 + *

+ * KEY 格式:websocket_connect_lock:${serverUrl} + * 用于保证 WebSocket 重连操作的线程安全 + */ + String WEBSOCKET_CONNECT_LOCK = "iot:websocket_connect_lock:%s"; + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java index 4ac4dd916d..065eb2d229 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java @@ -26,13 +26,26 @@ public interface ErrorCodeConstants { // ========== 设备 1-050-003-000 ============ ErrorCode DEVICE_NOT_EXISTS = new ErrorCode(1_050_003_000, "设备不存在"); ErrorCode DEVICE_NAME_EXISTS = new ErrorCode(1_050_003_001, "设备名称在同一产品下必须唯一"); - ErrorCode DEVICE_HAS_CHILDREN = new ErrorCode(1_050_003_002, "有子设备,不允许删除"); + ErrorCode DEVICE_GATEWAY_HAS_SUB = new ErrorCode(1_050_003_002, "网关设备存在已绑定的子设备,不允许删除"); ErrorCode DEVICE_KEY_EXISTS = new ErrorCode(1_050_003_003, "设备标识已经存在"); ErrorCode DEVICE_GATEWAY_NOT_EXISTS = new ErrorCode(1_050_003_004, "网关设备不存在"); ErrorCode DEVICE_NOT_GATEWAY = new ErrorCode(1_050_003_005, "设备不是网关设备"); ErrorCode DEVICE_IMPORT_LIST_IS_EMPTY = new ErrorCode(1_050_003_006, "导入设备数据不能为空!"); ErrorCode DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL = new ErrorCode(1_050_003_007, "下行设备消息失败,原因:设备未连接网关"); ErrorCode DEVICE_SERIAL_NUMBER_EXISTS = new ErrorCode(1_050_003_008, "设备序列号已存在,序列号必须全局唯一"); + ErrorCode DEVICE_NOT_GATEWAY_SUB = new ErrorCode(1_050_003_009, "设备【{}/{}】不是网关子设备类型,无法绑定到网关"); + ErrorCode DEVICE_GATEWAY_BINDTO_EXISTS = new ErrorCode(1_050_003_010, "设备【{}/{}】已绑定到其他网关,请先解绑"); + // 拓扑管理相关错误码 1-050-003-100 + ErrorCode DEVICE_TOPO_PARAMS_INVALID = new ErrorCode(1_050_003_100, "拓扑管理参数无效"); + ErrorCode DEVICE_TOPO_SUB_DEVICE_USERNAME_INVALID = new ErrorCode(1_050_003_101, "子设备用户名格式无效"); + ErrorCode DEVICE_TOPO_SUB_DEVICE_AUTH_FAILED = new ErrorCode(1_050_003_102, "子设备认证失败"); + ErrorCode DEVICE_TOPO_SUB_NOT_BINDTO_GATEWAY = new ErrorCode(1_050_003_103, "子设备【{}/{}】未绑定到该网关"); + // 设备注册相关错误码 1-050-003-200 + ErrorCode DEVICE_SUB_REGISTER_PARAMS_INVALID = new ErrorCode(1_050_003_200, "子设备注册参数无效"); + ErrorCode DEVICE_SUB_REGISTER_PRODUCT_NOT_GATEWAY_SUB = new ErrorCode(1_050_003_201, "产品【{}】不是网关子设备类型"); + ErrorCode DEVICE_REGISTER_DISABLED = new ErrorCode(1_050_003_210, "该产品未开启动态注册功能"); + ErrorCode DEVICE_REGISTER_SECRET_INVALID = new ErrorCode(1_050_003_211, "产品密钥验证失败"); + ErrorCode DEVICE_REGISTER_ALREADY_EXISTS = new ErrorCode(1_050_003_212, "设备已存在,不允许重复注册"); // ========== 产品分类 1-050-004-000 ========== ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); @@ -73,10 +86,12 @@ public interface ErrorCodeConstants { // ========== IoT 数据流转规则 1-050-010-000 ========== ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); + ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在"); // ========== IoT 数据流转目的 1-050-011-000 ========== ErrorCode DATA_SINK_NOT_EXISTS = new ErrorCode(1_050_011_000, "数据桥梁不存在"); ErrorCode DATA_SINK_DELETE_FAIL_USED_BY_RULE = new ErrorCode(1_050_011_001, "数据流转目的正在被数据流转规则使用,无法删除"); + ErrorCode DATA_SINK_NAME_EXISTS = new ErrorCode(1_050_011_002, "数据流转目的名称已存在"); // ========== IoT 场景联动 1-050-012-000 ========== ErrorCode RULE_SCENE_NOT_EXISTS = new ErrorCode(1_050_012_000, "场景联动不存在"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/device/IotDeviceMessageIdentifierEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/device/IotDeviceMessageIdentifierEnum.java deleted file mode 100644 index e9dbe2f658..0000000000 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/device/IotDeviceMessageIdentifierEnum.java +++ /dev/null @@ -1,45 +0,0 @@ -package cn.iocoder.yudao.module.iot.enums.device; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -// TODO @芋艿:需要添加对应的 DTO,以及上下行的链路,网关、网关服务、设备等 -/** - * IoT 设备消息标识符枚举 - */ -@Deprecated -@Getter -@RequiredArgsConstructor -public enum IotDeviceMessageIdentifierEnum { - - PROPERTY_GET("get"), // 下行 - PROPERTY_SET("set"), // 下行 - PROPERTY_REPORT("report"), // 上行 - - STATE_ONLINE("online"), // 上行 - STATE_OFFLINE("offline"), // 上行 - - CONFIG_GET("get"), // 上行 TODO 芋艿:【讨论】暂时没有上行的场景 - CONFIG_SET("set"), // 下行 - - SERVICE_INVOKE("${identifier}"), // 下行 - SERVICE_REPLY_SUFFIX("_reply"), // 芋艿:TODO 芋艿:【讨论】上行 or 下行 - - OTA_UPGRADE("upgrade"), // 下行 - OTA_PULL("pull"), // 上行 - OTA_PROGRESS("progress"), // 上行 - OTA_REPORT("report"), // 上行 - - REGISTER_REGISTER("register"), // 上行 - REGISTER_REGISTER_SUB("register_sub"), // 上行 - REGISTER_UNREGISTER_SUB("unregister_sub"), // 下行 - - TOPOLOGY_ADD("topology_add"), // 下行; - ; - - /** - * 标志符 - */ - private final String identifier; - -} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/device/IotDeviceMessageTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/device/IotDeviceMessageTypeEnum.java deleted file mode 100644 index 9131210ab2..0000000000 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/device/IotDeviceMessageTypeEnum.java +++ /dev/null @@ -1,38 +0,0 @@ -package cn.iocoder.yudao.module.iot.enums.device; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -/** - * IoT 设备消息类型枚举 - */ -@Deprecated -@Getter -@RequiredArgsConstructor -public enum IotDeviceMessageTypeEnum implements ArrayValuable { - - STATE("state"), // 设备状态 - PROPERTY("property"), // 设备属性:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 - EVENT("event"), // 设备事件:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 - SERVICE("service"), // 设备服务:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 - CONFIG("config"), // 设备配置:可参考 https://help.aliyun.com/zh/iot/user-guide/remote-configuration-1 远程配置 - OTA("ota"), // 设备 OTA:可参考 https://help.aliyun.com/zh/iot/user-guide/ota-update OTA 升级 - REGISTER("register"), // 设备注册:可参考 https://help.aliyun.com/zh/iot/user-guide/register-devices 设备身份注册 - TOPOLOGY("topology"),; // 设备拓扑:可参考 https://help.aliyun.com/zh/iot/user-guide/manage-topological-relationships 设备拓扑 - - public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageTypeEnum::getType).toArray(String[]::new); - - /** - * 属性 - */ - private final String type; - - @Override - public String[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/product/IotLocationTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/product/IotLocationTypeEnum.java deleted file mode 100644 index 11989ec714..0000000000 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/product/IotLocationTypeEnum.java +++ /dev/null @@ -1,38 +0,0 @@ -package cn.iocoder.yudao.module.iot.enums.product; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.util.Arrays; - -/** - * IoT 定位方式枚举类 - * - * @author alwayssuper - */ -@AllArgsConstructor -@Getter -public enum IotLocationTypeEnum implements ArrayValuable { - - IP(1, "IP 定位"), - DEVICE(2, "设备上报"), - MANUAL(3, "手动定位"); - - public static final Integer[] ARRAYS = Arrays.stream(values()).map(IotLocationTypeEnum::getType).toArray(Integer[]::new); - - /** - * 类型 - */ - private final Integer type; - /** - * 描述 - */ - private final String description; - - @Override - public Integer[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java index 6db097d2d8..5a622e5654 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java @@ -3,11 +3,19 @@ package cn.iocoder.yudao.module.iot.service.device; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +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.topic.auth.IotSubDeviceRegisterRespDTO; +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetRespDTO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import jakarta.validation.Valid; import javax.annotation.Nullable; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -37,18 +45,6 @@ public interface IotDeviceService { */ void updateDevice(@Valid IotDeviceSaveReqVO updateReqVO); - // TODO @芋艿:先这么实现。未来看情况,要不要自己实现 - - /** - * 更新设备的所属网关 - * - * @param id 编号 - * @param gatewayId 网关设备 ID - */ - default void updateDeviceGateway(Long id, Long gatewayId) { - updateDevice(new IotDeviceSaveReqVO().setId(id).setGatewayId(gatewayId)); - } - /** * 更新设备状态 * @@ -271,4 +267,112 @@ public interface IotDeviceService { */ void updateDeviceFirmware(Long deviceId, Long firmwareId); + /** + * 更新设备定位信息(GeoLocation 上报时调用) + * + * @param device 设备信息(用于清除缓存) + * @param longitude 经度 + * @param latitude 纬度 + */ + void updateDeviceLocation(IotDeviceDO device, BigDecimal longitude, BigDecimal latitude); + + /** + * 获得有位置信息的设备列表 + * + * @return 设备列表 + */ + List getDeviceListByHasLocation(); + + // ========== 网关-拓扑管理(后台操作) ========== + + /** + * 绑定子设备到网关 + * + * @param subIds 子设备编号列表 + * @param gatewayId 网关设备编号 + */ + void bindDeviceGateway(Collection subIds, Long gatewayId); + + /** + * 解绑子设备与网关 + * + * @param subIds 子设备编号列表 + * @param gatewayId 网关设备编号 + */ + void unbindDeviceGateway(Collection subIds, Long gatewayId); + + /** + * 获取未绑定网关的子设备分页 + * + * @param pageReqVO 分页查询参数(仅使用 productId、deviceName、nickname) + * @return 子设备分页 + */ + PageResult getUnboundSubDevicePage(IotDevicePageReqVO pageReqVO); + + /** + * 根据网关编号获取子设备列表 + * + * @param gatewayId 网关设备编号 + * @return 子设备列表 + */ + List getDeviceListByGatewayId(Long gatewayId); + + // ========== 网关-拓扑管理(设备上报) ========== + + /** + * 处理添加拓扑关系消息(网关设备上报) + * + * @param message 消息 + * @param gatewayDevice 网关设备 + * @return 成功添加的子设备列表 + */ + List handleTopoAddMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice); + + /** + * 处理删除拓扑关系消息(网关设备上报) + * + * @param message 消息 + * @param gatewayDevice 网关设备 + * @return 成功删除的子设备列表 + */ + List handleTopoDeleteMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice); + + /** + * 处理获取拓扑关系消息(网关设备上报) + * + * @param gatewayDevice 网关设备 + * @return 拓扑关系响应 + */ + IotDeviceTopoGetRespDTO handleTopoGetMessage(IotDeviceDO gatewayDevice); + + // ========== 设备动态注册 ========== + + /** + * 直连/网关设备动态注册 + * + * @param reqDTO 动态注册请求 + * @return 注册结果(包含 DeviceSecret) + */ + IotDeviceRegisterRespDTO registerDevice(@Valid IotDeviceRegisterReqDTO reqDTO); + + /** + * 网关子设备动态注册 + *

+ * 与 {@link #handleSubDeviceRegisterMessage} 方法的区别: + * 该方法网关设备信息通过 reqDTO 参数传入,而 {@link #handleSubDeviceRegisterMessage} 方法通过 gatewayDevice 参数传入 + * + * @param reqDTO 子设备注册请求(包含网关设备信息) + * @return 注册结果列表 + */ + List registerSubDevices(@Valid IotSubDeviceRegisterFullReqDTO reqDTO); + + /** + * 处理子设备动态注册消息(网关设备上报) + * + * @param message 消息 + * @param gatewayDevice 网关设备 + * @return 注册结果列表 + */ + List handleSubDeviceRegisterMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice); + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java index e8fe9c8098..4ec70e08fb 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java @@ -1,19 +1,33 @@ package cn.iocoder.yudao.module.iot.service.device; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +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.topic.auth.IotSubDeviceRegisterReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO; +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoChangeReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetRespDTO; import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceGroupDO; @@ -21,6 +35,7 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper; import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; +import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.annotation.Resource; import jakarta.validation.ConstraintViolationException; @@ -34,12 +49,14 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import javax.annotation.Nullable; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; +import static java.util.Collections.singletonList; /** * IoT 设备 Service 实现类 @@ -60,9 +77,20 @@ public class IotDeviceServiceImpl implements IotDeviceService { @Resource @Lazy // 延迟加载,解决循环依赖 private IotDeviceGroupService deviceGroupService; + @Resource + @Lazy // 延迟加载,解决循环依赖 + private IotDeviceMessageService deviceMessageService; + + private IotDeviceServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } @Override public Long createDevice(IotDeviceSaveReqVO createReqVO) { + return createDevice0(createReqVO).getId(); + } + + private IotDeviceDO createDevice0(IotDeviceSaveReqVO createReqVO) { // 1.1 校验产品是否存在 IotProductDO product = productService.getProduct(createReqVO.getProductId()); if (product == null) { @@ -80,7 +108,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { IotDeviceDO device = BeanUtils.toBean(createReqVO, IotDeviceDO.class); initDevice(device, product); deviceMapper.insert(device); - return device.getId(); + return device; } private void validateCreateDeviceParam(String productKey, String deviceName, @@ -116,11 +144,13 @@ public class IotDeviceServiceImpl implements IotDeviceService { private void initDevice(IotDeviceDO device, IotProductDO product) { device.setProductId(product.getId()).setProductKey(product.getProductKey()) - .setDeviceType(product.getDeviceType()); - // 生成密钥 - device.setDeviceSecret(generateDeviceSecret()); - // 设置设备状态为未激活 - device.setState(IotDeviceStateEnum.INACTIVE.getState()); + .setDeviceType(product.getDeviceType()) + .setDeviceSecret(generateDeviceSecret()) // 生成密钥 + .setState(IotDeviceStateEnum.INACTIVE.getState()); // 默认未激活 + } + + private String generateDeviceSecret() { + return IdUtil.fastSimpleUUID(); } @Override @@ -169,9 +199,10 @@ public class IotDeviceServiceImpl implements IotDeviceService { public void deleteDevice(Long id) { // 1.1 校验存在 IotDeviceDO device = validateDeviceExists(id); - // 1.2 如果是网关设备,检查是否有子设备 - if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(id) > 0) { - throw exception(DEVICE_HAS_CHILDREN); + // 1.2 如果是网关设备,检查是否有子设备绑定 + if (IotProductDeviceTypeEnum.isGateway(device.getDeviceType()) + && deviceMapper.selectCountByGatewayId(id) > 0) { + throw exception(DEVICE_GATEWAY_HAS_SUB); } // 2. 删除设备 @@ -192,10 +223,11 @@ public class IotDeviceServiceImpl implements IotDeviceService { if (CollUtil.isEmpty(devices)) { return; } - // 1.2 校验网关设备是否存在 + // 1.2 如果是网关设备,检查是否有子设备绑定 for (IotDeviceDO device : devices) { - if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(device.getId()) > 0) { - throw exception(DEVICE_HAS_CHILDREN); + if (IotProductDeviceTypeEnum.isGateway(device.getDeviceType()) + && deviceMapper.selectCountByGatewayId(device.getId()) > 0) { + throw exception(DEVICE_GATEWAY_HAS_SUB); } } @@ -295,6 +327,37 @@ public class IotDeviceServiceImpl implements IotDeviceService { // 2. 清空对应缓存 deleteDeviceCache(device); + + // 3. 网关设备下线时,联动所有子设备下线 + if (Objects.equals(state, IotDeviceStateEnum.OFFLINE.getState()) + && IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) { + handleGatewayOffline(device); + } + } + + /** + * 处理网关下线,联动所有子设备下线 + * + * @param gatewayDevice 网关设备 + */ + private void handleGatewayOffline(IotDeviceDO gatewayDevice) { + List subDevices = deviceMapper.selectListByGatewayId(gatewayDevice.getId()); + if (CollUtil.isEmpty(subDevices)) { + return; + } + for (IotDeviceDO subDevice : subDevices) { + if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) { + try { + updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState()); + log.info("[handleGatewayOffline][网关({}/{}) 下线,子设备({}/{}) 联动下线]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + subDevice.getProductKey(), subDevice.getDeviceName()); + } catch (Exception ex) { + log.error("[handleGatewayOffline][子设备({}/{}) 下线失败]", + subDevice.getProductKey(), subDevice.getDeviceName(), ex); + } + } + } } @Override @@ -315,15 +378,6 @@ public class IotDeviceServiceImpl implements IotDeviceService { return deviceMapper.selectCountByGroupId(groupId); } - /** - * 生成 deviceSecret - * - * @return 生成的 deviceSecret - */ - private String generateDeviceSecret() { - return IdUtil.fastSimpleUUID(); - } - @Override @Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入 public IotDeviceImportRespVO importDevice(List importDevices, boolean updateSupport) { @@ -376,8 +430,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { if (existDevice == null) { createDevice(new IotDeviceSaveReqVO() .setDeviceName(importDevice.getDeviceName()) - .setProductId(product.getId()).setGatewayId(gatewayId).setGroupIds(groupIds) - .setLocationType(importDevice.getLocationType())); + .setProductId(product.getId()).setGatewayId(gatewayId).setGroupIds(groupIds)); respVO.getCreateDeviceNames().add(importDevice.getDeviceName()); return; } @@ -386,7 +439,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { throw exception(DEVICE_KEY_EXISTS); } updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId()) - .setGatewayId(gatewayId).setGroupIds(groupIds).setLocationType(importDevice.getLocationType())); + .setGatewayId(gatewayId).setGroupIds(groupIds)); respVO.getUpdateDeviceNames().add(importDevice.getDeviceName()); } catch (ServiceException ex) { respVO.getFailureDeviceNames().put(importDevice.getDeviceName(), ex.getMessage()); @@ -399,7 +452,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { public IotDeviceAuthInfoRespVO getDeviceAuthInfo(Long id) { IotDeviceDO device = validateDeviceExists(id); // 使用 IotDeviceAuthUtils 生成认证信息 - IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo( + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo( device.getProductKey(), device.getDeviceName(), device.getDeviceSecret()); return BeanUtils.toBean(authInfo, IotDeviceAuthInfoRespVO.class); } @@ -447,7 +500,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { @Override public boolean authDevice(IotDeviceAuthReqDTO authReqDTO) { // 1. 校验设备是否存在 - IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authReqDTO.getUsername()); + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authReqDTO.getUsername()); if (deviceInfo == null) { log.error("[authDevice][认证失败,username({}) 格式不正确]", authReqDTO.getUsername()); return false; @@ -461,7 +514,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { } // 2. 校验密码 - IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, device.getDeviceSecret()); + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, device.getDeviceSecret()); if (ObjUtil.notEqual(authInfo.getPassword(), authReqDTO.getPassword())) { log.error("[authDevice][设备({}/{}) 密码不正确]", productKey, deviceName); return false; @@ -490,17 +543,388 @@ public class IotDeviceServiceImpl implements IotDeviceService { public void updateDeviceFirmware(Long deviceId, Long firmwareId) { // 1. 校验设备是否存在 IotDeviceDO device = validateDeviceExists(deviceId); - + // 2. 更新设备固件版本 IotDeviceDO updateObj = new IotDeviceDO().setId(deviceId).setFirmwareId(firmwareId); deviceMapper.updateById(updateObj); - + // 3. 清空对应缓存 deleteDeviceCache(device); } - private IotDeviceServiceImpl getSelf() { - return SpringUtil.getBean(getClass()); + @Override + public void updateDeviceLocation(IotDeviceDO device, BigDecimal longitude, BigDecimal latitude) { + // 1. 更新定位信息 + deviceMapper.updateById(new IotDeviceDO().setId(device.getId()) + .setLongitude(longitude).setLatitude(latitude)); + + // 2. 清空对应缓存 + deleteDeviceCache(device); + } + + @Override + public List getDeviceListByHasLocation() { + return deviceMapper.selectListByHasLocation(); + } + + // ========== 网关-拓扑管理(后台操作) ========== + + @Override + @Transactional(rollbackFor = Exception.class) + public void bindDeviceGateway(Collection subIds, Long gatewayId) { + if (CollUtil.isEmpty(subIds)) { + return; + } + // 1.1 校验网关设备存在且类型正确 + validateGatewayDeviceExists(gatewayId); + // 1.2 校验每个设备是否可绑定 + List devices = deviceMapper.selectByIds(subIds); + for (IotDeviceDO device : devices) { + checkSubDeviceCanBind(device, gatewayId); + } + + // 2. 批量更新数据库 + List updateList = convertList(devices, device -> + new IotDeviceDO().setId(device.getId()).setGatewayId(gatewayId)); + deviceMapper.updateBatch(updateList); + + // 3. 清空对应缓存 + deleteDeviceCache(devices); + + // 4. 下发网关设备拓扑变更通知(增加) + sendTopoChangeNotify(gatewayId, IotDeviceTopoChangeReqDTO.STATUS_CREATE, devices); + } + + private void checkSubDeviceCanBind(IotDeviceDO device, Long gatewayId) { + if (!IotProductDeviceTypeEnum.isGatewaySub(device.getDeviceType())) { + throw exception(DEVICE_NOT_GATEWAY_SUB, device.getProductKey(), device.getDeviceName()); + } + // 已绑定到其他网关,拒绝绑定(需先解绑) + if (device.getGatewayId() != null && ObjUtil.notEqual(device.getGatewayId(), gatewayId)) { + throw exception(DEVICE_GATEWAY_BINDTO_EXISTS, device.getProductKey(), device.getDeviceName()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void unbindDeviceGateway(Collection subIds, Long gatewayId) { + // 1. 校验设备存在 + if (CollUtil.isEmpty(subIds)) { + return; + } + List devices = deviceMapper.selectByIds(subIds); + devices.removeIf(device -> ObjUtil.notEqual(device.getGatewayId(), gatewayId)); + if (CollUtil.isEmpty(devices)) { + return; + } + + // 2. 批量更新数据库(将 gatewayId 设置为 null) + deviceMapper.updateGatewayIdBatch(convertList(devices, IotDeviceDO::getId), null); + + // 3. 清空对应缓存 + deleteDeviceCache(devices); + + // 4. 下发网关设备拓扑变更通知(删除) + sendTopoChangeNotify(gatewayId, IotDeviceTopoChangeReqDTO.STATUS_DELETE, devices); + } + + @Override + public PageResult getUnboundSubDevicePage(IotDevicePageReqVO pageReqVO) { + return deviceMapper.selectUnboundSubDevicePage(pageReqVO); + } + + @Override + public List getDeviceListByGatewayId(Long gatewayId) { + return deviceMapper.selectListByGatewayId(gatewayId); + } + + // ========== 网关-拓扑管理(设备上报) ========== + + @Override + public List handleTopoAddMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice) { + // 1.1 校验网关设备类型 + if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) { + throw exception(DEVICE_NOT_GATEWAY); + } + // 1.2 解析参数 + IotDeviceTopoAddReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceTopoAddReqDTO.class); + if (params == null || CollUtil.isEmpty(params.getSubDevices())) { + throw exception(DEVICE_TOPO_PARAMS_INVALID); + } + + // 2. 遍历处理每个子设备 + List addedSubDevices = new ArrayList<>(); + for (IotDeviceAuthReqDTO subDeviceAuth : params.getSubDevices()) { + try { + IotDeviceDO subDevice = addDeviceTopo(gatewayDevice, subDeviceAuth); + addedSubDevices.add(new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName())); + } catch (Exception ex) { + log.warn("[handleTopoAddMessage][网关({}/{}) 添加子设备失败,message={}]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), message, ex); + } + } + + // 3. 返回响应数据(包含成功添加的子设备列表) + return addedSubDevices; + } + + private IotDeviceDO addDeviceTopo(IotDeviceDO gatewayDevice, IotDeviceAuthReqDTO subDeviceAuth) { + // 1.1 解析子设备信息 + IotDeviceIdentity subDeviceInfo = IotDeviceAuthUtils.parseUsername(subDeviceAuth.getUsername()); + if (subDeviceInfo == null) { + throw exception(DEVICE_TOPO_SUB_DEVICE_USERNAME_INVALID); + } + // 1.2 校验子设备认证信息 + if (!authDevice(subDeviceAuth)) { + throw exception(DEVICE_TOPO_SUB_DEVICE_AUTH_FAILED); + } + // 1.3 获取子设备 + IotDeviceDO subDevice = getSelf().getDeviceFromCache(subDeviceInfo.getProductKey(), subDeviceInfo.getDeviceName()); + if (subDevice == null) { + throw exception(DEVICE_NOT_EXISTS); + } + // 1.4 校验子设备类型 + checkSubDeviceCanBind(subDevice, gatewayDevice.getId()); + + // 2. 更新数据库 + deviceMapper.updateById(new IotDeviceDO().setId(subDevice.getId()).setGatewayId(gatewayDevice.getId())); + log.info("[addDeviceTopo][网关({}/{}) 绑定子设备({}/{})]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + subDevice.getProductKey(), subDevice.getDeviceName()); + + // 3. 清空对应缓存 + deleteDeviceCache(subDevice); + return subDevice; + } + + @Override + public List handleTopoDeleteMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice) { + // 1.1 校验网关设备类型 + if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) { + throw exception(DEVICE_NOT_GATEWAY); + } + // 1.2 解析参数 + IotDeviceTopoDeleteReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceTopoDeleteReqDTO.class); + if (params == null || CollUtil.isEmpty(params.getSubDevices())) { + throw exception(DEVICE_TOPO_PARAMS_INVALID); + } + + // 2. 遍历处理每个子设备 + List deletedSubDevices = new ArrayList<>(); + for (IotDeviceIdentity subDeviceIdentity : params.getSubDevices()) { + try { + deleteDeviceTopo(gatewayDevice, subDeviceIdentity); + deletedSubDevices.add(subDeviceIdentity); + } catch (Exception ex) { + log.warn("[handleTopoDeleteMessage][网关({}/{}) 删除子设备失败,productKey={}, deviceName={}]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + subDeviceIdentity.getProductKey(), subDeviceIdentity.getDeviceName(), ex); + } + } + + // 3. 返回响应数据(包含成功删除的子设备列表) + return deletedSubDevices; + } + + private void deleteDeviceTopo(IotDeviceDO gatewayDevice, IotDeviceIdentity subDeviceIdentity) { + // 1.1 获取子设备 + IotDeviceDO subDevice = getSelf().getDeviceFromCache(subDeviceIdentity.getProductKey(), subDeviceIdentity.getDeviceName()); + if (subDevice == null) { + throw exception(DEVICE_NOT_EXISTS); + } + // 1.2 校验子设备是否绑定到该网关 + if (ObjUtil.notEqual(subDevice.getGatewayId(), gatewayDevice.getId())) { + throw exception(DEVICE_TOPO_SUB_NOT_BINDTO_GATEWAY, + subDeviceIdentity.getProductKey(), subDeviceIdentity.getDeviceName()); + } + + // 2. 更新数据库(将 gatewayId 设置为 null) + deviceMapper.updateGatewayIdBatch(singletonList(subDevice.getId()), null); + log.info("[deleteDeviceTopo][网关({}/{}) 解绑子设备({}/{})]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + subDevice.getProductKey(), subDevice.getDeviceName()); + + // 3. 清空对应缓存 + deleteDeviceCache(subDevice); + + // 4. 子设备下线 + if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) { + updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState()); + } + } + + @Override + public IotDeviceTopoGetRespDTO handleTopoGetMessage(IotDeviceDO gatewayDevice) { + // 1. 校验网关设备类型 + if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) { + throw exception(DEVICE_NOT_GATEWAY); + } + + // 2. 获取子设备列表并转换 + List subDevices = deviceMapper.selectListByGatewayId(gatewayDevice.getId()); + List subDeviceIdentities = convertList(subDevices, subDevice -> + new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName())); + return new IotDeviceTopoGetRespDTO().setSubDevices(subDeviceIdentities); + } + + /** + * 发送拓扑变更通知给网关设备 + * + * @param gatewayId 网关设备编号 + * @param status 变更状态(0-创建, 1-删除) + * @param subDevices 子设备列表 + * @see 阿里云 - 通知网关拓扑关系变化 + */ + private void sendTopoChangeNotify(Long gatewayId, Integer status, List subDevices) { + if (CollUtil.isEmpty(subDevices)) { + return; + } + // 1. 获取网关设备 + IotDeviceDO gatewayDevice = deviceMapper.selectById(gatewayId); + if (gatewayDevice == null) { + log.warn("[sendTopoChangeNotify][网关设备({}) 不存在,无法发送拓扑变更通知]", gatewayId); + return; + } + + try { + // 2.1 构建拓扑变更通知消息 + List subList = convertList(subDevices, subDevice -> + new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName())); + IotDeviceTopoChangeReqDTO params = new IotDeviceTopoChangeReqDTO(status, subList); + IotDeviceMessage notifyMessage = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.TOPO_CHANGE.getMethod(), params); + + // 2.2 发送消息 + deviceMessageService.sendDeviceMessage(notifyMessage, gatewayDevice); + log.info("[sendTopoChangeNotify][网关({}/{}) 发送拓扑变更通知成功,status={}, subDevices={}]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + status, subList); + } catch (Exception ex) { + log.error("[sendTopoChangeNotify][网关({}/{}) 发送拓扑变更通知失败,status={}]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), status, ex); + } + } + + // ========== 设备动态注册 ========== + + @Override + public IotDeviceRegisterRespDTO registerDevice(IotDeviceRegisterReqDTO reqDTO) { + // 1.1 校验产品 + IotProductDO product = TenantUtils.executeIgnore(() -> + productService.getProductByProductKey(reqDTO.getProductKey())); + if (product == null) { + throw exception(PRODUCT_NOT_EXISTS); + } + // 1.2 校验产品是否开启动态注册 + if (BooleanUtil.isFalse(product.getRegisterEnabled())) { + throw exception(DEVICE_REGISTER_DISABLED); + } + // 1.3 验证 productSecret + if (ObjUtil.notEqual(product.getProductSecret(), reqDTO.getProductSecret())) { + throw exception(DEVICE_REGISTER_SECRET_INVALID); + } + return TenantUtils.execute(product.getTenantId(), () -> { + // 1.4 校验设备是否已存在(已存在则不允许重复注册) + IotDeviceDO device = getSelf().getDeviceFromCache(reqDTO.getProductKey(), reqDTO.getDeviceName()); + if (device != null) { + throw exception(DEVICE_REGISTER_ALREADY_EXISTS); + } + + // 2.1 自动创建设备 + IotDeviceSaveReqVO createReqVO = new IotDeviceSaveReqVO() + .setDeviceName(reqDTO.getDeviceName()) + .setProductId(product.getId()); + device = createDevice0(createReqVO); + log.info("[registerDevice][产品({}) 自动创建设备({})]", + reqDTO.getProductKey(), reqDTO.getDeviceName()); + // 2.2 返回设备密钥 + return new IotDeviceRegisterRespDTO(device.getProductKey(), device.getDeviceName(), device.getDeviceSecret()); + }); + } + + @Override + public List registerSubDevices(IotSubDeviceRegisterFullReqDTO reqDTO) { + // 1. 校验网关设备 + IotDeviceDO gatewayDevice = getSelf().getDeviceFromCache(reqDTO.getGatewayProductKey(), reqDTO.getGatewayDeviceName()); + + // 2. 遍历注册每个子设备 + return TenantUtils.execute(gatewayDevice.getTenantId(), () -> + registerSubDevices0(gatewayDevice, reqDTO.getSubDevices())); + } + + @Override + public List handleSubDeviceRegisterMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice) { + // 1. 解析参数 + if (!(message.getParams() instanceof List)) { + throw exception(DEVICE_SUB_REGISTER_PARAMS_INVALID); + } + List subDevices = JsonUtils.convertList(message.getParams(), IotSubDeviceRegisterReqDTO.class); + + // 2. 遍历注册每个子设备 + return registerSubDevices0(gatewayDevice, subDevices); + } + + private List registerSubDevices0(IotDeviceDO gatewayDevice, + List subDevices) { + // 1.1 校验网关设备 + if (gatewayDevice == null) { + throw exception(DEVICE_NOT_EXISTS); + } + if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) { + throw exception(DEVICE_NOT_GATEWAY); + } + // 1.2 注册设备不能为空 + if (CollUtil.isEmpty(subDevices)) { + throw exception(DEVICE_SUB_REGISTER_PARAMS_INVALID); + } + + // 2. 遍历注册每个子设备 + List results = new ArrayList<>(subDevices.size()); + for (IotSubDeviceRegisterReqDTO subDevice : subDevices) { + try { + IotDeviceDO device = registerSubDevice0(gatewayDevice, subDevice); + results.add(new IotSubDeviceRegisterRespDTO( + subDevice.getProductKey(), subDevice.getDeviceName(), device.getDeviceSecret())); + } catch (Exception ex) { + log.error("[registerSubDevices0][子设备({}/{}) 注册失败]", + subDevice.getProductKey(), subDevice.getDeviceName(), ex); + } + } + return results; + } + + private IotDeviceDO registerSubDevice0(IotDeviceDO gatewayDevice, IotSubDeviceRegisterReqDTO params) { + // 1.1 校验产品 + IotProductDO product = productService.getProductByProductKey(params.getProductKey()); + if (product == null) { + throw exception(PRODUCT_NOT_EXISTS); + } + // 1.2 校验产品是否为网关子设备类型 + if (!IotProductDeviceTypeEnum.isGatewaySub(product.getDeviceType())) { + throw exception(DEVICE_SUB_REGISTER_PRODUCT_NOT_GATEWAY_SUB, params.getProductKey()); + } + // 1.3 校验设备是否已存在(子设备动态注册:设备必须已预注册) + IotDeviceDO existDevice = getSelf().getDeviceFromCache(params.getProductKey(), params.getDeviceName()); + if (existDevice == null) { + throw exception(DEVICE_NOT_EXISTS); + } + // 1.4 校验是否绑定到其他网关 + if (existDevice.getGatewayId() != null && ObjUtil.notEqual(existDevice.getGatewayId(), gatewayDevice.getId())) { + throw exception(DEVICE_GATEWAY_BINDTO_EXISTS, + existDevice.getProductKey(), existDevice.getDeviceName()); + } + + // 2. 绑定到网关(如果尚未绑定) + if (existDevice.getGatewayId() == null) { + // 2.1 更新数据库 + deviceMapper.updateById(new IotDeviceDO().setId(existDevice.getId()).setGatewayId(gatewayDevice.getId())); + // 2.2 清空对应缓存 + deleteDeviceCache(existDevice); + log.info("[registerSubDevice][网关({}/{}) 绑定子设备({}/{})]", + gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), + existDevice.getProductKey(), existDevice.getDeviceName()); + } + return existDevice; } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java index 4a300dfc30..e28f489997 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsD import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import javax.annotation.Nullable; @@ -75,7 +74,7 @@ public interface IotDeviceMessageService { */ List getDeviceMessageListByRequestIdsAndReply( @NotNull(message = "设备编号不能为空") Long deviceId, - @NotEmpty(message = "请求编号不能为空") List requestIds, + List requestIds, Boolean reply); /** diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java index 01d1c45eee..24a5bb91b7 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java @@ -1,5 +1,7 @@ package cn.iocoder.yudao.module.iot.service.device.message; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.ListUtil; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; @@ -16,6 +18,10 @@ import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsD 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.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; @@ -98,7 +104,6 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { return sendDeviceMessage(message, device); } - // TODO @芋艿:针对连接网关的设备,是不是 productKey、deviceName 需要调整下; @Override public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device) { return sendDeviceMessage(message, device, null); @@ -168,7 +173,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { // 2. 记录消息 getSelf().createDeviceLogAsync(message); - // 3. 回复消息。前提:非 _reply 消息,并且非禁用回复的消息 + // 3. 回复消息。前提:非 _reply 消息、非禁用回复的消息 if (IotDeviceMessageUtils.isReplyMessage(message) || IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod()) || StrUtil.isEmpty(message.getServerId())) { @@ -185,15 +190,14 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { } // TODO @芋艿:可优化:未来逻辑复杂后,可以独立拆除 Processor 处理器 - @SuppressWarnings("SameReturnValue") private Object handleUpstreamDeviceMessage0(IotDeviceMessage message, IotDeviceDO device) { // 设备上下线 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) { String stateStr = IotDeviceMessageUtils.getIdentifier(message); assert stateStr != null; Assert.notEmpty(stateStr, "设备状态不能为空"); - deviceService.updateDeviceState(device, Integer.valueOf(stateStr)); - // TODO 芋艿:子设备的关联 + Integer state = Integer.valueOf(stateStr); + deviceService.updateDeviceState(device, state); return null; } @@ -202,6 +206,11 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { devicePropertyService.saveDeviceProperty(device, message); return null; } + // 批量上报(属性+事件+子设备) + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod())) { + handlePackMessage(message, device); + return null; + } // OTA 上报升级进度 if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.OTA_PROGRESS.getMethod())) { @@ -209,10 +218,109 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { return null; } - // TODO @芋艿:这里可以按需,添加别的逻辑; + // 添加拓扑关系 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) { + return deviceService.handleTopoAddMessage(message, device); + } + // 删除拓扑关系 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod())) { + return deviceService.handleTopoDeleteMessage(message, device); + } + // 获取拓扑关系 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_GET.getMethod())) { + return deviceService.handleTopoGetMessage(device); + } + + // 子设备动态注册 + if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod())) { + return deviceService.handleSubDeviceRegisterMessage(message, device); + } + return null; } + // ========== 批量上报处理方法 ========== + + /** + * 处理批量上报消息 + *

+ * 将 pack 消息拆分成多条标准消息,发送到 MQ 让规则引擎处理 + * + * @param packMessage 批量消息 + * @param gatewayDevice 网关设备 + */ + private void handlePackMessage(IotDeviceMessage packMessage, IotDeviceDO gatewayDevice) { + // 1. 解析参数 + IotDevicePropertyPackPostReqDTO params = JsonUtils.convertObject( + packMessage.getParams(), IotDevicePropertyPackPostReqDTO.class); + if (params == null) { + log.warn("[handlePackMessage][消息({}) 参数解析失败]", packMessage); + return; + } + + // 2. 处理网关设备(自身)的数据 + sendDevicePackData(gatewayDevice, packMessage.getServerId(), params.getProperties(), params.getEvents()); + + // 3. 处理子设备的数据 + if (CollUtil.isEmpty(params.getSubDevices())) { + return; + } + for (IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData : params.getSubDevices()) { + try { + IotDeviceIdentity identity = subDeviceData.getIdentity(); + IotDeviceDO subDevice = deviceService.getDeviceFromCache(identity.getProductKey(), identity.getDeviceName()); + if (subDevice == null) { + log.warn("[handlePackMessage][子设备({}/{}) 不存在]", identity.getProductKey(), identity.getDeviceName()); + continue; + } + // 特殊:子设备不需要指定 serverId,因为子设备实际可能连接在不同的 gateway-server 上,导致 serverId 不同 + sendDevicePackData(subDevice, null, subDeviceData.getProperties(), subDeviceData.getEvents()); + } catch (Exception ex) { + log.error("[handlePackMessage][子设备({}/{}) 数据处理失败]", subDeviceData.getIdentity().getProductKey(), + subDeviceData.getIdentity().getDeviceName(), ex); + } + } + } + + /** + * 发送设备 pack 数据到 MQ(属性 + 事件) + * + * @param device 设备 + * @param serverId 服务标识 + * @param properties 属性数据 + * @param events 事件数据 + */ + private void sendDevicePackData(IotDeviceDO device, String serverId, + Map properties, + Map events) { + // 1. 发送属性消息 + if (MapUtil.isNotEmpty(properties)) { + IotDeviceMessage propertyMsg = IotDeviceMessage.requestOf( + device.getId(), device.getTenantId(), serverId, + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), + IotDevicePropertyPostReqDTO.of(properties)); + deviceMessageProducer.sendDeviceMessage(propertyMsg); + } + + // 2. 发送事件消息 + if (MapUtil.isNotEmpty(events)) { + for (Map.Entry eventEntry : events.entrySet()) { + String eventId = eventEntry.getKey(); + IotDevicePropertyPackPostReqDTO.EventValue eventValue = eventEntry.getValue(); + if (eventValue == null) { + continue; + } + IotDeviceMessage eventMsg = IotDeviceMessage.requestOf( + device.getId(), device.getTenantId(), serverId, + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), + IotDeviceEventPostReqDTO.of(eventId, eventValue.getValue(), eventValue.getTime())); + deviceMessageProducer.sendDeviceMessage(eventMsg); + } + } + } + + // ========= 设备消息查询 ========== + @Override public PageResult getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) { try { @@ -228,9 +336,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { } @Override - public List getDeviceMessageListByRequestIdsAndReply(Long deviceId, - List requestIds, - Boolean reply) { + public List getDeviceMessageListByRequestIdsAndReply(Long deviceId, List requestIds, Boolean reply) { + if (CollUtil.isEmpty(requestIds)) { + return ListUtil.of(); + } return deviceMessageMapper.selectListByRequestIdsAndReply(deviceId, requestIds, reply); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java index 4e1be3a0ca..afc90429b0 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java @@ -22,6 +22,7 @@ import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper; import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum; import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; import jakarta.annotation.Resource; @@ -30,10 +31,12 @@ import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.*; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.getBigDecimal; /** * IoT 设备【属性】数据 Service 实现类 @@ -66,6 +69,9 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { @Resource @Lazy // 延迟加载,解决循环依赖 private IotProductService productService; + @Resource + @Lazy // 延迟加载,解决循环依赖 + private IotDeviceService deviceService; @Resource private DevicePropertyRedisDAO deviceDataRedisDAO; @@ -126,48 +132,60 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { } @Override + @SuppressWarnings("PatternVariableCanBeUsed") public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) { if (!(message.getParams() instanceof Map)) { log.error("[saveDeviceProperty][消息内容({}) 的 data 类型不正确]", message); return; } + Map params = (Map) message.getParams(); + if (CollUtil.isEmpty(params)) { + log.error("[saveDeviceProperty][消息内容({}) 的 data 为空]", message); + return; + } // 1. 根据物模型,拼接合法的属性 // TODO @芋艿:【待定 004】赋能后,属性到底以 thingModel 为准(ik),还是 db 的表结构为准(tl)? List thingModels = thingModelService.getThingModelListByProductIdFromCache(device.getProductId()); Map properties = new HashMap<>(); - ((Map) message.getParams()).forEach((key, value) -> { + params.forEach((key, value) -> { IotThingModelDO thingModel = CollUtil.findOne(thingModels, o -> o.getIdentifier().equals(key)); if (thingModel == null || thingModel.getProperty() == null) { log.error("[saveDeviceProperty][消息({}) 的属性({}) 不存在]", message, key); return; } - if (ObjectUtils.equalsAny(thingModel.getProperty().getDataType(), + String dataType = thingModel.getProperty().getDataType(); + if (ObjectUtils.equalsAny(dataType, IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储 properties.put((String) key, JsonUtils.toJsonString(value)); - } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(thingModel.getProperty().getDataType())) { - properties.put((String) key, Convert.toDouble(value)); - } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(thingModel.getProperty().getDataType())) { + } else if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) { + properties.put((String) key, Convert.toInt(value)); + } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(dataType)) { properties.put((String) key, Convert.toFloat(value)); - } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(thingModel.getProperty().getDataType())) { + } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(dataType)) { + properties.put((String) key, Convert.toDouble(value)); + } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) { properties.put((String) key, Convert.toByte(value)); - } else { + } else { properties.put((String) key, value); } }); if (CollUtil.isEmpty(properties)) { log.error("[saveDeviceProperty][消息({}) 没有合法的属性]", message); - return; + } else { + // 2.1 保存设备属性【数据】 + devicePropertyMapper.insert(device, properties, LocalDateTimeUtil.toEpochMilli(message.getReportTime())); + + // 2.2 保存设备属性【日志】 + Map properties2 = convertMap(properties.entrySet(), Map.Entry::getKey, entry -> + IotDevicePropertyDO.builder().value(entry.getValue()).updateTime(message.getReportTime()).build()); + deviceDataRedisDAO.putAll(device.getId(), properties2); } - // 2.1 保存设备属性【数据】 - devicePropertyMapper.insert(device, properties, LocalDateTimeUtil.toEpochMilli(message.getReportTime())); - - // 2.2 保存设备属性【日志】 - Map properties2 = convertMap(properties.entrySet(), Map.Entry::getKey, entry -> - IotDevicePropertyDO.builder().value(entry.getValue()).updateTime(message.getReportTime()).build()); - deviceDataRedisDAO.putAll(device.getId(), properties2); + // 2.3 提取 GeoLocation 并更新设备定位 + // 为什么 properties 为空,也要执行定位更新?因为可能上报的属性里,没有合法属性,但是包含 GeoLocation 定位属性 + extractAndUpdateDeviceLocation(device, (Map) message.getParams()); } @Override @@ -213,4 +231,77 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { return deviceServerIdRedisDAO.get(id); } + // ========== 设备定位相关操作 ========== + + /** + * 从属性中提取 GeoLocation 并更新设备定位 + * + * @see 阿里云规范 + * GeoLocation 结构体包含:Longitude, Latitude, Altitude, CoordinateSystem + */ + private void extractAndUpdateDeviceLocation(IotDeviceDO device, Map params) { + // 1. 解析 GeoLocation 经纬度坐标 + BigDecimal[] location = parseGeoLocation(params); + if (location == null) { + return; + } + + // 2. 更新设备定位 + deviceService.updateDeviceLocation(device, location[0], location[1]); + log.info("[extractAndUpdateGeoLocation][设备({}) 定位更新: lng={}, lat={}]", + device.getId(), location[0], location[1]); + } + + /** + * 从属性参数中解析 GeoLocation,返回经纬度坐标数组 [longitude, latitude] + * + * @param params 属性参数 + * @return [经度, 纬度],解析失败返回 null + */ + @SuppressWarnings("unchecked") + private BigDecimal[] parseGeoLocation(Map params) { + if (params == null) { + return null; + } + // 1. 查找 GeoLocation 属性(标识符为 GeoLocation 或 geoLocation) + Object geoValue = params.get("GeoLocation"); + if (geoValue == null) { + geoValue = params.get("geoLocation"); + } + if (geoValue == null) { + return null; + } + + // 2. 转换为 Map + Map geoLocation = null; + if (geoValue instanceof Map) { + geoLocation = (Map) geoValue; + } else if (geoValue instanceof String) { + geoLocation = JsonUtils.parseObject((String) geoValue, Map.class); + } + if (geoLocation == null) { + return null; + } + + // 3. 提取经纬度(支持阿里云命名规范:首字母大写) + BigDecimal longitude = getBigDecimal(geoLocation, "Longitude"); + if (longitude == null) { + longitude = getBigDecimal(geoLocation, "longitude"); + } + BigDecimal latitude = getBigDecimal(geoLocation, "Latitude"); + if (latitude == null) { + latitude = getBigDecimal(geoLocation, "latitude"); + } + if (longitude == null || latitude == null) { + return null; + } + // 校验经纬度范围:经度 -180 到 180,纬度 -90 到 90 + if (longitude.compareTo(BigDecimal.valueOf(-180)) < 0 || longitude.compareTo(BigDecimal.valueOf(180)) > 0 + || latitude.compareTo(BigDecimal.valueOf(-90)) < 0 || latitude.compareTo(BigDecimal.valueOf(90)) > 0) { + log.warn("[parseGeoLocation][经纬度超出有效范围: lng={}, lat={}]", longitude, latitude); + return null; + } + return new BigDecimal[]{longitude, latitude}; + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java index 70e6afd03a..d4292ef521 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java @@ -105,6 +105,14 @@ public interface IotProductService { */ List getProductList(); + /** + * 根据设备类型获得产品列表 + * + * @param deviceType 设备类型(可选) + * @return 产品列表 + */ + List getProductList(@Nullable Integer deviceType); + /** * 获得产品数量 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java index 151590ab85..e001f46a2b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.service.product; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.hutool.core.util.IdUtil; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; @@ -53,19 +54,22 @@ public class IotProductServiceImpl implements IotProductService { // 2. 插入 IotProductDO product = BeanUtils.toBean(createReqVO, IotProductDO.class) - .setStatus(IotProductStatusEnum.UNPUBLISHED.getStatus()); + .setStatus(IotProductStatusEnum.UNPUBLISHED.getStatus()) + .setProductSecret(generateProductSecret()); productMapper.insert(product); return product.getId(); } + private String generateProductSecret() { + return IdUtil.fastSimpleUUID(); + } + @Override @CacheEvict(value = RedisKeyConstants.PRODUCT, key = "#updateReqVO.id") public void updateProduct(IotProductSaveReqVO updateReqVO) { updateReqVO.setProductKey(null); // 不更新产品标识 - // 1.1 校验存在 - IotProductDO iotProductDO = validateProductExists(updateReqVO.getId()); - // 1.2 发布状态不可更新 - validateProductStatus(iotProductDO); + // 1. 校验存在 + validateProductExists(updateReqVO.getId()); // 2. 更新 IotProductDO updateObj = BeanUtils.toBean(updateReqVO, IotProductDO.class); @@ -157,6 +161,11 @@ public class IotProductServiceImpl implements IotProductService { return productMapper.selectList(); } + @Override + public List getProductList(Integer deviceType) { + return productMapper.selectList(deviceType); + } + @Override public Long getProductCount(LocalDateTime createTime) { return productMapper.selectCountByCreateTime(createTime); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java index 8eafcb681a..ed52067cc3 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java @@ -32,6 +32,7 @@ import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NAME_EXISTS; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NOT_EXISTS; /** @@ -62,6 +63,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { @Override @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true) public Long createDataRule(IotDataRuleSaveReqVO createReqVO) { + // 校验名称唯一 + validateDataRuleNameUnique(null, createReqVO.getName()); // 校验数据源配置和数据目的 validateDataRuleConfig(createReqVO); // 新增 @@ -75,6 +78,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { public void updateDataRule(IotDataRuleSaveReqVO updateReqVO) { // 校验存在 validateDataRuleExists(updateReqVO.getId()); + // 校验名称唯一 + validateDataRuleNameUnique(updateReqVO.getId(), updateReqVO.getName()); // 校验数据源配置和数据目的 validateDataRuleConfig(updateReqVO); @@ -98,6 +103,29 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { } } + /** + * 校验数据流转规则名称唯一性 + * + * @param id 数据流转规则编号(用于更新时排除自身) + * @param name 数据流转规则名称 + */ + private void validateDataRuleNameUnique(Long id, String name) { + if (StrUtil.isBlank(name)) { + return; + } + IotDataRuleDO dataRule = dataRuleMapper.selectByName(name); + if (dataRule == null) { + return; + } + // 如果 id 为空,说明不用比较是否为相同 id 的规则 + if (id == null) { + throw exception(DATA_RULE_NAME_EXISTS); + } + if (!dataRule.getId().equals(id)) { + throw exception(DATA_RULE_NAME_EXISTS); + } + } + /** * 校验数据流转规则配置 * @@ -243,6 +271,10 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { if (ObjUtil.notEqual(action.getType(), dataSink.getType())) { return; } + if (CommonStatusEnum.isDisable(dataSink.getStatus())) { + log.warn("[executeDataRuleAction][消息({}) 数据目的({}) 状态为禁用]", message.getId(), dataSink.getId()); + return; + } try { action.execute(message, dataSink); log.info("[executeDataRuleAction][消息({}) 数据目的({}) 执行成功]", message.getId(), dataSink.getId()); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java index 9977afba22..09e11c8226 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; @@ -19,6 +20,7 @@ import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_DELETE_FAIL_USED_BY_RULE; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NAME_EXISTS; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NOT_EXISTS; /** @@ -39,6 +41,9 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { @Override public Long createDataSink(IotDataSinkSaveReqVO createReqVO) { + // 校验名称唯一 + validateDataSinkNameUnique(null, createReqVO.getName()); + // 新增 IotDataSinkDO dataBridge = BeanUtils.toBean(createReqVO, IotDataSinkDO.class); dataSinkMapper.insert(dataBridge); return dataBridge.getId(); @@ -48,6 +53,8 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { public void updateDataSink(IotDataSinkSaveReqVO updateReqVO) { // 校验存在 validateDataBridgeExists(updateReqVO.getId()); + // 校验名称唯一 + validateDataSinkNameUnique(updateReqVO.getId(), updateReqVO.getName()); // 更新 IotDataSinkDO updateObj = BeanUtils.toBean(updateReqVO, IotDataSinkDO.class); dataSinkMapper.updateById(updateObj); @@ -71,6 +78,29 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { } } + /** + * 校验数据流转目的名称唯一性 + * + * @param id 数据流转目的编号(用于更新时排除自身) + * @param name 数据流转目的名称 + */ + private void validateDataSinkNameUnique(Long id, String name) { + if (StrUtil.isBlank(name)) { + return; + } + IotDataSinkDO dataSink = dataSinkMapper.selectByName(name); + if (dataSink == null) { + return; + } + // 如果 id 为空,说明不用比较是否为相同 id 的目的 + if (id == null) { + throw exception(DATA_SINK_NAME_EXISTS); + } + if (!dataSink.getId().equals(id)) { + throw exception(DATA_SINK_NAME_EXISTS); + } + } + @Override public IotDataSinkDO getDataSink(Long id) { return dataSinkMapper.selectById(id); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java index 53a3b71480..74385d08dd 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java @@ -43,7 +43,6 @@ public class IotTcpDataRuleAction extends config.getConnectTimeoutMs(), config.getReadTimeoutMs(), config.getSsl(), - config.getSslCertPath(), config.getDataFormat() ); // 2.2 连接服务器 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java index c0445df906..7471642434 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java @@ -8,6 +8,10 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSock 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} 实现类 *

@@ -22,6 +26,18 @@ import org.springframework.stereotype.Component; public class IotWebSocketDataRuleAction extends IotDataRuleCacheableAction { + /** + * 锁等待超时时间(毫秒) + */ + private static final long LOCK_WAIT_TIME_MS = 5000; + + /** + * 重连锁,key 为 WebSocket 服务器地址 + *

+ * WebSocket 连接是与特定服务器实例绑定的,使用单机锁即可保证重连的线程安全 + */ + private final ConcurrentHashMap reconnectLocks = new ConcurrentHashMap<>(); + @Override public Integer getType() { return IotDataSinkTypeEnum.WEBSOCKET.getType(); @@ -62,12 +78,11 @@ public class IotWebSocketDataRuleAction extends protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { try { // 1.1 获取或创建 WebSocket 客户端 - // TODO @puhui999:需要加锁,保证必须连接上; IotWebSocketClient webSocketClient = getProducer(config); - // 1.2 检查连接状态,如果断开则重新连接 + + // 1.2 检查连接状态,如果断开则使用分布式锁保证重连的线程安全 if (!webSocketClient.isConnected()) { - log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl()); - webSocketClient.connect(); + reconnectWithLock(webSocketClient, config); } // 2.1 发送消息 @@ -82,4 +97,34 @@ public class IotWebSocketDataRuleAction extends } } + // 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(); + } + } + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java index 15b57b5405..faf59d3fbc 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp; +import cn.hutool.core.util.ObjUtil; 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.IotDataSinkTcpConfig; @@ -31,8 +32,6 @@ public class IotTcpClient { private final Integer connectTimeoutMs; private final Integer readTimeoutMs; private final Boolean ssl; - // TODO @puhui999:sslCertPath 是不是没在用? - private final String sslCertPath; private final String dataFormat; private Socket socket; @@ -41,15 +40,13 @@ public class IotTcpClient { private final AtomicBoolean connected = new AtomicBoolean(false); public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs, - Boolean ssl, String sslCertPath, String dataFormat) { + Boolean ssl, String dataFormat) { this.host = host; this.port = port; this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS; this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS; this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL; - this.sslCertPath = sslCertPath; - // TODO @puhui999:可以使用 StrUtil.defaultIfBlank 方法简化 - this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT; + this.dataFormat = ObjUtil.defaultIfBlank(dataFormat, IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT); } /** diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java index 2f55d6ee74..8eba723733 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java @@ -4,13 +4,9 @@ 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.net.URI; -import java.net.http.HttpClient; -import java.net.http.WebSocket; -import java.time.Duration; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean; *

* 负责与外部 WebSocket 服务器建立连接并发送设备消息 * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 - * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现 + * 基于 OkHttp WebSocket 实现,兼容 JDK 8+ + *

+ * 注意:该类的线程安全由调用方(IotWebSocketDataRuleAction)通过分布式锁保证 * * @author HUIHUI */ @Slf4j -public class IotWebSocketClient implements WebSocket.Listener { +public class IotWebSocketClient { private final String serverUrl; private final Integer connectTimeoutMs; private final Integer sendTimeoutMs; private final String dataFormat; - private WebSocket webSocket; + private OkHttpClient okHttpClient; + private volatile WebSocket webSocket; private final AtomicBoolean connected = new AtomicBoolean(false); - private final StringBuilder messageBuffer = new StringBuilder(); public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { this.serverUrl = serverUrl; @@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener { /** * 连接到 WebSocket 服务器 + *

+ * 注意:调用方需要通过分布式锁保证并发安全 */ - @SuppressWarnings("resource") public void connect() throws Exception { if (connected.get()) { log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); @@ -53,17 +52,30 @@ public class IotWebSocketClient implements WebSocket.Listener { } try { - HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofMillis(connectTimeoutMs)) + // 创建 OkHttpClient + okHttpClient = new OkHttpClient.Builder() + .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS) .build(); - CompletableFuture future = httpClient.newWebSocketBuilder() - .connectTimeout(Duration.ofMillis(connectTimeoutMs)) - .buildAsync(URI.create(serverUrl), this); + // 创建 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)); // 等待连接完成 - webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS); - connected.set(true); + 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(); @@ -72,36 +84,6 @@ public class IotWebSocketClient implements WebSocket.Listener { } } - @Override - public void onOpen(WebSocket webSocket) { - log.debug("[onOpen][WebSocket 连接已打开]"); - webSocket.request(1); - } - - @Override - public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { - messageBuffer.append(data); - if (last) { - log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer); - messageBuffer.setLength(0); - } - webSocket.request(1); - return null; - } - - @Override - public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { - connected.set(false); - log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason); - return null; - } - - @Override - public void onError(WebSocket webSocket, Throwable error) { - connected.set(false); - log.error("[onError][WebSocket 发生错误]", error); - } - /** * 发送设备消息 * @@ -109,7 +91,8 @@ public class IotWebSocketClient implements WebSocket.Listener { * @throws Exception 发送异常 */ public void sendMessage(IotDeviceMessage message) throws Exception { - if (!connected.get() || webSocket == null) { + WebSocket ws = this.webSocket; + if (!connected.get() || ws == null) { throw new IllegalStateException("WebSocket 客户端未连接"); } @@ -121,9 +104,11 @@ public class IotWebSocketClient implements WebSocket.Listener { messageData = message.toString(); } - // 发送消息并等待完成 - CompletableFuture future = webSocket.sendText(messageData, true); - future.get(sendTimeoutMs, TimeUnit.MILLISECONDS); + // 发送消息 + boolean success = ws.send(messageData); + if (!success) { + throw new Exception("WebSocket 发送消息失败,消息队列已满或连接已关闭"); + } log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]", message.getDeviceId(), messageData.length()); } catch (Exception e) { @@ -136,18 +121,18 @@ public class IotWebSocketClient implements WebSocket.Listener { * 关闭连接 */ public void close() { - if (!connected.get() && webSocket == null) { - return; - } - try { if (webSocket != null) { - webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭") - .orTimeout(5, TimeUnit.SECONDS) - .exceptionally(e -> { - log.warn("[close][发送关闭帧失败]", e); - return 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); @@ -174,4 +159,51 @@ public class IotWebSocketClient implements WebSocket.Listener { '}'; } + /** + * 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); + } + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java index f96bc9f450..4ea7338e33 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java @@ -23,6 +23,7 @@ import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager; import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler; +import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; @@ -62,6 +63,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { private List sceneRuleActions; @Resource private IotSceneRuleTimerHandler timerHandler; + @Resource + private IotTimerConditionEvaluator timerConditionEvaluator; @Override @CacheEvict(value = RedisKeyConstants.SCENE_RULE_LIST, allEntries = true) @@ -222,18 +225,98 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { return; } // 1.2 判断是否有定时触发器,避免脏数据 - IotSceneRuleDO.Trigger config = CollUtil.findOne(scene.getTriggers(), + IotSceneRuleDO.Trigger timerTrigger = CollUtil.findOne(scene.getTriggers(), trigger -> ObjUtil.equals(trigger.getType(), IotSceneRuleTriggerTypeEnum.TIMER.getType())); - if (config == null) { + if (timerTrigger == null) { log.error("[executeSceneRuleByTimer][规则场景({}) 不存在定时触发器]", scene); return; } - // 2. 执行规则场景 + // 2. 评估条件组(新增逻辑) + log.info("[executeSceneRuleByTimer][规则场景({}) 开始评估条件组]", id); + if (!evaluateTimerConditionGroups(scene, timerTrigger)) { + log.info("[executeSceneRuleByTimer][规则场景({}) 条件组不满足,跳过执行]", id); + return; + } + log.info("[executeSceneRuleByTimer][规则场景({}) 条件组评估通过,准备执行动作]", id); + + // 3. 执行规则场景 TenantUtils.execute(scene.getTenantId(), () -> executeSceneRuleAction(null, ListUtil.toList(scene))); } + /** + * 评估定时触发器的条件组 + * + * @param scene 场景规则 + * @param trigger 定时触发器 + * @return 是否满足条件 + */ + private boolean evaluateTimerConditionGroups(IotSceneRuleDO scene, IotSceneRuleDO.Trigger trigger) { + // 1. 如果没有条件组,直接返回 true(直接执行动作) + if (CollUtil.isEmpty(trigger.getConditionGroups())) { + log.debug("[evaluateTimerConditionGroups][规则场景({}) 无条件组配置,直接执行]", scene.getId()); + return true; + } + + // 2. 条件组之间是 OR 关系,任一条件组满足即可 + for (List conditionGroup : trigger.getConditionGroups()) { + if (evaluateSingleConditionGroup(scene, conditionGroup)) { + log.debug("[evaluateTimerConditionGroups][规则场景({}) 条件组匹配成功]", scene.getId()); + return true; + } + } + + // 3. 所有条件组都不满足 + log.debug("[evaluateTimerConditionGroups][规则场景({}) 所有条件组都不满足]", scene.getId()); + return false; + } + + /** + * 评估单个条件组 + * + * @param scene 场景规则 + * @param conditionGroup 条件组 + * @return 是否满足条件 + */ + private boolean evaluateSingleConditionGroup(IotSceneRuleDO scene, + List conditionGroup) { + // 1. 空条件组视为满足 + if (CollUtil.isEmpty(conditionGroup)) { + return true; + } + + // 2. 条件之间是 AND 关系,所有条件都必须满足 + for (IotSceneRuleDO.TriggerCondition condition : conditionGroup) { + if (!evaluateTimerCondition(scene, condition)) { + log.debug("[evaluateSingleConditionGroup][规则场景({}) 条件({}) 不满足]", + scene.getId(), condition); + return false; + } + } + + return true; + } + + /** + * 评估单个条件(定时触发器专用) + * + * @param scene 场景规则 + * @param condition 条件 + * @return 是否满足条件 + */ + private boolean evaluateTimerCondition(IotSceneRuleDO scene, IotSceneRuleDO.TriggerCondition condition) { + try { + boolean result = timerConditionEvaluator.evaluate(condition); + log.debug("[evaluateTimerCondition][规则场景({}) 条件类型({}) 评估结果: {}]", + scene.getId(), condition.getType(), result); + return result; + } catch (Exception e) { + log.error("[evaluateTimerCondition][规则场景({}) 条件评估异常]", scene.getId(), e); + return false; + } + } + /** * 基于消息,获得匹配的规则场景列表 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java new file mode 100644 index 0000000000..df1ac239b3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimeHelper.java @@ -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 场景规则时间匹配工具类 + *

+ * 提供时间条件匹配的通用方法,供 {@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 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 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); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcher.java index 2083bebac9..a54785ad69 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcher.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotCurrentTimeConditionMatcher.java @@ -1,21 +1,14 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; -import cn.hutool.core.lang.Assert; -import cn.hutool.core.text.CharPool; -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.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.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 java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.format.DateTimeFormatter; -import java.util.List; - /** * 当前时间条件匹配器:处理时间相关的子条件匹配逻辑 * @@ -25,16 +18,6 @@ import java.util.List; @Slf4j public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatcher { - /** - * 时间格式化器 - 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"); - @Override public IotSceneRuleConditionTypeEnum getSupportedConditionType() { return IotSceneRuleConditionTypeEnum.CURRENT_TIME; @@ -62,13 +45,13 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc return false; } - if (!isTimeOperator(operatorEnum)) { + if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) { IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "不支持的时间操作符: " + operator); return false; } // 2.1 执行时间匹配 - boolean matched = executeTimeMatching(operatorEnum, condition.getParam()); + boolean matched = IotSceneRuleTimeHelper.executeTimeMatching(operatorEnum, condition.getParam()); // 2.2 记录匹配结果 if (matched) { @@ -80,145 +63,6 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc return matched; } - /** - * 执行时间匹配逻辑 - * 直接实现时间条件匹配,不使用 Spring EL 表达式 - */ - private boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) { - try { - LocalDateTime now = LocalDateTime.now(); - - if (isDateTimeOperator(operatorEnum)) { - // 日期时间匹配(时间戳) - long currentTimestamp = now.toEpochSecond(java.time.ZoneOffset.of("+8")); - 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; - } - } - - /** - * 判断是否为日期时间操作符 - */ - private boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { - return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN || - operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN || - operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN; - } - - /** - * 判断是否为时间操作符 - */ - private boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) { - return operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN || - operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN || - operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN || - isDateTimeOperator(operatorEnum); - } - - /** - * 匹配日期时间(时间戳) - * 直接实现时间戳比较逻辑 - */ - private boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum, String param) { - try { - long targetTimestamp = Long.parseLong(param); - switch (operatorEnum) { - case DATE_TIME_GREATER_THAN: - return currentTimestamp > targetTimestamp; - case DATE_TIME_LESS_THAN: - return currentTimestamp < targetTimestamp; - case DATE_TIME_BETWEEN: - return matchDateTimeBetween(currentTimestamp, param); - default: - log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum); - return false; - } - } catch (Exception e) { - log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e); - return false; - } - } - - /** - * 匹配日期时间区间 - */ - private boolean matchDateTimeBetween(long currentTimestamp, String param) { - List 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()); - return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp; - } - - /** - * 匹配当日时间(HH:mm:ss) - * 直接实现时间比较逻辑 - */ - private boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum, String param) { - try { - LocalTime targetTime = parseTime(param); - switch (operatorEnum) { - case TIME_GREATER_THAN: - return currentTime.isAfter(targetTime); - case TIME_LESS_THAN: - return currentTime.isBefore(targetTime); - case TIME_BETWEEN: - return matchTimeBetween(currentTime, param); - default: - log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum); - return false; - } - } catch (Exception e) { - log.error("[matchTime][][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e); - return false; - } - } - - /** - * 匹配时间区间 - */ - private boolean matchTimeBetween(LocalTime currentTime, String param) { - List 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()); - return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime); - } - - /** - * 解析时间字符串 - * 支持 HH:mm 和 HH:mm:ss 两种格式 - */ - private 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); - } - } - @Override public int getPriority() { return 40; // 较低优先级 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java index d653c9c42e..1f019b5761 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java @@ -38,8 +38,7 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM // 1.3 检查消息中是否包含触发器指定的属性标识符 // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中 - // TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反) - if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) { + if (IotDeviceMessageUtils.notContainsIdentifier(message, trigger.getIdentifier())) { IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " + trigger.getIdentifier()); return false; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java index b5fa0330dc..642fb5ecb5 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcher.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; +import cn.hutool.core.util.StrUtil; 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.util.IotDeviceMessageUtils; @@ -8,6 +9,8 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum; import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper; import org.springframework.stereotype.Component; +import java.util.Map; + /** * 设备服务调用触发器匹配器:处理设备服务调用的触发器匹配逻辑 * @@ -28,13 +31,11 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效"); return false; } - // 1.2 检查消息方法是否匹配 if (!IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod().equals(message.getMethod())) { IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod() + ", 实际: " + message.getMethod()); return false; } - // 1.3 检查标识符是否匹配 String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) { @@ -42,13 +43,58 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger return false; } - // 2. 对于服务调用触发器,通常只需要匹配服务标识符即可 - // 不需要检查操作符和值,因为服务调用本身就是触发条件 - // TODO @puhui999: 服务调用时校验输入参数是否匹配条件? + // 2. 检查是否配置了参数条件 + if (hasParameterCondition(trigger)) { + return matchParameterCondition(message, trigger); + } + + // 3. 无参数条件时,标识符匹配即成功 IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); return true; } + /** + * 判断触发器是否配置了参数条件 + * + * @param trigger 触发器配置 + * @return 是否配置了参数条件 + */ + private boolean hasParameterCondition(IotSceneRuleDO.Trigger trigger) { + return StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue()); + } + + /** + * 匹配参数条件 + * + * @param message 设备消息 + * @param trigger 触发器配置 + * @return 是否匹配 + */ + private boolean matchParameterCondition(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) { + // 1.1 从消息中提取服务调用的输入参数 + Map inputParams = IotDeviceMessageUtils.extractServiceInputParams(message); + // TODO @puhui999:要考虑 empty 的情况么? + if (inputParams == null) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中缺少服务输入参数"); + return false; + } + // 1.2 获取要匹配的参数值(使用 identifier 作为参数名) + Object paramValue = inputParams.get(trigger.getIdentifier()); + if (paramValue == null) { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "服务输入参数中缺少指定参数: " + trigger.getIdentifier()); + return false; + } + + // 2. 使用条件评估器进行匹配 + boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(paramValue, trigger.getOperator(), trigger.getValue()); + if (matched) { + IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger); + } else { + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "服务输入参数条件不匹配"); + } + return matched; + } + @Override public int getPriority() { return 40; // 较低优先级 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/timer/IotTimerConditionEvaluator.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/timer/IotTimerConditionEvaluator.java new file mode 100644 index 0000000000..75f4e2ed51 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/timer/IotTimerConditionEvaluator.java @@ -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 jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * IoT 定时触发器条件评估器 + *

+ * 与设备触发器不同,定时触发器没有设备消息上下文, + * 需要主动查询设备属性和状态来评估条件。 + * + * @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 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; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClientTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClientTest.java new file mode 100644 index 0000000000..cd28f8f54e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClientTest.java @@ -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} 的单元测试 + *

+ * 测试 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")); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClientTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClientTest.java new file mode 100644 index 0000000000..d3568db8b9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClientTest.java @@ -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(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java new file mode 100644 index 0000000000..7fcae15713 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java @@ -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} 定时触发器条件组集成测试 + *

+ * 测试定时触发器的条件组评估功能: + * - 空条件组直接执行动作 + * - 条件组评估后决定是否执行动作 + * - 条件组之间的 OR 逻辑 + * - 条件组内的 AND 逻辑 + * - 所有条件组不满足时跳过执行 + *

+ * 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 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> 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 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> createSingleConditionGroups( + IotSceneRuleDO.TriggerCondition condition) { + List group = new ArrayList<>(); + group.add(condition); + List> groups = new ArrayList<>(); + groups.add(group); + return groups; + } + + /** + * 创建两个单条件组的条件组列表 + */ + private List> createTwoSingleConditionGroups( + IotSceneRuleDO.TriggerCondition cond1, IotSceneRuleDO.TriggerCondition cond2) { + List group1 = new ArrayList<>(); + group1.add(cond1); + List group2 = new ArrayList<>(); + group2.add(cond2); + List> groups = new ArrayList<>(); + groups.add(group1); + groups.add(group2); + return groups; + } + + /** + * 创建单个多条件组的条件组列表 + */ + private List> createSingleGroupWithMultipleConditions( + IotSceneRuleDO.TriggerCondition... conditions) { + List group = new ArrayList<>(Arrays.asList(conditions)); + List> 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> 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> 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> conditionGroups = + createSingleGroupWithMultipleConditions(condition1, condition2); + + IotSceneRuleDO sceneRule = createBaseSceneRule(); + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); + sceneRule.setTriggers(ListUtil.toList(trigger)); + + Map 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> conditionGroups = + createSingleGroupWithMultipleConditions(condition1, condition2); + + IotSceneRuleDO sceneRule = createBaseSceneRule(); + IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups); + sceneRule.setTriggers(ListUtil.toList(trigger)); + + Map 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> 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> 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> 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> 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> 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> 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> 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 group1 = new ArrayList<>(); + group1.add(group1Cond1); + group1.add(group1Cond2); + List group2 = new ArrayList<>(); + group2.add(group2Cond1); + group2.add(group2Cond2); + List> 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 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); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java index 3d75b19b37..f2f436e1fa 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceServiceInvokeTriggerMatcherTest.java @@ -378,6 +378,268 @@ public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMa assertFalse(result); } + + // ========== 参数条件匹配测试 ========== + + /** + * 测试无参数条件时的匹配逻辑 - 只要标识符匹配就返回 true + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.2** + */ + @Test + public void testMatches_noParameterCondition_success() { + // 准备参数 + String serviceIdentifier = "testService"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("level", 5) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(null); // 无参数条件 + trigger.setValue(null); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + /** + * 测试有参数条件时的匹配逻辑 - 参数条件匹配成功 + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.1** + */ + @Test + public void testMatches_withParameterCondition_greaterThan_success() { + // 准备参数 + String serviceIdentifier = "level"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("level", 5) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(">"); // 大于操作符 + trigger.setValue("3"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + /** + * 测试有参数条件时的匹配逻辑 - 参数条件匹配失败 + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.1** + */ + @Test + public void testMatches_withParameterCondition_greaterThan_failure() { + // 准备参数 + String serviceIdentifier = "level"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("level", 2) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(">"); // 大于操作符 + trigger.setValue("3"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + /** + * 测试有参数条件时的匹配逻辑 - 等于操作符 + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.1** + */ + @Test + public void testMatches_withParameterCondition_equals_success() { + // 准备参数 + String serviceIdentifier = "mode"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("mode", "auto") + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator("=="); // 等于操作符 + trigger.setValue("auto"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + + /** + * 测试参数缺失时的处理 - 消息中缺少 inputData + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.3** + */ + @Test + public void testMatches_withParameterCondition_missingInputData() { + // 准备参数 + String serviceIdentifier = "testService"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + // 缺少 inputData 字段 + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(">"); // 配置了参数条件 + trigger.setValue("3"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + /** + * 测试参数缺失时的处理 - inputData 中缺少指定参数 + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.3** + */ + @Test + public void testMatches_withParameterCondition_missingParam() { + // 准备参数 + String serviceIdentifier = "level"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("otherParam", 5) // 不是 level 参数 + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(">"); // 配置了参数条件 + trigger.setValue("3"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertFalse(result); + } + + /** + * 测试只有 operator 没有 value 时不触发参数条件匹配 + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.2** + */ + @Test + public void testMatches_onlyOperator_noValue() { + // 准备参数 + String serviceIdentifier = "testService"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("level", 5) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(">"); // 只有 operator + trigger.setValue(null); // 没有 value + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言:只有 operator 没有 value 时,不触发参数条件匹配,标识符匹配即成功 + assertTrue(result); + } + + /** + * 测试只有 value 没有 operator 时不触发参数条件匹配 + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.2** + */ + @Test + public void testMatches_onlyValue_noOperator() { + // 准备参数 + String serviceIdentifier = "testService"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputData", MapUtil.builder(new HashMap()) + .put("level", 5) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(null); // 没有 operator + trigger.setValue("3"); // 只有 value + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言:只有 value 没有 operator 时,不触发参数条件匹配,标识符匹配即成功 + assertTrue(result); + } + + /** + * 测试使用 inputParams 字段(替代 inputData) + * **Property 4: 服务调用触发器参数匹配逻辑** + * **Validates: Requirements 5.1** + */ + @Test + public void testMatches_withInputParams_success() { + // 准备参数 + String serviceIdentifier = "level"; + Map serviceParams = MapUtil.builder(new HashMap()) + .put("identifier", serviceIdentifier) + .put("inputParams", MapUtil.builder(new HashMap()) // 使用 inputParams 而不是 inputData + .put("level", 5) + .build()) + .build(); + IotDeviceMessage message = createServiceInvokeMessage(serviceParams); + IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger(); + trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType()); + trigger.setIdentifier(serviceIdentifier); + trigger.setOperator(">"); // 大于操作符 + trigger.setValue("3"); + + // 调用 + boolean result = matcher.matches(message, trigger); + + // 断言 + assertTrue(result); + } + // ========== 辅助方法 ========== /** diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java index 5dfbed08e1..54a0e67a41 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java @@ -6,6 +6,12 @@ import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import java.util.List; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; +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.topic.auth.IotSubDeviceRegisterRespDTO; + import java.util.List; /** @@ -31,6 +37,22 @@ public interface IotDeviceCommonApi { */ CommonResult getDevice(IotDeviceGetReqDTO infoReqDTO); + /** + * 直连/网关设备动态注册(一型一密) + * + * @param reqDTO 动态注册请求 + * @return 注册结果(包含 DeviceSecret) + */ + CommonResult registerDevice(IotDeviceRegisterReqDTO reqDTO); + + /** + * 网关子设备动态注册(网关代理转发) + * + * @param reqDTO 子设备注册请求(包含网关标识和子设备列表) + * @return 注册结果列表 + */ + CommonResult> registerSubDevices(IotSubDeviceRegisterFullReqDTO reqDTO); + /** * 获取所有启用的 Modbus 设备配置列表 * diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceAuthReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceAuthReqDTO.java index 9e62a2fc0c..2f25fb4964 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceAuthReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceAuthReqDTO.java @@ -1,7 +1,9 @@ package cn.iocoder.yudao.module.iot.core.biz.dto; import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; /** * IoT 设备认证 Request DTO @@ -9,6 +11,8 @@ import lombok.Data; * @author 芋道源码 */ @Data +@NoArgsConstructor +@AllArgsConstructor public class IotDeviceAuthReqDTO { /** diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotSubDeviceRegisterFullReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotSubDeviceRegisterFullReqDTO.java new file mode 100644 index 0000000000..76bf5ffb3f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotSubDeviceRegisterFullReqDTO.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * IoT 子设备动态注册 Request DTO + *

+ * 额外包含了网关设备的标识信息 + * + * @author 芋道源码 + */ +@Data +public class IotSubDeviceRegisterFullReqDTO { + + /** + * 网关设备 ProductKey + */ + @NotEmpty(message = "网关产品标识不能为空") + private String gatewayProductKey; + + /** + * 网关设备 DeviceName + */ + @NotEmpty(message = "网关设备名称不能为空") + private String gatewayDeviceName; + + /** + * 子设备注册列表 + */ + @NotNull(message = "子设备注册列表不能为空") + private List subDevices; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java index e62b78e245..d980032842 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java @@ -24,12 +24,28 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable { // TODO 芋艿:要不要加个 ping 消息; + // ========== 拓扑管理 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/manage-topological-relationships + + TOPO_ADD("thing.topo.add", "添加拓扑关系", true), + TOPO_DELETE("thing.topo.delete", "删除拓扑关系", true), + TOPO_GET("thing.topo.get", "获取拓扑关系", true), + TOPO_CHANGE("thing.topo.change", "拓扑关系变更通知", false), + + // ========== 设备注册 ========== + // 可参考:https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification + + DEVICE_REGISTER("thing.auth.register", "设备动态注册", true), + SUB_DEVICE_REGISTER("thing.auth.register.sub", "子设备动态注册", true), + // ========== 设备属性 ========== // 可参考:https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services PROPERTY_POST("thing.property.post", "属性上报", true), PROPERTY_SET("thing.property.set", "属性设置", false), + PROPERTY_PACK_POST("thing.event.property.pack.post", "批量上报(属性 + 事件 + 子设备)", true), // 网关独有 + // ========== 设备事件 ========== // 可参考:https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services @@ -50,6 +66,7 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable { OTA_UPGRADE("thing.ota.upgrade", "OTA 固定信息推送", false), OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true), + ; public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageTypeEnum.java deleted file mode 100644 index e2fe8be204..0000000000 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageTypeEnum.java +++ /dev/null @@ -1,37 +0,0 @@ -package cn.iocoder.yudao.module.iot.core.enums; - -import cn.iocoder.yudao.framework.common.core.ArrayValuable; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -import java.util.Arrays; - -/** - * IoT 设备消息类型枚举 - */ -@Getter -@RequiredArgsConstructor -public enum IotDeviceMessageTypeEnum implements ArrayValuable { - - STATE("state"), // 设备状态 -// PROPERTY("property"), // 设备属性:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 - EVENT("event"), // 设备事件:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 - SERVICE("service"), // 设备服务:可参考 https://help.aliyun.com/zh/iot/user-guide/device-properties-events-and-services 设备属性、事件、服务 - CONFIG("config"), // 设备配置:可参考 https://help.aliyun.com/zh/iot/user-guide/remote-configuration-1 远程配置 - OTA("ota"), // 设备 OTA:可参考 https://help.aliyun.com/zh/iot/user-guide/ota-update OTA 升级 - REGISTER("register"), // 设备注册:可参考 https://help.aliyun.com/zh/iot/user-guide/register-devices 设备身份注册 - TOPOLOGY("topology"),; // 设备拓扑:可参考 https://help.aliyun.com/zh/iot/user-guide/manage-topological-relationships 设备拓扑 - - public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageTypeEnum::getType).toArray(String[]::new); - - /** - * 属性 - */ - private final String type; - - @Override - public String[] array() { - return ARRAYS; - } - -} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java new file mode 100644 index 0000000000..5fbd713a8d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.iot.core.enums; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 协议类型枚举 + * + * 用于定义传输层协议类型 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum IotProtocolTypeEnum implements ArrayValuable { + + TCP("tcp"), + UDP("udp"), + WEBSOCKET("websocket"), + HTTP("http"), + MQTT("mqtt"), + EMQX("emqx"), + COAP("coap"), + MODBUS_TCP("modbus_tcp"); + + public static final String[] ARRAYS = Arrays.stream(values()).map(IotProtocolTypeEnum::getType).toArray(String[]::new); + + /** + * 类型 + */ + private final String type; + + @Override + public String[] array() { + return ARRAYS; + } + + public static IotProtocolTypeEnum of(String type) { + return ArrayUtil.firstMatch(e -> e.getType().equals(type), values()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotSerializeTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotSerializeTypeEnum.java new file mode 100644 index 0000000000..0f9400f362 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotSerializeTypeEnum.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.core.enums; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT 序列化类型枚举 + * + * 用于定义设备消息的序列化格式 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Getter +public enum IotSerializeTypeEnum implements ArrayValuable { + + JSON("json"), + BINARY("binary"); + + public static final String[] ARRAYS = Arrays.stream(values()).map(IotSerializeTypeEnum::getType).toArray(String[]::new); + + /** + * 类型 + */ + private final String type; + + @Override + public String[] array() { + return ARRAYS; + } + + public static IotSerializeTypeEnum of(String type) { + return ArrayUtil.firstMatch(e -> e.getType().equals(type), values()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java index c621467610..646eb36bc7 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageBus.java @@ -24,4 +24,14 @@ public interface IotMessageBus { */ void register(IotMessageSubscriber subscriber); + /** + * 取消注册消息订阅者 + * + * @param subscriber 订阅者 + */ + default void unregister(IotMessageSubscriber subscriber) { + // TODO 芋艿:暂时不实现,需求量不大,但是 + // throw new UnsupportedOperationException("取消注册消息订阅者功能,尚未实现"); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java index 23a055325c..fb5c712396 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/messagebus/core/IotMessageSubscriber.java @@ -26,4 +26,16 @@ public interface IotMessageSubscriber { */ void onMessage(T message); + /** + * 启动订阅 + */ + default void start() { + } + + /** + * 停止订阅 + */ + default void stop() { + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java index 6821c0d160..813b360433 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java @@ -60,7 +60,7 @@ public class IotDeviceMessage { */ private String serverId; - // ========== codec(编解码)字段 ========== + // ========== serialize(序列化)相关字段 ========== /** * 请求编号 @@ -94,7 +94,7 @@ public class IotDeviceMessage { */ private String msg; - // ========== 基础方法:只传递"codec(编解码)字段" ========== + // ========== 基础方法:只传递"serialize(序列化)相关字段" ========== public static IotDeviceMessage requestOf(String method) { return requestOf(null, method, null); @@ -108,6 +108,23 @@ public class IotDeviceMessage { return of(requestId, method, params, null, null, null); } + /** + * 创建设备请求消息(包含设备信息) + * + * @param deviceId 设备编号 + * @param tenantId 租户编号 + * @param serverId 服务标识 + * @param method 消息方法 + * @param params 消息参数 + * @return 消息对象 + */ + public static IotDeviceMessage requestOf(Long deviceId, Long tenantId, String serverId, + String method, Object params) { + IotDeviceMessage message = of(null, method, params, null, null, null); + return message.setId(IotDeviceMessageUtils.generateMessageId()) + .setDeviceId(deviceId).setTenantId(tenantId).setServerId(serverId); + } + public static IotDeviceMessage replyOf(String requestId, String method, Object data, Integer code, String msg) { if (code == null) { diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/IotDeviceIdentity.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/IotDeviceIdentity.java new file mode 100644 index 0000000000..1987026718 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/IotDeviceIdentity.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.iot.core.topic; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备标识 + * + * 用于标识一个设备的基本信息(productKey + deviceName) + * + * @author 芋道源码 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceIdentity { + + /** + * 产品标识 + */ + @NotEmpty(message = "产品标识不能为空") + private String productKey; + + /** + * 设备名称 + */ + @NotEmpty(message = "设备名称不能为空") + private String deviceName; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java new file mode 100644 index 0000000000..b8db15f188 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.iot.core.topic.auth; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * IoT 设备动态注册 Request DTO + *

+ * 用于直连设备/网关的一型一密动态注册:使用 productSecret 验证,返回 deviceSecret + * + * @author 芋道源码 + * @see 阿里云 - 一型一密 + */ +@Data +public class IotDeviceRegisterReqDTO { + + /** + * 产品标识 + */ + @NotEmpty(message = "产品标识不能为空") + private String productKey; + + /** + * 设备名称 + */ + @NotEmpty(message = "设备名称不能为空") + private String deviceName; + + /** + * 产品密钥 + */ + @NotEmpty(message = "产品密钥不能为空") + private String productSecret; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java new file mode 100644 index 0000000000..707f79890b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java @@ -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 + *

+ * 用于直连设备/网关的一型一密动态注册响应 + * + * @author 芋道源码 + * @see 阿里云 - 一型一密 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceRegisterRespDTO { + + /** + * 产品标识 + */ + private String productKey; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备密钥 + */ + private String deviceSecret; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java new file mode 100644 index 0000000000..cf34a1db2b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.core.topic.auth; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * IoT 子设备动态注册 Request DTO + *

+ * 用于 thing.auth.register.sub 消息的 params 数组元素 + * + * 特殊:网关子设备的动态注册,必须已经创建好该网关子设备(不然哪来的 {@link #deviceName} 字段)。更多的好处,是设备不用提前烧录 deviceSecret 密钥。 + * + * @author 芋道源码 + * @see 阿里云 - 动态注册子设备 + */ +@Data +public class IotSubDeviceRegisterReqDTO { + + /** + * 子设备 ProductKey + */ + @NotEmpty(message = "产品标识不能为空") + private String productKey; + + /** + * 子设备 DeviceName + */ + @NotEmpty(message = "设备名称不能为空") + private String deviceName; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java new file mode 100644 index 0000000000..a45f14defe --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java @@ -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 + *

+ * 用于 thing.auth.register.sub 响应的设备信息 + * + * @author 芋道源码 + * @see 阿里云 - 动态注册子设备 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotSubDeviceRegisterRespDTO { + + /** + * 子设备 ProductKey + */ + private String productKey; + + /** + * 子设备 DeviceName + */ + private String deviceName; + + /** + * 分配的 DeviceSecret + */ + private String deviceSecret; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java new file mode 100644 index 0000000000..3b6a7a7d4c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.core.topic.event; + +import lombok.Data; + +/** + * IoT 设备事件上报 Request DTO + *

+ * 用于 thing.event.post 消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - 设备上报事件 + */ +@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); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/package-info.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/package-info.java new file mode 100644 index 0000000000..bc97dd944a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/package-info.java @@ -0,0 +1,8 @@ +/** + * IoT Topic 消息体 DTO 定义 + *

+ * 定义设备与平台通信的消息体结构,遵循(参考)阿里云 Alink 协议规范 + * + * @see 阿里云 Alink 协议 + */ +package cn.iocoder.yudao.module.iot.core.topic; diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java new file mode 100644 index 0000000000..24494984eb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java @@ -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 + *

+ * 用于 thing.event.property.pack.post 消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - 网关批量上报数据 + */ +@Data +public class IotDevicePropertyPackPostReqDTO { + + /** + * 网关自身属性 + *

+ * key: 属性标识符 + * value: 属性值 + */ + private Map properties; + + /** + * 网关自身事件 + *

+ * key: 事件标识符 + * value: 事件值对象(包含 value 和 time) + */ + private Map events; + + /** + * 子设备数据列表 + */ + private List subDevices; + + /** + * 事件值对象 + */ + @Data + public static class EventValue { + + /** + * 事件参数 + */ + private Object value; + + /** + * 上报时间(毫秒时间戳) + */ + private Long time; + + } + + /** + * 子设备数据 + */ + @Data + public static class SubDeviceData { + + /** + * 子设备标识 + */ + private IotDeviceIdentity identity; + + /** + * 子设备属性 + *

+ * key: 属性标识符 + * value: 属性值 + */ + private Map properties; + + /** + * 子设备事件 + *

+ * key: 事件标识符 + * value: 事件值对象(包含 value 和 time) + */ + private Map events; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java new file mode 100644 index 0000000000..2e537442d7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.iot.core.topic.property; + +import java.util.HashMap; +import java.util.Map; + +/** + * IoT 设备属性上报 Request DTO + *

+ * 用于 thing.property.post 消息的 params 参数 + *

+ * 本质是一个 Map,key 为属性标识符,value 为属性值 + * + * @author 芋道源码 + * @see 阿里云 - 设备上报属性 + */ +public class IotDevicePropertyPostReqDTO extends HashMap { + + public IotDevicePropertyPostReqDTO() { + super(); + } + + public IotDevicePropertyPostReqDTO(Map properties) { + super(properties); + } + + /** + * 创建属性上报 DTO + * + * @param properties 属性数据 + * @return DTO 对象 + */ + public static IotDevicePropertyPostReqDTO of(Map properties) { + return new IotDevicePropertyPostReqDTO(properties); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java new file mode 100644 index 0000000000..97ec33200a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.iot.core.topic.topo; + +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * IoT 设备拓扑添加 Request DTO + *

+ * 用于 thing.topo.add 消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - 添加拓扑关系 + */ +@Data +public class IotDeviceTopoAddReqDTO { + + /** + * 子设备认证信息列表 + *

+ * 复用 {@link IotDeviceAuthReqDTO},包含 clientId、username、password + */ + @NotEmpty(message = "子设备认证信息列表不能为空") + private List subDevices; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java new file mode 100644 index 0000000000..0198206fe3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java @@ -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 + *

+ * 用于 thing.topo.change 下行消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - 通知网关拓扑关系变化 + */ +@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 subList; + + public static IotDeviceTopoChangeReqDTO ofCreate(List subList) { + return new IotDeviceTopoChangeReqDTO(STATUS_CREATE, subList); + } + + public static IotDeviceTopoChangeReqDTO ofDelete(List subList) { + return new IotDeviceTopoChangeReqDTO(STATUS_DELETE, subList); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java new file mode 100644 index 0000000000..71ee2bb8b2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.iot.core.topic.topo; + +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * IoT 设备拓扑删除 Request DTO + *

+ * 用于 thing.topo.delete 消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - 删除拓扑关系 + */ +@Data +public class IotDeviceTopoDeleteReqDTO { + + /** + * 子设备标识列表 + */ + @Valid + @NotEmpty(message = "子设备标识列表不能为空") + private List subDevices; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java new file mode 100644 index 0000000000..7a61af0a58 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.iot.core.topic.topo; + +import lombok.Data; + +/** + * IoT 设备拓扑关系获取 Request DTO + *

+ * 用于 thing.topo.get 请求的 params 参数(目前为空,预留扩展) + * + * @author 芋道源码 + * @see 阿里云 - 获取拓扑关系 + */ +@Data +public class IotDeviceTopoGetReqDTO { + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java new file mode 100644 index 0000000000..69c9b1555e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java @@ -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 + *

+ * 用于 thing.topo.get 响应 + * + * @author 芋道源码 + * @see 阿里云 - 获取拓扑关系 + */ +@Data +public class IotDeviceTopoGetRespDTO { + + /** + * 子设备列表 + */ + private List subDevices; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java index 2bc4880070..609d0a60ae 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java @@ -1,10 +1,10 @@ package cn.iocoder.yudao.module.iot.core.util; +import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.hutool.crypto.digest.HmacAlgorithm; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; /** * IoT 设备【认证】的工具类,参考阿里云 @@ -13,73 +13,40 @@ import lombok.NoArgsConstructor; */ public class IotDeviceAuthUtils { - /** - * 认证信息 - */ - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class AuthInfo { - - /** - * 客户端 ID - */ - private String clientId; - - /** - * 用户名 - */ - private String username; - - /** - * 密码 - */ - private String password; - - } - - /** - * 设备信息 - */ - @Data - public static class DeviceInfo { - - private String productKey; - - private String deviceName; - - } - - public static AuthInfo getAuthInfo(String productKey, String deviceName, String deviceSecret) { + public static IotDeviceAuthReqDTO getAuthInfo(String productKey, String deviceName, String deviceSecret) { String clientId = buildClientId(productKey, deviceName); String username = buildUsername(productKey, deviceName); - String content = "clientId" + clientId + - "deviceName" + deviceName + - "deviceSecret" + deviceSecret + - "productKey" + productKey; - String password = buildPassword(deviceSecret, content); - return new AuthInfo(clientId, username, password); + String password = buildPassword(deviceSecret, + buildContent(clientId, productKey, deviceName, deviceSecret)); + return new IotDeviceAuthReqDTO(clientId, username, password); } - private static String buildClientId(String productKey, String deviceName) { + public static String buildClientId(String productKey, String deviceName) { return String.format("%s.%s", productKey, deviceName); } - private static String buildUsername(String productKey, String deviceName) { + public static String buildUsername(String productKey, String deviceName) { return String.format("%s&%s", deviceName, productKey); } - private static String buildPassword(String deviceSecret, String content) { - return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, deviceSecret.getBytes()) + public static String buildPassword(String deviceSecret, String content) { + return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, StrUtil.utf8Bytes(deviceSecret)) .digestHex(content); } - public static DeviceInfo parseUsername(String username) { + private static String buildContent(String clientId, String productKey, String deviceName, String deviceSecret) { + return "clientId" + clientId + + "deviceName" + deviceName + + "deviceSecret" + deviceSecret + + "productKey" + productKey; + } + + public static IotDeviceIdentity parseUsername(String username) { String[] usernameParts = username.split("&"); if (usernameParts.length != 2) { return null; } - return new DeviceInfo().setProductKey(usernameParts[1]).setDeviceName(usernameParts[0]); + return new IotDeviceIdentity(usernameParts[1], usernameParts[0]); } } diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java index 5c1ac26005..b7d9894f0a 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java @@ -72,7 +72,7 @@ public class IotDeviceMessageUtils { /** * 判断消息中是否包含指定的标识符 - * + *

* 对于不同消息类型的处理: * - EVENT_POST/SERVICE_INVOKE:检查 params.identifier 是否匹配 * - STATE_UPDATE:检查 params.state 是否匹配 @@ -99,6 +99,17 @@ public class IotDeviceMessageUtils { return false; } + /** + * 判断消息中是否不包含指定的标识符 + * + * @param message 消息 + * @param identifier 要检查的标识符 + * @return 是否不包含 + */ + public static boolean notContainsIdentifier(IotDeviceMessage message, String identifier) { + return !containsIdentifier(message, identifier); + } + /** * 将 params 解析为 Map * @@ -144,20 +155,19 @@ public class IotDeviceMessageUtils { return null; } - // 策略1:如果 params 不是 Map,直接返回该值(适用于简单的单属性消息) + // 策略 1:如果 params 不是 Map,直接返回该值(适用于简单的单属性消息) if (!(params instanceof Map)) { return params; } + // 策略 2:直接通过标识符获取属性值 Map paramsMap = (Map) params; - - // 策略2:直接通过标识符获取属性值 Object directValue = paramsMap.get(identifier); if (directValue != null) { return directValue; } - // 策略3:从 properties 字段中获取(适用于标准属性上报消息) + // 策略 3:从 properties 字段中获取(适用于标准属性上报消息) Object properties = paramsMap.get("properties"); if (properties instanceof Map) { Map propertiesMap = (Map) properties; @@ -167,7 +177,7 @@ public class IotDeviceMessageUtils { } } - // 策略4:从 data 字段中获取(适用于某些消息格式) + // 策略 4:从 data 字段中获取(适用于某些消息格式) Object data = paramsMap.get("data"); if (data instanceof Map) { Map dataMap = (Map) data; @@ -177,13 +187,13 @@ public class IotDeviceMessageUtils { } } - // 策略5:从 value 字段中获取(适用于单值消息) + // 策略 5:从 value 字段中获取(适用于单值消息) Object value = paramsMap.get("value"); if (value != null) { return value; } - // 策略6:如果 Map 只有两个字段且包含 identifier,返回另一个字段的值 + // 策略 6:如果 Map 只有两个字段且包含 identifier,返回另一个字段的值 if (paramsMap.size() == 2 && paramsMap.containsKey("identifier")) { for (Map.Entry entry : paramsMap.entrySet()) { if (!"identifier".equals(entry.getKey())) { @@ -196,6 +206,43 @@ public class IotDeviceMessageUtils { return null; } + /** + * 从服务调用消息中提取输入参数 + *

+ * 服务调用消息的 params 结构通常为: + * { + * "identifier": "serviceIdentifier", + * "inputData": { ... } 或 "inputParams": { ... } + * } + * + * @param message 设备消息 + * @return 输入参数 Map,如果未找到则返回 null + */ + @SuppressWarnings("unchecked") + public static Map extractServiceInputParams(IotDeviceMessage message) { + // 1. 参数校验 + Object params = message.getParams(); + if (params == null) { + return null; + } + if (!(params instanceof Map)) { + return null; + } + Map paramsMap = (Map) params; + + // 尝试从 inputData 字段获取 + Object inputData = paramsMap.get("inputData"); + if (inputData instanceof Map) { + return (Map) inputData; + } + // 尝试从 inputParams 字段获取 + Object inputParams = paramsMap.get("inputParams"); + if (inputParams instanceof Map) { + return (Map) inputParams; + } + return null; + } + // ========== Topic 相关 ========== public static String buildMessageBusGatewayDeviceMessageTopic(String serverId) { diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java index a6d669d170..b0d39be519 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java @@ -1,13 +1,13 @@ package cn.iocoder.yudao.module.iot.core.util; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; /** * {@link IotDeviceMessageUtils} 的单元测试 @@ -138,4 +138,72 @@ public class IotDeviceMessageUtilsTest { Object result = IotDeviceMessageUtils.extractPropertyValue(message, "temperature"); assertEquals(25.5, result); // 应该返回直接标识符的值 } + + // ========== notContainsIdentifier 测试 ========== + + /** + * 测试 notContainsIdentifier 与 containsIdentifier 的互补性 + * **Property 2: notContainsIdentifier 与 containsIdentifier 互补性** + * **Validates: Requirements 4.1** + */ + @Test + public void testNotContainsIdentifier_complementary_whenContains() { + // 准备参数:消息包含指定标识符 + IotDeviceMessage message = new IotDeviceMessage(); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + Map params = new HashMap<>(); + params.put("temperature", 25); + message.setParams(params); + String identifier = "temperature"; + + // 调用 & 断言:验证互补性 + boolean containsResult = IotDeviceMessageUtils.containsIdentifier(message, identifier); + boolean notContainsResult = IotDeviceMessageUtils.notContainsIdentifier(message, identifier); + assertTrue(containsResult); + assertFalse(notContainsResult); + assertEquals(!containsResult, notContainsResult); + } + + /** + * 测试 notContainsIdentifier 与 containsIdentifier 的互补性 + * **Property 2: notContainsIdentifier 与 containsIdentifier 互补性** + * **Validates: Requirements 4.1** + */ + @Test + public void testNotContainsIdentifier_complementary_whenNotContains() { + // 准备参数:消息不包含指定标识符 + IotDeviceMessage message = new IotDeviceMessage(); + message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); + Map params = new HashMap<>(); + params.put("temperature", 25); + message.setParams(params); + String identifier = "humidity"; + + // 调用 & 断言:验证互补性 + boolean containsResult = IotDeviceMessageUtils.containsIdentifier(message, identifier); + boolean notContainsResult = IotDeviceMessageUtils.notContainsIdentifier(message, identifier); + assertFalse(containsResult); + assertTrue(notContainsResult); + assertEquals(!containsResult, notContainsResult); + } + + /** + * 测试 notContainsIdentifier 与 containsIdentifier 的互补性 - 空参数场景 + * **Property 2: notContainsIdentifier 与 containsIdentifier 互补性** + * **Validates: Requirements 4.1** + */ + @Test + public void testNotContainsIdentifier_complementary_nullParams() { + // 准备参数:params 为 null + IotDeviceMessage message = new IotDeviceMessage(); + message.setParams(null); + String identifier = "temperature"; + + // 调用 & 断言:验证互补性 + boolean containsResult = IotDeviceMessageUtils.containsIdentifier(message, identifier); + boolean notContainsResult = IotDeviceMessageUtils.notContainsIdentifier(message, identifier); + assertFalse(containsResult); + assertTrue(notContainsResult); + assertEquals(!containsResult, notContainsResult); + } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/pom.xml b/yudao-module-iot/yudao-module-iot-gateway/pom.xml index 5d76c59fd0..0731198fd7 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/pom.xml +++ b/yudao-module-iot/yudao-module-iot-gateway/pom.xml @@ -55,6 +55,12 @@ 3.2.1 + + + org.eclipse.californium + californium-core + + cn.iocoder.boot diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java index 9086480d3f..5a4e47fe18 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/alink/IotAlinkDeviceMessageCodec.java @@ -18,7 +18,7 @@ import org.springframework.stereotype.Component; @Component public class IotAlinkDeviceMessageCodec implements IotDeviceMessageCodec { - private static final String TYPE = "Alink"; + public static final String TYPE = "Alink"; @Data @NoArgsConstructor diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/simple/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/simple/package-info.java deleted file mode 100644 index 5bd676ad1a..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/simple/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * TODO @芋艿:实现一个 alink 的 xml 版本 - */ -package cn.iocoder.yudao.module.iot.gateway.codec.simple; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java index c1b6cc3912..7c043d97d9 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpBinaryDeviceMessageCodec.java @@ -13,7 +13,7 @@ import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; /** - * TCP 二进制格式 {@link IotDeviceMessage} 编解码器 + * TCP/UDP 二进制格式 {@link IotDeviceMessage} 编解码器 *

* 二进制协议格式(所有数值使用大端序): * diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java index 99082b4325..734c041fc0 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/codec/tcp/IotTcpJsonDeviceMessageCodec.java @@ -11,7 +11,7 @@ import lombok.NoArgsConstructor; import org.springframework.stereotype.Component; /** - * TCP JSON 格式 {@link IotDeviceMessage} 编解码器 + * TCP/UDP JSON 格式 {@link IotDeviceMessage} 编解码器 * * 采用纯 JSON 格式传输,格式如下: * { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java index 04a71e427a..0eebd894da 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.gateway.config; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxAuthEventProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol; @@ -18,14 +19,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttDownstreamSubscr import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsDownstreamSubscriber; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler; -import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; -import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.core.Vertx; import lombok.extern.slf4j.Slf4j; @@ -36,29 +30,20 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; + @Configuration @EnableConfigurationProperties(IotGatewayProperties.class) @Slf4j public class IotGatewayConfiguration { - /** - * IoT 网关 HTTP 协议配置类 - */ - @Configuration - @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.http", name = "enabled", havingValue = "true") - @Slf4j - public static class HttpProtocolConfiguration { + @Bean + public IotMessageSerializerManager iotMessageSerializerManager() { + return new IotMessageSerializerManager(); + } - @Bean - public IotHttpUpstreamProtocol iotHttpUpstreamProtocol(IotGatewayProperties gatewayProperties) { - return new IotHttpUpstreamProtocol(gatewayProperties.getProtocol().getHttp()); - } - - @Bean - public IotHttpDownstreamSubscriber iotHttpDownstreamSubscriber(IotHttpUpstreamProtocol httpUpstreamProtocol, - IotMessageBus messageBus) { - return new IotHttpDownstreamSubscriber(httpUpstreamProtocol, messageBus); - } + @Bean + public IotProtocolManager iotProtocolManager(IotGatewayProperties gatewayProperties) { + return new IotProtocolManager(gatewayProperties); } /** @@ -93,41 +78,6 @@ public class IotGatewayConfiguration { } } - /** - * IoT 网关 TCP 协议配置类 - */ - @Configuration - @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.tcp", name = "enabled", havingValue = "true") - @Slf4j - public static class TcpProtocolConfiguration { - - @Bean(name = "tcpVertx", destroyMethod = "close") - public Vertx tcpVertx() { - return Vertx.vertx(); - } - - @Bean - public IotTcpUpstreamProtocol iotTcpUpstreamProtocol(IotGatewayProperties gatewayProperties, - IotDeviceService deviceService, - IotDeviceMessageService messageService, - IotTcpConnectionManager connectionManager, - @Qualifier("tcpVertx") Vertx tcpVertx) { - return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(), - deviceService, messageService, connectionManager, tcpVertx); - } - - @Bean - public IotTcpDownstreamSubscriber iotTcpDownstreamSubscriber(IotTcpUpstreamProtocol protocolHandler, - IotDeviceMessageService messageService, - IotDeviceService deviceService, - IotTcpConnectionManager connectionManager, - IotMessageBus messageBus) { - return new IotTcpDownstreamSubscriber(protocolHandler, messageService, deviceService, connectionManager, - messageBus); - } - - } - /** * IoT 网关 MQTT 协议配置类 */ @@ -165,44 +115,6 @@ public class IotGatewayConfiguration { } - /** - * IoT 网关 MQTT WebSocket 协议配置类 - */ - @Configuration - @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.mqtt-ws", name = "enabled", havingValue = "true") - @Slf4j - public static class MqttWsProtocolConfiguration { - - @Bean(name = "mqttWsVertx", destroyMethod = "close") - public Vertx mqttWsVertx() { - return Vertx.vertx(); - } - - @Bean - public IotMqttWsUpstreamProtocol iotMqttWsUpstreamProtocol(IotGatewayProperties gatewayProperties, - IotDeviceMessageService messageService, - IotMqttWsConnectionManager connectionManager, - @Qualifier("mqttWsVertx") Vertx mqttWsVertx) { - return new IotMqttWsUpstreamProtocol(gatewayProperties.getProtocol().getMqttWs(), - messageService, connectionManager, mqttWsVertx); - } - - @Bean - public IotMqttWsDownstreamHandler iotMqttWsDownstreamHandler(IotDeviceMessageService messageService, - IotDeviceService deviceService, - IotMqttWsConnectionManager connectionManager) { - return new IotMqttWsDownstreamHandler(messageService, deviceService, connectionManager); - } - - @Bean - public IotMqttWsDownstreamSubscriber iotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol mqttWsUpstreamProtocol, - IotMqttWsDownstreamHandler downstreamHandler, - IotMessageBus messageBus) { - return new IotMqttWsDownstreamSubscriber(mqttWsUpstreamProtocol, downstreamHandler, messageBus); - } - - } - /** * IoT 网关 Modbus TCP 协议配置类 */ diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java index e4987c51cc..27a673f6e3 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java @@ -1,5 +1,14 @@ package cn.iocoder.yudao.module.iot.gateway.config; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketConfig; +import io.vertx.core.net.KeyCertOptions; +import io.vertx.core.net.TrustOptions; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -24,10 +33,15 @@ public class IotGatewayProperties { private TokenProperties token; /** - * 协议配置 + * 协议配置(旧版,保持兼容) */ private ProtocolProperties protocol; + /** + * 协议实例列表 + */ + private List protocols; + @Data public static class RpcProperties { @@ -68,31 +82,16 @@ public class IotGatewayProperties { @Data public static class ProtocolProperties { - /** - * HTTP 组件配置 - */ - private HttpProperties http; - /** * EMQX 组件配置 */ private EmqxProperties emqx; - /** - * TCP 组件配置 - */ - private TcpProperties tcp; - /** * MQTT 组件配置 */ private MqttProperties mqtt; - /** - * MQTT WebSocket 组件配置 - */ - private MqttWsProperties mqttWs; - /** * Modbus TCP 组件配置 */ @@ -299,47 +298,6 @@ public class IotGatewayProperties { } - @Data - public static class TcpProperties { - - /** - * 是否开启 - */ - @NotNull(message = "是否开启不能为空") - private Boolean enabled; - - /** - * 服务器端口 - */ - private Integer port = 8091; - - /** - * 心跳超时时间(毫秒) - */ - private Long keepAliveTimeoutMs = 30000L; - - /** - * 最大连接数 - */ - private Integer maxConnections = 1000; - - /** - * 是否启用SSL - */ - private Boolean sslEnabled = false; - - /** - * SSL证书路径 - */ - private String sslCertPath; - - /** - * SSL私钥路径 - */ - private String sslKeyPath; - - } - @Data public static class MqttProperties { @@ -368,6 +326,7 @@ public class IotGatewayProperties { */ private Integer keepAliveTimeoutSeconds = 300; + // NOTE:SSL 相关参数后续统一到 protocol 层级(优先级低) /** * 是否启用 SSL */ @@ -386,11 +345,11 @@ public class IotGatewayProperties { /** * 密钥证书选项 */ - private io.vertx.core.net.KeyCertOptions keyCertOptions; + private KeyCertOptions keyCertOptions; /** * 信任选项 */ - private io.vertx.core.net.TrustOptions trustOptions; + private TrustOptions trustOptions; /** * SSL 证书路径 */ @@ -412,99 +371,75 @@ public class IotGatewayProperties { } + // NOTE:暂未统一为 ProtocolProperties,待协议改造完成再调整 + /** + * 协议实例配置 + */ @Data - public static class MqttWsProperties { + public static class ProtocolInstanceProperties { /** - * 是否开启 + * 协议实例 ID,如 "http-alink"、"tcp-binary" */ - @NotNull(message = "是否开启不能为空") - private Boolean enabled; + @NotEmpty(message = "协议实例 ID 不能为空") + private String id; + /** + * 是否启用 + */ + @NotNull(message = "是否启用不能为空") + private Boolean enabled = true; + /** + * 协议类型 + * + * @see cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum + */ + @NotEmpty(message = "协议类型不能为空") + private String type; + /** + * 服务端口 + */ + @NotNull(message = "服务端口不能为空") + private Integer port; + /** + * 序列化类型(可选) + * + * @see cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum + * + * 为什么是可选的呢? + * 1. {@link IotProtocolTypeEnum#HTTP}、${@link IotProtocolTypeEnum#COAP} 协议,目前强制是 JSON 格式 + * 2. {@link IotProtocolTypeEnum#EMQX} 协议,目前支持根据产品(设备)配置的序列化类型来解析 + */ + private String serialize; /** - * WebSocket 服务器端口(默认:8083) + * HTTP 协议配置 */ - private Integer port = 8083; + @Valid + private IotHttpConfig http; /** - * WebSocket 路径(默认:/mqtt) + * TCP 协议配置 */ - @NotEmpty(message = "WebSocket 路径不能为空") - private String path = "/mqtt"; + @Valid + private IotTcpConfig tcp; /** - * 最大消息大小(字节) + * UDP 协议配置 */ - private Integer maxMessageSize = 8192; + @Valid + private IotUdpConfig udp; /** - * 连接超时时间(秒) + * CoAP 协议配置 */ - private Integer connectTimeoutSeconds = 60; + @Valid + private IotCoapConfig coap; /** - * 保持连接超时时间(秒) + * WebSocket 协议配置 */ - private Integer keepAliveTimeoutSeconds = 300; - - /** - * 是否启用 SSL(wss://) - */ - private Boolean sslEnabled = false; - - /** - * SSL 配置 - */ - private SslOptions sslOptions = new SslOptions(); - - /** - * WebSocket 子协议(通常为 "mqtt" 或 "mqttv3.1") - */ - @NotEmpty(message = "WebSocket 子协议不能为空") - private String subProtocol = "mqtt"; - - /** - * 最大帧大小(字节) - */ - private Integer maxFrameSize = 65536; - - /** - * SSL 配置选项 - */ - @Data - public static class SslOptions { - - /** - * 密钥证书选项 - */ - private io.vertx.core.net.KeyCertOptions keyCertOptions; - - /** - * 信任选项 - */ - private io.vertx.core.net.TrustOptions trustOptions; - - /** - * SSL 证书路径 - */ - private String certPath; - - /** - * SSL 私钥路径 - */ - private String keyPath; - - /** - * 信任存储路径 - */ - private String trustStorePath; - - /** - * 信任存储密码 - */ - private String trustStorePassword; - - } + @Valid + private IotWebSocketConfig websocket; } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java new file mode 100644 index 0000000000..bdfc28bc91 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol; + +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; + +/** + * IoT 协议接口 + * + * 定义传输层协议的生命周期管理 + * + * @author 芋道源码 + */ +public interface IotProtocol { + + /** + * 获取协议实例 ID + * + * @return 协议实例 ID,如 "http-alink"、"tcp-binary" + */ + String getId(); + + /** + * 获取服务器 ID(用于消息追踪,全局唯一) + * + * @return 服务器 ID + */ + String getServerId(); + + /** + * 获取协议类型 + * + * @return 协议类型枚举 + */ + IotProtocolTypeEnum getType(); + + /** + * 启动协议服务 + */ + void start(); + + /** + * 停止协议服务 + */ + void stop(); + + /** + * 检查协议服务是否正在运行 + * + * @return 是否正在运行 + */ + boolean isRunning(); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java new file mode 100644 index 0000000000..2e2150f6f7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol; + +import cn.hutool.core.util.StrUtil; +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.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 协议下行消息订阅者抽象类 + * + * 负责接收来自消息总线的下行消息,并委托给子类进行业务处理 + * + * @author 芋道源码 + */ +@AllArgsConstructor +@Slf4j +public abstract class IotProtocolDownstreamSubscriber implements IotMessageSubscriber { + + private final IotProtocol protocol; + + private final IotMessageBus messageBus; + + @Override + public String getTopic() { + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); + } + + /** + * 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group + */ + @Override + public String getGroup() { + return getTopic(); + } + + @Override + public void start() { + messageBus.register(this); + log.info("[start][{} 下行消息订阅成功,Topic:{}]", protocol.getType().name(), getTopic()); + } + + @Override + public void stop() { + messageBus.unregister(this); + log.info("[stop][{} 下行消息订阅已停止,Topic:{}]", protocol.getType().name(), getTopic()); + } + + @Override + public void onMessage(IotDeviceMessage message) { + log.debug("[onMessage][接收到下行消息, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); + try { + // 1. 校验 + String method = message.getMethod(); + if (StrUtil.isBlank(method)) { + log.warn("[onMessage][消息方法为空, messageId: {}, deviceId: {}]", + message.getId(), message.getDeviceId()); + return; + } + + // 2. 处理下行消息 + handleMessage(message); + } catch (Exception e) { + log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId(), e); + } + } + + /** + * 处理下行消息 + * + * @param message 下行消息 + */ + protected abstract void handleMessage(IotDeviceMessage message); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java new file mode 100644 index 0000000000..45b6789041 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java @@ -0,0 +1,165 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketProtocol; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.SmartLifecycle; + +import java.util.ArrayList; +import java.util.List; + +/** + * IoT 协议管理器:负责根据配置创建和管理协议实例 + * + * @author 芋道源码 + */ +@Slf4j +public class IotProtocolManager implements SmartLifecycle { + + private final IotGatewayProperties gatewayProperties; + + /** + * 协议实例列表 + */ + private final List protocols = new ArrayList<>(); + + @Getter + private volatile boolean running = false; + + public IotProtocolManager(IotGatewayProperties gatewayProperties) { + this.gatewayProperties = gatewayProperties; + } + + @Override + public void start() { + if (running) { + return; + } + List protocolConfigs = gatewayProperties.getProtocols(); + if (CollUtil.isEmpty(protocolConfigs)) { + log.info("[start][没有配置协议实例,跳过启动]"); + return; + } + + for (IotGatewayProperties.ProtocolInstanceProperties config : protocolConfigs) { + if (BooleanUtil.isFalse(config.getEnabled())) { + log.info("[start][协议实例 {} 未启用,跳过]", config.getId()); + continue; + } + IotProtocol protocol = createProtocol(config); + if (protocol == null) { + continue; + } + protocol.start(); + protocols.add(protocol); + } + running = true; + log.info("[start][协议管理器启动完成,共启动 {} 个协议实例]", protocols.size()); + } + + @Override + public void stop() { + if (!running) { + return; + } + for (IotProtocol protocol : protocols) { + try { + protocol.stop(); + } catch (Exception e) { + log.error("[stop][协议实例 {} 停止失败]", protocol.getId(), e); + } + } + protocols.clear(); + running = false; + log.info("[stop][协议管理器已停止]"); + } + + /** + * 创建协议实例 + * + * @param config 协议实例配置 + * @return 协议实例 + */ + @SuppressWarnings({"EnhancedSwitchMigration"}) + private IotProtocol createProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { + IotProtocolTypeEnum protocolType = IotProtocolTypeEnum.of(config.getType()); + if (protocolType == null) { + log.error("[createProtocol][协议实例 {} 的协议类型 {} 不存在]", config.getId(), config.getType()); + return null; + } + switch (protocolType) { + case HTTP: + return createHttpProtocol(config); + case TCP: + return createTcpProtocol(config); + case UDP: + return createUdpProtocol(config); + case COAP: + return createCoapProtocol(config); + case WEBSOCKET: + return createWebSocketProtocol(config); + default: + throw new IllegalArgumentException(String.format( + "[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType)); + } + } + + /** + * 创建 HTTP 协议实例 + * + * @param config 协议实例配置 + * @return HTTP 协议实例 + */ + private IotHttpProtocol createHttpProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { + return new IotHttpProtocol(config); + } + + /** + * 创建 TCP 协议实例 + * + * @param config 协议实例配置 + * @return TCP 协议实例 + */ + private IotTcpProtocol createTcpProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { + return new IotTcpProtocol(config); + } + + /** + * 创建 UDP 协议实例 + * + * @param config 协议实例配置 + * @return UDP 协议实例 + */ + private IotUdpProtocol createUdpProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { + return new IotUdpProtocol(config); + } + + /** + * 创建 CoAP 协议实例 + * + * @param config 协议实例配置 + * @return CoAP 协议实例 + */ + private IotCoapProtocol createCoapProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { + return new IotCoapProtocol(config); + } + + /** + * 创建 WebSocket 协议实例 + * + * @param config 协议实例配置 + * @return WebSocket 协议实例 + */ + private IotWebSocketProtocol createWebSocketProtocol(IotGatewayProperties.ProtocolInstanceProperties config) { + return new IotWebSocketProtocol(config); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapConfig.java new file mode 100644 index 0000000000..45fe3007e5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapConfig.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * IoT CoAP 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotCoapConfig { + + /** + * 最大消息大小(字节) + */ + @NotNull(message = "最大消息大小不能为空") + @Min(value = 64, message = "最大消息大小必须大于 64 字节") + private Integer maxMessageSize = 1024; + + /** + * ACK 超时时间(毫秒) + */ + @NotNull(message = "ACK 超时时间不能为空") + @Min(value = 100, message = "ACK 超时时间必须大于 100 毫秒") + private Integer ackTimeoutMs = 2000; + + /** + * 最大重传次数 + */ + @NotNull(message = "最大重传次数不能为空") + @Min(value = 0, message = "最大重传次数必须大于等于 0") + private Integer maxRetransmit = 4; + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java new file mode 100644 index 0000000000..28fa998807 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java @@ -0,0 +1,173 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.downstream.IotCoapDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapAuthHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapAuthResource; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapRegisterHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapRegisterResource; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapRegisterSubHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapRegisterSubResource; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapUpstreamTopicResource; +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 org.springframework.util.Assert; + +import java.util.concurrent.TimeUnit; + +/** + * IoT CoAP 协议实现 + *

+ * 基于 Eclipse Californium 实现,支持: + * 1. 认证:POST /auth + * 2. 设备动态注册:POST /auth/register/device + * 3. 子设备动态注册:POST /auth/register/sub-device/{productKey}/{deviceName} + * 4. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post + * 5. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post + * + * @author 芋道源码 + */ +@Slf4j +public class IotCoapProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolInstanceProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * CoAP 服务器 + */ + private CoapServer coapServer; + + /** + * 下行消息订阅者 + */ + private final IotCoapDownstreamSubscriber downstreamSubscriber; + + public IotCoapProtocol(ProtocolInstanceProperties properties) { + IotCoapConfig coapConfig = properties.getCoap(); + Assert.notNull(coapConfig, "CoAP 协议配置(coap)不能为空"); + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + this.downstreamSubscriber = new IotCoapDownstreamSubscriber(this, messageBus); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.COAP; + } + + @Override + public void start() { + if (running) { + log.warn("[start][IoT CoAP 协议 {} 已经在运行中]", getId()); + return; + } + + IotCoapConfig coapConfig = properties.getCoap(); + try { + // 1.1 创建 CoAP 配置 + Configuration config = Configuration.createStandardWithoutFile(); + config.set(CoapConfig.COAP_PORT, properties.getPort()); + config.set(CoapConfig.MAX_MESSAGE_SIZE, coapConfig.getMaxMessageSize()); + config.set(CoapConfig.ACK_TIMEOUT, coapConfig.getAckTimeoutMs(), TimeUnit.MILLISECONDS); + config.set(CoapConfig.MAX_RETRANSMIT, coapConfig.getMaxRetransmit()); + // 1.2 创建 CoAP 服务器 + coapServer = new CoapServer(config); + + // 2.1 添加 /auth 认证资源 + IotCoapAuthHandler authHandler = new IotCoapAuthHandler(serverId); + IotCoapAuthResource authResource = new IotCoapAuthResource(authHandler); + coapServer.add(authResource); + // 2.2 添加 /auth/register/device 设备动态注册资源(一型一密) + IotCoapRegisterHandler registerHandler = new IotCoapRegisterHandler(); + IotCoapRegisterResource registerResource = new IotCoapRegisterResource(registerHandler); + // 2.3 添加 /auth/register/sub-device/{productKey}/{deviceName} 子设备动态注册资源 + IotCoapRegisterSubHandler registerSubHandler = new IotCoapRegisterSubHandler(); + IotCoapRegisterSubResource registerSubResource = new IotCoapRegisterSubResource(registerSubHandler); + authResource.add(new CoapResource("register") {{ + add(registerResource); + add(registerSubResource); + }}); + // 2.4 添加 /topic 根资源(用于上行消息) + IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler(serverId); + IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(serverId, upstreamHandler); + coapServer.add(topicResource); + + // 3. 启动服务器 + coapServer.start(); + running = true; + log.info("[start][IoT CoAP 协议 {} 启动成功,端口:{},serverId:{}]", + getId(), properties.getPort(), serverId); + + // 4. 启动下行消息订阅者 + this.downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT CoAP 协议 {} 启动失败]", getId(), e); + if (coapServer != null) { + coapServer.destroy(); + coapServer = null; + } + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + // 1. 停止下行消息订阅者 + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT CoAP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT CoAP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + + // 2. 关闭 CoAP 服务器 + if (coapServer != null) { + try { + coapServer.stop(); + coapServer.destroy(); + coapServer = null; + log.info("[stop][IoT CoAP 协议 {} 服务器已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT CoAP 协议 {} 服务器停止失败]", getId(), e); + } + } + running = false; + log.info("[stop][IoT CoAP 协议 {} 已停止]", getId()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java new file mode 100644 index 0000000000..188d2e6428 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 CoAP 订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotCoapDownstreamSubscriber extends IotProtocolDownstreamSubscriber { + + public IotCoapDownstreamSubscriber(IotCoapProtocol protocol, IotMessageBus messageBus) { + super(protocol, messageBus); + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + // 如需支持,可通过 CoAP Observe 模式实现(设备订阅资源,服务器推送变更) + log.warn("[handleMessage][IoT 网关 CoAP 协议暂不支持下行消息,忽略消息:{}]", message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java new file mode 100644 index 0000000000..994fb147d2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java @@ -0,0 +1,186 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import cn.hutool.core.collection.CollUtil; +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.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; +import lombok.extern.slf4j.Slf4j; +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 java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; + +/** + * IoT 网关 CoAP 协议的处理器抽象基类:提供通用的前置处理(认证)、请求解析、响应处理、全局的异常捕获等 + * + * @author 芋道源码 + */ +@Slf4j +public abstract class IotCoapAbstractHandler { + + /** + * 自定义 CoAP Option 编号,用于携带 Token + *

+ * CoAP Option 范围 2048-65535 属于实验/自定义范围 + */ + public static final int OPTION_TOKEN = 2088; + + private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); + + /** + * 处理 CoAP 请求(模板方法) + * + * @param exchange CoAP 交换对象 + */ + public final void handle(CoapExchange exchange) { + try { + // 1. 前置处理 + beforeHandle(exchange); + + // 2. 执行业务逻辑 + CommonResult result = handle0(exchange); + writeResponse(exchange, result); + } catch (ServiceException e) { + // 业务异常,返回对应的错误码和消息 + writeResponse(exchange, CommonResult.error(e.getCode(), e.getMessage())); + } catch (IllegalArgumentException e) { + // 参数校验异常(hutool Assert 抛出),返回 BAD_REQUEST + writeResponse(exchange, CommonResult.error(BAD_REQUEST.getCode(), e.getMessage())); + } catch (Exception e) { + // 其他未知异常,返回 INTERNAL_SERVER_ERROR + log.error("[handle][CoAP 请求处理异常]", e); + writeResponse(exchange, CommonResult.error(INTERNAL_SERVER_ERROR)); + } + } + + /** + * 处理 CoAP 请求(子类实现) + * + * @param exchange CoAP 交换对象 + * @return 处理结果 + */ + protected abstract CommonResult handle0(CoapExchange exchange); + + /** + * 前置处理:认证等 + * + * @param exchange CoAP 交换对象 + */ + private void beforeHandle(CoapExchange exchange) { + // 1.1 如果不需要认证,则不走前置处理 + if (!requiresAuthentication()) { + return; + } + // 1.2 从自定义 Option 获取 token + String token = getTokenFromOption(exchange); + if (StrUtil.isEmpty(token)) { + throw exception(UNAUTHORIZED); + } + // 1.3 校验 token + IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token); + if (deviceInfo == null) { + throw exception(UNAUTHORIZED); + } + + // 2.1 解析 productKey 和 deviceName + List uriPath = exchange.getRequestOptions().getUriPath(); + String productKey = getProductKey(uriPath); + String deviceName = getDeviceName(uriPath); + if (StrUtil.isEmpty(productKey) || StrUtil.isEmpty(deviceName)) { + throw exception(BAD_REQUEST); + } + // 2.2 校验设备信息是否匹配 + if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey()) + || ObjUtil.notEqual(deviceName, deviceInfo.getDeviceName())) { + throw exception(FORBIDDEN); + } + } + + // ========== Token 相关方法 ========== + + /** + * 是否需要认证(子类可覆盖) + *

+ * 默认不需要认证 + * + * @return 是否需要认证 + */ + protected boolean requiresAuthentication() { + return false; + } + + /** + * 从 URI 路径中获取 productKey(子类实现) + *

+ * 默认抛出异常,需要认证的子类必须实现此方法 + * + * @param uriPath URI 路径 + * @return productKey + */ + protected String getProductKey(List uriPath) { + throw new UnsupportedOperationException("子类需要实现 getProductKey 方法"); + } + + /** + * 从 URI 路径中获取 deviceName(子类实现) + *

+ * 默认抛出异常,需要认证的子类必须实现此方法 + * + * @param uriPath URI 路径 + * @return deviceName + */ + protected String getDeviceName(List uriPath) { + throw new UnsupportedOperationException("子类需要实现 getDeviceName 方法"); + } + + /** + * 从自定义 CoAP Option 中获取 Token + * + * @param exchange CoAP 交换对象 + * @return Token 值,如果不存在则返回 null + */ + protected String getTokenFromOption(CoapExchange exchange) { + Option option = CollUtil.findOne(exchange.getRequestOptions().getOthers(), + o -> o.getNumber() == OPTION_TOKEN); + return option != null ? new String(option.getValue()) : null; + } + + // ========== 序列化相关方法 ========== + + /** + * 解析请求体为指定类型 + * + * @param exchange CoAP 交换对象 + * @param clazz 目标类型 + * @param 目标类型泛型 + * @return 解析后的对象,解析失败返回 null + */ + protected T deserializeRequest(CoapExchange exchange, Class clazz) { + byte[] payload = exchange.getRequestPayload(); + if (ArrayUtil.isEmpty(payload)) { + return null; + } + return JsonUtils.parseObject(payload, clazz); + } + + private static String serializeResponse(Object data) { + return JsonUtils.toJsonString(data); + } + + protected void writeResponse(CoapExchange exchange, CommonResult data) { + String json = serializeResponse(data); + exchange.respond(CoAP.ResponseCode.CONTENT, json, MediaTypeRegistry.APPLICATION_JSON); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAuthHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAuthHandler.java new file mode 100644 index 0000000000..0b1914e091 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAuthHandler.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +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.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.service.auth.IotDeviceTokenService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.server.resources.CoapExchange; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; + +/** + * IoT 网关 CoAP 协议的【认证】处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotCoapAuthHandler extends IotCoapAbstractHandler { + + private final String serverId; + + private final IotDeviceTokenService deviceTokenService; + private final IotDeviceCommonApi deviceApi; + private final IotDeviceMessageService deviceMessageService; + + public IotCoapAuthHandler(String serverId) { + this.serverId = serverId; + this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + } + + @Override + @SuppressWarnings("DuplicatedCode") + protected CommonResult handle0(CoapExchange exchange) { + // 1. 解析参数 + IotDeviceAuthReqDTO request = deserializeRequest(exchange, IotDeviceAuthReqDTO.class); + Assert.notNull(request, "请求体不能为空"); + Assert.notBlank(request.getClientId(), "clientId 不能为空"); + Assert.notBlank(request.getUsername(), "username 不能为空"); + Assert.notBlank(request.getPassword(), "password 不能为空"); + + // 2.1 执行认证 + CommonResult result = deviceApi.authDevice(request); + result.checkError(); + if (BooleanUtil.isFalse(result.getData())) { + throw exception(DEVICE_AUTH_FAIL); + } + // 2.2 生成 Token + IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(request.getUsername()); + 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(), serverId); + + // 4. 构建响应数据 + return CommonResult.success(MapUtil.of("token", token)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAuthResource.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAuthResource.java new file mode 100644 index 0000000000..95b6fefd46 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAuthResource.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +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 IotCoapAuthHandler authHandler; + + public IotCoapAuthResource(IotCoapAuthHandler authHandler) { + super(PATH); + this.authHandler = authHandler; + log.info("[IotCoapAuthResource][创建 CoAP 认证资源: /{}]", PATH); + } + + @Override + public void handlePOST(CoapExchange exchange) { + log.debug("[handlePOST][收到 /auth POST 请求]"); + authHandler.handle(exchange); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterHandler.java new file mode 100644 index 0000000000..a00cce4971 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterHandler.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import cn.hutool.core.lang.Assert; +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 lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.server.resources.CoapExchange; + +/** + * IoT 网关 CoAP 协议的【设备动态注册】处理器 + *

+ * 用于直连设备/网关的一型一密动态注册,不需要认证 + * + * @author 芋道源码 + * @see 阿里云 - 一型一密 + */ +@Slf4j +public class IotCoapRegisterHandler extends IotCoapAbstractHandler { + + private final IotDeviceCommonApi deviceApi; + + public IotCoapRegisterHandler() { + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + } + + @Override + protected CommonResult handle0(CoapExchange exchange) { + // 1. 解析参数 + IotDeviceRegisterReqDTO request = deserializeRequest(exchange, IotDeviceRegisterReqDTO.class); + Assert.notNull(request, "请求体不能为空"); + Assert.notBlank(request.getProductKey(), "productKey 不能为空"); + Assert.notBlank(request.getDeviceName(), "deviceName 不能为空"); + Assert.notBlank(request.getProductSecret(), "productSecret 不能为空"); + + // 2. 调用动态注册 + CommonResult result = deviceApi.registerDevice(request); + result.checkError(); + + // 3. 构建响应数据 + return CommonResult.success(result.getData()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterResource.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterResource.java new file mode 100644 index 0000000000..f8f6b0cf9a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterResource.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.server.resources.CoapExchange; + +/** + * IoT 网关 CoAP 协议的设备动态注册资源(/auth/register/device) + *

+ * 用于直连设备/网关的一型一密动态注册,不需要认证 + * + * @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); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterSubHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterSubHandler.java new file mode 100644 index 0000000000..8827cc3dbb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterSubHandler.java @@ -0,0 +1,84 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +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.biz.dto.IotSubDeviceRegisterFullReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.server.resources.CoapExchange; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * IoT 网关 CoAP 协议的【子设备动态注册】处理器 + *

+ * 用于子设备的动态注册,需要网关认证 + * + * @author 芋道源码 + * @see 阿里云 - 动态注册子设备 + */ +@Slf4j +public class IotCoapRegisterSubHandler extends IotCoapAbstractHandler { + + private final IotDeviceCommonApi deviceApi; + + public IotCoapRegisterSubHandler() { + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + } + + @Override + @SuppressWarnings("DuplicatedCode") + protected CommonResult handle0(CoapExchange exchange) { + // 1.1 解析通用参数(从 URI 路径获取网关设备信息) + List uriPath = exchange.getRequestOptions().getUriPath(); + String productKey = getProductKey(uriPath); + String deviceName = getDeviceName(uriPath); + // 1.2 解析子设备列表 + SubDeviceRegisterRequest request = deserializeRequest(exchange, SubDeviceRegisterRequest.class); + Assert.notNull(request, "请求参数不能为空"); + Assert.notEmpty(request.getParams(), "params 不能为空"); + + // 2. 调用子设备动态注册 + IotSubDeviceRegisterFullReqDTO reqDTO = new IotSubDeviceRegisterFullReqDTO() + .setGatewayProductKey(productKey) + .setGatewayDeviceName(deviceName) + .setSubDevices(request.getParams()); + CommonResult> result = deviceApi.registerSubDevices(reqDTO); + result.checkError(); + + // 3. 返回结果 + return success(result.getData()); + } + + @Override + protected boolean requiresAuthentication() { + return true; + } + + @Override + protected String getProductKey(List uriPath) { + // 路径格式:/auth/register/sub-device/{productKey}/{deviceName} + return CollUtil.get(uriPath, 3); + } + + @Override + protected String getDeviceName(List uriPath) { + // 路径格式:/auth/register/sub-device/{productKey}/{deviceName} + return CollUtil.get(uriPath, 4); + } + + @Data + public static class SubDeviceRegisterRequest { + + private List params; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterSubResource.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterSubResource.java new file mode 100644 index 0000000000..3cc42b606a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapRegisterSubResource.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +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 协议的子设备动态注册资源(/auth/register/sub-device/{productKey}/{deviceName}) + *

+ * 用于子设备的动态注册,需要网关认证 + *

+ * 支持动态路径匹配:productKey 和 deviceName 是网关设备的标识 + * + * @author 芋道源码 + */ +@Slf4j +public class IotCoapRegisterSubResource extends CoapResource { + + public static final String PATH = "sub-device"; + + private final IotCoapRegisterSubHandler registerSubHandler; + + /** + * 创建根资源(/auth/register/sub-device) + */ + public IotCoapRegisterSubResource(IotCoapRegisterSubHandler registerSubHandler) { + this(PATH, registerSubHandler); + log.info("[IotCoapRegisterSubResource][创建 CoAP 子设备动态注册资源: /auth/register/{}]", PATH); + } + + /** + * 创建子资源(动态路径) + */ + private IotCoapRegisterSubResource(String name, IotCoapRegisterSubHandler registerSubHandler) { + super(name); + this.registerSubHandler = registerSubHandler; + } + + @Override + public Resource getChild(String name) { + // 递归创建动态子资源,支持 /sub-device/{productKey}/{deviceName} 路径 + return new IotCoapRegisterSubResource(name, registerSubHandler); + } + + @Override + public void handlePOST(CoapExchange exchange) { + log.debug("[handlePOST][收到子设备动态注册请求]"); + registerSubHandler.handle(exchange); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java new file mode 100644 index 0000000000..d9e349ba58 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.text.StrPool; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import lombok.extern.slf4j.Slf4j; +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 extends IotCoapAbstractHandler { + + private final String serverId; + + private final IotDeviceMessageService deviceMessageService; + + public IotCoapUpstreamHandler(String serverId) { + this.serverId = serverId; + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + } + + @Override + @SuppressWarnings("DuplicatedCode") + protected CommonResult handle0(CoapExchange exchange) { + // 1.1 解析通用参数 + List uriPath = exchange.getRequestOptions().getUriPath(); + String productKey = getProductKey(uriPath); + String deviceName = getDeviceName(uriPath); + String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size())); + // 1.2 解析消息 + IotDeviceMessage message = deserializeRequest(exchange, IotDeviceMessage.class); + Assert.notNull(message, "请求参数不能为空"); + Assert.equals(method, message.getMethod(), "method 不匹配"); + + // 2. 发送消息 + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + + // 3. 返回结果 + return CommonResult.success(MapUtil.of("messageId", message.getId())); + } + + @Override + protected boolean requiresAuthentication() { + return true; + } + + @Override + protected String getProductKey(List uriPath) { + // 路径格式:/topic/sys/{productKey}/{deviceName}/... + return CollUtil.get(uriPath, 2); + } + + @Override + protected String getDeviceName(List uriPath) { + // 路径格式:/topic/sys/{productKey}/{deviceName}/... + return CollUtil.get(uriPath, 3); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamTopicResource.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamTopicResource.java new file mode 100644 index 0000000000..65185b575d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamTopicResource.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +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 String serverId; + private final IotCoapUpstreamHandler upstreamHandler; + + /** + * 创建根资源(/topic) + */ + public IotCoapUpstreamTopicResource(String serverId, + IotCoapUpstreamHandler upstreamHandler) { + this(PATH, serverId, upstreamHandler); + log.info("[IotCoapUpstreamTopicResource][创建 CoAP 上行 Topic 资源: /{}]", PATH); + } + + /** + * 创建子资源(动态路径) + */ + private IotCoapUpstreamTopicResource(String name, + String serverId, + IotCoapUpstreamHandler upstreamHandler) { + super(name); + this.serverId = serverId; + this.upstreamHandler = upstreamHandler; + } + + @Override + public Resource getChild(String name) { + // 递归创建动态子资源,支持任意深度路径 + return new IotCoapUpstreamTopicResource(name, serverId, upstreamHandler); + } + + @Override + public void handleGET(CoapExchange exchange) { + upstreamHandler.handle(exchange); + } + + @Override + public void handlePOST(CoapExchange exchange) { + upstreamHandler.handle(exchange); + } + + @Override + public void handlePUT(CoapExchange exchange) { + upstreamHandler.handle(exchange); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/package-info.java new file mode 100644 index 0000000000..3de662a5ca --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/package-info.java @@ -0,0 +1,6 @@ +/** + * CoAP 协议实现包 + *

+ * 提供基于 Eclipse Californium 的 IoT 设备连接和消息处理功能 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java index 61bf12376b..4b5bad2d59 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxDownstreamSubscriber.java @@ -1,11 +1,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; 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.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxDownstreamHandler; -import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; /** @@ -14,55 +13,18 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotEmqxDownstreamSubscriber implements IotMessageSubscriber { +public class IotEmqxDownstreamSubscriber extends IotProtocolDownstreamSubscriber { private final IotEmqxDownstreamHandler downstreamHandler; - private final IotMessageBus messageBus; - - private final IotEmqxUpstreamProtocol protocol; - public IotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol protocol, IotMessageBus messageBus) { - this.protocol = protocol; - this.messageBus = messageBus; + super(protocol, messageBus); this.downstreamHandler = new IotEmqxDownstreamHandler(protocol); } - @PostConstruct - public void init() { - messageBus.register(this); - } - @Override - public String getTopic() { - return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); + protected void handleMessage(IotDeviceMessage message) { + downstreamHandler.handle(message); } - @Override - public String getGroup() { - // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group - return getTopic(); - } - - @Override - public void onMessage(IotDeviceMessage message) { - log.debug("[onMessage][接收到下行消息, messageId: {}, method: {}, deviceId: {}]", - message.getId(), message.getMethod(), message.getDeviceId()); - try { - // 1. 校验 - String method = message.getMethod(); - if (method == null) { - log.warn("[onMessage][消息方法为空, messageId: {}, deviceId: {}]", - message.getId(), message.getDeviceId()); - return; - } - - // 2. 处理下行消息 - downstreamHandler.handle(message); - } catch (Exception e) { - log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", - message.getId(), message.getMethod(), message.getDeviceId(), e); - } - } - -} \ No newline at end of file +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java index a888158746..47b2f1646e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxUpstreamProtocol.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 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.IotProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxUpstreamHandler; import io.netty.handler.codec.mqtt.MqttQoS; import io.vertx.core.Vertx; @@ -28,11 +30,13 @@ import java.util.concurrent.atomic.AtomicBoolean; * @author 芋道源码 */ @Slf4j -public class IotEmqxUpstreamProtocol { +public class IotEmqxUpstreamProtocol implements IotProtocol { + + private static final String ID = "emqx"; private final IotGatewayProperties.EmqxProperties emqxProperties; - private volatile boolean isRunning = false; + private volatile boolean running = false; private final Vertx vertx; @@ -50,9 +54,20 @@ public class IotEmqxUpstreamProtocol { this.vertx = vertx; } + @Override + public String getId() { + return ID; + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.EMQX; + } + + @Override @PostConstruct public void start() { - if (isRunning) { + if (running) { return; } @@ -61,7 +76,7 @@ public class IotEmqxUpstreamProtocol { startMqttClient(); // 2. 标记服务为运行状态 - isRunning = true; + running = true; log.info("[start][IoT 网关 EMQX 协议启动成功]"); } catch (Exception e) { log.error("[start][IoT 网关 EMQX 协议服务启动失败,应用将关闭]", e); @@ -88,9 +103,10 @@ public class IotEmqxUpstreamProtocol { } } + @Override @PreDestroy public void stop() { - if (!isRunning) { + if (!running) { return; } @@ -98,10 +114,15 @@ public class IotEmqxUpstreamProtocol { stopMqttClient(); // 2. 标记服务为停止状态 - isRunning = false; + running = false; log.info("[stop][IoT 网关 MQTT 协议服务已停止]"); } + @Override + public boolean isRunning() { + return running; + } + /** * 启动 MQTT 客户端 */ @@ -185,7 +206,7 @@ public class IotEmqxUpstreamProtocol { * 延迟重连 */ private void reconnectWithDelay() { - if (!isRunning) { + if (!running) { return; } if (mqttClient != null && mqttClient.isConnected()) { @@ -195,7 +216,7 @@ public class IotEmqxUpstreamProtocol { long delay = emqxProperties.getReconnectDelayMs(); log.info("[reconnectWithDelay][将在 {} 毫秒后尝试重连 MQTT Broker]", delay); vertx.setTimer(delay, timerId -> { - if (!isRunning) { + if (!running) { return; } if (mqttClient != null && mqttClient.isConnected()) { @@ -305,7 +326,7 @@ public class IotEmqxUpstreamProtocol { private void setupMqttHandlers() { // 1. 设置断开重连监听器 mqttClient.closeHandler(closeEvent -> { - if (!isRunning) { + if (!running) { return; } log.warn("[closeHandler][MQTT 连接已断开, 准备重连]"); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/package-info.java new file mode 100644 index 0000000000..b64dd122bb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/package-info.java @@ -0,0 +1 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java index d6957bd52f..6b6694fd90 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/router/IotEmqxAuthEventHandler.java @@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult; 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.core.util.IotDeviceAuthUtils; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.core.json.JsonObject; @@ -103,7 +104,7 @@ public class IotEmqxAuthEventHandler { JsonObject body = null; try { // 1. 解析请求体 - body = parseRequestBody(context); + body = parseEventRequestBody(context); if (body == null) { return; } @@ -152,7 +153,9 @@ public class IotEmqxAuthEventHandler { } /** - * 解析请求体 + * 解析认证接口请求体 + *

+ * 认证接口解析失败时返回 JSON 格式响应(包含 result 字段) * * @param context 路由上下文 * @return 请求体JSON对象,解析失败时返回null @@ -173,6 +176,30 @@ public class IotEmqxAuthEventHandler { } } + /** + * 解析事件接口请求体 + *

+ * 事件接口解析失败时仅返回 200 状态码,无响应体(符合 EMQX Webhook 规范) + * + * @param context 路由上下文 + * @return 请求体JSON对象,解析失败时返回null + */ + private JsonObject parseEventRequestBody(RoutingContext context) { + try { + JsonObject body = context.body().asJsonObject(); + if (body == null) { + log.info("[parseEventRequestBody][请求体为空]"); + context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); + return null; + } + return body; + } catch (Exception e) { + log.error("[parseEventRequestBody][body({}) 解析请求体失败]", context.body().asString(), e); + context.response().setStatusCode(SUCCESS_STATUS_CODE).end(); + return null; + } + } + /** * 执行设备认证 * @@ -201,7 +228,7 @@ public class IotEmqxAuthEventHandler { */ private void handleDeviceStateChange(String username, boolean online) { // 1. 解析设备信息 - IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username); if (deviceInfo == null) { log.debug("[handleDeviceStateChange][跳过非设备({})连接]", username); return; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpConfig.java new file mode 100644 index 0000000000..968a9ae625 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpConfig.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import lombok.Data; + +/** + * IoT HTTP 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotHttpConfig { + + /** + * 是否启用 SSL + */ + private Boolean sslEnabled = false; + + /** + * SSL 证书路径 + */ + private String sslCertPath; + + /** + * SSL 私钥路径 + */ + private String sslKeyPath; + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpDownstreamSubscriber.java deleted file mode 100644 index 585bbdd30b..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpDownstreamSubscriber.java +++ /dev/null @@ -1,45 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.http; - -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 jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT 网关 HTTP 订阅者:接收下行给设备的消息 - * - * @author 芋道源码 - */ -@RequiredArgsConstructor -@Slf4j -public class IotHttpDownstreamSubscriber implements IotMessageSubscriber { - - private final IotHttpUpstreamProtocol 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) { - log.info("[onMessage][IoT 网关 HTTP 协议不支持下行消息,忽略消息:{}]", message); - } - -} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java new file mode 100644 index 0000000000..f3a3c0d14d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.downstream.IotHttpDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpAuthHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpRegisterHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpRegisterSubHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpUpstreamHandler; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.net.PemKeyCertOptions; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.handler.BodyHandler; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT HTTP 协议实现 + *

+ * 基于 Vert.x 实现 HTTP 服务器,接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotHttpProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolInstanceProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * Vert.x 实例 + */ + private Vertx vertx; + /** + * HTTP 服务器 + */ + private HttpServer httpServer; + + /** + * 下行消息订阅者 + */ + private IotHttpDownstreamSubscriber downstreamSubscriber; + + public IotHttpProtocol(ProtocolInstanceProperties properties) { + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + this.downstreamSubscriber = new IotHttpDownstreamSubscriber(this, messageBus); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.HTTP; + } + + @Override + public void start() { + if (running) { + log.warn("[start][IoT HTTP 协议 {} 已经在运行中]", getId()); + return; + } + + // 1.1 创建 Vertx 实例(每个 Protocol 独立管理) + this.vertx = Vertx.vertx(); + + // 1.2 创建路由 + Router router = Router.router(vertx); + router.route().handler(BodyHandler.create()); + + // 1.3 创建处理器,添加路由处理器 + IotHttpAuthHandler authHandler = new IotHttpAuthHandler(this); + router.post(IotHttpAuthHandler.PATH).handler(authHandler); + IotHttpRegisterHandler registerHandler = new IotHttpRegisterHandler(); + router.post(IotHttpRegisterHandler.PATH).handler(registerHandler); + IotHttpRegisterSubHandler registerSubHandler = new IotHttpRegisterSubHandler(); + router.post(IotHttpRegisterSubHandler.PATH).handler(registerSubHandler); + IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(this); + router.post(IotHttpUpstreamHandler.PATH).handler(upstreamHandler); + + // 1.4 启动 HTTP 服务器 + IotHttpConfig httpConfig = properties.getHttp(); + HttpServerOptions options = new HttpServerOptions().setPort(properties.getPort()); + if (httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled())) { + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() + .setKeyPath(httpConfig.getSslKeyPath()) + .setCertPath(httpConfig.getSslCertPath()); + options = options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); + } + try { + httpServer = vertx.createHttpServer(options) + .requestHandler(router) + .listen() + .result(); + running = true; + log.info("[start][IoT HTTP 协议 {} 启动成功,端口:{},serverId:{}]", + getId(), properties.getPort(), serverId); + + // 2. 启动下行消息订阅者 + this.downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT HTTP 协议 {} 启动失败]", getId(), e); + // 启动失败时关闭 Vertx + if (vertx != null) { + vertx.close(); + vertx = null; + } + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + // 1. 停止下行消息订阅者 + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT HTTP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT HTTP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; + } + + // 2.1 关闭 HTTP 服务器 + if (httpServer != null) { + try { + httpServer.close().result(); + log.info("[stop][IoT HTTP 协议 {} 服务器已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT HTTP 协议 {} 服务器停止失败]", getId(), e); + } + httpServer = null; + } + // 2.2 关闭 Vertx 实例 + if (vertx != null) { + try { + vertx.close().result(); + log.info("[stop][IoT HTTP 协议 {} Vertx 已关闭]", getId()); + } catch (Exception e) { + log.error("[stop][IoT HTTP 协议 {} Vertx 关闭失败]", getId(), e); + } + vertx = null; + } + running = false; + log.info("[stop][IoT HTTP 协议 {} 已停止]", getId()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java deleted file mode 100644 index eda59d13ff..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpUpstreamProtocol.java +++ /dev/null @@ -1,86 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.http; - -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.http.router.IotHttpAuthHandler; -import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpUpstreamHandler; -import io.vertx.core.AbstractVerticle; -import io.vertx.core.Vertx; -import io.vertx.core.http.HttpServer; -import io.vertx.core.http.HttpServerOptions; -import io.vertx.core.net.PemKeyCertOptions; -import io.vertx.ext.web.Router; -import io.vertx.ext.web.handler.BodyHandler; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT 网关 HTTP 协议:接收设备上行消息 - * - * @author 芋道源码 - */ -@Slf4j -public class IotHttpUpstreamProtocol extends AbstractVerticle { - - private final IotGatewayProperties.HttpProperties httpProperties; - - private HttpServer httpServer; - - @Getter - private final String serverId; - - public IotHttpUpstreamProtocol(IotGatewayProperties.HttpProperties httpProperties) { - this.httpProperties = httpProperties; - this.serverId = IotDeviceMessageUtils.generateServerId(httpProperties.getServerPort()); - } - - @Override - @PostConstruct - public void start() { - // 创建路由 - Vertx vertx = Vertx.vertx(); - Router router = Router.router(vertx); - router.route().handler(BodyHandler.create()); - - // 创建处理器,添加路由处理器 - IotHttpAuthHandler authHandler = new IotHttpAuthHandler(this); - router.post(IotHttpAuthHandler.PATH).handler(authHandler); - IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(this); - router.post(IotHttpUpstreamHandler.PATH).handler(upstreamHandler); - - // 启动 HTTP 服务器 - HttpServerOptions options = new HttpServerOptions() - .setPort(httpProperties.getServerPort()); - if (Boolean.TRUE.equals(httpProperties.getSslEnabled())) { - PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions().setKeyPath(httpProperties.getSslKeyPath()) - .setCertPath(httpProperties.getSslCertPath()); - options = options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); - } - try { - httpServer = vertx.createHttpServer(options) - .requestHandler(router) - .listen() - .result(); - log.info("[start][IoT 网关 HTTP 协议启动成功,端口:{}]", httpProperties.getServerPort()); - } catch (Exception e) { - log.error("[start][IoT 网关 HTTP 协议启动失败]", e); - throw e; - } - } - - @Override - @PreDestroy - public void stop() { - if (httpServer != null) { - try { - httpServer.close().result(); - log.info("[stop][IoT 网关 HTTP 协议已停止]"); - } catch (Exception e) { - log.error("[stop][IoT 网关 HTTP 协议停止失败]", e); - } - } - } - -} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java new file mode 100644 index 0000000000..bfac16ca5e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 HTTP 订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ + +@Slf4j +public class IotHttpDownstreamSubscriber extends IotProtocolDownstreamSubscriber { + + public IotHttpDownstreamSubscriber(IotProtocol protocol, IotMessageBus messageBus) { + super(protocol, messageBus); + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + log.info("[handleMessage][IoT 网关 HTTP 协议不支持下行消息,忽略消息:{}]", message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java similarity index 66% rename from yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java rename to yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java index f5461c2c51..c403ee973f 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAbstractHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java @@ -1,23 +1,23 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.http.router; +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; import cn.hutool.core.lang.Assert; +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.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; -import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; import io.vertx.core.Handler; import io.vertx.core.http.HttpHeaders; import io.vertx.ext.web.RoutingContext; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; -import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; -import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; @@ -26,7 +26,6 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU * * @author 芋道源码 */ -@RequiredArgsConstructor @Slf4j public abstract class IotHttpAbstractHandler implements Handler { @@ -42,19 +41,35 @@ public abstract class IotHttpAbstractHandler implements Handler CommonResult result = handle0(context); writeResponse(context, result); } catch (ServiceException e) { + // 已知异常,返回对应的错误码和错误信息 writeResponse(context, CommonResult.error(e.getCode(), e.getMessage())); + } catch (IllegalArgumentException e) { + // 参数校验异常,返回 400 错误 + writeResponse(context, CommonResult.error(BAD_REQUEST.getCode(), e.getMessage())); } catch (Exception e) { + // 其他未知异常,返回 500 错误 log.error("[handle][path({}) 处理异常]", context.request().path(), e); writeResponse(context, CommonResult.error(INTERNAL_SERVER_ERROR)); } } + /** + * 处理 HTTP 请求(子类实现) + * + * @param context RoutingContext 对象 + * @return 处理结果 + */ protected abstract CommonResult handle0(RoutingContext context); + /** + * 前置处理:认证等 + * + * @param context RoutingContext 对象 + */ private void beforeHandle(RoutingContext context) { // 如果不需要认证,则不走前置处理 String path = context.request().path(); - if (ObjUtil.equal(path, IotHttpAuthHandler.PATH)) { + if (ObjectUtils.equalsAny(path, IotHttpAuthHandler.PATH, IotHttpRegisterHandler.PATH)) { return; } @@ -73,7 +88,7 @@ public abstract class IotHttpAbstractHandler implements Handler } // 校验 token - IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.verifyToken(token); + IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token); Assert.notNull(deviceInfo, "设备信息不能为空"); // 校验设备信息是否匹配 if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey()) @@ -82,12 +97,26 @@ public abstract class IotHttpAbstractHandler implements Handler } } + // ========== 序列化相关方法 ========== + + protected static T deserializeRequest(RoutingContext context, Class clazz) { + byte[] body = context.body().buffer() != null ? context.body().buffer().getBytes() : null; + if (ArrayUtil.isEmpty(body)) { + throw invalidParamException("请求体不能为空"); + } + return JsonUtils.parseObject(body, clazz); + } + + private static String serializeResponse(Object data) { + return JsonUtils.toJsonString(data); + } + @SuppressWarnings("deprecation") - public static void writeResponse(RoutingContext context, Object data) { + public static void writeResponse(RoutingContext context, CommonResult data) { context.response() .setStatusCode(200) .putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE) - .end(JsonUtils.toJsonString(data)); + .end(serializeResponse(data)); } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAuthHandler.java similarity index 64% rename from yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java rename to yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAuthHandler.java index e6a52cdf0f..21aa5a8fb4 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpAuthHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAuthHandler.java @@ -1,23 +1,20 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.http.router; +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; 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.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.util.IotDeviceAuthUtils; -import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; -import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; @@ -32,7 +29,7 @@ public class IotHttpAuthHandler extends IotHttpAbstractHandler { public static final String PATH = "/auth"; - private final IotHttpUpstreamProtocol protocol; + private final String serverId; private final IotDeviceTokenService deviceTokenService; @@ -40,39 +37,31 @@ public class IotHttpAuthHandler extends IotHttpAbstractHandler { private final IotDeviceMessageService deviceMessageService; - public IotHttpAuthHandler(IotHttpUpstreamProtocol protocol) { - this.protocol = protocol; + public IotHttpAuthHandler(IotHttpProtocol protocol) { + this.serverId = protocol.getServerId(); this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); } @Override + @SuppressWarnings("DuplicatedCode") public CommonResult handle0(RoutingContext context) { // 1. 解析参数 - JsonObject body = context.body().asJsonObject(); - String clientId = body.getString("clientId"); - if (StrUtil.isEmpty(clientId)) { - throw invalidParamException("clientId 不能为空"); - } - String username = body.getString("username"); - if (StrUtil.isEmpty(username)) { - throw invalidParamException("username 不能为空"); - } - String password = body.getString("password"); - if (StrUtil.isEmpty(password)) { - throw invalidParamException("password 不能为空"); - } + IotDeviceAuthReqDTO request = deserializeRequest(context, IotDeviceAuthReqDTO.class); + Assert.notNull(request, "请求参数不能为空"); + Assert.notBlank(request.getClientId(), "clientId 不能为空"); + Assert.notBlank(request.getUsername(), "username 不能为空"); + Assert.notBlank(request.getPassword(), "password 不能为空"); // 2.1 执行认证 - CommonResult result = deviceApi.authDevice(new IotDeviceAuthReqDTO() - .setClientId(clientId).setUsername(username).setPassword(password)); + CommonResult result = deviceApi.authDevice(request); result.checkError(); - if (!BooleanUtil.isTrue(result.getData())) { + if (BooleanUtil.isFalse(result.getData())) { throw exception(DEVICE_AUTH_FAIL); } // 2.2 生成 Token - IotDeviceAuthUtils.DeviceInfo deviceInfo = deviceTokenService.parseUsername(username); + IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(request.getUsername()); Assert.notNull(deviceInfo, "设备信息不能为空"); String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); Assert.notBlank(token, "生成 token 不能为空位"); @@ -80,7 +69,7 @@ public class IotHttpAuthHandler extends IotHttpAbstractHandler { // 3. 执行上线 IotDeviceMessage message = IotDeviceMessage.buildStateUpdateOnline(); deviceMessageService.sendDeviceMessage(message, - deviceInfo.getProductKey(), deviceInfo.getDeviceName(), protocol.getServerId()); + deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId); // 构建响应数据 return success(MapUtil.of("token", token)); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpRegisterHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpRegisterHandler.java new file mode 100644 index 0000000000..08c60f3c9d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpRegisterHandler.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; + +import cn.hutool.core.lang.Assert; +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.ext.web.RoutingContext; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * IoT 网关 HTTP 协议的【设备动态注册】处理器 + *

+ * 用于直连设备/网关的一型一密动态注册,不需要认证 + * + * @author 芋道源码 + * @see 阿里云 - 一型一密 + */ +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 handle0(RoutingContext context) { + // 1. 解析参数 + IotDeviceRegisterReqDTO request = deserializeRequest(context, IotDeviceRegisterReqDTO.class); + Assert.notNull(request, "请求参数不能为空"); + Assert.notBlank(request.getProductKey(), "productKey 不能为空"); + Assert.notBlank(request.getDeviceName(), "deviceName 不能为空"); + Assert.notBlank(request.getProductSecret(), "productSecret 不能为空"); + + // 2. 调用动态注册 + CommonResult result = deviceApi.registerDevice(request); + result.checkError(); + + // 3. 返回结果 + return success(result.getData()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpRegisterSubHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpRegisterSubHandler.java new file mode 100644 index 0000000000..46932204db --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpRegisterSubHandler.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; + +import cn.hutool.core.lang.Assert; +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.biz.dto.IotSubDeviceRegisterFullReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO; +import io.vertx.ext.web.RoutingContext; +import lombok.Data; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * IoT 网关 HTTP 协议的【子设备动态注册】处理器 + *

+ * 用于子设备的动态注册,需要网关认证 + * + * @author 芋道源码 + * @see 阿里云 - 动态注册子设备 + */ +public class IotHttpRegisterSubHandler extends IotHttpAbstractHandler { + + /** + * 路径:/auth/register/sub-device/:productKey/:deviceName + *

+ * 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 handle0(RoutingContext context) { + // 1.1 解析通用参数 + String productKey = context.pathParam("productKey"); + String deviceName = context.pathParam("deviceName"); + // 1.2 解析子设备列表 + SubDeviceRegisterRequest request = deserializeRequest(context, SubDeviceRegisterRequest.class); + Assert.notNull(request, "请求参数不能为空"); + Assert.notEmpty(request.getParams(), "params 不能为空"); + + // 2. 调用子设备动态注册 + IotSubDeviceRegisterFullReqDTO reqDTO = new IotSubDeviceRegisterFullReqDTO() + .setGatewayProductKey(productKey) + .setGatewayDeviceName(deviceName) + .setSubDevices(request.getParams()); + CommonResult> result = deviceApi.registerSubDevices(reqDTO); + result.checkError(); + + // 3. 返回结果 + return success(result.getData()); + } + + @Data + public static class SubDeviceRegisterRequest { + + private List params; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java similarity index 69% rename from yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java rename to yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java index d7d4d52ff2..aa408dc79b 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.http.router; +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; @@ -6,10 +6,9 @@ import cn.hutool.core.text.StrPool; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; -import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.ext.web.RoutingContext; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -17,39 +16,37 @@ import lombok.extern.slf4j.Slf4j; * * @author 芋道源码 */ -@RequiredArgsConstructor @Slf4j public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { public static final String PATH = "/topic/sys/:productKey/:deviceName/*"; - private final IotHttpUpstreamProtocol protocol; + private final String serverId; private final IotDeviceMessageService deviceMessageService; - public IotHttpUpstreamHandler(IotHttpUpstreamProtocol protocol) { - this.protocol = protocol; + public IotHttpUpstreamHandler(IotHttpProtocol protocol) { + this.serverId = protocol.getServerId(); this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); } @Override protected CommonResult handle0(RoutingContext context) { - // 1. 解析通用参数 + // 1.1 解析通用参数 String productKey = context.pathParam("productKey"); String deviceName = context.pathParam("deviceName"); String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT); - - // 2.1 解析消息 - byte[] bytes = context.body().buffer().getBytes(); - IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(bytes, - productKey, deviceName); + // 1.2 根据 Content-Type 反序列化消息 + IotDeviceMessage message = deserializeRequest(context, IotDeviceMessage.class); + Assert.notNull(message, "请求参数不能为空"); Assert.equals(method, message.getMethod(), "method 不匹配"); - // 2.2 发送消息 + + // 2. 发送消息 deviceMessageService.sendDeviceMessage(message, - productKey, deviceName, protocol.getServerId()); + productKey, deviceName, serverId); // 3. 返回结果 return CommonResult.success(MapUtil.of("messageId", message.getId())); } -} \ No newline at end of file +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/package-info.java new file mode 100644 index 0000000000..20124f8d07 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/package-info.java @@ -0,0 +1,6 @@ +/** + * HTTP 协议实现包 + *

+ * 提供基于 Vert.x HTTP Server 的 IoT 设备连接和消息处理功能 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java index 3b62368fd9..fe9b600b99 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttDownstreamSubscriber.java @@ -1,11 +1,9 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt; 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.IotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; -import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; /** @@ -16,64 +14,27 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotMqttDownstreamSubscriber implements IotMessageSubscriber { - - private final IotMqttUpstreamProtocol upstreamProtocol; +public class IotMqttDownstreamSubscriber extends IotProtocolDownstreamSubscriber { private final IotMqttDownstreamHandler downstreamHandler; - private final IotMessageBus messageBus; - - public IotMqttDownstreamSubscriber(IotMqttUpstreamProtocol upstreamProtocol, + public IotMqttDownstreamSubscriber(IotMqttUpstreamProtocol protocol, IotMqttDownstreamHandler downstreamHandler, IotMessageBus messageBus) { - this.upstreamProtocol = upstreamProtocol; + super(protocol, messageBus); this.downstreamHandler = downstreamHandler; - this.messageBus = messageBus; - } - - @PostConstruct - public void subscribe() { - messageBus.register(this); - log.info("[subscribe][MQTT 协议下行消息订阅成功,主题:{}]", getTopic()); } @Override - public String getTopic() { - return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(upstreamProtocol.getServerId()); - } - - @Override - public String getGroup() { - // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group - return getTopic(); - } - - @Override - public void onMessage(IotDeviceMessage message) { - log.debug("[onMessage][接收到下行消息, messageId: {}, method: {}, deviceId: {}]", - message.getId(), message.getMethod(), message.getDeviceId()); - try { - // 1. 校验 - String method = message.getMethod(); - if (method == null) { - log.warn("[onMessage][消息方法为空, messageId: {}, deviceId: {}]", - message.getId(), message.getDeviceId()); - return; - } - - // 2. 委托给下行处理器处理业务逻辑 - boolean success = downstreamHandler.handleDownstreamMessage(message); - if (success) { - log.debug("[onMessage][下行消息处理成功, messageId: {}, method: {}, deviceId: {}]", - message.getId(), message.getMethod(), message.getDeviceId()); - } else { - log.warn("[onMessage][下行消息处理失败, messageId: {}, method: {}, deviceId: {}]", - message.getId(), message.getMethod(), message.getDeviceId()); - } - } catch (Exception e) { - log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", - message.getId(), message.getMethod(), message.getDeviceId(), e); + protected void handleMessage(IotDeviceMessage message) { + boolean success = downstreamHandler.handleDownstreamMessage(message); + if (success) { + log.debug("[handleMessage][下行消息处理成功, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); + } else { + log.warn("[handleMessage][下行消息处理失败, messageId: {}, method: {}, deviceId: {}]", + message.getId(), message.getMethod(), message.getDeviceId()); } } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java index fc0b6672c1..46fbc7c3fa 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttUpstreamProtocol.java @@ -1,7 +1,9 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 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.IotProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttUpstreamHandler; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; @@ -19,7 +21,11 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotMqttUpstreamProtocol { +public class IotMqttUpstreamProtocol implements IotProtocol { + + private static final String ID = "mqtt"; + + private volatile boolean running = false; private final IotGatewayProperties.MqttProperties mqttProperties; @@ -45,7 +51,23 @@ public class IotMqttUpstreamProtocol { this.serverId = IotDeviceMessageUtils.generateServerId(mqttProperties.getPort()); } + @Override + public String getId() { + return ID; + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.MQTT; + } + + @Override + public boolean isRunning() { + return running; + } + // TODO @haohao:这里的编写,是不是和 tcp 对应的,风格保持一致哈; + @Override @PostConstruct public void start() { // 创建服务器选项 @@ -71,6 +93,7 @@ public class IotMqttUpstreamProtocol { // 启动服务器 try { mqttServer.listen().result(); + running = true; log.info("[start][IoT 网关 MQTT 协议启动成功,端口:{}]", mqttProperties.getPort()); } catch (Exception e) { log.error("[start][IoT 网关 MQTT 协议启动失败]", e); @@ -78,11 +101,13 @@ public class IotMqttUpstreamProtocol { } } + @Override @PreDestroy public void stop() { if (mqttServer != null) { try { mqttServer.close().result(); + running = false; log.info("[stop][IoT 网关 MQTT 协议已停止]"); } catch (Exception e) { log.error("[stop][IoT 网关 MQTT 协议停止失败]", e); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java index d7c4adbd00..082a2ad797 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager; import cn.hutool.core.util.StrUtil; import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.buffer.Buffer; import io.vertx.mqtt.MqttEndpoint; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -87,9 +88,9 @@ public class IotMqttConnectionManager { connectionMap.remove(oldEndpoint); } + // 注册新连接 connectionMap.put(endpoint, connectionInfo); deviceEndpointMap.put(deviceId, endpoint); - log.info("[registerConnection][注册设备连接,设备 ID: {},连接: {},product key: {},device name: {}]", deviceId, getEndpointAddress(endpoint), connectionInfo.getProductKey(), connectionInfo.getDeviceName()); } @@ -101,13 +102,12 @@ public class IotMqttConnectionManager { */ public void unregisterConnection(MqttEndpoint endpoint) { ConnectionInfo connectionInfo = connectionMap.remove(endpoint); - if (connectionInfo != null) { - Long deviceId = connectionInfo.getDeviceId(); - deviceEndpointMap.remove(deviceId); - - log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", deviceId, - getEndpointAddress(endpoint)); + if (connectionInfo == null) { + return; } + Long deviceId = connectionInfo.getDeviceId(); + deviceEndpointMap.remove(deviceId); + log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", deviceId, getEndpointAddress(endpoint)); } /** @@ -166,7 +166,7 @@ public class IotMqttConnectionManager { } try { - endpoint.publish(topic, io.vertx.core.buffer.Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain); + endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain); log.debug("[sendToDevice][发送消息成功,设备 ID: {},主题: {},QoS: {}]", deviceId, topic, qos); return true; } catch (Exception e) { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java index 4c0eb6e612..d40dba447c 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/router/IotMqttUpstreamHandler.java @@ -1,18 +1,26 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; 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.IotDeviceGetReqDTO; 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.protocol.mqtt.IotMqttUpstreamProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; import io.netty.handler.codec.mqtt.MqttQoS; import io.vertx.mqtt.MqttEndpoint; @@ -20,6 +28,7 @@ import io.vertx.mqtt.MqttTopicSubscription; import lombok.extern.slf4j.Slf4j; import java.util.List; +import java.util.Map; /** * MQTT 上行消息处理器 @@ -29,6 +38,16 @@ import java.util.List; @Slf4j public class IotMqttUpstreamHandler { + /** + * 默认编解码类型(MQTT 使用 Alink 协议) + */ + private static final String DEFAULT_CODEC_TYPE = "Alink"; + + /** + * register 请求的 topic 后缀 + */ + private static final String REGISTER_TOPIC_SUFFIX = "/thing/auth/register"; + private final IotDeviceMessageService deviceMessageService; private final IotMqttConnectionManager connectionManager; @@ -84,20 +103,28 @@ public class IotMqttUpstreamHandler { }); // 4. 设置消息处理器 - endpoint.publishHandler(message -> { + endpoint.publishHandler(mqttMessage -> { try { - processMessage(clientId, message.topicName(), message.payload().getBytes()); + // 4.1 根据 topic 判断是否为 register 请求 + String topic = mqttMessage.topicName(); + byte[] payload = mqttMessage.payload().getBytes(); + if (topic.endsWith(REGISTER_TOPIC_SUFFIX)) { + // register 请求:使用默认编解码器处理(设备可能未注册) + processRegisterMessage(clientId, topic, payload, endpoint); + } else { + // 业务请求:正常处理 + processMessage(clientId, topic, payload); + } - // 根据 QoS 级别发送相应的确认消息 - if (message.qosLevel() == MqttQoS.AT_LEAST_ONCE) { + // 4.2 根据 QoS 级别发送相应的确认消息 + if (mqttMessage.qosLevel() == MqttQoS.AT_LEAST_ONCE) { // QoS 1: 发送 PUBACK 确认 - endpoint.publishAcknowledge(message.messageId()); - } else if (message.qosLevel() == MqttQoS.EXACTLY_ONCE) { + endpoint.publishAcknowledge(mqttMessage.messageId()); + } else if (mqttMessage.qosLevel() == MqttQoS.EXACTLY_ONCE) { // QoS 2: 发送 PUBREC 确认 - endpoint.publishReceived(message.messageId()); + endpoint.publishReceived(mqttMessage.messageId()); } // QoS 0 无需确认 - } catch (Exception e) { log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]", clientId, connectionManager.getEndpointAddress(endpoint), e.getMessage()); @@ -160,10 +187,9 @@ public class IotMqttUpstreamHandler { return; } + // 3. 解码消息(使用从 topic 解析的 productKey 和 deviceName) String productKey = topicParts[2]; String deviceName = topicParts[3]; - - // 3. 解码消息(使用从 topic 解析的 productKey 和 deviceName) try { IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName); if (message == null) { @@ -171,10 +197,9 @@ public class IotMqttUpstreamHandler { return; } + // 4. 处理业务消息(认证已在连接时完成) log.info("[processMessage][收到设备消息,设备: {}.{}, 方法: {}]", productKey, deviceName, message.getMethod()); - - // 4. 处理业务消息(认证已在连接时完成) handleBusinessRequest(message, productKey, deviceName); } catch (Exception e) { log.error("[processMessage][消息处理异常,客户端 ID: {},主题: {},错误: {}]", @@ -214,7 +239,7 @@ public class IotMqttUpstreamHandler { } // 4. 获取设备信息 - IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username); if (deviceInfo == null) { log.warn("[authenticateDevice][用户名格式不正确,客户端 ID: {},用户名: {}]", clientId, username); return false; @@ -245,6 +270,186 @@ public class IotMqttUpstreamHandler { } } + /** + * 处理 register 消息(设备动态注册,使用默认编解码器) + * + * @param clientId 客户端 ID + * @param topic 主题 + * @param payload 消息内容 + * @param endpoint MQTT 连接端点 + */ + private void processRegisterMessage(String clientId, String topic, byte[] payload, MqttEndpoint endpoint) { + // 1.1 基础检查 + if (ArrayUtil.isEmpty(payload)) { + return; + } + // 1.2 解析主题,获取 productKey 和 deviceName + String[] topicParts = topic.split("/"); + if (topicParts.length < 4 || StrUtil.hasBlank(topicParts[2], topicParts[3])) { + log.warn("[processRegisterMessage][topic({}) 格式不正确]", topic); + return; + } + String productKey = topicParts[2]; + String deviceName = topicParts[3]; + + // 2. 使用默认编解码器解码消息(设备可能未注册,无法获取 codecType) + IotDeviceMessage message; + try { + message = deviceMessageService.decodeDeviceMessage(payload, DEFAULT_CODEC_TYPE); + if (message == null) { + log.warn("[processRegisterMessage][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); + return; + } + } catch (Exception e) { + log.error("[processRegisterMessage][消息解码异常,客户端 ID: {},主题: {},错误: {}]", + clientId, topic, e.getMessage(), e); + return; + } + + // 3. 处理设备动态注册请求 + log.info("[processRegisterMessage][收到设备注册消息,设备: {}.{}, 方法: {}]", + productKey, deviceName, message.getMethod()); + try { + handleRegisterRequest(message, productKey, deviceName, endpoint); + } catch (Exception e) { + log.error("[processRegisterMessage][消息处理异常,客户端 ID: {},主题: {},错误: {}]", + clientId, topic, e.getMessage(), e); + } + } + + /** + * 处理设备动态注册请求(一型一密,不需要 deviceSecret) + * + * @param message 消息信息 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param endpoint MQTT 连接端点 + * @see 阿里云 - 一型一密 + */ + private void handleRegisterRequest(IotDeviceMessage message, String productKey, String deviceName, MqttEndpoint endpoint) { + String clientId = endpoint.clientIdentifier(); + try { + // 1. 解析注册参数 + IotDeviceRegisterReqDTO params = parseRegisterParams(message.getParams()); + if (params == null) { + log.warn("[handleRegisterRequest][注册参数解析失败,客户端 ID: {}]", clientId); + sendRegisterErrorResponse(endpoint, productKey, deviceName, message.getRequestId(), "注册参数不完整"); + return; + } + + // 2. 调用动态注册 API + CommonResult result = deviceApi.registerDevice(params); + if (result.isError()) { + log.warn("[handleRegisterRequest][注册失败,客户端 ID: {},错误: {}]", clientId, result.getMsg()); + sendRegisterErrorResponse(endpoint, productKey, deviceName, message.getRequestId(), result.getMsg()); + return; + } + + // 3. 发送成功响应(包含 deviceSecret) + sendRegisterSuccessResponse(endpoint, productKey, deviceName, message.getRequestId(), result.getData()); + log.info("[handleRegisterRequest][注册成功,设备名: {},客户端 ID: {}]", + params.getDeviceName(), clientId); + } catch (Exception e) { + log.error("[handleRegisterRequest][注册处理异常,客户端 ID: {}]", clientId, e); + sendRegisterErrorResponse(endpoint, productKey, deviceName, message.getRequestId(), "注册处理异常"); + } + } + + /** + * 解析注册参数 + * + * @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 paramMap = (Map) 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 endpoint MQTT 连接端点 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param requestId 请求 ID + * @param registerResp 注册响应 + */ + private void sendRegisterSuccessResponse(MqttEndpoint endpoint, String productKey, String deviceName, + 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, DEFAULT_CODEC_TYPE); + + // 3. 构建响应主题并发送(格式:/sys/{productKey}/{deviceName}/thing/auth/register_reply) + String replyTopic = IotMqttTopicUtils.buildTopicByMethod( + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), productKey, deviceName, true); + endpoint.publish(replyTopic, io.vertx.core.buffer.Buffer.buffer(encodedData), + MqttQoS.AT_LEAST_ONCE, false, false); + log.debug("[sendRegisterSuccessResponse][发送注册成功响应,主题: {}]", replyTopic); + } catch (Exception e) { + log.error("[sendRegisterSuccessResponse][发送注册成功响应异常,客户端 ID: {}]", + endpoint.clientIdentifier(), e); + } + } + + /** + * 发送注册错误响应 + * + * @param endpoint MQTT 连接端点 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param requestId 请求 ID + * @param errorMessage 错误消息 + */ + private void sendRegisterErrorResponse(MqttEndpoint endpoint, String productKey, String deviceName, + String requestId, String errorMessage) { + try { + // 1. 构建响应消息 + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), null, 400, errorMessage); + + // 2. 编码消息(使用默认编解码器,因为设备可能还未注册) + byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, DEFAULT_CODEC_TYPE); + + // 3. 构建响应主题并发送(格式:/sys/{productKey}/{deviceName}/thing/auth/register_reply) + String replyTopic = IotMqttTopicUtils.buildTopicByMethod( + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), productKey, deviceName, true); + endpoint.publish(replyTopic, io.vertx.core.buffer.Buffer.buffer(encodedData), + MqttQoS.AT_LEAST_ONCE, false, false); + log.debug("[sendRegisterErrorResponse][发送注册错误响应,主题: {}]", replyTopic); + } catch (Exception e) { + log.error("[sendRegisterErrorResponse][发送注册错误响应异常,客户端 ID: {}]", + endpoint.clientIdentifier(), e); + } + } + /** * 处理业务请求 */ @@ -257,9 +462,7 @@ public class IotMqttUpstreamHandler { /** * 注册连接 */ - private void registerConnection(MqttEndpoint endpoint, IotDeviceRespDTO device, - String clientId) { - + private void registerConnection(MqttEndpoint endpoint, IotDeviceRespDTO device, String clientId) { IotMqttConnectionManager.ConnectionInfo connectionInfo = new IotMqttConnectionManager.ConnectionInfo() .setDeviceId(device.getId()) .setProductKey(device.getProductKey()) @@ -267,7 +470,6 @@ public class IotMqttUpstreamHandler { .setClientId(clientId) .setAuthenticated(true) .setRemoteAddress(connectionManager.getEndpointAddress(endpoint)); - connectionManager.registerConnection(endpoint, device.getId(), connectionInfo); } @@ -296,15 +498,13 @@ public class IotMqttUpstreamHandler { IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); - log.info("[cleanupConnection][设备离线,设备 ID: {},设备名称: {}]", - connectionInfo.getDeviceId(), connectionInfo.getDeviceName()); + log.info("[cleanupConnection][设备离线,设备 ID: {},设备名称: {}]", connectionInfo.getDeviceId(), connectionInfo.getDeviceName()); } // 注销连接 connectionManager.unregisterConnection(endpoint); } catch (Exception e) { - log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", - endpoint.clientIdentifier(), e.getMessage()); + log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", endpoint.clientIdentifier(), e.getMessage()); } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java deleted file mode 100644 index 302824d6df..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java +++ /dev/null @@ -1,79 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; - -import cn.hutool.core.util.StrUtil; -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.mqttws.router.IotMqttWsDownstreamHandler; -import jakarta.annotation.PostConstruct; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT MQTT WebSocket 下行消息订阅器 - *

- * 订阅消息总线的设备下行消息,并通过 WebSocket 发送到设备 - * - * @author 芋道源码 - */ -@Slf4j -public class IotMqttWsDownstreamSubscriber implements IotMessageSubscriber { - - private final IotMqttWsUpstreamProtocol upstreamProtocol; - private final IotMqttWsDownstreamHandler downstreamHandler; - private final IotMessageBus messageBus; - - public IotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol upstreamProtocol, - IotMqttWsDownstreamHandler downstreamHandler, - IotMessageBus messageBus) { - this.upstreamProtocol = upstreamProtocol; - this.downstreamHandler = downstreamHandler; - this.messageBus = messageBus; - } - - @PostConstruct - public void init() { - messageBus.register(this); - log.info("[init][MQTT WebSocket 下行消息订阅器已启动,topic: {}]", getTopic()); - } - - @Override - public String getTopic() { - return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(upstreamProtocol.getServerId()); - } - - @Override - public String getGroup() { - // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group - return getTopic(); - } - - @Override - public void onMessage(IotDeviceMessage message) { - log.debug("[onMessage][收到下行消息,deviceId: {},method: {}]", - message.getDeviceId(), message.getMethod()); - try { - // 1. 校验 - String method = message.getMethod(); - if (StrUtil.isBlank(method)) { - log.warn("[onMessage][消息方法为空,deviceId: {}]", message.getDeviceId()); - return; - } - - // 2. 委托给下行处理器处理业务逻辑 - boolean success = downstreamHandler.handleDownstreamMessage(message); - if (success) { - log.debug("[onMessage][下行消息处理成功,deviceId: {},method: {}]", - message.getDeviceId(), message.getMethod()); - } else { - log.warn("[onMessage][下行消息处理失败,deviceId: {},method: {}]", - message.getDeviceId(), message.getMethod()); - } - } catch (Exception e) { - log.error("[onMessage][处理下行消息失败,deviceId: {},method: {}]", - message.getDeviceId(), message.getMethod(), e); - } - } - -} - diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java deleted file mode 100644 index 6944d47dad..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java +++ /dev/null @@ -1,146 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; - -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.mqttws.manager.IotMqttWsConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsUpstreamHandler; -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.http.ServerWebSocket; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT 网关 MQTT WebSocket 协议:接收设备上行消息 - *

- * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持: - * - 标准 MQTT 3.1.1 协议 - * - WebSocket 协议升级 - * - SSL/TLS 加密(wss://) - * - 设备认证与连接管理 - * - QoS 0/1/2 消息质量保证 - * - * @author 芋道源码 - */ -@Slf4j -public class IotMqttWsUpstreamProtocol { - - private final IotGatewayProperties.MqttWsProperties mqttWsProperties; - - private final IotDeviceMessageService messageService; - - private final IotMqttWsConnectionManager connectionManager; - - private final Vertx vertx; - - @Getter - private final String serverId; - - private HttpServer httpServer; - - public IotMqttWsUpstreamProtocol(IotGatewayProperties.MqttWsProperties mqttWsProperties, - IotDeviceMessageService messageService, - IotMqttWsConnectionManager connectionManager, - Vertx vertx) { - this.mqttWsProperties = mqttWsProperties; - this.messageService = messageService; - this.connectionManager = connectionManager; - this.vertx = vertx; - this.serverId = IotDeviceMessageUtils.generateServerId(mqttWsProperties.getPort()); - } - - @PostConstruct - public void start() { - // 创建 HTTP 服务器选项 - HttpServerOptions options = new HttpServerOptions() - .setPort(mqttWsProperties.getPort()) - .setIdleTimeout(mqttWsProperties.getKeepAliveTimeoutSeconds()) - .setMaxWebSocketFrameSize(mqttWsProperties.getMaxFrameSize()) - .setMaxWebSocketMessageSize(mqttWsProperties.getMaxMessageSize()) - // 配置 WebSocket 子协议支持 - .addWebSocketSubProtocol(mqttWsProperties.getSubProtocol()); - - // 配置 SSL(如果启用) - if (Boolean.TRUE.equals(mqttWsProperties.getSslEnabled())) { - options.setSsl(true) - .setKeyCertOptions(mqttWsProperties.getSslOptions().getKeyCertOptions()) - .setTrustOptions(mqttWsProperties.getSslOptions().getTrustOptions()); - log.info("[start][MQTT WebSocket 已启用 SSL/TLS (wss://)]"); - } - - // 创建 HTTP 服务器 - httpServer = vertx.createHttpServer(options); - - // 设置 WebSocket 处理器 - httpServer.webSocketHandler(this::handleWebSocketConnection); - - // 启动服务器 - try { - httpServer.listen().result(); - log.info("[start][IoT 网关 MQTT WebSocket 协议启动成功,端口: {},路径: {},支持子协议: {}]", - mqttWsProperties.getPort(), mqttWsProperties.getPath(), - "mqtt, mqttv3.1, " + mqttWsProperties.getSubProtocol()); - } catch (Exception e) { - log.error("[start][IoT 网关 MQTT WebSocket 协议启动失败]", e); - throw e; - } - } - - @PreDestroy - public void stop() { - if (httpServer != null) { - try { - // 关闭所有连接 - connectionManager.closeAllConnections(); - - // 关闭服务器 - httpServer.close().result(); - log.info("[stop][IoT 网关 MQTT WebSocket 协议已停止]"); - } catch (Exception e) { - log.error("[stop][IoT 网关 MQTT WebSocket 协议停止失败]", e); - } - } - } - - /** - * 处理 WebSocket 连接请求 - * - * @param socket WebSocket 连接 - */ - private void handleWebSocketConnection(ServerWebSocket socket) { - String path = socket.path(); - String subProtocol = socket.subProtocol(); - - log.info("[handleWebSocketConnection][收到 WebSocket 连接请求,path: {},subProtocol: {},remoteAddress: {}]", - path, subProtocol, socket.remoteAddress()); - - // 验证路径 - if (!mqttWsProperties.getPath().equals(path)) { - log.warn("[handleWebSocketConnection][WebSocket 路径不匹配,拒绝连接,path: {},期望: {}]", - path, mqttWsProperties.getPath()); - socket.close(); - return; - } - - // 验证子协议 - // Vert.x 已经自动进行了子协议协商,这里只需要验证是否为 MQTT 相关协议 - if (subProtocol != null && !subProtocol.startsWith("mqtt")) { - log.warn("[handleWebSocketConnection][WebSocket 子协议不支持,拒绝连接,subProtocol: {}]", subProtocol); - socket.close(); - return; - } - - log.info("[handleWebSocketConnection][WebSocket 连接已接受,remoteAddress: {},subProtocol: {}]", - socket.remoteAddress(), subProtocol); - - // 创建处理器并处理连接 - IotMqttWsUpstreamHandler handler = new IotMqttWsUpstreamHandler( - this, messageService, connectionManager); - handler.handle(socket); - } - -} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java deleted file mode 100644 index fee3e359c8..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java +++ /dev/null @@ -1,259 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager; - -import cn.hutool.core.collection.CollUtil; -import io.vertx.core.http.ServerWebSocket; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArraySet; - -/** - * IoT MQTT WebSocket 连接管理器 - * - * @author 芋道源码 - */ -@Slf4j -@Component -public class IotMqttWsConnectionManager { - - /** - * 存储设备连接 - * Key: 设备标识(deviceKey) - * Value: WebSocket 连接 - */ - private final Map connections = new ConcurrentHashMap<>(); - - /** - * 存储设备标识与 Socket ID 的映射 - * Key: 设备标识(deviceKey) - * Value: Socket ID(UUID) - */ - private final Map deviceKeyToSocketId = new ConcurrentHashMap<>(); - - /** - * 存储 Socket ID 与设备标识的映射 - * Key: Socket ID(UUID) - * Value: 设备标识(deviceKey) - */ - private final Map socketIdToDeviceKey = new ConcurrentHashMap<>(); - - /** - * 存储设备订阅的主题 - * Key: 设备标识(deviceKey) - * Value: 订阅的主题集合 - */ - private final Map> deviceSubscriptions = new ConcurrentHashMap<>(); - - /** - * 添加连接 - * - * @param deviceKey 设备标识 - * @param socket WebSocket 连接 - * @param socketId Socket ID(UUID) - */ - public void addConnection(String deviceKey, ServerWebSocket socket, String socketId) { - connections.put(deviceKey, socket); - deviceKeyToSocketId.put(deviceKey, socketId); - socketIdToDeviceKey.put(socketId, deviceKey); - log.info("[addConnection][设备连接已添加,deviceKey: {},socketId: {},当前连接数: {}]", - deviceKey, socketId, connections.size()); - } - - /** - * 移除连接 - * - * @param deviceKey 设备标识 - */ - public void removeConnection(String deviceKey) { - ServerWebSocket socket = connections.remove(deviceKey); - String socketId = deviceKeyToSocketId.remove(deviceKey); - if (socketId != null) { - socketIdToDeviceKey.remove(socketId); - } - if (socket != null) { - log.info("[removeConnection][设备连接已移除,deviceKey: {},socketId: {},当前连接数: {}]", - deviceKey, socketId, connections.size()); - } - } - - /** - * 根据 Socket ID 移除连接 - * - * @param socketId WebSocket 文本框架 ID - */ - public void removeConnectionBySocketId(String socketId) { - String deviceKey = socketIdToDeviceKey.remove(socketId); - if (deviceKey != null) { - connections.remove(deviceKey); - log.info("[removeConnectionBySocketId][设备连接已移除,socketId: {},deviceKey: {},当前连接数: {}]", - socketId, deviceKey, connections.size()); - } - } - - /** - * 获取连接 - * - * @param deviceKey 设备标识 - * @return WebSocket 连接 - */ - public ServerWebSocket getConnection(String deviceKey) { - return connections.get(deviceKey); - } - - /** - * 根据 Socket ID 获取设备标识 - * - * @param socketId WebSocket 文本框架 ID - * @return 设备标识 - */ - public String getDeviceKeyBySocketId(String socketId) { - return socketIdToDeviceKey.get(socketId); - } - - /** - * 检查设备是否在线 - * - * @param deviceKey 设备标识 - * @return 是否在线 - */ - public boolean isOnline(String deviceKey) { - return connections.containsKey(deviceKey); - } - - /** - * 获取当前连接数 - * - * @return 连接数 - */ - public int getConnectionCount() { - return connections.size(); - } - - /** - * 关闭所有连接 - */ - public void closeAllConnections() { - connections.forEach((deviceKey, socket) -> { - try { - socket.close(); - log.info("[closeAllConnections][关闭设备连接,deviceKey: {}]", deviceKey); - } catch (Exception e) { - log.error("[closeAllConnections][关闭设备连接失败,deviceKey: {}]", deviceKey, e); - } - }); - connections.clear(); - deviceKeyToSocketId.clear(); - socketIdToDeviceKey.clear(); - deviceSubscriptions.clear(); - log.info("[closeAllConnections][所有连接已关闭]"); - } - - // ==================== 订阅管理方法 ==================== - - /** - * 添加订阅 - * - * @param deviceKey 设备标识 - * @param topic 订阅主题 - */ - public void addSubscription(String deviceKey, String topic) { - deviceSubscriptions.computeIfAbsent(deviceKey, k -> new CopyOnWriteArraySet<>()).add(topic); - log.debug("[addSubscription][设备订阅主题,deviceKey: {},topic: {}]", deviceKey, topic); - } - - /** - * 移除订阅 - * - * @param deviceKey 设备标识 - * @param topic 订阅主题 - */ - public void removeSubscription(String deviceKey, String topic) { - Set topics = deviceSubscriptions.get(deviceKey); - if (topics != null) { - topics.remove(topic); - log.debug("[removeSubscription][设备取消订阅,deviceKey: {},topic: {}]", deviceKey, topic); - } - } - - /** - * 检查设备是否订阅了指定主题 - * 支持 MQTT 通配符匹配(+ 和 #) - * - * @param deviceKey 设备标识 - * @param topic 发布主题 - * @return 是否匹配 - */ - public boolean isSubscribed(String deviceKey, String topic) { - Set subscriptions = deviceSubscriptions.get(deviceKey); - if (CollUtil.isEmpty(subscriptions)) { - return false; - } - - // 检查是否有匹配的订阅 - for (String subscription : subscriptions) { - if (topicMatches(subscription, topic)) { - return true; - } - } - return false; - } - - /** - * 获取设备的所有订阅 - * - * @param deviceKey 设备标识 - * @return 订阅主题集合 - */ - public Set getSubscriptions(String deviceKey) { - return deviceSubscriptions.get(deviceKey); - } - - // TODO @haohao:这个方法,是不是也可以考虑抽到 IotMqttTopicUtils 里面去哈;感觉更简洁一点? - /** - * MQTT 主题匹配 - * 支持通配符: - * - +:匹配单层主题 - * - #:匹配多层主题(必须在末尾) - * - * @param subscription 订阅主题(可能包含通配符) - * @param topic 发布主题(不包含通配符) - * @return 是否匹配 - */ - private boolean topicMatches(String subscription, String topic) { - // 完全匹配 - if (subscription.equals(topic)) { - return true; - } - - // 不包含通配符 - // TODO @haohao:这里要不要枚举下哈;+ # - if (!subscription.contains("+") && !subscription.contains("#")) { - return false; - } - - String[] subscriptionParts = subscription.split("/"); - String[] topicParts = topic.split("/"); - int i = 0; - for (; i < subscriptionParts.length && i < topicParts.length; i++) { - String subPart = subscriptionParts[i]; - String topicPart = topicParts[i]; - - // # 匹配剩余所有层级,且必须在末尾 - if (subPart.equals("#")) { - return i == subscriptionParts.length - 1; - } - - // 不是通配符且不匹配 - if (!subPart.equals("+") && !subPart.equals(topicPart)) { - return false; - } - } - - // 检查是否都匹配完 - return i == subscriptionParts.length && i == topicParts.length; - } - -} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java deleted file mode 100644 index b9af4afe3a..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java +++ /dev/null @@ -1,15 +0,0 @@ -/** - * IoT 网关 MQTT WebSocket 协议实现 - *

- * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持: - * - 标准 MQTT 3.1.1 协议 - * - WebSocket 协议升级 - * - SSL/TLS 加密(wss://) - * - 设备认证与连接管理 - * - QoS 0/1/2 消息质量保证 - * - 双向消息通信(上行/下行) - * - * @author 芋道源码 - */ -package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; - diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java deleted file mode 100644 index 3aeb6c5c48..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java +++ /dev/null @@ -1,221 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router; - -import cn.hutool.core.util.StrUtil; -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.protocol.mqttws.manager.IotMqttWsConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; -import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; -import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.http.ServerWebSocket; -import lombok.extern.slf4j.Slf4j; - -import java.util.concurrent.atomic.AtomicInteger; - -/** - * IoT MQTT WebSocket 下行消息处理器 - *

- * 处理从消息总线发送到设备的消息,包括: - * - 属性设置 - * - 服务调用 - * - 事件通知 - * - * @author 芋道源码 - */ -@Slf4j -public class IotMqttWsDownstreamHandler { - - private final IotDeviceMessageService deviceMessageService; - - private final IotDeviceService deviceService; - - private final IotMqttWsConnectionManager connectionManager; - - /** - * 消息 ID 生成器(用于发布消息) - */ - private final AtomicInteger messageIdGenerator = new AtomicInteger(1); - - public IotMqttWsDownstreamHandler(IotDeviceMessageService deviceMessageService, - IotDeviceService deviceService, - IotMqttWsConnectionManager connectionManager) { - this.deviceMessageService = deviceMessageService; - this.deviceService = deviceService; - this.connectionManager = connectionManager; - } - - /** - * 处理下行消息 - * - * @param message 设备消息 - * @return 是否处理成功 - */ - public boolean handleDownstreamMessage(IotDeviceMessage message) { - try { - // 1. 基础校验 - if (message == null || message.getDeviceId() == null) { - log.warn("[handleDownstreamMessage][消息或设备 ID 为空,忽略处理]"); - return false; - } - - // 2. 获取设备信息 - IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); - if (deviceInfo == null) { - log.warn("[handleDownstreamMessage][设备不存在,设备 ID:{}]", message.getDeviceId()); - return false; - } - - // 3. 构建设备标识 - String deviceKey = deviceInfo.getProductKey() + ":" + deviceInfo.getDeviceName(); - - // 4. 检查设备是否在线 - if (!connectionManager.isOnline(deviceKey)) { - log.warn("[handleDownstreamMessage][设备离线,无法发送消息,deviceKey: {}]", deviceKey); - return false; - } - - // 5. 构建主题 - String topic = buildDownstreamTopic(message, deviceInfo); - if (StrUtil.isBlank(topic)) { - log.warn("[handleDownstreamMessage][主题构建失败,设备 ID:{},方法:{}]", - message.getDeviceId(), message.getMethod()); - return false; - } - - // 6. 检查设备是否订阅了该主题 - if (!connectionManager.isSubscribed(deviceKey, topic)) { - log.warn("[handleDownstreamMessage][设备未订阅该主题,deviceKey: {},topic: {}]", deviceKey, topic); - return false; - } - - // 8. 编码消息 - byte[] payload = deviceMessageService.encodeDeviceMessage(message, - deviceInfo.getProductKey(), deviceInfo.getDeviceName()); - if (payload == null || payload.length == 0) { - log.warn("[handleDownstreamMessage][消息编码失败,设备 ID:{}]", message.getDeviceId()); - return false; - } - - // 9. 发送消息到设备 - return sendMessageToDevice(deviceKey, topic, payload, 1); - } catch (Exception e) { - if (message != null) { - log.error("[handleDownstreamMessage][处理下行消息异常,设备 ID:{},错误:{}]", - message.getDeviceId(), e.getMessage(), e); - } - return false; - } - } - - /** - * 构建下行消息主题 - * - * @param message 设备消息 - * @param deviceInfo 设备信息 - * @return 主题 - */ - private String buildDownstreamTopic(IotDeviceMessage message, IotDeviceRespDTO deviceInfo) { - String method = message.getMethod(); - if (StrUtil.isBlank(method)) { - return null; - } - - // 使用工具类构建主题,支持回复消息处理 - boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); - return IotMqttTopicUtils.buildTopicByMethod(method, deviceInfo.getProductKey(), - deviceInfo.getDeviceName(), isReply); - } - - /** - * 发送消息到设备 - * - * @param deviceKey 设备标识(productKey:deviceName) - * @param topic 主题 - * @param payload 消息内容 - * @param qos QoS 级别 - * @return 是否发送成功 - */ - private boolean sendMessageToDevice(String deviceKey, String topic, byte[] payload, int qos) { - // 获取设备连接 - ServerWebSocket socket = connectionManager.getConnection(deviceKey); - if (socket == null) { - log.warn("[sendMessageToDevice][设备未连接,deviceKey: {}]", deviceKey); - return false; - } - - try { - int messageId = qos > 0 ? generateMessageId() : 0; - - // 手动编码 MQTT PUBLISH 消息 - io.netty.buffer.ByteBuf byteBuf = io.netty.buffer.Unpooled.buffer(); - - // 固定头:消息类型(PUBLISH=3) + DUP(0) + QoS + RETAIN - int fixedHeaderByte1 = 0x30 | (qos << 1); // PUBLISH类型 - byteBuf.writeByte(fixedHeaderByte1); - - // 计算剩余长度 - int topicLength = topic.getBytes().length; - int remainingLength = 2 + topicLength + (qos > 0 ? 2 : 0) + payload.length; - - // 写入剩余长度(简化版本,假设小于 128 字节) - if (remainingLength < 128) { - byteBuf.writeByte(remainingLength); - } else { - // 处理大于 127 的情况 - int x = remainingLength; - do { - int encodedByte = x % 128; - x = x / 128; - if (x > 0) { - encodedByte = encodedByte | 128; - } - byteBuf.writeByte(encodedByte); - } while (x > 0); - } - - // 可变头:主题名称 - byteBuf.writeShort(topicLength); - byteBuf.writeBytes(topic.getBytes()); - - // 可变头:消息 ID(仅 QoS > 0 时) - if (qos > 0) { - byteBuf.writeShort(messageId); - } - - // 有效载荷 - byteBuf.writeBytes(payload); - - // 发送 - byte[] bytes = new byte[byteBuf.readableBytes()]; - byteBuf.readBytes(bytes); - byteBuf.release(); - socket.writeBinaryMessage(Buffer.buffer(bytes)); - - log.info("[sendMessageToDevice][消息已发送到设备,deviceKey: {},topic: {},qos: {},messageId: {}]", - deviceKey, topic, qos, messageId); - return true; - } catch (Exception e) { - log.error("[sendMessageToDevice][发送消息到设备失败,deviceKey: {},topic: {}]", deviceKey, topic, e); - return false; - } - } - - /** - * 生成消息 ID - * - * @return 消息 ID - */ - private int generateMessageId() { - int id = messageIdGenerator.getAndIncrement(); - // MQTT 消息 ID 范围是 1-65535 - // TODO @haohao:并发可能有问题; - if (id > 65535) { - messageIdGenerator.set(1); - return 1; - } - return id; - } - -} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java deleted file mode 100644 index d11d109502..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java +++ /dev/null @@ -1,753 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router; - -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.module.iot.core.biz.IotDeviceCommonApi; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; -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.IotDeviceAuthUtils; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; -import io.netty.channel.embedded.EmbeddedChannel; -import io.netty.handler.codec.DecoderException; -import io.netty.handler.codec.mqtt.*; -import io.vertx.core.buffer.Buffer; -import io.vertx.core.http.ServerWebSocket; -import lombok.extern.slf4j.Slf4j; - -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * IoT MQTT WebSocket 上行消息处理器 - *

- * 处理来自设备的 MQTT 消息,包括: - * - CONNECT:设备连接认证 - * - PUBLISH:设备发布消息 - * - SUBSCRIBE:设备订阅主题 - * - UNSUBSCRIBE:设备取消订阅 - * - PINGREQ:心跳请求 - * - DISCONNECT:设备断开连接 - * - * @author 芋道源码 - */ -@Slf4j -public class IotMqttWsUpstreamHandler { - - private final IotMqttWsUpstreamProtocol upstreamProtocol; - - private final IotDeviceCommonApi deviceApi; - - private final IotDeviceMessageService messageService; - - private final IotMqttWsConnectionManager connectionManager; - - /** - * 存储 WebSocket 连接到 Socket ID 的映射 - * Key: WebSocket 对象 - * Value: Socket ID(UUID) - */ - private final ConcurrentHashMap socketIdMap = new ConcurrentHashMap<>(); - - /** - * 存储 Socket ID 对应的设备信息 - * Key: Socket ID(UUID) - * Value: 设备信息 - */ - private final ConcurrentHashMap socketDeviceMap = new ConcurrentHashMap<>(); - - /** - * 存储设备的消息 ID 生成器(用于 QoS > 0 的消息) - */ - private final ConcurrentHashMap deviceMessageIdMap = new ConcurrentHashMap<>(); - - /** - * MQTT 解码通道(用于解析 WebSocket 中的 MQTT 二进制消息) - */ - private final ThreadLocal decoderChannelThreadLocal = ThreadLocal - .withInitial(() -> new EmbeddedChannel(new MqttDecoder())); - - /** - * MQTT 编码通道(用于编码 MQTT 响应消息) - */ - private final ThreadLocal encoderChannelThreadLocal = ThreadLocal - .withInitial(() -> new EmbeddedChannel(MqttEncoder.INSTANCE)); - - public IotMqttWsUpstreamHandler(IotMqttWsUpstreamProtocol upstreamProtocol, - IotDeviceMessageService messageService, - IotMqttWsConnectionManager connectionManager) { - this.upstreamProtocol = upstreamProtocol; - this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); - this.messageService = messageService; - this.connectionManager = connectionManager; - } - - /** - * 处理 WebSocket 连接 - * - * @param socket WebSocket 连接 - */ - public void handle(ServerWebSocket socket) { - // 生成唯一的 Socket ID(因为 MQTT 使用二进制协议,textHandlerID() 会返回 null) - String socketId = IdUtil.simpleUUID(); - socketIdMap.put(socket, socketId); - - log.info("[handle][WebSocket 连接建立,socketId: {},remoteAddress: {}]", - socketId, socket.remoteAddress()); - - // 设置二进制数据处理器 - socket.binaryMessageHandler(buffer -> { - try { - handleMqttMessage(socket, buffer); - } catch (Exception e) { - log.error("[handle][处理 MQTT 消息异常,socketId: {}]", socketId, e); - socket.close(); - } - }); - - // 设置关闭处理器 - socket.closeHandler(v -> { - socketIdMap.remove(socket); - IotDeviceRespDTO device = socketDeviceMap.remove(socketId); - if (device != null) { - String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); - connectionManager.removeConnection(deviceKey); - deviceMessageIdMap.remove(deviceKey); - // 发送设备离线消息 - sendOfflineMessage(device); - log.info("[handle][WebSocket 连接关闭,deviceKey: {},socketId: {}]", deviceKey, socketId); - } - }); - - // 设置异常处理器 - socket.exceptionHandler(e -> { - log.error("[handle][WebSocket 连接异常,socketId: {}]", socketId, e); - socketIdMap.remove(socket); - IotDeviceRespDTO device = socketDeviceMap.remove(socketId); - if (device != null) { - String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); - connectionManager.removeConnection(deviceKey); - deviceMessageIdMap.remove(deviceKey); - } - socket.close(); - }); - } - - /** - * 处理 MQTT 消息 - * - * @param socket WebSocket 连接 - * @param buffer 消息缓冲区 - */ - private void handleMqttMessage(ServerWebSocket socket, Buffer buffer) { - String socketId = socketIdMap.get(socket); - ByteBuf byteBuf = Unpooled.wrappedBuffer(buffer.getBytes()); - - try { - // 使用 EmbeddedChannel 解码 MQTT 消息 - EmbeddedChannel decoderChannel = decoderChannelThreadLocal.get(); - decoderChannel.writeInbound(byteBuf.retain()); - - // 读取解码后的消息 - MqttMessage mqttMessage = decoderChannel.readInbound(); - if (mqttMessage == null) { - log.warn("[handleMqttMessage][MQTT 消息解码失败,socketId: {}]", socketId); - return; - } - - MqttMessageType messageType = mqttMessage.fixedHeader().messageType(); - log.debug("[handleMqttMessage][收到 MQTT 消息,类型: {},socketId: {}]", messageType, socketId); - - // 根据消息类型分发处理 - switch (messageType) { - case CONNECT: - handleConnect(socket, (MqttConnectMessage) mqttMessage); - break; - case PUBLISH: - handlePublish(socket, (MqttPublishMessage) mqttMessage); - break; - case PUBACK: - handlePubAck(socket, mqttMessage); - break; - case PUBREC: - handlePubRec(socket, mqttMessage); - break; - case PUBREL: - handlePubRel(socket, mqttMessage); - break; - case PUBCOMP: - handlePubComp(socket, mqttMessage); - break; - case SUBSCRIBE: - handleSubscribe(socket, (MqttSubscribeMessage) mqttMessage); - break; - case UNSUBSCRIBE: - handleUnsubscribe(socket, (MqttUnsubscribeMessage) mqttMessage); - break; - case PINGREQ: - handlePingReq(socket); - break; - case DISCONNECT: - handleDisconnect(socket); - break; - default: - log.warn("[handleMqttMessage][不支持的消息类型: {},socketId: {}]", messageType, socketId); - } - } catch (DecoderException e) { - log.error("[handleMqttMessage][MQTT 消息解码异常,socketId: {}]", socketId, e); - socket.close(); - } catch (Exception e) { - log.error("[handleMqttMessage][处理 MQTT 消息失败,socketId: {}]", socketId, e); - socket.close(); - } finally { - byteBuf.release(); - } - } - - /** - * 处理 CONNECT 消息(设备认证) - */ - private void handleConnect(ServerWebSocket socket, MqttConnectMessage message) { - String socketId = socketIdMap.get(socket); - try { - // 1. 解析 CONNECT 消息 - MqttConnectPayload payload = message.payload(); - String clientId = payload.clientIdentifier(); - String username = payload.userName(); - String password = payload.passwordInBytes() != null - ? new String(payload.passwordInBytes(), StandardCharsets.UTF_8) - : null; - - log.info("[handleConnect][收到 CONNECT 消息,clientId: {},username: {},socketId: {}]", - clientId, username, socketId); - - // 2. 设备认证 - IotDeviceRespDTO device = authenticateDevice(clientId, username, password); - if (device == null) { - log.warn("[handleConnect][设备认证失败,clientId: {},socketId: {}]", clientId, socketId); - sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD); - socket.close(); - return; - } - - // 3. 保存设备信息 - socketDeviceMap.put(socketId, device); - String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); - connectionManager.addConnection(deviceKey, socket, socketId); - deviceMessageIdMap.put(deviceKey, new AtomicInteger(1)); - - log.info("[handleConnect][设备认证成功,deviceId: {},deviceKey: {},socketId: {}]", - device.getId(), deviceKey, socketId); - - // 4. 发送 CONNACK - sendConnAck(socket, MqttConnectReturnCode.CONNECTION_ACCEPTED); - - // 5. 发送设备上线消息 - sendOnlineMessage(device); - } catch (Exception e) { - log.error("[handleConnect][处理 CONNECT 消息失败,socketId: {}]", socketId, e); - sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE); - socket.close(); - } - } - - /** - * 处理 PUBLISH 消息(设备发布消息) - */ - private void handlePublish(ServerWebSocket socket, MqttPublishMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - - if (device == null) { - log.warn("[handlePublish][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - try { - // 1. 解析 PUBLISH 消息 - MqttFixedHeader fixedHeader = message.fixedHeader(); - MqttPublishVariableHeader variableHeader = message.variableHeader(); - ByteBuf payload = message.payload(); - - String topic = variableHeader.topicName(); - int messageId = variableHeader.packetId(); - MqttQoS qos = fixedHeader.qosLevel(); - - log.debug("[handlePublish][收到 PUBLISH 消息,topic: {},messageId: {},QoS: {},deviceId: {}]", - topic, messageId, qos, device.getId()); - - // 2. 读取 payload - byte[] payloadBytes = new byte[payload.readableBytes()]; - payload.readBytes(payloadBytes); - - // 3. 解码并发送消息 - IotDeviceMessage deviceMessage = messageService.decodeDeviceMessage(payloadBytes, - device.getProductKey(), device.getDeviceName()); - if (deviceMessage != null) { - deviceMessage.setServerId(upstreamProtocol.getServerId()); - messageService.sendDeviceMessage(deviceMessage, device.getProductKey(), - device.getDeviceName(), upstreamProtocol.getServerId()); - log.info("[handlePublish][设备消息已发送,method: {},deviceId: {}]", - deviceMessage.getMethod(), device.getId()); - } - - // 4. 根据 QoS 级别发送相应的确认消息 - if (qos == MqttQoS.AT_LEAST_ONCE) { - // QoS 1:发送 PUBACK - sendPubAck(socket, messageId); - } else if (qos == MqttQoS.EXACTLY_ONCE) { - // QoS 2:发送 PUBREC - sendPubRec(socket, messageId); - } - // QoS 0 无需确认 - } catch (Exception e) { - log.error("[handlePublish][处理 PUBLISH 消息失败,deviceId: {}]", device.getId(), e); - } - } - - /** - * 处理 PUBACK 消息(QoS 1 确认) - */ - private void handlePubAck(ServerWebSocket socket, MqttMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { - log.warn("[handlePubAck][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); - log.debug("[handlePubAck][收到 PUBACK,messageId: {},deviceId: {}]", messageId, device.getId()); - } - - /** - * 处理 PUBREC 消息(QoS 2 第一步确认) - */ - private void handlePubRec(ServerWebSocket socket, MqttMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { - log.warn("[handlePubRec][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); - log.debug("[handlePubRec][收到 PUBREC,messageId: {},deviceId: {}]", messageId, device.getId()); - // 发送 PUBREL - sendPubRel(socket, messageId); - } - - /** - * 处理 PUBREL 消息(QoS 2 第二步) - */ - private void handlePubRel(ServerWebSocket socket, MqttMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - - if (device == null) { - log.warn("[handlePubRel][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); - log.debug("[handlePubRel][收到 PUBREL,messageId: {},deviceId: {}]", messageId, device.getId()); - // 发送 PUBCOMP - sendPubComp(socket, messageId); - } - - /** - * 处理 PUBCOMP 消息(QoS 2 完成确认) - */ - private void handlePubComp(ServerWebSocket socket, MqttMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - - if (device == null) { - log.warn("[handlePubComp][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); - log.debug("[handlePubComp][收到 PUBCOMP,messageId: {},deviceId: {}]", messageId, device.getId()); - } - - /** - * 处理 SUBSCRIBE 消息(设备订阅主题) - */ - private void handleSubscribe(ServerWebSocket socket, MqttSubscribeMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { - log.warn("[handleSubscribe][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - try { - // 1. 解析 SUBSCRIBE 消息 - int messageId = message.variableHeader().messageId(); - MqttSubscribePayload payload = message.payload(); - String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); - - log.info("[handleSubscribe][设备订阅请求,deviceKey: {},messageId: {},主题数量: {}]", - deviceKey, messageId, payload.topicSubscriptions().size()); - - // 2. 构建 QoS 列表并记录订阅信息 - int[] grantedQosList = new int[payload.topicSubscriptions().size()]; - for (int i = 0; i < payload.topicSubscriptions().size(); i++) { - MqttTopicSubscription subscription = payload.topicSubscriptions().get(i); - String topic = subscription.topicFilter(); - grantedQosList[i] = subscription.qualityOfService().value(); - - // 记录订阅信息到连接管理器 - connectionManager.addSubscription(deviceKey, topic); - - log.info("[handleSubscribe][订阅主题: {},QoS: {},deviceKey: {}]", - topic, subscription.qualityOfService(), deviceKey); - } - - // 3. 发送 SUBACK - sendSubAck(socket, messageId, grantedQosList); - } catch (Exception e) { - log.error("[handleSubscribe][处理 SUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); - } - } - - /** - * 处理 UNSUBSCRIBE 消息(设备取消订阅) - */ - private void handleUnsubscribe(ServerWebSocket socket, MqttUnsubscribeMessage message) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { - log.warn("[handleUnsubscribe][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - try { - // 1. 解析 UNSUBSCRIBE 消息 - int messageId = message.variableHeader().messageId(); - MqttUnsubscribePayload payload = message.payload(); - String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); - - log.info("[handleUnsubscribe][设备取消订阅,deviceKey: {},messageId: {},主题数量: {}]", - deviceKey, messageId, payload.topics().size()); - - // 2. 移除订阅信息 - for (String topic : payload.topics()) { - connectionManager.removeSubscription(deviceKey, topic); - log.info("[handleUnsubscribe][取消订阅主题: {},deviceKey: {}]", topic, deviceKey); - } - - // 3. 发送 UNSUBACK - sendUnsubAck(socket, messageId); - } catch (Exception e) { - log.error("[handleUnsubscribe][处理 UNSUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); - } - } - - /** - * 处理 PINGREQ 消息(心跳请求) - */ - private void handlePingReq(ServerWebSocket socket) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.get(socketId); - if (device == null) { - log.warn("[handlePingReq][设备未认证,socketId: {}]", socketId); - socket.close(); - return; - } - - log.debug("[handlePingReq][收到心跳请求,deviceId: {}]", device.getId()); - // 发送 PINGRESP - sendPingResp(socket); - } - - /** - * 处理 DISCONNECT 消息(设备断开连接) - */ - private void handleDisconnect(ServerWebSocket socket) { - String socketId = socketIdMap.get(socket); - IotDeviceRespDTO device = socketDeviceMap.remove(socketId); - if (device != null) { - String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); - connectionManager.removeConnection(deviceKey); - deviceMessageIdMap.remove(deviceKey); - sendOfflineMessage(device); - log.info("[handleDisconnect][设备主动断开连接,deviceKey: {}]", deviceKey); - } - - socket.close(); - } - - // ==================== 设备认证和状态相关方法 ==================== - - /** - * 设备认证 - */ - private IotDeviceRespDTO authenticateDevice(String clientId, String username, String password) { - try { - // 1. 参数校验 - if (StrUtil.hasEmpty(clientId, username, password)) { - log.warn("[authenticateDevice][认证参数不完整,clientId: {},username: {}]", clientId, username); - return null; - } - - // 2. 构建认证参数并调用 API - IotDeviceAuthReqDTO authParams = new IotDeviceAuthReqDTO() - .setClientId(clientId) - .setUsername(username) - .setPassword(password); - - CommonResult authResult = deviceApi.authDevice(authParams); - if (!authResult.isSuccess() || !BooleanUtil.isTrue(authResult.getData())) { - log.warn("[authenticateDevice][设备认证失败,clientId: {}]", clientId); - return null; - } - - // 3. 获取设备信息 - IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); - if (deviceInfo == null) { - log.warn("[authenticateDevice][用户名格式不正确,username: {}]", username); - return null; - } - - IotDeviceGetReqDTO getReqDTO = new IotDeviceGetReqDTO() - .setProductKey(deviceInfo.getProductKey()) - .setDeviceName(deviceInfo.getDeviceName()); - - CommonResult deviceResult = deviceApi.getDevice(getReqDTO); - if (!deviceResult.isSuccess() || deviceResult.getData() == null) { - log.warn("[authenticateDevice][获取设备信息失败,username: {}]", username); - return null; - } - - return deviceResult.getData(); - } catch (Exception e) { - log.error("[authenticateDevice][设备认证异常,clientId: {}]", clientId, e); - return null; - } - } - - /** - * 发送设备上线消息 - */ - private void sendOnlineMessage(IotDeviceRespDTO device) { - try { - IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); - messageService.sendDeviceMessage(onlineMessage, device.getProductKey(), - device.getDeviceName(), upstreamProtocol.getServerId()); - log.info("[sendOnlineMessage][设备上线,deviceId: {}]", device.getId()); - } catch (Exception e) { - log.error("[sendOnlineMessage][发送设备上线消息失败,deviceId: {}]", device.getId(), e); - } - } - - /** - * 发送设备离线消息 - */ - private void sendOfflineMessage(IotDeviceRespDTO device) { - try { - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); - messageService.sendDeviceMessage(offlineMessage, device.getProductKey(), - device.getDeviceName(), upstreamProtocol.getServerId()); - log.info("[sendOfflineMessage][设备离线,deviceId: {}]", device.getId()); - } catch (Exception e) { - log.error("[sendOfflineMessage][发送设备离线消息失败,deviceId: {}]", device.getId(), e); - } - } - - // ==================== 发送响应消息的辅助方法 ==================== - - /** - * 发送 CONNACK 消息 - */ - private void sendConnAck(ServerWebSocket socket, MqttConnectReturnCode returnCode) { - try { - // 构建 CONNACK 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(returnCode, false); - MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader); - - // 编码并发送 - sendMqttMessage(socket, connAckMessage); - log.debug("[sendConnAck][发送 CONNACK 消息,returnCode: {}]", returnCode); - } catch (Exception e) { - log.error("[sendConnAck][发送 CONNACK 消息失败]", e); - } - } - - /** - * 发送 PUBACK 消息(QoS 1 确认) - */ - private void sendPubAck(ServerWebSocket socket, int messageId) { - try { - // 构建 PUBACK 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.PUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); - MqttMessage pubAckMessage = new MqttMessage(fixedHeader, variableHeader); - - // 编码并发送 - sendMqttMessage(socket, pubAckMessage); - log.debug("[sendPubAck][发送 PUBACK 消息,messageId: {}]", messageId); - } catch (Exception e) { - log.error("[sendPubAck][发送 PUBACK 消息失败,messageId: {}]", messageId, e); - } - } - - /** - * 发送 PUBREC 消息(QoS 2 第一步确认) - */ - private void sendPubRec(ServerWebSocket socket, int messageId) { - try { - // 构建 PUBREC 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.PUBREC, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); - MqttMessage pubRecMessage = new MqttMessage(fixedHeader, variableHeader); - - // 编码并发送 - sendMqttMessage(socket, pubRecMessage); - log.debug("[sendPubRec][发送 PUBREC 消息,messageId: {}]", messageId); - } catch (Exception e) { - log.error("[sendPubRec][发送 PUBREC 消息失败,messageId: {}]", messageId, e); - } - } - - /** - * 发送 PUBREL 消息(QoS 2 第二步) - */ - private void sendPubRel(ServerWebSocket socket, int messageId) { - try { - // 构建 PUBREL 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); - MqttMessage pubRelMessage = new MqttMessage(fixedHeader, variableHeader); - - // 编码并发送 - sendMqttMessage(socket, pubRelMessage); - log.debug("[sendPubRel][发送 PUBREL 消息,messageId: {}]", messageId); - } catch (Exception e) { - log.error("[sendPubRel][发送 PUBREL 消息失败,messageId: {}]", messageId, e); - } - } - - /** - * 发送 PUBCOMP 消息(QoS 2 完成确认) - */ - private void sendPubComp(ServerWebSocket socket, int messageId) { - try { - // 构建 PUBCOMP 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); - MqttMessage pubCompMessage = new MqttMessage(fixedHeader, variableHeader); - - // 编码并发送 - sendMqttMessage(socket, pubCompMessage); - log.debug("[sendPubComp][发送 PUBCOMP 消息,messageId: {}]", messageId); - } catch (Exception e) { - log.error("[sendPubComp][发送 PUBCOMP 消息失败,messageId: {}]", messageId, e); - } - } - - /** - * 发送 SUBACK 消息 - */ - private void sendSubAck(ServerWebSocket socket, int messageId, int[] grantedQosList) { - try { - // 构建 SUBACK 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.SUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); - MqttSubAckPayload payload = new MqttSubAckPayload(grantedQosList); - MqttSubAckMessage subAckMessage = new MqttSubAckMessage(fixedHeader, variableHeader, payload); - - // 编码并发送 - sendMqttMessage(socket, subAckMessage); - log.debug("[sendSubAck][发送 SUBACK 消息,messageId: {},主题数量: {}]", messageId, grantedQosList.length); - } catch (Exception e) { - log.error("[sendSubAck][发送 SUBACK 消息失败,messageId: {}]", messageId, e); - } - } - - /** - * 发送 UNSUBACK 消息 - */ - private void sendUnsubAck(ServerWebSocket socket, int messageId) { - try { - // 构建 UNSUBACK 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.UNSUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); - MqttUnsubAckMessage unsubAckMessage = new MqttUnsubAckMessage(fixedHeader, variableHeader); - - // 编码并发送 - sendMqttMessage(socket, unsubAckMessage); - log.debug("[sendUnsubAck][发送 UNSUBACK 消息,messageId: {}]", messageId); - } catch (Exception e) { - log.error("[sendUnsubAck][发送 UNSUBACK 消息失败,messageId: {}]", messageId, e); - } - } - - /** - * 发送 PINGRESP 消息 - */ - private void sendPingResp(ServerWebSocket socket) { - try { - // 构建 PINGRESP 消息 - MqttFixedHeader fixedHeader = new MqttFixedHeader( - MqttMessageType.PINGRESP, false, MqttQoS.AT_MOST_ONCE, false, 0); - MqttMessage pingRespMessage = new MqttMessage(fixedHeader); - - // 编码并发送 - sendMqttMessage(socket, pingRespMessage); - log.debug("[sendPingResp][发送 PINGRESP 消息]"); - } catch (Exception e) { - log.error("[sendPingResp][发送 PINGRESP 消息失败]", e); - } - } - - /** - * 发送 MQTT 消息到 WebSocket - */ - private void sendMqttMessage(ServerWebSocket socket, MqttMessage mqttMessage) { - ByteBuf byteBuf = null; - try { - // 使用 EmbeddedChannel 编码 MQTT 消息 - EmbeddedChannel encoderChannel = encoderChannelThreadLocal.get(); - encoderChannel.writeOutbound(mqttMessage); - - // 读取编码后的 ByteBuf - byteBuf = encoderChannel.readOutbound(); - if (byteBuf != null) { - byte[] bytes = new byte[byteBuf.readableBytes()]; - byteBuf.readBytes(bytes); - socket.writeBinaryMessage(Buffer.buffer(bytes)); - } - } finally { - if (byteBuf != null) { - byteBuf.release(); - } - } - } - -} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpConfig.java new file mode 100644 index 0000000000..16dd3b50e5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpConfig.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; + +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpCodecTypeEnum; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * IoT TCP 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotTcpConfig { + + /** + * 最大连接数 + */ + @NotNull(message = "最大连接数不能为空") + @Min(value = 1, message = "最大连接数必须大于 0") + private Integer maxConnections = 1000; + /** + * 心跳超时时间(毫秒) + */ + @NotNull(message = "心跳超时时间不能为空") + @Min(value = 1000, message = "心跳超时时间必须大于 1000 毫秒") + private Long keepAliveTimeoutMs = 30000L; + + /** + * 是否启用 SSL + */ + @NotNull(message = "是否启用 SSL 不能为空") + private Boolean sslEnabled = false; + /** + * SSL 证书路径 + */ + private String sslCertPath; + /** + * SSL 私钥路径 + */ + private String sslKeyPath; + + /** + * 拆包配置 + */ + @Valid + private CodecConfig codec; + + /** + * TCP 拆包配置 + */ + @Data + public static class CodecConfig { + + /** + * 拆包类型 + * + * @see IotTcpCodecTypeEnum + */ + @NotNull(message = "拆包类型不能为空") + private String type; + + /** + * LENGTH_FIELD: 长度字段偏移量 + *

+ * 表示长度字段在消息中的起始位置(从 0 开始) + */ + private Integer lengthFieldOffset; + /** + * LENGTH_FIELD: 长度字段长度(字节数) + *

+ * 常见值:1(最大 255)、2(最大 65535)、4(最大 2GB) + */ + private Integer lengthFieldLength; + /** + * LENGTH_FIELD: 长度调整值 + *

+ * 用于调整长度字段的值,例如长度字段包含头部长度时需要减去头部长度 + */ + private Integer lengthAdjustment = 0; + /** + * LENGTH_FIELD: 跳过的初始字节数 + *

+ * 解码后跳过的字节数,通常等于 lengthFieldOffset + lengthFieldLength + */ + private Integer initialBytesToStrip = 0; + + /** + * DELIMITER: 分隔符 + *

+ * 支持转义字符:\n(换行)、\r(回车)、\r\n(回车换行) + */ + private String delimiter; + + /** + * FIXED_LENGTH: 固定消息长度(字节) + *

+ * 每条消息的固定长度 + */ + private Integer fixedLength; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java deleted file mode 100644 index e4d46b3af6..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpDownstreamSubscriber.java +++ /dev/null @@ -1,67 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; - -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.tcp.manager.IotTcpConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router.IotTcpDownstreamHandler; -import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; -import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT 网关 TCP 下游订阅者:接收下行给设备的消息 - * - * @author 芋道源码 - */ -@Slf4j -@RequiredArgsConstructor -public class IotTcpDownstreamSubscriber implements IotMessageSubscriber { - - private final IotTcpUpstreamProtocol protocol; - - private final IotDeviceMessageService messageService; - - private final IotDeviceService deviceService; - - private final IotTcpConnectionManager connectionManager; - - private final IotMessageBus messageBus; - - private IotTcpDownstreamHandler downstreamHandler; - - @PostConstruct - public void init() { - // 初始化下游处理器 - this.downstreamHandler = new IotTcpDownstreamHandler(messageService, deviceService, connectionManager); - - messageBus.register(this); - log.info("[init][TCP 下游订阅者初始化完成,服务器 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); - } - } - -} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java new file mode 100644 index 0000000000..3a31f505b5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java @@ -0,0 +1,206 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodecFactory; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream.IotTcpDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream.IotTcpDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.upstream.IotTcpUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; +import io.vertx.core.Vertx; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetServerOptions; +import io.vertx.core.net.PemKeyCertOptions; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.Assert; + +/** + * IoT TCP 协议实现 + *

+ * 基于 Vert.x 实现 TCP 服务器,接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolInstanceProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * Vert.x 实例 + */ + private Vertx vertx; + /** + * TCP 服务器 + */ + private NetServer tcpServer; + /** + * TCP 连接管理器 + */ + private final IotTcpConnectionManager connectionManager; + + /** + * 下行消息订阅者 + */ + private final IotTcpDownstreamSubscriber downstreamSubscriber; + + /** + * 消息序列化器 + */ + private final IotMessageSerializer serializer; + /** + * TCP 帧编解码器 + */ + private final IotTcpFrameCodec frameCodec; + + public IotTcpProtocol(ProtocolInstanceProperties properties) { + IotTcpConfig tcpConfig = properties.getTcp(); + Assert.notNull(tcpConfig, "TCP 协议配置(tcp)不能为空"); + Assert.notNull(tcpConfig.getCodec(), "TCP 拆包配置(tcp.codec)不能为空"); + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化序列化器 + IotSerializeTypeEnum serializeType = IotSerializeTypeEnum.of(properties.getSerialize()); + Assert.notNull(serializeType, "不支持的序列化类型:" + properties.getSerialize()); + IotMessageSerializerManager serializerManager = SpringUtil.getBean(IotMessageSerializerManager.class); + this.serializer = serializerManager.get(serializeType); + // 初始化帧编解码器 + this.frameCodec = IotTcpFrameCodecFactory.create(tcpConfig.getCodec()); + + // 初始化连接管理器 + this.connectionManager = new IotTcpConnectionManager(tcpConfig.getMaxConnections()); + + // 初始化下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotTcpDownstreamHandler downstreamHandler = new IotTcpDownstreamHandler(connectionManager, frameCodec, serializer); + this.downstreamSubscriber = new IotTcpDownstreamSubscriber(this, downstreamHandler, messageBus); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.TCP; + } + + @Override + public void start() { + if (running) { + log.warn("[start][IoT TCP 协议 {} 已经在运行中]", getId()); + return; + } + + // 1.1 创建 Vertx 实例 + this.vertx = Vertx.vertx(); + + // 1.2 创建服务器选项 + IotTcpConfig tcpConfig = properties.getTcp(); + NetServerOptions options = new NetServerOptions() + .setPort(properties.getPort()) + .setTcpKeepAlive(true) + .setTcpNoDelay(true) + .setReuseAddress(true) + .setIdleTimeout((int) (tcpConfig.getKeepAliveTimeoutMs() / 1000)); // 设置空闲超时 + if (Boolean.TRUE.equals(tcpConfig.getSslEnabled())) { + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() + .setKeyPath(tcpConfig.getSslKeyPath()) + .setCertPath(tcpConfig.getSslCertPath()); + options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); + } + + // 1.3 创建服务器并设置连接处理器 + tcpServer = vertx.createNetServer(options); + tcpServer.connectHandler(socket -> { + IotTcpUpstreamHandler handler = new IotTcpUpstreamHandler(serverId, frameCodec, serializer, connectionManager); + handler.handle(socket); + }); + + // 1.4 启动 TCP 服务器 + try { + tcpServer.listen().result(); + running = true; + log.info("[start][IoT TCP 协议 {} 启动成功,端口:{},serverId:{}]", + getId(), properties.getPort(), serverId); + + // 2. 启动下行消息订阅者 + this.downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT TCP 协议 {} 启动失败]", getId(), e); + // 启动失败时关闭资源 + if (tcpServer != null) { + tcpServer.close(); + tcpServer = null; + } + if (vertx != null) { + vertx.close(); + vertx = null; + } + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + // 1. 停止下行消息订阅者 + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT TCP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT TCP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + + // 2.1 关闭 TCP 服务器 + if (tcpServer != null) { + try { + tcpServer.close().result(); + log.info("[stop][IoT TCP 协议 {} 服务器已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT TCP 协议 {} 服务器停止失败]", getId(), e); + } + tcpServer = null; + } + // 2.2 关闭 Vertx 实例 + if (vertx != null) { + try { + vertx.close().result(); + log.info("[stop][IoT TCP 协议 {} Vertx 已关闭]", getId()); + } catch (Exception e) { + log.error("[stop][IoT TCP 协议 {} Vertx 关闭失败]", getId(), e); + } + vertx = null; + } + running = false; + log.info("[stop][IoT TCP 协议 {} 已停止]", getId()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java deleted file mode 100644 index 791c6cbfc2..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpUpstreamProtocol.java +++ /dev/null @@ -1,99 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; - -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.tcp.manager.IotTcpConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router.IotTcpUpstreamHandler; -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.net.NetServer; -import io.vertx.core.net.NetServerOptions; -import io.vertx.core.net.PemKeyCertOptions; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT 网关 TCP 协议:接收设备上行消息 - * - * @author 芋道源码 - */ -@Slf4j -public class IotTcpUpstreamProtocol { - - private final IotGatewayProperties.TcpProperties tcpProperties; - - private final IotDeviceService deviceService; - - private final IotDeviceMessageService messageService; - - private final IotTcpConnectionManager connectionManager; - - private final Vertx vertx; - - @Getter - private final String serverId; - - private NetServer tcpServer; - - public IotTcpUpstreamProtocol(IotGatewayProperties.TcpProperties tcpProperties, - IotDeviceService deviceService, - IotDeviceMessageService messageService, - IotTcpConnectionManager connectionManager, - Vertx vertx) { - this.tcpProperties = tcpProperties; - this.deviceService = deviceService; - this.messageService = messageService; - this.connectionManager = connectionManager; - this.vertx = vertx; - this.serverId = IotDeviceMessageUtils.generateServerId(tcpProperties.getPort()); - } - - @PostConstruct - public void start() { - // 创建服务器选项 - NetServerOptions options = new NetServerOptions() - .setPort(tcpProperties.getPort()) - .setTcpKeepAlive(true) - .setTcpNoDelay(true) - .setReuseAddress(true); - // 配置 SSL(如果启用) - if (Boolean.TRUE.equals(tcpProperties.getSslEnabled())) { - PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() - .setKeyPath(tcpProperties.getSslKeyPath()) - .setCertPath(tcpProperties.getSslCertPath()); - options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); - } - - // 创建服务器并设置连接处理器 - tcpServer = vertx.createNetServer(options); - tcpServer.connectHandler(socket -> { - IotTcpUpstreamHandler handler = new IotTcpUpstreamHandler(this, messageService, deviceService, - connectionManager); - handler.handle(socket); - }); - - // 启动服务器 - try { - tcpServer.listen().result(); - log.info("[start][IoT 网关 TCP 协议启动成功,端口:{}]", tcpProperties.getPort()); - } catch (Exception e) { - log.error("[start][IoT 网关 TCP 协议启动失败]", e); - throw e; - } - } - - @PreDestroy - public void stop() { - if (tcpServer != null) { - try { - tcpServer.close().result(); - log.info("[stop][IoT 网关 TCP 协议已停止]"); - } catch (Exception e) { - log.error("[stop][IoT 网关 TCP 协议停止失败]", e); - } - } - } -} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpCodecTypeEnum.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpCodecTypeEnum.java new file mode 100644 index 0000000000..344001f56f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpCodecTypeEnum.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec; + +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.delimiter.IotTcpDelimiterFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.length.IotTcpFixedLengthFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.length.IotTcpLengthFieldFrameCodec; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * IoT TCP 拆包类型枚举 + * + * @author 芋道源码 + */ +@AllArgsConstructor +@Getter +public enum IotTcpCodecTypeEnum { + + /** + * 基于固定长度的拆包 + */ + FIXED_LENGTH("fixed_length", IotTcpFixedLengthFrameCodec.class), + + /** + * 基于分隔符的拆包 + */ + DELIMITER("delimiter", IotTcpDelimiterFrameCodec.class), + + /** + * 基于长度字段的拆包 + */ + LENGTH_FIELD("length_field", IotTcpLengthFieldFrameCodec.class), + ; + + /** + * 类型标识 + */ + private final String type; + /** + * 编解码器类 + */ + private final Class codecClass; + + /** + * 根据类型获取枚举 + * + * @param type 类型标识 + * @return 枚举值 + */ + public static IotTcpCodecTypeEnum of(String type) { + return ArrayUtil.firstMatch(e -> e.getType().equalsIgnoreCase(type), values()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpFrameCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpFrameCodec.java new file mode 100644 index 0000000000..d002d2043f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpFrameCodec.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec; + +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.parsetools.RecordParser; + +/** + * IoT TCP 帧编解码器接口 + *

+ * 用于解决 TCP 粘包/拆包问题,提供解码(拆包)和编码(加帧)能力 + * + * @author 芋道源码 + */ +public interface IotTcpFrameCodec { + + /** + * 获取编解码器类型 + * + * @return 编解码器类型 + */ + IotTcpCodecTypeEnum getType(); + + /** + * 创建解码器(RecordParser) + *

+ * 每个连接调用一次,返回的 parser 需绑定到 socket.handler() + * + * @param handler 消息处理器,当收到完整的消息帧后回调 + * @return RecordParser 实例 + */ + RecordParser createDecodeParser(Handler handler); + + /** + * 编码消息(加帧) + *

+ * 根据不同的编解码类型添加帧头/分隔符 + * + * @param data 原始数据 + * @return 编码后的数据(带帧头/分隔符) + */ + Buffer encode(byte[] data); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpFrameCodecFactory.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpFrameCodecFactory.java new file mode 100644 index 0000000000..a40783ac6f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/IotTcpFrameCodecFactory.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ReflectUtil; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; + +/** + * IoT TCP 帧编解码器工厂 + * + * @author 芋道源码 + */ +public class IotTcpFrameCodecFactory { + + /** + * 根据配置创建编解码器 + * + * @param config 拆包配置 + * @return 编解码器实例,如果配置为空则返回 null + */ + public static IotTcpFrameCodec create(IotTcpConfig.CodecConfig config) { + Assert.notNull(config, "CodecConfig 不能为空"); + IotTcpCodecTypeEnum type = IotTcpCodecTypeEnum.of(config.getType()); + Assert.notNull(type, "不支持的 CodecType 类型:" + config.getType()); + return ReflectUtil.newInstance(type.getCodecClass(), config); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/delimiter/IotTcpDelimiterFrameCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/delimiter/IotTcpDelimiterFrameCodec.java new file mode 100644 index 0000000000..6e15e95a21 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/delimiter/IotTcpDelimiterFrameCodec.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.delimiter; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpCodecTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.parsetools.RecordParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.Assert; + +/** + * IoT TCP 分隔符帧编解码器 + *

+ * 基于分隔符的拆包策略,消息格式:消息内容 + 分隔符 + *

+ * 支持的分隔符: + *

    + *
  • \n - 换行符
  • + *
  • \r - 回车符
  • + *
  • \r\n - 回车换行
  • + *
  • 自定义字符串
  • + *
+ * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpDelimiterFrameCodec implements IotTcpFrameCodec { + + /** + * 最大记录大小(64KB),防止 DoS 攻击 + */ + private static final int MAX_RECORD_SIZE = 65536; + + /** + * 解析后的分隔符字节数组 + */ + private final byte[] delimiterBytes; + + public IotTcpDelimiterFrameCodec(IotTcpConfig.CodecConfig config) { + Assert.hasText(config.getDelimiter(), "delimiter 不能为空"); + this.delimiterBytes = parseDelimiter(config.getDelimiter()); + } + + @Override + public IotTcpCodecTypeEnum getType() { + return IotTcpCodecTypeEnum.DELIMITER; + } + + @Override + public RecordParser createDecodeParser(Handler handler) { + RecordParser parser = RecordParser.newDelimited(Buffer.buffer(delimiterBytes)); + parser.maxRecordSize(MAX_RECORD_SIZE); // 设置最大记录大小,防止 DoS 攻击 + // 处理完整消息(不包含分隔符) + parser.handler(handler); + parser.exceptionHandler(ex -> { + throw new RuntimeException("[createDecodeParser][解析异常]", ex); + }); + return parser; + } + + @Override + public Buffer encode(byte[] data) { + Buffer buffer = Buffer.buffer(); + buffer.appendBytes(data); + buffer.appendBytes(delimiterBytes); + return buffer; + } + + /** + * 解析分隔符字符串为字节数组 + *

+ * 支持转义字符:\n、\r、\r\n、\t + * + * @param delimiter 分隔符字符串 + * @return 分隔符字节数组 + */ + private byte[] parseDelimiter(String delimiter) { + // 处理转义字符 + String parsed = delimiter + .replace("\\r\\n", "\r\n") + .replace("\\r", "\r") + .replace("\\n", "\n") + .replace("\\t", "\t"); + return StrUtil.utf8Bytes(parsed); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/length/IotTcpFixedLengthFrameCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/length/IotTcpFixedLengthFrameCodec.java new file mode 100644 index 0000000000..eda77c4d59 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/length/IotTcpFixedLengthFrameCodec.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.length; + +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpCodecTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.parsetools.RecordParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.Assert; + +/** + * IoT TCP 定长帧编解码器 + *

+ * 基于固定长度的拆包策略,每条消息固定字节数 + * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpFixedLengthFrameCodec implements IotTcpFrameCodec { + + /** + * 固定消息长度 + */ + private final int fixedLength; + + public IotTcpFixedLengthFrameCodec(IotTcpConfig.CodecConfig config) { + Assert.notNull(config.getFixedLength(), "fixedLength 不能为空"); + this.fixedLength = config.getFixedLength(); + } + + @Override + public IotTcpCodecTypeEnum getType() { + return IotTcpCodecTypeEnum.FIXED_LENGTH; + } + + @Override + public RecordParser createDecodeParser(Handler handler) { + RecordParser parser = RecordParser.newFixed(fixedLength); + parser.handler(handler); + parser.exceptionHandler(ex -> { + throw new RuntimeException("[createDecodeParser][解析异常]", ex); + }); + return parser; + } + + @Override + public Buffer encode(byte[] data) { + // 校验数据长度不能超过固定长度 + if (data.length > fixedLength) { + throw new IllegalArgumentException(String.format( + "数据长度 %d 超过固定长度 %d", data.length, fixedLength)); + } + Buffer buffer = Buffer.buffer(fixedLength); + buffer.appendBytes(data); + // 如果数据不足固定长度,填充 0(RecordParser.newFixed 解码时按固定长度读取,所以发送端需要填充) + if (data.length < fixedLength) { + byte[] padding = new byte[fixedLength - data.length]; + buffer.appendBytes(padding); + } + return buffer; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/length/IotTcpLengthFieldFrameCodec.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/length/IotTcpLengthFieldFrameCodec.java new file mode 100644 index 0000000000..4200b6b1fb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/codec/length/IotTcpLengthFieldFrameCodec.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.length; + +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpCodecTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.parsetools.RecordParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.Assert; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * IoT TCP 长度字段帧编解码器 + *

+ * 基于长度字段的拆包策略,消息格式:[长度字段][消息体] + *

+ * 参数说明: + *

    + *
  • lengthFieldOffset: 长度字段在消息中的偏移量
  • + *
  • lengthFieldLength: 长度字段的字节数(1/2/4)
  • + *
  • lengthAdjustment: 长度调整值,用于调整长度字段的实际含义
  • + *
  • initialBytesToStrip: 解码后跳过的字节数
  • + *
+ * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpLengthFieldFrameCodec implements IotTcpFrameCodec { + + /** + * 最大帧长度(64KB),防止 DoS 攻击 + */ + private static final int MAX_FRAME_LENGTH = 65536; + + private final int lengthFieldOffset; + private final int lengthFieldLength; + private final int lengthAdjustment; + private final int initialBytesToStrip; + + /** + * 头部长度 = 长度字段偏移量 + 长度字段长度 + */ + private final int headerLength; + + public IotTcpLengthFieldFrameCodec(IotTcpConfig.CodecConfig config) { + Assert.notNull(config.getLengthFieldOffset(), "lengthFieldOffset 不能为空"); + Assert.notNull(config.getLengthFieldLength(), "lengthFieldLength 不能为空"); + Assert.notNull(config.getLengthAdjustment(), "lengthAdjustment 不能为空"); + Assert.notNull(config.getInitialBytesToStrip(), "initialBytesToStrip 不能为空"); + this.lengthFieldOffset = config.getLengthFieldOffset(); + this.lengthFieldLength = config.getLengthFieldLength(); + this.lengthAdjustment = config.getLengthAdjustment(); + this.initialBytesToStrip = config.getInitialBytesToStrip(); + this.headerLength = lengthFieldOffset + lengthFieldLength; + } + + @Override + public IotTcpCodecTypeEnum getType() { + return IotTcpCodecTypeEnum.LENGTH_FIELD; + } + + @Override + public RecordParser createDecodeParser(Handler handler) { + // 创建状态机:先读取头部,再读取消息体 + RecordParser parser = RecordParser.newFixed(headerLength); + parser.maxRecordSize(MAX_FRAME_LENGTH); // 设置最大记录大小,防止 DoS 攻击 + final AtomicReference bodyLength = new AtomicReference<>(null); // 消息体长度,null 表示读取头部阶段 + final AtomicReference headerBuffer = new AtomicReference<>(null); // 头部消息 + + // 处理读取到的数据 + parser.handler(buffer -> { + if (bodyLength.get() == null) { + // 阶段 1: 读取头部,解析长度字段 + headerBuffer.set(buffer.copy()); + int length = readLength(buffer, lengthFieldOffset, lengthFieldLength); + int frameBodyLength = length + lengthAdjustment; + // 检查帧长度是否合法 + if (frameBodyLength < 0) { + throw new IllegalStateException(String.format( + "[createDecodeParser][帧长度异常,length: %d, frameBodyLength: %d]", + length, frameBodyLength)); + } + // 消息体为空,抛出异常 + if (frameBodyLength == 0) { + throw new IllegalStateException("[createDecodeParser][消息体不能为空]"); + } + + // 【重要】切换到读取消息体模式 + bodyLength.set(frameBodyLength); + parser.fixedSizeMode(frameBodyLength); + } else { + // 阶段 2: 读取消息体,组装完整帧 + Buffer frame = processFrame(headerBuffer.get(), buffer); + // 重置状态,准备读取下一帧 + bodyLength.set(null); + headerBuffer.set(null); + parser.fixedSizeMode(headerLength); + + // 【重要】处理完整消息 + handler.handle(frame); + } + }); + parser.exceptionHandler(ex -> { + throw new RuntimeException("[createDecodeParser][解析异常]", ex); + }); + return parser; + } + + @Override + public Buffer encode(byte[] data) { + Buffer buffer = Buffer.buffer(); + // 计算要写入的长度值 + int lengthValue = data.length - lengthAdjustment; + // 写入偏移量前的填充字节(如果有) + for (int i = 0; i < lengthFieldOffset; i++) { + buffer.appendByte((byte) 0); + } + // 写入长度字段 + writeLength(buffer, lengthValue, lengthFieldLength); + // 写入消息体 + buffer.appendBytes(data); + return buffer; + } + + /** + * 从 Buffer 中读取长度字段 + */ + @SuppressWarnings("EnhancedSwitchMigration") + private int readLength(Buffer buffer, int offset, int length) { + switch (length) { + case 1: + return buffer.getUnsignedByte(offset); + case 2: + return buffer.getUnsignedShort(offset); + case 4: + return buffer.getInt(offset); + default: + throw new IllegalArgumentException("不支持的长度字段长度: " + length); + } + } + + /** + * 向 Buffer 中写入长度字段 + */ + private void writeLength(Buffer buffer, int length, int fieldLength) { + switch (fieldLength) { + case 1: + buffer.appendByte((byte) length); + break; + case 2: + buffer.appendShort((short) length); + break; + case 4: + buffer.appendInt(length); + break; + default: + throw new IllegalArgumentException("不支持的长度字段长度: " + fieldLength); + } + } + + /** + * 处理帧数据(根据 initialBytesToStrip 跳过指定字节) + */ + private Buffer processFrame(Buffer header, Buffer body) { + Buffer fullFrame = Buffer.buffer(); + if (header != null) { + fullFrame.appendBuffer(header); + } + if (body != null) { + fullFrame.appendBuffer(body); + } + // 根据 initialBytesToStrip 跳过指定字节 + if (initialBytesToStrip > 0 && initialBytesToStrip < fullFrame.length()) { + return fullFrame.slice(initialBytesToStrip, fullFrame.length()); + } + return fullFrame; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java new file mode 100644 index 0000000000..986bfbe60d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import io.vertx.core.buffer.Buffer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 TCP 下行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class IotTcpDownstreamHandler { + + private final IotTcpConnectionManager connectionManager; + + /** + * TCP 帧编解码器(处理粘包/拆包) + */ + private final IotTcpFrameCodec codec; + /** + * 消息序列化器(处理业务消息序列化/反序列化) + */ + private final IotMessageSerializer serializer; + + /** + * 处理下行消息 + */ + public void handle(IotDeviceMessage message) { + try { + log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + // 1. 检查设备连接 + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId( + message.getDeviceId()); + if (connectionInfo == null) { + log.warn("[handle][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + return; + } + + // 2. 序列化 + 帧编码 + byte[] serializedData = serializer.serialize(message); + Buffer frameData = codec.encode(serializedData); + + // 3. 发送到设备 + boolean success = connectionManager.sendToDevice(message.getDeviceId(), frameData.getBytes()); + if (!success) { + throw new RuntimeException("下行消息发送失败"); + } + log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]", + message.getDeviceId(), message.getMethod(), message.getId(), frameData.length()); + } catch (Exception e) { + log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]", + message.getDeviceId(), message.getMethod(), message, e); + } + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java new file mode 100644 index 0000000000..7a29e6c00c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 TCP 下游订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpDownstreamSubscriber extends IotProtocolDownstreamSubscriber { + + private final IotTcpDownstreamHandler downstreamHandler; + + public IotTcpDownstreamSubscriber(IotProtocol protocol, + IotTcpDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + super(protocol, messageBus); + this.downstreamHandler = downstreamHandler; + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + downstreamHandler.handle(message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java new file mode 100644 index 0000000000..93fadd8bbe --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java @@ -0,0 +1,327 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.upstream; + +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +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.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +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.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.Assert; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; + +/** + * TCP 上行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotTcpUpstreamHandler implements Handler { + + private static final String AUTH_METHOD = "auth"; + + private final String serverId; + + /** + * TCP 帧编解码器(处理粘包/拆包) + */ + private final IotTcpFrameCodec codec; + /** + * 消息序列化器(处理业务消息序列化/反序列化) + */ + private final IotMessageSerializer serializer; + /** + * TCP 连接管理器 + */ + private final IotTcpConnectionManager connectionManager; + + private final IotDeviceMessageService deviceMessageService; + private final IotDeviceService deviceService; + private final IotDeviceCommonApi deviceApi; + + public IotTcpUpstreamHandler(String serverId, + IotTcpFrameCodec codec, + IotMessageSerializer serializer, + IotTcpConnectionManager connectionManager) { + Assert.notNull(codec, "TCP FrameCodec 必须配置"); + Assert.notNull(serializer, "消息序列化器必须配置"); + Assert.notNull(connectionManager, "连接管理器不能为空"); + this.serverId = serverId; + this.codec = codec; + this.serializer = serializer; + this.connectionManager = connectionManager; + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.deviceService = SpringUtil.getBean(IotDeviceService.class); + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + } + + @Override + @SuppressWarnings("DuplicatedCode") + public void handle(NetSocket socket) { + String remoteAddress = String.valueOf(socket.remoteAddress()); + log.debug("[handle][设备连接,地址: {}]", remoteAddress); + + // 1. 设置异常和关闭处理器 + socket.exceptionHandler(ex -> { + log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); + socket.close(); + }); + socket.closeHandler(v -> { + log.debug("[handle][连接关闭,地址: {}]", remoteAddress); + cleanupConnection(socket); + }); + + // 2.1 设置消息处理器 + Handler messageHandler = buffer -> { + try { + processMessage(buffer, socket); + } catch (Exception e) { + log.error("[handle][消息处理失败,地址: {}]", remoteAddress, e); + socket.close(); + } + }; + // 2.2 使用拆包器处理粘包/拆包 + RecordParser parser = codec.createDecodeParser(messageHandler); + socket.handler(parser); + log.debug("[handle][启用 {} 拆包器,地址: {}]", codec.getType(), remoteAddress); + } + + /** + * 处理消息 + * + * @param buffer 消息 + * @param socket 网络连接 + */ + private void processMessage(Buffer buffer, NetSocket socket) { + IotDeviceMessage message = null; + try { + // 1. 反序列化消息 + message = serializer.deserialize(buffer.getBytes()); + if (message == null) { + sendErrorResponse(socket, null, null, BAD_REQUEST.getCode(), "消息反序列化失败"); + return; + } + + // 2. 根据消息类型路由处理 + if (AUTH_METHOD.equals(message.getMethod())) { + // 认证请求 + handleAuthenticationRequest(message, socket); + } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) { + // 设备动态注册请求 + handleRegisterRequest(message, socket); + } else { + // 业务消息 + handleBusinessRequest(message, socket); + } + } catch (ServiceException e) { + // 业务异常,返回对应的错误码和错误信息 + log.warn("[processMessage][业务异常,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + String requestId = message != null ? message.getRequestId() : null; + String method = message != null ? message.getMethod() : null; + sendErrorResponse(socket, requestId, method, e.getCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + // 参数校验失败,返回 400 + log.warn("[processMessage][参数校验失败,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + String requestId = message != null ? message.getRequestId() : null; + String method = message != null ? message.getMethod() : null; + sendErrorResponse(socket, requestId, method, BAD_REQUEST.getCode(), e.getMessage()); + } catch (Exception e) { + // 其他异常,返回 500,并重新抛出让上层关闭连接 + log.error("[processMessage][处理消息失败,地址: {}]", socket.remoteAddress(), e); + String requestId = message != null ? message.getRequestId() : null; + String method = message != null ? message.getMethod() : null; + sendErrorResponse(socket, requestId, method, + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + throw e; + } + } + + /** + * 处理认证请求 + * + * @param message 消息信息 + * @param socket 网络连接 + */ + @SuppressWarnings("DuplicatedCode") + private void handleAuthenticationRequest(IotDeviceMessage message, NetSocket socket) { + // 1. 解析认证参数 + IotDeviceAuthReqDTO authParams = JsonUtils.convertObject(message.getParams(), IotDeviceAuthReqDTO.class); + Assert.notNull(authParams, "认证参数不能为空"); + Assert.hasText(authParams.getUsername(), "username 不能为空"); + Assert.hasText(authParams.getPassword(), "password 不能为空"); + + // 2.1 执行认证 + CommonResult authResult = deviceApi.authDevice(authParams); + authResult.checkError(); + if (BooleanUtil.isFalse(authResult.getData())) { + throw exception(DEVICE_AUTH_FAIL); + } + // 2.2 解析设备信息 + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); + Assert.notNull(deviceInfo, "解析设备信息失败"); + // 2.3 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + Assert.notNull(device, "设备不存在"); + + // 3.1 注册连接 + registerConnection(socket, device); + // 3.2 发送上线消息 + sendOnlineMessage(device); + // 3.3 发送成功响应 + sendSuccessResponse(socket, message.getRequestId(), AUTH_METHOD, "认证成功"); + log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]", device.getId(), device.getDeviceName()); + } + + /** + * 处理设备动态注册请求(一型一密,不需要认证) + * + * @param message 消息信息 + * @param socket 网络连接 + * @see 阿里云 - 一型一密 + */ + @SuppressWarnings("DuplicatedCode") + private void handleRegisterRequest(IotDeviceMessage message, NetSocket socket) { + // 1. 解析注册参数 + IotDeviceRegisterReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceRegisterReqDTO.class); + Assert.notNull(params, "注册参数不能为空"); + Assert.hasText(params.getProductKey(), "productKey 不能为空"); + Assert.hasText(params.getDeviceName(), "deviceName 不能为空"); + + // 2. 调用动态注册 + CommonResult result = deviceApi.registerDevice(params); + result.checkError(); + + // 3. 发送成功响应 + sendSuccessResponse(socket, message.getRequestId(), + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), result.getData()); + log.info("[handleRegisterRequest][注册成功,地址: {},设备名: {}]", + socket.remoteAddress(), params.getDeviceName()); + } + + /** + * 处理业务请求 + * + * @param message 消息信息 + * @param socket 网络连接 + */ + private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket) { + // 1. 获取认证信息并处理业务消息 + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo == null) { + log.error("[handleBusinessRequest][无法获取连接信息,地址: {}]", socket.remoteAddress()); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), + UNAUTHORIZED.getCode(), "设备未认证,无法处理业务消息"); + return; + } + + // 2. 发送消息到消息总线 + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); + } + + /** + * 注册连接信息 + * + * @param socket 网络连接 + * @param device 设备 + */ + private void registerConnection(NetSocket socket, IotDeviceRespDTO device) { + IotTcpConnectionManager.ConnectionInfo connectionInfo = new IotTcpConnectionManager.ConnectionInfo() + .setDeviceId(device.getId()) + .setProductKey(device.getProductKey()) + .setDeviceName(device.getDeviceName()); + connectionManager.registerConnection(socket, device.getId(), connectionInfo); + } + + /** + * 发送设备上线消息 + * + * @param device 设备信息 + */ + private void sendOnlineMessage(IotDeviceRespDTO device) { + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), + device.getDeviceName(), serverId); + } + + /** + * 清理连接 + * + * @param socket 网络连接 + */ + private void cleanupConnection(NetSocket socket) { + // 1. 发送离线消息 + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo != null) { + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + } + + // 2. 注销连接 + connectionManager.unregisterConnection(socket); + } + + // ===================== 发送响应消息 ===================== + + /** + * 发送成功响应 + * + * @param socket 网络连接 + * @param requestId 请求 ID + * @param method 方法名 + * @param data 响应数据 + */ + private void sendSuccessResponse(NetSocket socket, String requestId, String method, Object data) { + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, method, data, SUCCESS.getCode(), null); + writeResponse(socket, responseMessage); + } + + /** + * 发送错误响应 + * + * @param socket 网络连接 + * @param requestId 请求 ID + * @param method 方法名 + * @param code 错误码 + * @param msg 错误消息 + */ + private void sendErrorResponse(NetSocket socket, String requestId, String method, Integer code, String msg) { + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, method, null, code, msg); + writeResponse(socket, responseMessage); + } + + /** + * 写入响应到 Socket + * + * @param socket 网络连接 + * @param responseMessage 响应消息 + */ + private void writeResponse(NetSocket socket, IotDeviceMessage responseMessage) { + byte[] serializedData = serializer.serialize(responseMessage); + Buffer frameData = codec.encode(serializedData); + socket.write(frameData); + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java index c0d209814e..36c5928762 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java @@ -1,9 +1,9 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager; +import io.vertx.core.buffer.Buffer; import io.vertx.core.net.NetSocket; import lombok.Data; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -19,9 +19,13 @@ import java.util.concurrent.ConcurrentHashMap; * @author 芋道源码 */ @Slf4j -@Component public class IotTcpConnectionManager { + /** + * 最大连接数 + */ + private final int maxConnections; + /** * 连接信息映射:NetSocket -> 连接信息 */ @@ -32,6 +36,10 @@ public class IotTcpConnectionManager { */ private final Map deviceSocketMap = new ConcurrentHashMap<>(); + public IotTcpConnectionManager(int maxConnections) { + this.maxConnections = maxConnections; + } + /** * 注册设备连接(包含认证信息) * @@ -40,6 +48,10 @@ public class IotTcpConnectionManager { * @param connectionInfo 连接信息 */ public void registerConnection(NetSocket socket, Long deviceId, ConnectionInfo connectionInfo) { + // 检查连接数是否已达上限 + if (connectionMap.size() >= maxConnections) { + throw new IllegalStateException("连接数已达上限: " + maxConnections); + } // 如果设备已有其他连接,先清理旧连接 NetSocket oldSocket = deviceSocketMap.get(deviceId); if (oldSocket != null && oldSocket != socket) { @@ -50,9 +62,9 @@ public class IotTcpConnectionManager { 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()); } @@ -64,27 +76,13 @@ public class IotTcpConnectionManager { */ public void unregisterConnection(NetSocket socket) { ConnectionInfo connectionInfo = connectionMap.remove(socket); - if (connectionInfo != null) { - Long deviceId = connectionInfo.getDeviceId(); - deviceSocketMap.remove(deviceId); - log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", - deviceId, socket.remoteAddress()); + if (connectionInfo == null) { + return; } - } - - /** - * 检查连接是否已认证 - */ - public boolean isAuthenticated(NetSocket socket) { - ConnectionInfo info = connectionMap.get(socket); - return info != null && info.isAuthenticated(); - } - - /** - * 检查连接是否未认证 - */ - public boolean isNotAuthenticated(NetSocket socket) { - return !isAuthenticated(socket); + Long deviceId = connectionInfo.getDeviceId(); + // 仅当 deviceSocketMap 中的 socket 是当前 socket 时才移除,避免误删新连接 + deviceSocketMap.remove(deviceId, socket); + log.info("[unregisterConnection][注销设备连接,设备 ID: {},连接: {}]", deviceId, socket.remoteAddress()); } /** @@ -95,17 +93,11 @@ public class IotTcpConnectionManager { } /** - * 检查设备是否在线 + * 根据设备 ID 获取连接信息 */ - public boolean isDeviceOnline(Long deviceId) { - return deviceSocketMap.containsKey(deviceId); - } - - /** - * 检查设备是否离线 - */ - public boolean isDeviceOffline(Long deviceId) { - return !isDeviceOnline(deviceId); + public ConnectionInfo getConnectionInfoByDeviceId(Long deviceId) { + NetSocket socket = deviceSocketMap.get(deviceId); + return socket != null ? connectionMap.get(socket) : null; } /** @@ -119,7 +111,7 @@ public class IotTcpConnectionManager { } try { - socket.write(io.vertx.core.buffer.Buffer.buffer(data)); + socket.write(Buffer.buffer(data)); log.debug("[sendToDevice][发送消息成功,设备 ID: {},数据长度: {} 字节]", deviceId, data.length); return true; } catch (Exception e) { @@ -149,20 +141,6 @@ public class IotTcpConnectionManager { */ private String deviceName; - /** - * 客户端 ID - */ - private String clientId; - /** - * 消息编解码类型(认证后确定) - */ - private String codecType; - // TODO @haohao:有没可能不要 authenticated 字段,通过 deviceId 或者其他的?进一步简化,想的是哈。 - /** - * 是否已认证 - */ - private boolean authenticated; - } } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/package-info.java new file mode 100644 index 0000000000..1b59f5446e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/package-info.java @@ -0,0 +1,6 @@ +/** + * TCP 协议实现包 + *

+ * 提供基于 Vert.x TCP Server 的 IoT 设备连接和消息处理功能 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java deleted file mode 100644 index 3ee31d82e4..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpDownstreamHandler.java +++ /dev/null @@ -1,63 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.router; - -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.gateway.protocol.tcp.manager.IotTcpConnectionManager; -import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; -import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * IoT 网关 TCP 下行消息处理器 - * - * @author 芋道源码 - */ -@Slf4j -@RequiredArgsConstructor -public class IotTcpDownstreamHandler { - - private final IotDeviceMessageService deviceMessageService; - - private final IotDeviceService deviceService; - - private final IotTcpConnectionManager connectionManager; - - /** - * 处理下行消息 - */ - public void handle(IotDeviceMessage message) { - try { - log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", - message.getDeviceId(), message.getMethod(), message.getId()); - - // 1.1 获取设备信息 - IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); - if (deviceInfo == null) { - log.error("[handle][设备不存在,设备 ID: {}]", message.getDeviceId()); - return; - } - // 1.2 检查设备是否在线 - if (connectionManager.isDeviceOffline(message.getDeviceId())) { - log.warn("[handle][设备不在线,设备 ID: {}]", message.getDeviceId()); - return; - } - - // 2. 根据产品 Key 和设备名称编码消息并发送到设备 - byte[] bytes = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(), - deviceInfo.getDeviceName()); - boolean success = connectionManager.sendToDevice(message.getDeviceId(), bytes); - 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); - } - } - -} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java deleted file mode 100644 index 0aff8f72f2..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/router/IotTcpUpstreamHandler.java +++ /dev/null @@ -1,408 +0,0 @@ -package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.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.mq.message.IotDeviceMessage; -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.tcp.IotTcpUpstreamProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; -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.buffer.Buffer; -import io.vertx.core.net.NetSocket; -import lombok.extern.slf4j.Slf4j; - -/** - * TCP 上行消息处理器 - * - * @author 芋道源码 - */ -@Slf4j -public class IotTcpUpstreamHandler implements Handler { - - 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"; - - private final IotDeviceMessageService deviceMessageService; - - private final IotDeviceService deviceService; - - private final IotTcpConnectionManager connectionManager; - - private final IotDeviceCommonApi deviceApi; - - private final String serverId; - - public IotTcpUpstreamHandler(IotTcpUpstreamProtocol protocol, - IotDeviceMessageService deviceMessageService, - IotDeviceService deviceService, - IotTcpConnectionManager connectionManager) { - this.deviceMessageService = deviceMessageService; - this.deviceService = deviceService; - this.connectionManager = connectionManager; - this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); - this.serverId = protocol.getServerId(); - } - - @Override - public void handle(NetSocket socket) { - String clientId = IdUtil.simpleUUID(); - log.debug("[handle][设备连接,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); - - // 设置异常和关闭处理器 - socket.exceptionHandler(ex -> { - log.warn("[handle][连接异常,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); - cleanupConnection(socket); - }); - socket.closeHandler(v -> { - log.debug("[handle][连接关闭,客户端 ID: {},地址: {}]", clientId, socket.remoteAddress()); - cleanupConnection(socket); - }); - - // 设置消息处理器 - socket.handler(buffer -> { - try { - processMessage(clientId, buffer, socket); - } catch (Exception e) { - log.error("[handle][消息解码失败,断开连接,客户端 ID: {},地址: {},错误: {}]", - clientId, socket.remoteAddress(), e.getMessage()); - cleanupConnection(socket); - socket.close(); - } - }); - } - - /** - * 处理消息 - * - * @param clientId 客户端 ID - * @param buffer 消息 - * @param socket 网络连接 - * @throws Exception 消息解码失败时抛出异常 - */ - private void processMessage(String clientId, Buffer buffer, NetSocket socket) throws Exception { - // 1. 基础检查 - if (buffer == null || buffer.length() == 0) { - return; - } - - // 2. 获取消息格式类型 - String codecType = getMessageCodecType(buffer, socket); - - // 3. 解码消息 - IotDeviceMessage message; - try { - message = deviceMessageService.decodeDeviceMessage(buffer.getBytes(), codecType); - if (message == null) { - throw new Exception("解码后消息为空"); - } - } catch (Exception e) { - // 消息格式错误时抛出异常,由上层处理连接断开 - throw new Exception("消息解码失败: " + e.getMessage(), e); - } - - // 4. 根据消息类型路由处理 - try { - if (AUTH_METHOD.equals(message.getMethod())) { - // 认证请求 - handleAuthenticationRequest(clientId, message, codecType, socket); - } else { - // 业务消息 - handleBusinessRequest(clientId, message, codecType, socket); - } - } catch (Exception e) { - log.error("[processMessage][处理消息失败,客户端 ID: {},消息方法: {}]", - clientId, message.getMethod(), e); - // 发送错误响应,避免客户端一直等待 - try { - sendErrorResponse(socket, message.getRequestId(), "消息处理失败", codecType); - } catch (Exception responseEx) { - log.error("[processMessage][发送错误响应失败,客户端 ID: {}]", clientId, responseEx); - } - } - } - - /** - * 处理认证请求 - * - * @param clientId 客户端 ID - * @param message 消息信息 - * @param codecType 消息编解码类型 - * @param socket 网络连接 - */ - private void handleAuthenticationRequest(String clientId, IotDeviceMessage message, String codecType, - NetSocket socket) { - try { - // 1.1 解析认证参数 - IotDeviceAuthReqDTO authParams = parseAuthParams(message.getParams()); - if (authParams == null) { - log.warn("[handleAuthenticationRequest][认证参数解析失败,客户端 ID: {}]", clientId); - sendErrorResponse(socket, message.getRequestId(), "认证参数不完整", codecType); - return; - } - // 1.2 执行认证 - if (!validateDeviceAuth(authParams)) { - log.warn("[handleAuthenticationRequest][认证失败,客户端 ID: {},username: {}]", - clientId, authParams.getUsername()); - sendErrorResponse(socket, message.getRequestId(), "认证失败", codecType); - return; - } - - // 2.1 解析设备信息 - IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); - if (deviceInfo == null) { - sendErrorResponse(socket, message.getRequestId(), "解析设备信息失败", codecType); - return; - } - // 2.2 获取设备信息 - IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), - deviceInfo.getDeviceName()); - if (device == null) { - sendErrorResponse(socket, message.getRequestId(), "设备不存在", codecType); - return; - } - - // 3.1 注册连接 - registerConnection(socket, device, clientId, codecType); - // 3.2 发送上线消息 - sendOnlineMessage(device); - // 3.3 发送成功响应 - sendSuccessResponse(socket, message.getRequestId(), "认证成功", codecType); - log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]", - device.getId(), device.getDeviceName()); - } catch (Exception e) { - log.error("[handleAuthenticationRequest][认证处理异常,客户端 ID: {}]", clientId, e); - sendErrorResponse(socket, message.getRequestId(), "认证处理异常", codecType); - } - } - - /** - * 处理业务请求 - * - * @param clientId 客户端 ID - * @param message 消息信息 - * @param codecType 消息编解码类型 - * @param socket 网络连接 - */ - private void handleBusinessRequest(String clientId, IotDeviceMessage message, String codecType, NetSocket socket) { - try { - // 1. 检查认证状态 - if (connectionManager.isNotAuthenticated(socket)) { - log.warn("[handleBusinessRequest][设备未认证,客户端 ID: {}]", clientId); - sendErrorResponse(socket, message.getRequestId(), "请先进行认证", codecType); - return; - } - - // 2. 获取认证信息并处理业务消息 - IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); - - // 3. 发送消息到消息总线 - 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 buffer 消息 - * @param socket 网络连接 - * @return 消息编解码类型 - */ - private String getMessageCodecType(Buffer buffer, NetSocket socket) { - // 1. 如果已认证,优先使用缓存的编解码类型 - IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); - if (connectionInfo != null && connectionInfo.isAuthenticated() && - StrUtil.isNotBlank(connectionInfo.getCodecType())) { - return connectionInfo.getCodecType(); - } - - // 2. 未认证时检测消息格式类型 - return IotTcpBinaryDeviceMessageCodec.isBinaryFormatQuick(buffer.getBytes()) ? CODEC_TYPE_BINARY - : CODEC_TYPE_JSON; - } - - /** - * 注册连接信息 - * - * @param socket 网络连接 - * @param device 设备 - * @param clientId 客户端 ID - * @param codecType 消息编解码类型 - */ - private void registerConnection(NetSocket socket, IotDeviceRespDTO device, - String clientId, String codecType) { - IotTcpConnectionManager.ConnectionInfo connectionInfo = new IotTcpConnectionManager.ConnectionInfo() - .setDeviceId(device.getId()) - .setProductKey(device.getProductKey()) - .setDeviceName(device.getDeviceName()) - .setClientId(clientId) - .setCodecType(codecType) - .setAuthenticated(true); - // 注册连接 - 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 网络连接 - */ - private void cleanupConnection(NetSocket socket) { - try { - // 1. 发送离线消息(如果已认证) - IotTcpConnectionManager.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 网络连接 - * @param success 是否成功 - * @param message 消息 - * @param requestId 请求 ID - * @param codecType 消息编解码类型 - */ - private void sendResponse(NetSocket socket, 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, AUTH_METHOD, responseData, - code, message); - - byte[] encodedData = deviceMessageService.encodeDeviceMessage(responseMessage, codecType); - socket.write(Buffer.buffer(encodedData)); - - } catch (Exception e) { - log.error("[sendResponse][发送响应失败,requestId: {}]", requestId, e); - } - } - - /** - * 验证设备认证信息 - * - * @param authParams 认证参数 - * @return 是否认证成功 - */ - private boolean validateDeviceAuth(IotDeviceAuthReqDTO authParams) { - try { - CommonResult 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 网络连接 - * @param requestId 请求 ID - * @param errorMessage 错误消息 - * @param codecType 消息编解码类型 - */ - private void sendErrorResponse(NetSocket socket, String requestId, String errorMessage, String codecType) { - sendResponse(socket, false, errorMessage, requestId, codecType); - } - - /** - * 发送成功响应 - * - * @param socket 网络连接 - * @param requestId 请求 ID - * @param message 消息 - * @param codecType 消息编解码类型 - */ - @SuppressWarnings("SameParameterValue") - private void sendSuccessResponse(NetSocket socket, String requestId, String message, String codecType) { - sendResponse(socket, true, message, requestId, codecType); - } - - /** - * 解析认证参数 - * - * @param params 参数对象(通常为 Map 类型) - * @return 认证参数 DTO,解析失败时返回 null - */ - @SuppressWarnings("unchecked") - private IotDeviceAuthReqDTO parseAuthParams(Object params) { - if (params == null) { - return null; - } - - try { - // 参数默认为 Map 类型,直接转换 - if (params instanceof java.util.Map) { - java.util.Map paramMap = (java.util.Map) 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 转换 - String jsonStr = JsonUtils.toJsonString(params); - return JsonUtils.parseObject(jsonStr, IotDeviceAuthReqDTO.class); - } catch (Exception e) { - log.error("[parseAuthParams][解析认证参数({})失败]", params, e); - return null; - } - } - -} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpConfig.java new file mode 100644 index 0000000000..95a6291e4d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpConfig.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * IoT UDP 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotUdpConfig { + + /** + * 最大会话数 + */ + @NotNull(message = "最大会话数不能为空") + @Min(value = 1, message = "最大会话数必须大于 0") + private Integer maxSessions = 1000; + /** + * 会话超时时间(毫秒) + *

+ * 基于 Guava Cache 的 expireAfterAccess 实现自动过期清理 + */ + @NotNull(message = "会话超时时间不能为空") + @Min(value = 1000, message = "会话超时时间必须大于 1000 毫秒") + private Long sessionTimeoutMs = 60000L; + + /** + * 接收缓冲区大小(字节) + */ + @NotNull(message = "接收缓冲区大小不能为空") + @Min(value = 1024, message = "接收缓冲区大小必须大于 1024 字节") + private Integer receiveBufferSize = 65536; + /** + * 发送缓冲区大小(字节) + */ + @NotNull(message = "发送缓冲区大小不能为空") + @Min(value = 1024, message = "发送缓冲区大小必须大于 1024 字节") + private Integer sendBufferSize = 65536; + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java new file mode 100644 index 0000000000..647a713b55 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java @@ -0,0 +1,190 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream.IotUdpDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream.IotUdpDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream.IotUdpUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +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 org.springframework.util.Assert; + +/** + * IoT UDP 协议实现 + *

+ * 基于 Vert.x 实现 UDP 服务器,接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotUdpProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolInstanceProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * Vert.x 实例 + */ + private Vertx vertx; + /** + * UDP 服务器 + */ + @Getter + private DatagramSocket udpSocket; + /** + * UDP 会话管理器 + */ + private final IotUdpSessionManager sessionManager; + + /** + * 下行消息订阅者 + */ + private final IotUdpDownstreamSubscriber downstreamSubscriber; + + /** + * 消息序列化器 + */ + private final IotMessageSerializer serializer; + + public IotUdpProtocol(ProtocolInstanceProperties properties) { + IotUdpConfig udpConfig = properties.getUdp(); + Assert.notNull(udpConfig, "UDP 协议配置(udp)不能为空"); + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化序列化器 + IotSerializeTypeEnum serializeType = IotSerializeTypeEnum.of(properties.getSerialize()); + Assert.notNull(serializeType, "不支持的序列化类型:" + properties.getSerialize()); + IotMessageSerializerManager serializerManager = SpringUtil.getBean(IotMessageSerializerManager.class); + this.serializer = serializerManager.get(serializeType); + + // 初始化会话管理器 + this.sessionManager = new IotUdpSessionManager(udpConfig.getMaxSessions(), udpConfig.getSessionTimeoutMs()); + + // 初始化下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotUdpDownstreamHandler downstreamHandler = new IotUdpDownstreamHandler(this, sessionManager, serializer); + this.downstreamSubscriber = new IotUdpDownstreamSubscriber(this, downstreamHandler, messageBus); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.UDP; + } + + @Override + public void start() { + if (running) { + log.warn("[start][IoT UDP 协议 {} 已经在运行中]", getId()); + return; + } + + // 1.1 创建 Vertx 实例 + this.vertx = Vertx.vertx(); + + // 1.2 创建 UDP Socket 选项 + IotUdpConfig udpConfig = properties.getUdp(); + DatagramSocketOptions options = new DatagramSocketOptions() + .setReceiveBufferSize(udpConfig.getReceiveBufferSize()) + .setSendBufferSize(udpConfig.getSendBufferSize()) + .setReuseAddress(true); + + // 1.3 创建 UDP Socket + udpSocket = vertx.createDatagramSocket(options); + + // 1.4 创建上行消息处理器 + IotUdpUpstreamHandler upstreamHandler = new IotUdpUpstreamHandler(serverId, sessionManager, serializer); + + // 1.5 启动 UDP 服务器(阻塞式) + try { + udpSocket.listen(properties.getPort(), "0.0.0.0").result(); + // 设置数据包处理器 + udpSocket.handler(packet -> upstreamHandler.handle(packet, udpSocket)); + running = true; + log.info("[start][IoT UDP 协议 {} 启动成功,端口:{},serverId:{}]", + getId(), properties.getPort(), serverId); + + // 2. 启动下行消息订阅者 + this.downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT UDP 协议 {} 启动失败]", getId(), e); + // 启动失败时关闭资源 + if (udpSocket != null) { + udpSocket.close(); + udpSocket = null; + } + if (vertx != null) { + vertx.close(); + vertx = null; + } + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + // 1. 停止下行消息订阅者 + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT UDP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT UDP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + + // 2.1 关闭 UDP Socket + if (udpSocket != null) { + try { + udpSocket.close().result(); + log.info("[stop][IoT UDP 协议 {} 服务器已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT UDP 协议 {} 服务器停止失败]", getId(), e); + } + udpSocket = null; + } + // 2.2 关闭 Vertx 实例 + if (vertx != null) { + try { + vertx.close().result(); + log.info("[stop][IoT UDP 协议 {} Vertx 已关闭]", getId()); + } catch (Exception e) { + log.error("[stop][IoT UDP 协议 {} Vertx 关闭失败]", getId(), e); + } + vertx = null; + } + running = false; + log.info("[stop][IoT UDP 协议 {} 已停止]", getId()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java new file mode 100644 index 0000000000..6caf71abec --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import io.vertx.core.datagram.DatagramSocket; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 UDP 下行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class IotUdpDownstreamHandler { + + private final IotUdpProtocol protocol; + + private final IotUdpSessionManager sessionManager; + + /** + * 消息序列化器(处理业务消息序列化/反序列化) + */ + private final IotMessageSerializer serializer; + + /** + * 处理下行消息 + */ + public void handle(IotDeviceMessage message) { + try { + log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + // 1. 检查设备会话 + IotUdpSessionManager.SessionInfo sessionInfo = sessionManager.getSession(message.getDeviceId()); + if (sessionInfo == null) { + log.warn("[handle][会话信息不存在,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + return; + } + DatagramSocket socket = protocol.getUdpSocket(); + if (socket == null) { + log.error("[handle][UDP Socket 不可用,设备 ID: {}]", message.getDeviceId()); + return; + } + + // 2. 序列化消息 + byte[] serializedData = serializer.serialize(message); + + // 3. 发送到设备 + boolean success = sessionManager.sendToDevice(message.getDeviceId(), serializedData, socket); + if (!success) { + throw new RuntimeException("下行消息发送失败"); + } + log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]", + message.getDeviceId(), message.getMethod(), message.getId(), serializedData.length); + } catch (Exception e) { + log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]", + message.getDeviceId(), message.getMethod(), message, e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java new file mode 100644 index 0000000000..ea0bc99b39 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 UDP 下游订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotUdpDownstreamSubscriber extends IotProtocolDownstreamSubscriber { + + private final IotUdpDownstreamHandler downstreamHandler; + + public IotUdpDownstreamSubscriber(IotProtocol protocol, + IotUdpDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + super(protocol, messageBus); + this.downstreamHandler = downstreamHandler; + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + downstreamHandler.handle(message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java new file mode 100644 index 0000000000..dd41a52527 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java @@ -0,0 +1,376 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +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.exception.ServiceException; +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.protocol.udp.manager.IotUdpSessionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +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 org.springframework.util.Assert; + +import java.net.InetSocketAddress; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; + +/** + * UDP 上行消息处理器 + *

+ * 采用无状态 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 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 String serverId; + + /** + * 消息序列化器(处理业务消息序列化/反序列化) + */ + private final IotMessageSerializer serializer; + /** + * UDP 会话管理器 + */ + private final IotUdpSessionManager sessionManager; + + private final IotDeviceMessageService deviceMessageService; + private final IotDeviceService deviceService; + private final IotDeviceTokenService deviceTokenService; + private final IotDeviceCommonApi deviceApi; + + public IotUdpUpstreamHandler(String serverId, + IotUdpSessionManager sessionManager, + IotMessageSerializer serializer) { + Assert.notNull(serializer, "消息序列化器必须配置"); + Assert.notNull(sessionManager, "会话管理器不能为空"); + this.serverId = serverId; + this.sessionManager = sessionManager; + this.serializer = serializer; + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.deviceService = SpringUtil.getBean(IotDeviceService.class); + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); + } + + // TODO done @AI:vertx 有 udp 的实现么?当前已使用 Vert.x DatagramSocket 实现 + /** + * 处理 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(); + String addressKey = sessionManager.buildAddressKey(senderAddress); + log.debug("[handle][收到 UDP 数据包,来源: {},数据长度: {} 字节]", addressKey, data.length()); + processMessage(data, senderAddress, socket); + } + + /** + * 处理消息 + * + * @param buffer 消息 + * @param senderAddress 发送者地址 + * @param socket UDP Socket + */ + private void processMessage(Buffer buffer, InetSocketAddress senderAddress, DatagramSocket socket) { + String addressKey = sessionManager.buildAddressKey(senderAddress); + // 1.1 基础检查 + if (ArrayUtil.isEmpty(buffer)) { + return; + } + // 1.2 反序列化消息 + IotDeviceMessage message = serializer.deserialize(buffer.getBytes()); + if (message == null) { + sendErrorResponse(socket, senderAddress, null, null, BAD_REQUEST.getCode(), "消息反序列化失败"); + return; + } + + // 2. 根据消息类型路由处理 + try { + if (AUTH_METHOD.equals(message.getMethod())) { + // 认证请求 + handleAuthenticationRequest(message, senderAddress, socket); + } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) { + // 设备动态注册请求 + handleRegisterRequest(message, senderAddress, socket); + } else { + // 业务消息 + handleBusinessRequest(message, senderAddress, socket); + } + } catch (ServiceException e) { + // 业务异常,返回对应的错误码和错误信息 + log.warn("[processMessage][业务异常,来源: {},requestId: {},method: {},错误: {}]", + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + e.getCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + // 参数校验失败,返回 400 + log.warn("[processMessage][参数校验失败,来源: {},requestId: {},method: {},错误: {}]", + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + BAD_REQUEST.getCode(), e.getMessage()); + } catch (Exception e) { + // 其他异常,返回 500 + log.error("[processMessage][处理消息失败,来源: {},requestId: {},method: {}]", + addressKey, message.getRequestId(), message.getMethod(), e); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + } + } + + /** + * 处理认证请求 + * + * @param message 消息信息 + * @param senderAddress 发送者地址 + * @param socket UDP Socket + */ + @SuppressWarnings("DuplicatedCode") + private void handleAuthenticationRequest(IotDeviceMessage message, InetSocketAddress senderAddress, + DatagramSocket socket) { + String clientId = IdUtil.simpleUUID(); + // 1. 解析认证参数 + IotDeviceAuthReqDTO authParams = JsonUtils.convertObject(message.getParams(), IotDeviceAuthReqDTO.class); + Assert.notNull(authParams, "认证参数不能为空"); + Assert.hasText(authParams.getUsername(), "username 不能为空"); + Assert.hasText(authParams.getPassword(), "password 不能为空"); + + // 2.1 执行认证 + CommonResult authResult = deviceApi.authDevice(authParams); + authResult.checkError(); + if (!BooleanUtil.isTrue(authResult.getData())) { + throw exception(DEVICE_AUTH_FAIL); + } + // 2.2 解析设备信息 + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); + Assert.notNull(deviceInfo, "解析设备信息失败"); + // 2.3 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), + deviceInfo.getDeviceName()); + Assert.notNull(device, "设备不存在"); + + // 3. 生成 JWT Token(无状态) + String token = deviceTokenService.createToken(device.getProductKey(), device.getDeviceName()); + + // 4.1 注册会话 + registerSession(senderAddress, device, clientId); + // 4.2 发送上线消息 + sendOnlineMessage(device); + // 4.3 发送成功响应(包含 token) + sendSuccessResponse(socket, senderAddress, message.getRequestId(), AUTH_METHOD, + MapUtil.of("token", token)); + log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {},来源: {}]", + device.getId(), device.getDeviceName(), sessionManager.buildAddressKey(senderAddress)); + } + + /** + * 处理设备动态注册请求(一型一密,不需要认证) + * + * @param message 消息信息 + * @param senderAddress 发送者地址 + * @param socket UDP Socket + * @see 阿里云 - 一型一密 + */ + @SuppressWarnings("DuplicatedCode") + private void handleRegisterRequest(IotDeviceMessage message, InetSocketAddress senderAddress, + DatagramSocket socket) { + // 1. 解析注册参数 + IotDeviceRegisterReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceRegisterReqDTO.class); + Assert.notNull(params, "注册参数不能为空"); + Assert.hasText(params.getProductKey(), "productKey 不能为空"); + Assert.hasText(params.getDeviceName(), "deviceName 不能为空"); + + // 2. 调用动态注册 + CommonResult result = deviceApi.registerDevice(params); + result.checkError(); + + // 3. 发送成功响应 + sendSuccessResponse(socket, senderAddress, message.getRequestId(), + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), result.getData()); + log.info("[handleRegisterRequest][注册成功,来源: {},设备名: {}]", + sessionManager.buildAddressKey(senderAddress), params.getDeviceName()); + } + + /** + * 处理业务请求 + *

+ * 请求参数格式: + * - token:JWT 令牌 + * - body:实际请求内容(可以是 Map、List 或其他类型) + * + * @param message 消息信息 + * @param senderAddress 发送者地址 + * @param socket UDP Socket + */ + @SuppressWarnings("unchecked") + private void handleBusinessRequest(IotDeviceMessage message, InetSocketAddress senderAddress, + DatagramSocket socket) { + String addressKey = sessionManager.buildAddressKey(senderAddress); + // 1.1 从消息中提取 token 和 body + String token = null; + Object body = null; + if (message.getParams() instanceof Map) { + Map paramsMap = (Map) 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(), message.getMethod(), + UNAUTHORIZED.getCode(), "请先进行认证"); + return; + } + // 1.2 验证 token,获取设备信息 + IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token); + if (deviceInfo == null) { + log.warn("[handleBusinessRequest][token 无效或已过期,来源: {}]", addressKey); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + UNAUTHORIZED.getCode(), "token 无效或已过期"); + return; + } + // 1.3 获取设备详细信息 + 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(), message.getMethod(), + BAD_REQUEST.getCode(), "设备不存在"); + return; + } + + // 2. 更新会话地址(如有变化) + sessionManager.updateSessionAddress(device.getId(), senderAddress); + + // 3. 将 body 设置为实际的 params,发送消息到消息总线 + message.setParams(body); + deviceMessageService.sendDeviceMessage(message, device.getProductKey(), + device.getDeviceName(), serverId); + log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", + device.getId(), message.getMethod(), addressKey); + } + + /** + * 注册会话信息 + * + * @param address 设备地址 + * @param device 设备 + * @param clientId 客户端 ID + */ + private void registerSession(InetSocketAddress address, IotDeviceRespDTO device, String clientId) { + IotUdpSessionManager.SessionInfo sessionInfo = new IotUdpSessionManager.SessionInfo() + .setDeviceId(device.getId()) + .setProductKey(device.getProductKey()) + .setDeviceName(device.getDeviceName()) + .setAddress(address); + sessionManager.registerSession(device.getId(), sessionInfo); + } + + /** + * 发送设备上线消息 + * + * @param device 设备信息 + */ + private void sendOnlineMessage(IotDeviceRespDTO device) { + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + deviceMessageService.sendDeviceMessage(onlineMessage, device.getProductKey(), + device.getDeviceName(), serverId); + } + + // ===================== 发送响应消息 ===================== + + /** + * 发送成功响应 + * + * @param socket UDP Socket + * @param address 目标地址 + * @param requestId 请求 ID + * @param method 方法名 + * @param data 响应数据 + */ + private void sendSuccessResponse(DatagramSocket socket, InetSocketAddress address, + String requestId, String method, Object data) { + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, method, data, SUCCESS.getCode(), null); + writeResponse(socket, address, responseMessage); + } + + /** + * 发送错误响应 + * + * @param socket UDP Socket + * @param address 目标地址 + * @param requestId 请求 ID + * @param method 方法名 + * @param code 错误码 + * @param msg 错误消息 + */ + private void sendErrorResponse(DatagramSocket socket, InetSocketAddress address, + String requestId, String method, Integer code, String msg) { + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, method, null, code, msg); + writeResponse(socket, address, responseMessage); + } + + /** + * 写入响应到 Socket + * + * @param socket UDP Socket + * @param address 目标地址 + * @param responseMessage 响应消息 + */ + private void writeResponse(DatagramSocket socket, InetSocketAddress address, IotDeviceMessage responseMessage) { + try { + byte[] serializedData = serializer.serialize(responseMessage); + socket.send(Buffer.buffer(serializedData), address.getPort(), address.getHostString(), result -> { + if (result.failed()) { + log.error("[writeResponse][发送响应失败,地址: {}]", + sessionManager.buildAddressKey(address), result.cause()); + } + }); + } catch (Exception e) { + log.error("[writeResponse][发送响应异常,地址: {}]", + sessionManager.buildAddressKey(address), e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java new file mode 100644 index 0000000000..8195c99961 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager; + +import cn.hutool.core.util.ObjUtil; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.datagram.DatagramSocket; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; + +/** + * IoT 网关 UDP 会话管理器 + *

+ * 基于 Guava Cache 实现会话的自动过期清理: + * 1. 管理设备会话信息(设备 ID -> 地址映射) + * 2. 自动清理超时会话(expireAfterAccess) + * 3. 限制最大会话数(maximumSize) + * + * @author 芋道源码 + */ +@Slf4j +public class IotUdpSessionManager { + + /** + * 设备会话缓存:设备 ID -> 会话信息 + *

+ * 使用 Guava Cache 自动管理过期:expireAfterAccess:每次访问(get/put)自动刷新过期时间 + */ + private final Cache deviceSessionCache; + + private final int maxSessions; + + public IotUdpSessionManager(int maxSessions, long sessionTimeoutMs) { + this.maxSessions = maxSessions; + this.deviceSessionCache = CacheBuilder.newBuilder() + .maximumSize(maxSessions) + .expireAfterAccess(sessionTimeoutMs, TimeUnit.MILLISECONDS) + .build(); + } + + /** + * 注册设备会话 + * + * @param deviceId 设备 ID + * @param sessionInfo 会话信息 + */ + public void registerSession(Long deviceId, SessionInfo sessionInfo) { + // 检查是否为新设备,且会话数已达上限 + if (deviceSessionCache.getIfPresent(deviceId) == null + && deviceSessionCache.size() >= maxSessions) { + throw new IllegalStateException("会话数已达上限: " + maxSessions); + } + // 注册会话 + deviceSessionCache.put(deviceId, sessionInfo); + log.info("[registerSession][注册设备会话,设备 ID: {},地址: {},productKey: {},deviceName: {}]", + deviceId, buildAddressKey(sessionInfo.getAddress()), + sessionInfo.getProductKey(), sessionInfo.getDeviceName()); + } + + /** + * 获取会话信息 + *

+ * 注意:调用此方法会自动刷新会话的过期时间 + * + * @param deviceId 设备 ID + * @return 会话信息,不存在则返回 null + */ + public SessionInfo getSession(Long deviceId) { + return deviceSessionCache.getIfPresent(deviceId); + } + + /** + * 更新设备会话地址(设备地址变更时调用) + *

+ * 注意:getIfPresent 已自动刷新过期时间,无需重新 put + * + * @param deviceId 设备 ID + * @param newAddress 新地址 + */ + public void updateSessionAddress(Long deviceId, InetSocketAddress newAddress) { + // 地址未变化,无需更新 + SessionInfo sessionInfo = deviceSessionCache.getIfPresent(deviceId); + if (sessionInfo == null) { + return; + } + if (ObjUtil.equals(newAddress, sessionInfo.getAddress())) { + return; + } + + // 更新地址 + String oldAddressKey = buildAddressKey(sessionInfo.getAddress()); + sessionInfo.setAddress(newAddress); + log.debug("[updateSessionAddress][更新设备地址,设备 ID: {},旧地址: {},新地址: {}]", + deviceId, oldAddressKey, buildAddressKey(newAddress)); + } + + /** + * 发送消息到设备 + * + * @param deviceId 设备 ID + * @param data 数据 + * @param socket UDP Socket + * @return 是否发送成功 + */ + public boolean sendToDevice(Long deviceId, byte[] data, DatagramSocket socket) { + SessionInfo sessionInfo = deviceSessionCache.getIfPresent(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); + return; + } + log.error("[sendToDevice][发送消息失败,设备 ID: {},地址: {}]", + deviceId, buildAddressKey(address), result.cause()); + }); + return true; + } catch (Exception e) { + log.error("[sendToDevice][发送消息异常,设备 ID: {}]", deviceId, e); + return false; + } + } + + /** + * 构建地址 Key(用于日志输出) + * + * @param address 地址 + * @return 地址 Key + */ + public String buildAddressKey(InetSocketAddress address) { + return address.getHostString() + ":" + address.getPort(); + } + + /** + * 会话信息 + */ + @Data + public static class SessionInfo { + + /** + * 设备 ID + */ + private Long deviceId; + /** + * 产品 Key + */ + private String productKey; + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备地址 + */ + private InetSocketAddress address; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/package-info.java new file mode 100644 index 0000000000..b1fcaa3f9d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/package-info.java @@ -0,0 +1,6 @@ +/** + * UDP 协议实现包 + *

+ * 提供基于 Vert.x DatagramSocket 的 IoT 设备连接和消息处理功能 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketConfig.java new file mode 100644 index 0000000000..e64e11dc51 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketConfig.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * IoT WebSocket 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotWebSocketConfig { + + /** + * WebSocket 路径(默认:/ws) + */ + @NotEmpty(message = "WebSocket 路径不能为空") + private String path = "/ws"; + + /** + * 最大消息大小(字节,默认 64KB) + */ + @NotNull(message = "最大消息大小不能为空") + private Integer maxMessageSize = 65536; + /** + * 最大帧大小(字节,默认 64KB) + */ + @NotNull(message = "最大帧大小不能为空") + private Integer maxFrameSize = 65536; + + /** + * 空闲超时时间(秒,默认 60) + */ + @NotNull(message = "空闲超时时间不能为空") + private Integer idleTimeoutSeconds = 60; + + /** + * 是否启用 SSL(wss://) + */ + @NotNull(message = "是否启用 SSL 不能为空") + private Boolean sslEnabled = false; + + /** + * SSL 证书路径 + */ + private String sslCertPath; + + /** + * SSL 私钥路径 + */ + private String sslKeyPath; + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java new file mode 100644 index 0000000000..67d5608936 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java @@ -0,0 +1,205 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; + +import cn.hutool.core.util.ObjUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolInstanceProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream.IotWebSocketDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream.IotWebSocketDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.upstream.IotWebSocketUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; +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 org.springframework.util.Assert; + +/** + * IoT WebSocket 协议实现 + *

+ * 基于 Vert.x 实现 WebSocket 服务器,接收设备上行消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotWebSocketProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolInstanceProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * Vert.x 实例 + */ + private Vertx vertx; + /** + * WebSocket 服务器 + */ + private HttpServer httpServer; + /** + * WebSocket 连接管理器 + */ + private final IotWebSocketConnectionManager connectionManager; + + /** + * 下行消息订阅者 + */ + private final IotWebSocketDownstreamSubscriber downstreamSubscriber; + + /** + * 消息序列化器 + */ + private final IotMessageSerializer serializer; + + public IotWebSocketProtocol(ProtocolInstanceProperties properties) { + Assert.notNull(properties, "协议实例配置不能为空"); + Assert.notNull(properties.getWebsocket(), "WebSocket 协议配置(websocket)不能为空"); + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化序列化器 + IotSerializeTypeEnum serializeType = IotSerializeTypeEnum.of(properties.getSerialize()); + Assert.notNull(serializeType, "不支持的序列化类型:" + properties.getSerialize()); + IotMessageSerializerManager serializerManager = SpringUtil.getBean(IotMessageSerializerManager.class); + this.serializer = serializerManager.get(serializeType); + + // 初始化连接管理器 + this.connectionManager = new IotWebSocketConnectionManager(); + + // 初始化下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotWebSocketDownstreamHandler downstreamHandler = new IotWebSocketDownstreamHandler(serializer, connectionManager); + this.downstreamSubscriber = new IotWebSocketDownstreamSubscriber(this, downstreamHandler, messageBus); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.WEBSOCKET; + } + + @Override + @SuppressWarnings("deprecation") + public void start() { + if (running) { + log.warn("[start][IoT WebSocket 协议 {} 已经在运行中]", getId()); + return; + } + + // 1.1 创建 Vertx 实例 + this.vertx = Vertx.vertx(); + + // 1.2 创建服务器选项 + IotWebSocketConfig wsConfig = properties.getWebsocket(); + HttpServerOptions options = new HttpServerOptions() + .setPort(properties.getPort()) + .setIdleTimeout(wsConfig.getIdleTimeoutSeconds()) + .setMaxWebSocketFrameSize(wsConfig.getMaxFrameSize()) + .setMaxWebSocketMessageSize(wsConfig.getMaxMessageSize()); + if (Boolean.TRUE.equals(wsConfig.getSslEnabled())) { + PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions() + .setKeyPath(wsConfig.getSslKeyPath()) + .setCertPath(wsConfig.getSslCertPath()); + options.setSsl(true).setKeyCertOptions(pemKeyCertOptions); + } + + // 1.3 创建服务器并设置 WebSocket 处理器 + httpServer = vertx.createHttpServer(options); + httpServer.webSocketHandler(socket -> { + // 验证路径 + if (ObjUtil.notEqual(wsConfig.getPath(), socket.path())) { + log.warn("[webSocketHandler][WebSocket 路径不匹配,拒绝连接,路径: {},期望: {}]", + socket.path(), wsConfig.getPath()); + socket.reject(); + return; + } + // 创建上行处理器 + IotWebSocketUpstreamHandler handler = new IotWebSocketUpstreamHandler(serverId, serializer, connectionManager); + handler.handle(socket); + }); + + // 1.4 启动服务器 + try { + httpServer.listen().result(); + running = true; + log.info("[start][IoT WebSocket 协议 {} 启动成功,端口:{},路径:{},serverId:{}]", + getId(), properties.getPort(), wsConfig.getPath(), serverId); + + // 2. 启动下行消息订阅者 + downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT WebSocket 协议 {} 启动失败]", getId(), e); + if (httpServer != null) { + httpServer.close(); + httpServer = null; + } + if (vertx != null) { + vertx.close(); + vertx = null; + } + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + // 1. 停止下行消息订阅者 + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT WebSocket 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT WebSocket 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + + // 2.1 关闭 WebSocket 服务器 + if (httpServer != null) { + try { + httpServer.close().result(); + log.info("[stop][IoT WebSocket 协议 {} 服务器已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT WebSocket 协议 {} 服务器停止失败]", getId(), e); + } + httpServer = null; + } + // 2.2 关闭 Vertx 实例 + if (vertx != null) { + try { + vertx.close().result(); + log.info("[stop][IoT WebSocket 协议 {} Vertx 已关闭]", getId()); + } catch (Exception e) { + log.error("[stop][IoT WebSocket 协议 {} Vertx 关闭失败]", getId(), e); + } + vertx = null; + } + running = false; + log.info("[stop][IoT WebSocket 协议 {} 已停止]", getId()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java new file mode 100644 index 0000000000..6391fd42fb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 WebSocket 下行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +@RequiredArgsConstructor +public class IotWebSocketDownstreamHandler { + + private final IotMessageSerializer serializer; + + 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 = serializer.serialize(message); + String bytesContent = StrUtil.utf8Str(bytes); + + // 3. 发送到设备 + boolean success = connectionManager.sendToDevice(connectionInfo.getDeviceId(), bytesContent); + if (!success) { + throw new RuntimeException("下行消息发送失败"); + } + log.info("[handle][下行消息发送成功,设备 ID: {},方法: {},消息 ID: {},数据长度: {} 字节]", + message.getDeviceId(), message.getMethod(), message.getId(), bytes.length); + } catch (Exception e) { + log.error("[handle][处理下行消息失败,设备 ID: {},方法: {},消息内容: {}]", + message.getDeviceId(), message.getMethod(), message, e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java new file mode 100644 index 0000000000..efe5f437e8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketProtocol; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT 网关 WebSocket 下游订阅者:接收下行给设备的消息 + * + * @author 芋道源码 + */ +@Slf4j +public class IotWebSocketDownstreamSubscriber extends IotProtocolDownstreamSubscriber { + + private final IotWebSocketDownstreamHandler downstreamHandler; + + public IotWebSocketDownstreamSubscriber(IotWebSocketProtocol protocol, + IotWebSocketDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + super(protocol, messageBus); + this.downstreamHandler = downstreamHandler; + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + downstreamHandler.handle(message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java new file mode 100644 index 0000000000..c838198115 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java @@ -0,0 +1,304 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.upstream; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +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.protocol.websocket.manager.IotWebSocketConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +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 org.springframework.util.Assert; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; + + +/** + * WebSocket 上行消息处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotWebSocketUpstreamHandler implements Handler { + + private static final String AUTH_METHOD = "auth"; + + private final String serverId; + + /** + * 消息序列化器(处理业务消息序列化/反序列化) + */ + private final IotMessageSerializer serializer; + /** + * 连接管理器 + */ + private final IotWebSocketConnectionManager connectionManager; + + private final IotDeviceMessageService deviceMessageService; + private final IotDeviceService deviceService; + private final IotDeviceCommonApi deviceApi; + + public IotWebSocketUpstreamHandler(String serverId, + IotMessageSerializer serializer, + IotWebSocketConnectionManager connectionManager) { + this.serverId = serverId; + this.serializer = serializer; + this.connectionManager = connectionManager; + this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.deviceService = SpringUtil.getBean(IotDeviceService.class); + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + } + + @Override + @SuppressWarnings("DuplicatedCode") + public void handle(ServerWebSocket socket) { + String remoteAddress = String.valueOf(socket.remoteAddress()); + log.debug("[handle][设备连接,地址: {}]", remoteAddress); + + // 1. 设置异常和关闭处理器 + socket.exceptionHandler(ex -> { + log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); + socket.close(); + }); + socket.closeHandler(v -> { + log.debug("[handle][连接关闭,地址: {}]", remoteAddress); + cleanupConnection(socket); + }); + + // 2. 设置消息处理器(仅支持文本帧) + socket.textMessageHandler(message -> { + try { + processMessage(StrUtil.utf8Bytes(message), socket); + } catch (Exception e) { + log.error("[handle][消息解码失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); + socket.close(); + } + }); + } + + /** + * 处理消息 + * + * @param payload 消息负载 + * @param socket WebSocket 连接 + */ + private void processMessage(byte[] payload, ServerWebSocket socket) { + IotDeviceMessage message = null; + try { + // 1.1 基础检查 + if (ArrayUtil.isEmpty(payload)) { + return; + } + // 1.2 解码消息 + message = serializer.deserialize(payload); + Assert.notNull(message, "消息反序列化失败"); + Assert.hasText(message.getMethod(), "method 不能为空"); + + // 2. 根据消息类型路由处理 + if (AUTH_METHOD.equals(message.getMethod())) { + handleAuthenticationRequest(message, socket); + } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) { + handleRegisterRequest(message, socket); + } else { + handleBusinessRequest(message, socket); + } + } catch (ServiceException e) { + log.warn("[processMessage][业务异常,错误: {}]", e.getMessage()); + String requestId = message != null ? message.getRequestId() : null; + String method = message != null ? message.getMethod() : null; + sendErrorResponse(socket, requestId, method, e.getCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + log.warn("[processMessage][参数校验失败,错误: {}]", e.getMessage()); + String requestId = message != null ? message.getRequestId() : null; + String method = message != null ? message.getMethod() : null; + sendErrorResponse(socket, requestId, method, BAD_REQUEST.getCode(), e.getMessage()); + } catch (Exception e) { + log.error("[processMessage][处理消息失败]", e); + String requestId = message != null ? message.getRequestId() : null; + String method = message != null ? message.getMethod() : null; + sendErrorResponse(socket, requestId, method, INTERNAL_SERVER_ERROR.getCode(), + INTERNAL_SERVER_ERROR.getMsg()); + throw e; + } + } + + /** + * 处理认证请求 + * + * @param message 消息信息 + * @param socket WebSocket 连接 + */ + @SuppressWarnings("DuplicatedCode") + private void handleAuthenticationRequest(IotDeviceMessage message, ServerWebSocket socket) { + // 1. 解析认证参数 + IotDeviceAuthReqDTO authParams = JsonUtils.convertObject(message.getParams(), IotDeviceAuthReqDTO.class); + Assert.notNull(authParams, "认证参数不能为空"); + Assert.hasText(authParams.getUsername(), "username 不能为空"); + Assert.hasText(authParams.getPassword(), "password 不能为空"); + + // 2.1 执行认证 + CommonResult authResult = deviceApi.authDevice(authParams); + authResult.checkError(); + if (BooleanUtil.isFalse(authResult.getData())) { + throw exception(DEVICE_AUTH_FAIL); + } + // 2.2 解析设备信息 + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authParams.getUsername()); + Assert.notNull(deviceInfo, "解析设备信息失败"); + // 2.3 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + Assert.notNull(device, "设备不存在"); + + // 3.1 注册连接 + registerConnection(socket, device); + // 3.2 发送上线消息 + sendOnlineMessage(device); + // 3.3 发送成功响应 + sendSuccessResponse(socket, message.getRequestId(), AUTH_METHOD, "认证成功"); + log.info("[handleAuthenticationRequest][认证成功,设备 ID: {},设备名: {}]", device.getId(), device.getDeviceName()); + } + + /** + * 处理设备动态注册请求(一型一密,不需要认证) + * + * @param message 消息信息 + * @param socket WebSocket 连接 + * @see 阿里云 - 一型一密 + */ + @SuppressWarnings("DuplicatedCode") + private void handleRegisterRequest(IotDeviceMessage message, ServerWebSocket socket) { + // 1. 解析注册参数 + IotDeviceRegisterReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceRegisterReqDTO.class); + Assert.notNull(params, "注册参数不能为空"); + Assert.hasText(params.getProductKey(), "productKey 不能为空"); + Assert.hasText(params.getDeviceName(), "deviceName 不能为空"); + + // 2. 调用动态注册 + CommonResult result = deviceApi.registerDevice(params); + result.checkError(); + + // 3. 发送成功响应(包含 deviceSecret) + sendSuccessResponse(socket, message.getRequestId(), + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), result.getData()); + log.info("[handleRegisterRequest][注册成功,设备名: {}]", params.getDeviceName()); + } + + /** + * 处理业务请求 + * + * @param message 消息信息 + * @param socket WebSocket 连接 + */ + private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket) { + // 1. 获取认证信息并处理业务消息 + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo == null) { + log.warn("[handleBusinessRequest][连接未认证,拒绝处理业务消息]"); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), + UNAUTHORIZED.getCode(), "设备未认证,无法处理业务消息"); + return; + } + + // 2. 发送消息到消息总线 + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message); + } + + /** + * 注册连接信息 + * + * @param socket WebSocket 连接 + * @param device 设备 + */ + private void registerConnection(ServerWebSocket socket, IotDeviceRespDTO device) { + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = new IotWebSocketConnectionManager.ConnectionInfo() + .setDeviceId(device.getId()) + .setProductKey(device.getProductKey()) + .setDeviceName(device.getDeviceName()); + 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 requestId 请求 ID + * @param method 请求方法 + * @param data 响应数据 + */ + private void sendSuccessResponse(ServerWebSocket socket, String requestId, String method, Object data) { + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, method, data, SUCCESS.getCode(), null); + writeResponse(socket, responseMessage); + } + + private void sendErrorResponse(ServerWebSocket socket, String requestId, String method, Integer code, String msg) { + IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(requestId, method, null, code, msg); + writeResponse(socket, responseMessage); + } + + /** + * 写入响应消息 + */ + private void writeResponse(ServerWebSocket socket, IotDeviceMessage responseMessage) { + byte[] payload = serializer.serialize(responseMessage); + socket.writeTextMessage(StrUtil.utf8Str(payload)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java new file mode 100644 index 0000000000..8b09da0f98 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java @@ -0,0 +1,139 @@ +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 java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * IoT 网关 WebSocket 连接管理器 + *

+ * 统一管理 WebSocket 连接的认证状态、设备会话和消息发送功能: + * 1. 管理 WebSocket 连接的认证状态 + * 2. 管理设备会话和在线状态 + * 3. 管理消息发送到设备 + * + * @author 芋道源码 + */ +@Slf4j +public class IotWebSocketConnectionManager { + + /** + * 连接信息映射:ServerWebSocket -> 连接信息 + */ + private final Map connectionMap = new ConcurrentHashMap<>(); + + /** + * 设备 ID -> ServerWebSocket 的映射 + */ + private final Map 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 中的 socket 是当前 socket 时才移除,避免误删新连接 + deviceSocketMap.remove(deviceId, socket); + 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; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotMessageSerializer.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotMessageSerializer.java new file mode 100644 index 0000000000..095dae0b6d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotMessageSerializer.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.iot.gateway.serialize; + +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; + +/** + * IoT 设备消息序列化器接口 + * + * 用于序列化和反序列化设备消息 + * + * @author 芋道源码 + */ +public interface IotMessageSerializer { + + /** + * 序列化消息 + * + * @param message 消息 + * @return 编码后的消息内容 + */ + byte[] serialize(IotDeviceMessage message); + + /** + * 反序列化消息 + * + * @param bytes 消息内容 + * @return 解码后的消息内容 + */ + IotDeviceMessage deserialize(byte[] bytes); + + /** + * 获取序列化类型 + * + * @return 序列化类型枚举 + */ + IotSerializeTypeEnum getType(); + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotMessageSerializerManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotMessageSerializerManager.java new file mode 100644 index 0000000000..0c072a5a1f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/IotMessageSerializerManager.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.iot.gateway.serialize; + +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.serialize.binary.IotBinarySerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +import lombok.extern.slf4j.Slf4j; + +import java.util.EnumMap; +import java.util.Map; + +/** + * IoT 序列化器管理器 + * + * 负责根据枚举创建和管理序列化器实例 + * + * @author 芋道源码 + */ +@Slf4j +public class IotMessageSerializerManager { + + private final Map serializerMap = new EnumMap<>(IotSerializeTypeEnum.class); + + public IotMessageSerializerManager() { + // 遍历枚举,创建对应的序列化器 + for (IotSerializeTypeEnum type : IotSerializeTypeEnum.values()) { + IotMessageSerializer serializer = createSerializer(type); + serializerMap.put(type, serializer); + log.info("[IotSerializerManager][序列化器 {} 创建成功]", type); + } + } + + /** + * 根据类型创建序列化器 + * + * @param type 序列化类型 + * @return 序列化器实例 + */ + @SuppressWarnings("EnhancedSwitchMigration") + private IotMessageSerializer createSerializer(IotSerializeTypeEnum type) { + switch (type) { + case JSON: + return new IotJsonSerializer(); + case BINARY: + return new IotBinarySerializer(); + default: + throw new IllegalArgumentException("未知的序列化类型:" + type); + } + } + + /** + * 获取序列化器 + * + * @param type 序列化类型 + * @return 序列化器实例 + */ + public IotMessageSerializer get(IotSerializeTypeEnum type) { + return serializerMap.get(type); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/binary/IotBinarySerializer.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/binary/IotBinarySerializer.java new file mode 100644 index 0000000000..7227c4d7f9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/binary/IotBinarySerializer.java @@ -0,0 +1,254 @@ +package cn.iocoder.yudao.module.iot.gateway.serialize.binary; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +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.serialize.IotMessageSerializer; +import io.vertx.core.buffer.Buffer; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; + +/** + * 二进制格式的消息序列化器 + * + * 二进制协议格式(所有数值使用大端序): + * + *

+ * +--------+--------+--------+---------------------------+--------+--------+
+ * | 魔术字 | 版本号 | 消息类型|         消息长度(4 字节)          |
+ * +--------+--------+--------+---------------------------+--------+--------+
+ * |           消息 ID 长度(2 字节)        |      消息 ID (变长字符串)         |
+ * +--------+--------+--------+--------+--------+--------+--------+--------+
+ * |           方法名长度(2 字节)        |      方法名(变长字符串)         |
+ * +--------+--------+--------+--------+--------+--------+--------+--------+
+ * |                        消息体数据(变长)                              |
+ * +--------+--------+--------+--------+--------+--------+--------+--------+
+ * 
+ * + * 消息体格式: + * - 请求消息:params 数据(JSON) + * - 响应消息:code (4字节) + msg 长度(2字节) + msg 字符串 + data 数据(JSON) + * + * @author 芋道源码 + */ +@Slf4j +public class IotBinarySerializer implements IotMessageSerializer { + + /** + * 协议魔术字,用于协议识别 + */ + private static final byte MAGIC_NUMBER = (byte) 0x7E; + + /** + * 协议版本号 + */ + private static final byte PROTOCOL_VERSION = (byte) 0x01; + + /** + * 请求消息类型 + */ + private static final byte REQUEST = (byte) 0x01; + + /** + * 响应消息类型 + */ + private static final byte RESPONSE = (byte) 0x02; + + /** + * 协议头部固定长度(魔术字 + 版本号 + 消息类型 + 消息长度) + */ + private static final int HEADER_FIXED_LENGTH = 7; + + /** + * 最小消息长度(头部 + 消息ID长度 + 方法名长度) + */ + private static final int MIN_MESSAGE_LENGTH = HEADER_FIXED_LENGTH + 4; + + @Override + public IotSerializeTypeEnum getType() { + return IotSerializeTypeEnum.BINARY; + } + + @Override + public byte[] serialize(IotDeviceMessage message) { + Assert.notNull(message, "消息不能为空"); + Assert.notBlank(message.getMethod(), "消息方法不能为空"); + try { + // 1. 确定消息类型 + byte messageType = determineMessageType(message); + // 2. 构建消息体 + byte[] bodyData = buildMessageBody(message, messageType); + // 3. 构建完整消息 + return buildCompleteMessage(message, messageType, bodyData); + } catch (Exception e) { + log.error("[encode][二进制消息编码失败,消息: {}]", message, e); + throw new RuntimeException("二进制消息编码失败: " + e.getMessage(), e); + } + } + + @Override + public IotDeviceMessage deserialize(byte[] bytes) { + Assert.notNull(bytes, "待解码数据不能为空"); + Assert.isTrue(bytes.length >= MIN_MESSAGE_LENGTH, "数据包长度不足"); + try { + Buffer buffer = Buffer.buffer(bytes); + int index = 0; + + // 1. 验证魔术字 + byte magic = buffer.getByte(index++); + Assert.isTrue(magic == MAGIC_NUMBER, "无效的协议魔术字: " + magic); + + // 2. 验证版本号 + byte version = buffer.getByte(index++); + Assert.isTrue(version == PROTOCOL_VERSION, "不支持的协议版本: " + version); + + // 3. 读取消息类型 + byte messageType = buffer.getByte(index++); + Assert.isTrue(messageType == REQUEST || messageType == RESPONSE, "无效的消息类型: " + messageType); + + // 4. 读取消息长度 + int messageLength = buffer.getInt(index); + index += 4; + Assert.isTrue(messageLength == buffer.length(), + "消息长度不匹配,期望: " + messageLength + ", 实际: " + buffer.length()); + + // 5. 读取消息 ID + short messageIdLength = buffer.getShort(index); + index += 2; + String messageId = buffer.getString(index, index + messageIdLength, StandardCharsets.UTF_8.name()); + index += messageIdLength; + + // 6. 读取方法名 + short methodLength = buffer.getShort(index); + index += 2; + String method = buffer.getString(index, index + methodLength, StandardCharsets.UTF_8.name()); + index += methodLength; + + // 7. 解析消息体 + return parseMessageBody(buffer, index, messageType, messageId, method); + } catch (Exception e) { + log.error("[decode][二进制消息解码失败,数据长度: {}]", bytes.length, e); + throw new RuntimeException("二进制消息解码失败: " + e.getMessage(), e); + } + } + + /** + * 快速检测是否为二进制格式 + * + * @param data 数据 + * @return 是否为二进制格式 + */ + public static boolean isBinaryFormat(byte[] data) { + return data != null && data.length >= 1 && data[0] == MAGIC_NUMBER; + } + + private byte determineMessageType(IotDeviceMessage message) { + if (message.getCode() != null) { + return RESPONSE; + } + return REQUEST; + } + + private byte[] buildMessageBody(IotDeviceMessage message, byte messageType) { + Buffer bodyBuffer = Buffer.buffer(); + if (messageType == RESPONSE) { + // code + bodyBuffer.appendInt(message.getCode() != null ? message.getCode() : 0); + // msg + String msg = message.getMsg() != null ? message.getMsg() : ""; + byte[] msgBytes = StrUtil.utf8Bytes(msg); + bodyBuffer.appendShort((short) msgBytes.length); + bodyBuffer.appendBytes(msgBytes); + // data + if (message.getData() != null) { + bodyBuffer.appendBytes(JsonUtils.toJsonByte(message.getData())); + } + } else { + // 请求消息只处理 params 参数 + if (message.getParams() != null) { + bodyBuffer.appendBytes(JsonUtils.toJsonByte(message.getParams())); + } + } + return bodyBuffer.getBytes(); + } + + private byte[] buildCompleteMessage(IotDeviceMessage message, byte messageType, byte[] bodyData) { + Buffer buffer = Buffer.buffer(); + // 1. 写入协议头部 + buffer.appendByte(MAGIC_NUMBER); + buffer.appendByte(PROTOCOL_VERSION); + buffer.appendByte(messageType); + // 2. 预留消息长度位置 + int lengthPosition = buffer.length(); + buffer.appendInt(0); + // 3. 写入消息 ID + String messageId = StrUtil.isNotBlank(message.getRequestId()) ? message.getRequestId() + : IotDeviceMessageUtils.generateMessageId(); + byte[] messageIdBytes = StrUtil.utf8Bytes(messageId); + buffer.appendShort((short) messageIdBytes.length); + buffer.appendBytes(messageIdBytes); + // 4. 写入方法名 + byte[] methodBytes = StrUtil.utf8Bytes(message.getMethod()); + buffer.appendShort((short) methodBytes.length); + buffer.appendBytes(methodBytes); + // 5. 写入消息体 + buffer.appendBytes(bodyData); + // 6. 更新消息长度 + buffer.setInt(lengthPosition, buffer.length()); + return buffer.getBytes(); + } + + private IotDeviceMessage parseMessageBody(Buffer buffer, int startIndex, byte messageType, + String messageId, String method) { + if (startIndex >= buffer.length()) { + return IotDeviceMessage.of(messageId, method, null, null, null, null); + } + + if (messageType == RESPONSE) { + return parseResponseMessage(buffer, startIndex, messageId, method); + } else { + Object payload = parseJsonData(buffer, startIndex, buffer.length()); + return IotDeviceMessage.of(messageId, method, payload, null, null, null); + } + } + + private IotDeviceMessage parseResponseMessage(Buffer buffer, int startIndex, String messageId, String method) { + int index = startIndex; + + // 1. 读取响应码 + Integer code = buffer.getInt(index); + index += 4; + + // 2. 读取响应消息 + short msgLength = buffer.getShort(index); + index += 2; + String msg = msgLength > 0 ? buffer.getString(index, index + msgLength, StandardCharsets.UTF_8.name()) : null; + index += msgLength; + + // 3. 读取响应数据 + Object data = null; + if (index < buffer.length()) { + data = parseJsonData(buffer, index, buffer.length()); + } + + return IotDeviceMessage.of(messageId, method, null, data, code, msg); + } + + private Object parseJsonData(Buffer buffer, int startIndex, int endIndex) { + if (startIndex >= endIndex) { + return null; + } + try { + String jsonStr = buffer.getString(startIndex, endIndex, StandardCharsets.UTF_8.name()); + return JsonUtils.parseObject(jsonStr, Object.class); + } catch (Exception e) { + log.warn("[parseJsonData][JSON 解析失败,返回原始字符串]", e); + return buffer.getString(startIndex, endIndex, StandardCharsets.UTF_8.name()); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/json/IotJsonSerializer.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/json/IotJsonSerializer.java new file mode 100644 index 0000000000..7fa657075c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/serialize/json/IotJsonSerializer.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.iot.gateway.serialize.json; + +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; + +/** + * JSON 格式的消息序列化器 + * + * 直接使用 JsonUtils 序列化/反序列化 {@link IotDeviceMessage},不包装额外字段 + * + * @author 芋道源码 + */ +public class IotJsonSerializer implements IotMessageSerializer { + + @Override + public IotSerializeTypeEnum getType() { + return IotSerializeTypeEnum.JSON; + } + + @Override + public byte[] serialize(IotDeviceMessage message) { + Assert.notNull(message, "消息不能为空"); + return JsonUtils.toJsonByte(message); + } + + @Override + public IotDeviceMessage deserialize(byte[] bytes) { + Assert.notNull(bytes, "待解码数据不能为空"); + IotDeviceMessage message = JsonUtils.parseObject(bytes, IotDeviceMessage.class); + Assert.notNull(message, "消息解码失败"); + return message; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java index 9aab67236b..6864c8de73 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenService.java @@ -1,6 +1,6 @@ package cn.iocoder.yudao.module.iot.gateway.service.auth; -import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; /** * IoT 设备 Token Service 接口 @@ -24,7 +24,7 @@ public interface IotDeviceTokenService { * @param token 设备 Token * @return 设备信息 */ - IotDeviceAuthUtils.DeviceInfo verifyToken(String token); + IotDeviceIdentity verifyToken(String token); /** * 解析用户名 @@ -32,6 +32,6 @@ public interface IotDeviceTokenService { * @param username 用户名 * @return 设备信息 */ - IotDeviceAuthUtils.DeviceInfo parseUsername(String username); + IotDeviceIdentity parseUsername(String username); } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java index 79ba4e77e7..cc6e3fd37b 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/auth/IotDeviceTokenServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.json.JSONObject; import cn.hutool.jwt.JWT; import cn.hutool.jwt.JWTUtil; import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import jakarta.annotation.Resource; @@ -48,7 +49,7 @@ public class IotDeviceTokenServiceImpl implements IotDeviceTokenService { } @Override - public IotDeviceAuthUtils.DeviceInfo verifyToken(String token) { + public IotDeviceIdentity verifyToken(String token) { Assert.notBlank(token, "token 不能为空"); // 校验 JWT Token boolean verify = JWTUtil.verify(token, gatewayProperties.getToken().getSecret().getBytes()); @@ -68,11 +69,11 @@ public class IotDeviceTokenServiceImpl implements IotDeviceTokenService { String deviceName = payload.getStr("deviceName"); Assert.notBlank(productKey, "productKey 不能为空"); Assert.notBlank(deviceName, "deviceName 不能为空"); - return new IotDeviceAuthUtils.DeviceInfo().setProductKey(productKey).setDeviceName(deviceName); + return new IotDeviceIdentity(productKey, deviceName); } @Override - public IotDeviceAuthUtils.DeviceInfo parseUsername(String username) { + public IotDeviceIdentity parseUsername(String username) { return IotDeviceAuthUtils.parseUsername(username); } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java index faaacca563..dfda30db40 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java @@ -7,7 +7,13 @@ import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; +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.topic.auth.IotSubDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; + +import java.util.List; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -62,6 +68,16 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi { return doPost("/rpc-api/iot/modbus/enabled-configs", null, new ParameterizedTypeReference<>() { }); } + @Override + public CommonResult registerDevice(IotDeviceRegisterReqDTO reqDTO) { + return doPost("/register", reqDTO, new ParameterizedTypeReference<>() { }); + } + + @Override + public CommonResult> registerSubDevices(IotSubDeviceRegisterFullReqDTO reqDTO) { + return doPost("/register-sub", reqDTO, new ParameterizedTypeReference<>() { }); + } + private CommonResult doPost(String url, T body, ParameterizedTypeReference> responseType) { try { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml index 4a000ff560..705c34fbb8 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml @@ -42,14 +42,80 @@ yudao: secret: yudaoIotGatewayTokenSecret123456789 # Token 密钥,至少32位 expiration: 7d - # 协议配置 - protocol: + # 协议实例列表 + protocols: # ==================================== # 针对引入的 HTTP 组件的配置 # ==================================== - http: + - id: http-json + type: http + port: 8092 enabled: false - server-port: 8092 + http: + ssl-enabled: false + # ==================================== + # 针对引入的 TCP 组件的配置 + # ==================================== + - id: tcp-json + type: tcp + port: 8091 + enabled: false + serialize: json + tcp: + max-connections: 1000 + keep-alive-timeout-ms: 30000 + ssl-enabled: false + codec: + type: delimiter # 拆包类型:length_field / delimiter / fixed_length + delimiter: "\\n" # 分隔符(支持转义:\\n=换行, \\r=回车, \\t=制表符) +# type: length_field # 拆包类型:length_field / delimiter / fixed_length +# length-field-offset: 0 # 长度字段偏移量 +# length-field-length: 4 # 长度字段长度 +# length-adjustment: 0 # 长度调整值 +# initial-bytes-to-strip: 4 # 初始跳过的字节数 +# type: fixed_length # 拆包类型:length_field / delimiter / fixed_length +# fixed-length: 256 # 固定长度 + # ==================================== + # 针对引入的 UDP 组件的配置 + # ==================================== + - id: udp-json + type: udp + port: 8093 + enabled: false + serialize: json + udp: + max-sessions: 1000 # 最大会话数 + session-timeout-ms: 60000 # 会话超时时间(毫秒),基于 Guava Cache 自动过期 + receive-buffer-size: 65536 # 接收缓冲区大小(字节) + send-buffer-size: 65536 # 发送缓冲区大小(字节) + # ==================================== + # 针对引入的 WebSocket 组件的配置 + # ==================================== + - id: websocket-json + type: websocket + port: 8094 + enabled: true + serialize: json + websocket: + path: /ws + max-message-size: 65536 # 最大消息大小(字节,默认 64KB) + max-frame-size: 65536 # 最大帧大小(字节,默认 64KB) + idle-timeout-seconds: 60 # 空闲超时时间(秒,默认 60) + ssl-enabled: false # 是否启用 SSL(wss://) + # ==================================== + # 针对引入的 CoAP 组件的配置 + # ==================================== + - id: coap-json + type: coap + port: 5683 + enabled: true + coap: + max-message-size: 1024 # 最大消息大小(字节) + ack-timeout-ms: 2000 # ACK 超时时间(毫秒) + max-retransmit: 4 # 最大重传次数 + + # 协议配置(旧版,保持兼容) + protocol: # ==================================== # 针对引入的 EMQX 组件的配置 # ==================================== @@ -85,17 +151,6 @@ yudao: trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径 trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码 # ==================================== - # 针对引入的 TCP 组件的配置 - # ==================================== - tcp: - enabled: false - port: 8091 - keep-alive-timeout-ms: 30000 - max-connections: 1000 - ssl-enabled: false - ssl-cert-path: "classpath:certs/client.jks" - ssl-key-path: "classpath:certs/client.jks" - # ==================================== # 针对引入的 MQTT 组件的配置 # ==================================== mqtt: @@ -140,6 +195,8 @@ logging: cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG + cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG + cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG # 根日志级别 root: INFO diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotDirectDeviceCoapProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotDirectDeviceCoapProtocolIntegrationTest.java new file mode 100644 index 0000000000..6c852affca --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotDirectDeviceCoapProtocolIntegrationTest.java @@ -0,0 +1,228 @@ +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 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 static cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapAbstractHandler.OPTION_TOKEN; + +/** + * IoT 直连设备 CoAP 协议集成测试(手动测试) + * + *

测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 CoAP 协议直接连接平台 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(CoAP 端口 5683)
  2. + *
  3. 运行 {@link #testDeviceRegister()} 测试直连设备动态注册(一型一密)
  4. + *
  5. 运行 {@link #testAuth()} 获取设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量
  6. + *
  7. 运行以下测试方法: + *
      + *
    • {@link #testPropertyPost()} - 设备属性上报
    • + *
    • {@link #testEventPost()} - 设备事件上报
    • + *
    + *
  8. + *
+ * + * @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.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(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.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(OPTION_TOKEN, TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testEventPost][响应码: {}]", response.getCode()); + log.info("[testEventPost][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + + // ===================== 动态注册测试 ===================== + + /** + * 直连设备动态注册测试(一型一密) + *

+ * 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret) + *

+ * 注意:此接口不需要 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(); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotGatewayDeviceCoapProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotGatewayDeviceCoapProtocolIntegrationTest.java new file mode 100644 index 0000000000..f350325dd8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotGatewayDeviceCoapProtocolIntegrationTest.java @@ -0,0 +1,377 @@ +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 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; + +import static cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapAbstractHandler.OPTION_TOKEN; + +/** + * IoT 网关设备 CoAP 协议集成测试(手动测试) + * + *

测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 CoAP 协议管理子设备拓扑关系 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(CoAP 端口 5683)
  2. + *
  3. 运行 {@link #testAuth()} 获取网关设备 token,将返回的 token 粘贴到 {@link #GATEWAY_TOKEN} 常量
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testTopoAdd()} - 添加子设备拓扑关系
    • + *
    • {@link #testTopoDelete()} - 删除子设备拓扑关系
    • + *
    • {@link #testTopoGet()} - 获取子设备拓扑关系
    • + *
    • {@link #testSubDeviceRegister()} - 子设备动态注册
    • + *
    • {@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)
    • + *
    + *
  6. + *
+ * + * @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(); + } + } + + // ===================== 拓扑管理测试 ===================== + + /** + * 添加子设备拓扑关系测试 + *

+ * 网关设备向平台上报需要绑定的子设备信息 + */ + @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(OPTION_TOKEN, GATEWAY_TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testTopoAdd][响应码: {}]", response.getCode()); + log.info("[testTopoAdd][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + + /** + * 删除子设备拓扑关系测试 + *

+ * 网关设备向平台上报需要解绑的子设备信息 + */ + @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(OPTION_TOKEN, GATEWAY_TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testTopoDelete][响应码: {}]", response.getCode()); + log.info("[testTopoDelete][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + + /** + * 获取子设备拓扑关系测试 + *

+ * 网关设备向平台查询已绑定的子设备列表 + */ + @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(OPTION_TOKEN, GATEWAY_TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testTopoGet][响应码: {}]", response.getCode()); + log.info("[testTopoGet][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + + // ===================== 子设备注册测试 ===================== + + /** + * 子设备动态注册测试 + *

+ * 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret + *

+ * 注意:此接口需要网关 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(OPTION_TOKEN, GATEWAY_TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testSubDeviceRegister][响应码: {}]", response.getCode()); + log.info("[testSubDeviceRegister][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + + // ===================== 批量上报测试 ===================== + + /** + * 批量上报属性测试(网关 + 子设备) + *

+ * 网关设备批量上报自身属性、事件,以及子设备的属性、事件 + */ + @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 gatewayProperties = MapUtil.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 gatewayEvents = MapUtil.builder() + .put("statusReport", gatewayEvent) + .build(); + // 1.4 构建【网关子设备】属性 + Map subDeviceProperties = MapUtil.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 subDeviceEvents = MapUtil.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(OPTION_TOKEN, GATEWAY_TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testPropertyPackPost][响应码: {}]", response.getCode()); + log.info("[testPropertyPackPost][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotGatewaySubDeviceCoapProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotGatewaySubDeviceCoapProtocolIntegrationTest.java new file mode 100644 index 0000000000..4d909a2d29 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotGatewaySubDeviceCoapProtocolIntegrationTest.java @@ -0,0 +1,200 @@ +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 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 static cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.IotCoapAbstractHandler.OPTION_TOKEN; + +/** + * IoT 网关子设备 CoAP 协议集成测试(手动测试) + * + *

测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 + * + *

重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。 + *

网关设备转发子设备请求时,Token 使用子设备自己的信息。 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(CoAP 端口 5683)
  2. + *
  3. 确保子设备已通过 {@link IotGatewayDeviceCoapProtocolIntegrationTest#testTopoAdd()} 绑定到网关
  4. + *
  5. 运行 {@link #testAuth()} 获取子设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量
  6. + *
  7. 运行以下测试方法: + *
      + *
    • {@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)
    • + *
    • {@link #testEventPost()} - 子设备事件上报(由网关代理转发)
    • + *
    + *
  8. + *
+ * + * @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.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(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.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(OPTION_TOKEN, TOKEN)); + + CoapResponse response = client.advanced(request); + // 2.2 输出结果 + log.info("[testEventPost][响应码: {}]", response.getCode()); + log.info("[testEventPost][响应体: {}]", response.getResponseText()); + } finally { + client.shutdown(); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotDirectDeviceHttpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotDirectDeviceHttpProtocolIntegrationTest.java new file mode 100644 index 0000000000..ea412a2079 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotDirectDeviceHttpProtocolIntegrationTest.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import cn.hutool.core.map.MapUtil; +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 协议集成测试(手动测试) + * + *

测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 HTTP 协议直接连接平台 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(HTTP 端口 8092)
  2. + *
  3. 运行 {@link #testDeviceRegister()} 测试直连设备动态注册(一型一密)
  4. + *
  5. 运行 {@link #testAuth()} 获取设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量
  6. + *
  7. 运行以下测试方法: + *
      + *
    • {@link #testPropertyPost()} - 设备属性上报
    • + *
    • {@link #testEventPost()} - 设备事件上报
    • + *
    + *
  8. + *
+ * + * @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("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .put("params", IotDevicePropertyPostReqDTO.of(MapUtil.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("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod()) + .put("params", IotDeviceEventPostReqDTO.of( + "eat", + MapUtil.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()); + } + } + + // ===================== 动态注册测试 ===================== + + /** + * 直连设备动态注册测试(一型一密) + *

+ * 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret) + *

+ * 注意:此接口不需要 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() + .setProductKey(PRODUCT_KEY) + .setDeviceName("test-" + System.currentTimeMillis()) + .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 进行一机一密认证]"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotGatewayDeviceHttpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotGatewayDeviceHttpProtocolIntegrationTest.java new file mode 100644 index 0000000000..779c588b76 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotGatewayDeviceHttpProtocolIntegrationTest.java @@ -0,0 +1,299 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.map.MapUtil; +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 协议集成测试(手动测试) + * + *

测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 HTTP 协议管理子设备拓扑关系 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(HTTP 端口 8092)
  2. + *
  3. 运行 {@link #testAuth()} 获取网关设备 token,将返回的 token 粘贴到 {@link #GATEWAY_TOKEN} 常量
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testTopoAdd()} - 添加子设备拓扑关系
    • + *
    • {@link #testTopoDelete()} - 删除子设备拓扑关系
    • + *
    • {@link #testTopoGet()} - 获取子设备拓扑关系
    • + *
    • {@link #testSubDeviceRegister()} - 子设备动态注册
    • + *
    • {@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)
    • + *
    + *
  6. + *
+ * + * @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 常量中]"); + } + + // ===================== 拓扑管理测试 ===================== + + /** + * 添加子设备拓扑关系测试 + *

+ * 网关设备向平台上报需要绑定的子设备信息 + */ + @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("method", IotDeviceMessageMethodEnum.TOPO_ADD.getMethod()) + .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()); + } + } + + /** + * 删除子设备拓扑关系测试 + *

+ * 网关设备向平台上报需要解绑的子设备信息 + */ + @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("method", IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod()) + .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()); + } + } + + /** + * 获取子设备拓扑关系测试 + *

+ * 网关设备向平台查询已绑定的子设备列表 + */ + @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("method", IotDeviceMessageMethodEnum.TOPO_GET.getMethod()) + .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()); + } + } + + // ===================== 子设备注册测试 ===================== + + /** + * 子设备动态注册测试 + *

+ * 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret + *

+ * 注意:此接口需要网关 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("method", IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod()) + .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()); + } + } + + // ===================== 批量上报测试 ===================== + + /** + * 批量上报属性测试(网关 + 子设备) + *

+ * 网关设备批量上报自身属性、事件,以及子设备的属性、事件 + */ + @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 gatewayProperties = MapUtil.builder() + .put("temperature", 25.5) + .build(); + // 1.3 构建【网关设备】自身事件 + IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue() + .setValue(MapUtil.builder().put("message", "gateway started").build()) + .setTime(System.currentTimeMillis()); + Map gatewayEvents = MapUtil.builder() + .put("statusReport", gatewayEvent) + .build(); + // 1.4 构建【网关子设备】属性 + Map subDeviceProperties = MapUtil.builder() + .put("power", 100) + .build(); + // 1.5 构建【网关子设备】事件 + IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue() + .setValue(MapUtil.builder().put("errorCode", 0).build()) + .setTime(System.currentTimeMillis()); + Map subDeviceEvents = MapUtil.builder() + .put("healthCheck", subDeviceEvent) + .build(); + // 1.6 构建子设备数据 + IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData() + .setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)) + .setProperties(subDeviceProperties) + .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("method", IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod()) + .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()); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotGatewaySubDeviceHttpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotGatewaySubDeviceHttpProtocolIntegrationTest.java new file mode 100644 index 0000000000..f6b9399bcc --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotGatewaySubDeviceHttpProtocolIntegrationTest.java @@ -0,0 +1,157 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http; + +import cn.hutool.core.map.MapUtil; +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 协议集成测试(手动测试) + * + *

测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 + * + *

重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。 + *

网关设备转发子设备请求时,URL 和 Token 都使用子设备自己的信息。 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(HTTP 端口 8092)
  2. + *
  3. 确保子设备已通过 {@link IotGatewayDeviceHttpProtocolIntegrationTest#testTopoAdd()} 绑定到网关
  4. + *
  5. 运行 {@link #testAuth()} 获取子设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量
  6. + *
  7. 运行以下测试方法: + *
      + *
    • {@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)
    • + *
    • {@link #testEventPost()} - 子设备事件上报(由网关代理转发)
    • + *
    + *
  8. + *
+ * + * @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("method", IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .put("params", IotDevicePropertyPostReqDTO.of(MapUtil.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("method", IotDeviceMessageMethodEnum.EVENT_POST.getMethod()) + .put("params", IotDeviceEventPostReqDTO.of( + "alarm", + MapUtil.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()); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java new file mode 100644 index 0000000000..67a8ced4dd --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java @@ -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 协议集成测试(手动测试) + * + *

测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 MQTT 协议直接连接平台 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(MQTT 端口 1883)
  2. + *
  3. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 设备连接认证
    • + *
    • {@link #testPropertyPost()} - 设备属性上报
    • + *
    • {@link #testEventPost()} - 设备事件上报
    • + *
    • {@link #testSubscribe()} - 订阅下行消息
    • + *
    + *
  4. + *
+ * + *

注意: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.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.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); + } + + // ===================== 设备动态注册测试(一型一密) ===================== + + /** + * 直连设备动态注册测试(一型一密) + *

+ * 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret) + *

+ * 注意:此接口不需要认证 + */ + @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 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 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 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 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); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotGatewayDeviceMqttProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotGatewayDeviceMqttProtocolIntegrationTest.java new file mode 100644 index 0000000000..517206734c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotGatewayDeviceMqttProtocolIntegrationTest.java @@ -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 协议集成测试(手动测试) + * + *

测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 MQTT 协议管理子设备拓扑关系 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(MQTT 端口 1883)
  2. + *
  3. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 网关设备连接认证
    • + *
    • {@link #testTopoAdd()} - 添加子设备拓扑关系
    • + *
    • {@link #testTopoDelete()} - 删除子设备拓扑关系
    • + *
    • {@link #testTopoGet()} - 获取子设备拓扑关系
    • + *
    • {@link #testSubDeviceRegister()} - 子设备动态注册
    • + *
    • {@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)
    • + *
    + *
  4. + *
+ * + *

注意: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][测试超时]"); + } + } + + // ===================== 拓扑管理测试 ===================== + + /** + * 添加子设备拓扑关系测试 + *

+ * 网关设备向平台上报需要绑定的子设备信息 + */ + @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); + } + + /** + * 删除子设备拓扑关系测试 + *

+ * 网关设备向平台上报需要解绑的子设备信息 + */ + @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); + } + + /** + * 获取子设备拓扑关系测试 + *

+ * 网关设备向平台查询已绑定的子设备列表 + */ + @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); + } + + // ===================== 子设备注册测试 ===================== + + /** + * 子设备动态注册测试 + *

+ * 网关设备代理子设备进行动态注册,平台返回子设备的 deviceSecret + *

+ * 注意:此接口需要网关认证 + */ + @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); + } + + // ===================== 批量上报测试 ===================== + + /** + * 批量上报属性测试(网关 + 子设备) + *

+ * 网关设备批量上报自身属性、事件,以及子设备的属性、事件 + */ + @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 gatewayProperties = MapUtil.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 gatewayEvents = MapUtil + .builder() + .put("statusReport", gatewayEvent) + .build(); + + // 2.4 构建【网关子设备】属性 + Map subDeviceProperties = MapUtil.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 subDeviceEvents = MapUtil + .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 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 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 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 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); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotGatewaySubDeviceMqttProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotGatewaySubDeviceMqttProtocolIntegrationTest.java new file mode 100644 index 0000000000..c14d2c676b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotGatewaySubDeviceMqttProtocolIntegrationTest.java @@ -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 协议集成测试(手动测试) + * + *

测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 + * + *

重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。 + *

网关设备转发子设备请求时,使用子设备自己的认证信息连接。 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(MQTT 端口 1883)
  2. + *
  3. 确保子设备已通过 {@link IotGatewayDeviceMqttProtocolIntegrationTest#testTopoAdd()} 绑定到网关
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 子设备连接认证
    • + *
    • {@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)
    • + *
    • {@link #testEventPost()} - 子设备事件上报(由网关代理转发)
    • + *
    + *
  6. + *
+ * + *

注意: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.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.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 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 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 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 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); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotDirectDeviceTcpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotDirectDeviceTcpProtocolIntegrationTest.java new file mode 100644 index 0000000000..192dce359c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotDirectDeviceTcpProtocolIntegrationTest.java @@ -0,0 +1,286 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; + +import cn.hutool.core.map.MapUtil; +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.protocol.tcp.codec.IotTcpCodecTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodecFactory; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +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.TimeUnit; + +/** + * IoT 直连设备 TCP 协议集成测试(手动测试) + * + *

测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 TCP 协议直接连接平台 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(TCP 端口 8091)
  2. + *
  3. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 设备认证
    • + *
    • {@link #testDeviceRegister()} - 设备动态注册(一型一密)
    • + *
    • {@link #testPropertyPost()} - 设备属性上报
    • + *
    • {@link #testEventPost()} - 设备事件上报
    • + *
    + *
  4. + *
+ * + *

注意: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; + + private static Vertx vertx; + private static NetClient netClient; + + // ===================== 编解码器 ===================== + + /** + * 消息序列化器 + */ + private static final IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + /** + * TCP 帧编解码器 + */ + private static final IotTcpFrameCodec FRAME_CODEC = IotTcpFrameCodecFactory.create( + new IotTcpConfig.CodecConfig() + .setType(IotTcpCodecTypeEnum.DELIMITER.getType()) + .setDelimiter("\\n") +// .setType(IotTcpCodecTypeEnum.LENGTH_FIELD.getType()) +// .setLengthFieldOffset(0) +// .setLengthFieldLength(4) +// .setLengthAdjustment(0) +// .setInitialBytesToStrip(4) +// .setType(IotTcpCodecTypeEnum.LENGTH_FIELD.getType()) +// .setFixedLength(256) + ); + + // ===================== 直连设备信息(根据实际情况修改,从 iot_device 表查询) ===================== + + private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT"; + private static final String DEVICE_NAME = "small"; + private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3"; + + @BeforeAll + static void setUp() { + vertx = Vertx.vertx(); + NetClientOptions options = new NetClientOptions() + .setConnectTimeout(TIMEOUT_MS) + .setIdleTimeout(TIMEOUT_MS); + netClient = vertx.createNetClient(options); + } + + @AfterAll + static void tearDown() { + if (netClient != null) { + netClient.close(); + } + if (vertx != null) { + vertx.close(); + } + } + + // ===================== 认证测试 ===================== + + /** + * 认证测试:获取设备 Token + */ + @Test + public void testAuth() throws Exception { + // 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.requestOf("auth", authReqDTO); + + // 2. 发送并接收响应 + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testAuth][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 动态注册测试 ===================== + + /** + * 直连设备动态注册测试(一型一密) + *

+ * 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret) + *

+ * 注意:此接口不需要认证 + */ + @Test + public void testDeviceRegister() throws Exception { + // 1. 构建注册消息 + IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO() + .setProductKey(PRODUCT_KEY) + .setDeviceName("test-tcp-" + System.currentTimeMillis()) + .setProductSecret("test-product-secret"); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO); + + // 2. 发送并接收响应 + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testDeviceRegister][响应消息: {}]", response); + log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]"); + } finally { + socket.close(); + } + } + + // ===================== 直连设备属性上报测试 ===================== + + /** + * 属性上报测试 + */ + @Test + public void testPropertyPost() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testPropertyPost][认证响应: {}]", authResponse); + + // 2. 构建属性上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), + IotDevicePropertyPostReqDTO.of(MapUtil.builder() + .put("width", 1) + .put("height", "2") + .build())); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testPropertyPost][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 直连设备事件上报测试 ===================== + + /** + * 事件上报测试 + */ + @Test + public void testEventPost() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testEventPost][认证响应: {}]", authResponse); + + // 2. 构建事件上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), + IotDeviceEventPostReqDTO.of( + "eat", + MapUtil.builder().put("rice", 3).build(), + System.currentTimeMillis())); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testEventPost][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 辅助方法 ===================== + + /** + * 建立 TCP 连接 + * + * @return 连接 Future + */ + private CompletableFuture connect() { + CompletableFuture future = new CompletableFuture<>(); + netClient.connect(SERVER_PORT, SERVER_HOST) + .onSuccess(future::complete) + .onFailure(future::completeExceptionally); + return future; + } + + /** + * 执行设备认证 + * + * @param socket TCP 连接 + * @return 认证响应消息 + */ + private IotDeviceMessage authenticate(NetSocket socket) throws Exception { + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); + IotDeviceMessage request = IotDeviceMessage.requestOf("auth", authInfo); + return sendAndReceive(socket, request); + } + + /** + * 发送消息并接收响应 + * + * @param socket TCP 连接 + * @param request 请求消息 + * @return 响应消息 + */ + private IotDeviceMessage sendAndReceive(NetSocket socket, IotDeviceMessage request) throws Exception { + // 1. 使用 FRAME_CODEC 创建解码器 + CompletableFuture responseFuture = new CompletableFuture<>(); + RecordParser parser = FRAME_CODEC.createDecodeParser(buffer -> { + try { + // 反序列化响应 + IotDeviceMessage response = SERIALIZER.deserialize(buffer.getBytes()); + responseFuture.complete(response); + } catch (Exception e) { + responseFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 2.1 序列化 + 帧编码 + byte[] serializedData = SERIALIZER.serialize(request); + Buffer frameData = FRAME_CODEC.encode(serializedData); + log.info("[sendAndReceive][发送消息: {},数据长度: {} 字节]", request.getMethod(), frameData.length()); + // 2.2 发送请求 + socket.write(frameData); + + // 3. 等待响应 + IotDeviceMessage response = responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + log.info("[sendAndReceive][收到响应,数据长度: {} 字节]", SERIALIZER.serialize(response).length); + return response; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotGatewayDeviceTcpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotGatewayDeviceTcpProtocolIntegrationTest.java new file mode 100644 index 0000000000..5bb113b919 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotGatewayDeviceTcpProtocolIntegrationTest.java @@ -0,0 +1,390 @@ +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.protocol.tcp.codec.IotTcpCodecTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodecFactory; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +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.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * IoT 网关设备 TCP 协议集成测试(手动测试) + * + *

测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 TCP 协议管理子设备拓扑关系 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(TCP 端口 8091)
  2. + *
  3. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 网关设备认证
    • + *
    • {@link #testTopoAdd()} - 添加子设备拓扑关系
    • + *
    • {@link #testTopoDelete()} - 删除子设备拓扑关系
    • + *
    • {@link #testTopoGet()} - 获取子设备拓扑关系
    • + *
    • {@link #testSubDeviceRegister()} - 子设备动态注册
    • + *
    • {@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)
    • + *
    + *
  4. + *
+ * + *

注意: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; + + private static Vertx vertx; + private static NetClient netClient; + + // ===================== 编解码器 ===================== + + /** + * 消息序列化器 + */ + private static final IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + /** + * TCP 帧编解码器 + */ + private static final IotTcpFrameCodec FRAME_CODEC = IotTcpFrameCodecFactory.create( + new IotTcpConfig.CodecConfig() + .setType(IotTcpCodecTypeEnum.DELIMITER.getType()) + .setDelimiter("\\n") +// .setType(IotTcpCodecTypeEnum.LENGTH_FIELD.getType()) +// .setLengthFieldOffset(0) +// .setLengthFieldLength(4) +// .setLengthAdjustment(0) +// .setInitialBytesToStrip(4) +// .setType(IotTcpCodecTypeEnum.LENGTH_FIELD.getType()) +// .setFixedLength(256) + ); + + // ===================== 网关设备信息(根据实际情况修改,从 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 + static void setUp() { + vertx = Vertx.vertx(); + NetClientOptions options = new NetClientOptions() + .setConnectTimeout(TIMEOUT_MS) + .setIdleTimeout(TIMEOUT_MS); + netClient = vertx.createNetClient(options); + } + + @AfterAll + static void tearDown() { + if (netClient != null) { + netClient.close(); + } + if (vertx != null) { + vertx.close(); + } + } + + // ===================== 认证测试 ===================== + + /** + * 网关设备认证测试:获取网关设备 Token + */ + @Test + public void testAuth() throws Exception { + // 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.requestOf("auth", authReqDTO); + + // 2. 发送并接收响应 + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testAuth][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 拓扑管理测试 ===================== + + /** + * 添加子设备拓扑关系测试 + */ + @Test + public void testTopoAdd() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 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.requestOf( + IdUtil.fastSimpleUUID(), + IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(), + params); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testTopoAdd][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + /** + * 删除子设备拓扑关系测试 + */ + @Test + public void testTopoDelete() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testTopoDelete][认证响应: {}]", authResponse); + + // 2. 构建请求参数 + IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO(); + params.setSubDevices(Collections.singletonList( + new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME))); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(), + params); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testTopoDelete][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + /** + * 获取子设备拓扑关系测试 + */ + @Test + public void testTopoGet() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testTopoGet][认证响应: {}]", authResponse); + + // 2. 构建请求参数 + IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO(); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.TOPO_GET.getMethod(), + params); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testTopoGet][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 子设备注册测试 ===================== + + /** + * 子设备动态注册测试 + */ + @Test + public void testSubDeviceRegister() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testSubDeviceRegister][认证响应: {}]", authResponse); + + // 2. 构建请求参数 + IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO() + .setProductKey(SUB_DEVICE_PRODUCT_KEY) + .setDeviceName("mougezishebei"); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(), + Collections.singletonList(subDevice)); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testSubDeviceRegister][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 批量上报测试 ===================== + + /** + * 批量上报属性测试(网关 + 子设备) + */ + @Test + public void testPropertyPackPost() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testPropertyPackPost][认证响应: {}]", authResponse); + + // 2.1 构建【网关设备】自身属性 + Map gatewayProperties = MapUtil.builder() + .put("temperature", 25.5) + .build(); + // 2.2 构建【网关设备】自身事件 + IotDevicePropertyPackPostReqDTO.EventValue gatewayEvent = new IotDevicePropertyPackPostReqDTO.EventValue() + .setValue(MapUtil.builder().put("message", "gateway started").build()) + .setTime(System.currentTimeMillis()); + Map gatewayEvents = MapUtil.builder() + .put("statusReport", gatewayEvent) + .build(); + // 2.3 构建【网关子设备】属性 + Map subDeviceProperties = MapUtil.builder() + .put("power", 100) + .build(); + // 2.4 构建【网关子设备】事件 + IotDevicePropertyPackPostReqDTO.EventValue subDeviceEvent = new IotDevicePropertyPackPostReqDTO.EventValue() + .setValue(MapUtil.builder().put("errorCode", 0).build()) + .setTime(System.currentTimeMillis()); + Map subDeviceEvents = MapUtil.builder() + .put("healthCheck", subDeviceEvent) + .build(); + // 2.5 构建子设备数据 + IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData = new IotDevicePropertyPackPostReqDTO.SubDeviceData() + .setIdentity(new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME)) + .setProperties(subDeviceProperties) + .setEvents(subDeviceEvents); + // 2.6 构建请求参数 + IotDevicePropertyPackPostReqDTO params = new IotDevicePropertyPackPostReqDTO(); + params.setProperties(gatewayProperties); + params.setEvents(gatewayEvents); + params.setSubDevices(ListUtil.of(subDeviceData)); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(), + params); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testPropertyPackPost][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 辅助方法 ===================== + + /** + * 建立 TCP 连接 + * + * @return 连接 Future + */ + private CompletableFuture connect() { + CompletableFuture future = new CompletableFuture<>(); + netClient.connect(SERVER_PORT, SERVER_HOST) + .onSuccess(future::complete) + .onFailure(future::completeExceptionally); + return future; + } + + /** + * 执行网关设备认证 + * + * @param socket TCP 连接 + * @return 认证响应消息 + */ + private IotDeviceMessage authenticate(NetSocket socket) throws Exception { + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo( + GATEWAY_PRODUCT_KEY, GATEWAY_DEVICE_NAME, GATEWAY_DEVICE_SECRET); + IotDeviceMessage request = IotDeviceMessage.requestOf("auth", authInfo); + return sendAndReceive(socket, request); + } + + /** + * 发送消息并接收响应(复用 IotTcpFrameCodec 编解码逻辑) + * + * @param socket TCP 连接 + * @param request 请求消息 + * @return 响应消息 + */ + private IotDeviceMessage sendAndReceive(NetSocket socket, IotDeviceMessage request) throws Exception { + // 1. 使用 FRAME_CODEC 创建解码器(复用 gateway 的拆包逻辑) + CompletableFuture responseFuture = new CompletableFuture<>(); + RecordParser parser = FRAME_CODEC.createDecodeParser(buffer -> { + try { + // 反序列化响应 + IotDeviceMessage response = SERIALIZER.deserialize(buffer.getBytes()); + responseFuture.complete(response); + } catch (Exception e) { + responseFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 2.1 序列化 + 帧编码 + byte[] serializedData = SERIALIZER.serialize(request); + Buffer frameData = FRAME_CODEC.encode(serializedData); + log.info("[sendAndReceive][发送消息: {},数据长度: {} 字节]", request.getMethod(), frameData.length()); + // 2.2 发送请求 + socket.write(frameData); + + // 3. 等待响应 + IotDeviceMessage response = responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + log.info("[sendAndReceive][收到响应,数据长度: {} 字节]", + SERIALIZER.serialize(response).length); + return response; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotGatewaySubDeviceTcpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotGatewaySubDeviceTcpProtocolIntegrationTest.java new file mode 100644 index 0000000000..22b654a869 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotGatewaySubDeviceTcpProtocolIntegrationTest.java @@ -0,0 +1,266 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp; + +import cn.hutool.core.map.MapUtil; +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.protocol.tcp.codec.IotTcpCodecTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodecFactory; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +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.TimeUnit; + +/** + * IoT 网关子设备 TCP 协议集成测试(手动测试) + * + *

测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 + * + *

重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(TCP 端口 8091)
  2. + *
  3. 确保子设备已通过 {@link IotGatewayDeviceTcpProtocolIntegrationTest#testTopoAdd()} 绑定到网关
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 子设备认证
    • + *
    • {@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)
    • + *
    • {@link #testEventPost()} - 子设备事件上报(由网关代理转发)
    • + *
    + *
  6. + *
+ * + *

注意: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; + + private static Vertx vertx; + private static NetClient netClient; + + // ===================== 编解码器 ===================== + + /** + * 消息序列化器 + */ + private static final IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + /** + * TCP 帧编解码器 + */ + private static final IotTcpFrameCodec FRAME_CODEC = IotTcpFrameCodecFactory.create( + new IotTcpConfig.CodecConfig() + .setType(IotTcpCodecTypeEnum.DELIMITER.getType()) + .setDelimiter("\\n") +// .setType(IotTcpCodecTypeEnum.LENGTH_FIELD.getType()) +// .setLengthFieldOffset(0) +// .setLengthFieldLength(4) +// .setLengthAdjustment(0) +// .setInitialBytesToStrip(4) +// .setType(IotTcpCodecTypeEnum.LENGTH_FIELD.getType()) +// .setFixedLength(256) + ); + + // ===================== 网关子设备信息(根据实际情况修改,从 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 + static void setUp() { + vertx = Vertx.vertx(); + NetClientOptions options = new NetClientOptions() + .setConnectTimeout(TIMEOUT_MS) + .setIdleTimeout(TIMEOUT_MS); + netClient = vertx.createNetClient(options); + } + + @AfterAll + static void tearDown() { + if (netClient != null) { + netClient.close(); + } + if (vertx != null) { + vertx.close(); + } + } + + // ===================== 认证测试 ===================== + + /** + * 子设备认证测试:获取子设备 Token + */ + @Test + public void testAuth() throws Exception { + // 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.requestOf("auth", authReqDTO); + + // 2. 发送并接收响应 + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testAuth][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 子设备属性上报测试 ===================== + + /** + * 子设备属性上报测试 + */ + @Test + public void testPropertyPost() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testPropertyPost][认证响应: {}]", authResponse); + + // 2. 构建属性上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), + IotDevicePropertyPostReqDTO.of(MapUtil.builder() + .put("power", 100) + .put("status", "online") + .put("temperature", 36.5) + .build())); + log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]"); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testPropertyPost][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 子设备事件上报测试 ===================== + + /** + * 子设备事件上报测试 + */ + @Test + public void testEventPost() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先进行认证 + IotDeviceMessage authResponse = authenticate(socket); + log.info("[testEventPost][认证响应: {}]", authResponse); + + // 2. 构建事件上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), + IotDeviceEventPostReqDTO.of( + "alarm", + MapUtil.builder() + .put("level", "warning") + .put("message", "temperature too high") + .put("threshold", 40) + .put("current", 42) + .build(), + System.currentTimeMillis())); + log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]"); + + // 3. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(socket, request); + log.info("[testEventPost][响应消息: {}]", response); + } finally { + socket.close(); + } + } + + // ===================== 辅助方法 ===================== + + /** + * 建立 TCP 连接 + * + * @return 连接 Future + */ + private CompletableFuture connect() { + CompletableFuture future = new CompletableFuture<>(); + netClient.connect(SERVER_PORT, SERVER_HOST) + .onSuccess(future::complete) + .onFailure(future::completeExceptionally); + return future; + } + + /** + * 执行子设备认证 + * + * @param socket TCP 连接 + * @return 认证响应消息 + */ + private IotDeviceMessage authenticate(NetSocket socket) throws Exception { + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); + IotDeviceMessage request = IotDeviceMessage.requestOf("auth", authInfo); + return sendAndReceive(socket, request); + } + + /** + * 发送消息并接收响应(复用 IotTcpFrameCodec 编解码逻辑) + * + * @param socket TCP 连接 + * @param request 请求消息 + * @return 响应消息 + */ + private IotDeviceMessage sendAndReceive(NetSocket socket, IotDeviceMessage request) throws Exception { + // 1. 使用 FRAME_CODEC 创建解码器(复用 gateway 的拆包逻辑) + CompletableFuture responseFuture = new CompletableFuture<>(); + RecordParser parser = FRAME_CODEC.createDecodeParser(buffer -> { + try { + // 反序列化响应 + IotDeviceMessage response = SERIALIZER.deserialize(buffer.getBytes()); + responseFuture.complete(response); + } catch (Exception e) { + responseFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 2.1 序列化 + 帧编码 + byte[] serializedData = SERIALIZER.serialize(request); + Buffer frameData = FRAME_CODEC.encode(serializedData); + log.info("[sendAndReceive][发送消息: {},数据长度: {} 字节]", request.getMethod(), frameData.length()); + // 2.2 发送请求 + socket.write(frameData); + + // 3. 等待响应 + IotDeviceMessage response = responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + log.info("[sendAndReceive][收到响应,数据长度: {} 字节]", + SERIALIZER.serialize(response).length); + return response; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotDirectDeviceUdpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotDirectDeviceUdpProtocolIntegrationTest.java new file mode 100644 index 0000000000..74169b2f12 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotDirectDeviceUdpProtocolIntegrationTest.java @@ -0,0 +1,209 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; + +import cn.hutool.core.map.MapUtil; +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.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +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 协议集成测试(手动测试) + * + *

测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 UDP 协议直接连接平台 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(UDP 端口 8093)
  2. + *
  3. 运行 {@link #testAuth()} 获取设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testPropertyPost()} - 设备属性上报
    • + *
    • {@link #testEventPost()} - 设备事件上报
    • + *
    + *
  6. + *
+ * + *

注意: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; + + // ===================== 序列化器 ===================== + + /** + * 消息序列化器 + */ + private static final IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + // ===================== 直连设备信息(根据实际情况修改,从 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.eyJwcm9kdWN0S2V5IjoiNGF5bVpnT1RPT0NyREtSVCIsImV4cCI6MTc3MDUyNTA0MywiZGV2aWNlTmFtZSI6InNtYWxsIn0.W9Mo-Oe1ZNLDkINndKieUeW1XhDzhVp0W0zTAwO6hJM"; + + // ===================== 认证测试 ===================== + + /** + * 认证测试:获取设备 Token + */ + @Test + public void testAuth() throws Exception { + // 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.requestOf("auth", authReqDTO); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testAuth][响应消息: {}]", response); + log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]"); + } + + // ===================== 动态注册测试 ===================== + + /** + * 直连设备动态注册测试(一型一密) + *

+ * 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret) + *

+ * 注意:此接口不需要认证 + */ + @Test + public void testDeviceRegister() throws Exception { + // 1. 构建注册消息 + IotDeviceRegisterReqDTO registerReqDTO = new IotDeviceRegisterReqDTO() + .setProductKey(PRODUCT_KEY) + .setDeviceName("test-udp-" + System.currentTimeMillis()) + .setProductSecret("test-product-secret"); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod(), registerReqDTO); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testDeviceRegister][响应消息: {}]", response); + log.info("[testDeviceRegister][成功后可使用返回的 deviceSecret 进行一机一密认证]"); + } + + // ===================== 直连设备属性上报测试 ===================== + + /** + * 属性上报测试 + */ + @Test + public void testPropertyPost() throws Exception { + // 1. 构建属性上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), + withToken(IotDevicePropertyPostReqDTO.of(MapUtil.builder() + .put("width", 1) + .put("height", "2") + .build()))); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testPropertyPost][响应消息: {}]", response); + } + + // ===================== 直连设备事件上报测试 ===================== + + /** + * 事件上报测试 + */ + @Test + public void testEventPost() throws Exception { + // 1. 构建事件上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), + withToken(IotDeviceEventPostReqDTO.of( + "eat", + MapUtil.builder().put("rice", 3).build(), + System.currentTimeMillis()))); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testEventPost][响应消息: {}]", response); + } + + // ===================== 辅助方法 ===================== + + /** + * 构建带 token 的 params + *

+ * 返回格式:{token: "xxx", body: params} + * - token:JWT 令牌 + * - body:实际请求内容(可以是 Map、List 或其他类型) + * + * @param params 原始参数(Map、List 或对象) + * @return 包含 token 和 body 的 Map + */ + private Map withToken(Object params) { + Map result = new HashMap<>(); + result.put("token", TOKEN); + result.put("body", params); + return result; + } + + /** + * 发送 UDP 消息并接收响应 + * + * @param request 请求消息 + * @return 响应消息 + */ + private IotDeviceMessage sendAndReceive(IotDeviceMessage request) throws Exception { + // 1. 序列化请求 + byte[] payload = SERIALIZER.serialize(request); + log.info("[sendAndReceive][发送消息: {},数据长度: {} 字节]", request.getMethod(), payload.length); + + // 2. 发送请求 + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(TIMEOUT_MS); + InetAddress address = InetAddress.getByName(SERVER_HOST); + DatagramPacket sendPacket = new DatagramPacket(payload, payload.length, address, SERVER_PORT); + socket.send(sendPacket); + + // 3. 接收响应 + byte[] receiveData = new byte[4096]; + DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length); + try { + socket.receive(receivePacket); + byte[] responseBytes = new byte[receivePacket.getLength()]; + System.arraycopy(receivePacket.getData(), 0, responseBytes, 0, receivePacket.getLength()); + log.info("[sendAndReceive][收到响应,数据长度: {} 字节]", responseBytes.length); + return SERIALIZER.deserialize(responseBytes); + } catch (java.net.SocketTimeoutException e) { + log.warn("[sendAndReceive][接收响应超时]"); + return null; + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotGatewayDeviceUdpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotGatewayDeviceUdpProtocolIntegrationTest.java new file mode 100644 index 0000000000..0acdeae38a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotGatewayDeviceUdpProtocolIntegrationTest.java @@ -0,0 +1,267 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; + +import cn.hutool.core.collection.ListUtil; +import cn.hutool.core.map.MapUtil; +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.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +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.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * IoT 网关设备 UDP 协议集成测试(手动测试) + * + *

测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 UDP 协议管理子设备拓扑关系 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(UDP 端口 8093)
  2. + *
  3. 运行 {@link #testAuth()} 获取网关设备 token,将返回的 token 粘贴到 {@link #GATEWAY_TOKEN} 常量
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testTopoAdd()} - 添加子设备拓扑关系
    • + *
    • {@link #testTopoDelete()} - 删除子设备拓扑关系
    • + *
    • {@link #testTopoGet()} - 获取子设备拓扑关系
    • + *
    • {@link #testSubDeviceRegister()} - 子设备动态注册
    • + *
    • {@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)
    • + *
    + *
  6. + *
+ * + *

注意:UDP 协议是无状态的,每次请求需要在 params 中携带 token(与 HTTP 通过 Header 传递不同) + * + * @author 芋道源码 + */ +@Slf4j +@Disabled +public class IotGatewayDeviceUdpProtocolIntegrationTest { + + private static final String SERVER_HOST = "127.0.0.1"; + private static final int SERVER_PORT = 8093; + private static final int TIMEOUT_MS = 5000; + + // ===================== 序列化器 ===================== + + /** + * 消息序列化器 + */ + private static final IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + // ===================== 网关设备信息(根据实际情况修改,从 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. 构建认证消息 + 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.requestOf("auth", authReqDTO); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testAuth][响应消息: {}]", response); + log.info("[testAuth][请将返回的 token 复制到 GATEWAY_TOKEN 常量中]"); + } + + // ===================== 拓扑管理测试 ===================== + + /** + * 添加子设备拓扑关系测试 + */ + @Test + public void testTopoAdd() throws Exception { + // 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()); + IotDeviceTopoAddReqDTO params = new IotDeviceTopoAddReqDTO(); + params.setSubDevices(Collections.singletonList(subDeviceAuth)); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.TOPO_ADD.getMethod(), withToken(params)); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testTopoAdd][响应消息: {}]", response); + } + + /** + * 删除子设备拓扑关系测试 + */ + @Test + public void testTopoDelete() throws Exception { + // 1. 构建请求参数 + IotDeviceTopoDeleteReqDTO params = new IotDeviceTopoDeleteReqDTO(); + params.setSubDevices(Collections.singletonList( + new IotDeviceIdentity(SUB_DEVICE_PRODUCT_KEY, SUB_DEVICE_NAME))); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod(), withToken(params)); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testTopoDelete][响应消息: {}]", response); + } + + /** + * 获取子设备拓扑关系测试 + */ + @Test + public void testTopoGet() throws Exception { + // 1. 构建请求参数 + IotDeviceTopoGetReqDTO params = new IotDeviceTopoGetReqDTO(); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.TOPO_GET.getMethod(), withToken(params)); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testTopoGet][响应消息: {}]", response); + } + + // ===================== 子设备注册测试 ===================== + + /** + * 子设备动态注册测试 + */ + @Test + public void testSubDeviceRegister() throws Exception { + // 1. 构建请求参数 + IotSubDeviceRegisterReqDTO subDevice = new IotSubDeviceRegisterReqDTO(); + subDevice.setProductKey(SUB_DEVICE_PRODUCT_KEY); + subDevice.setDeviceName("mougezishebei"); + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod(), + withToken(Collections.singletonList(subDevice))); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testSubDeviceRegister][响应消息: {}]", response); + } + + // ===================== 批量上报测试 ===================== + + /** + * 批量上报属性测试(网关 + 子设备) + */ + @Test + public void testPropertyPackPost() throws Exception { + // 1.1 构建【网关设备】自身属性 + Map gatewayProperties = MapUtil.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 gatewayEvents = MapUtil.builder() + .put("statusReport", gatewayEvent) + .build(); + // 1.3 构建【网关子设备】属性 + Map subDeviceProperties = MapUtil.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 subDeviceEvents = MapUtil.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.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod(), withToken(params)); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testPropertyPackPost][响应消息: {}]", response); + } + + // ===================== 辅助方法 ===================== + + /** + * 构建带 token 的 params + */ + private Map withToken(Object params) { + Map result = new HashMap<>(); + result.put("token", GATEWAY_TOKEN); + result.put("body", params); + return result; + } + + /** + * 发送 UDP 消息并接收响应 + */ + private IotDeviceMessage sendAndReceive(IotDeviceMessage request) throws Exception { + byte[] payload = SERIALIZER.serialize(request); + log.info("[sendAndReceive][发送消息: {},数据长度: {} 字节]", request.getMethod(), payload.length); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(TIMEOUT_MS); + 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[] responseBytes = new byte[receivePacket.getLength()]; + System.arraycopy(receivePacket.getData(), 0, responseBytes, 0, receivePacket.getLength()); + return SERIALIZER.deserialize(responseBytes); + } catch (java.net.SocketTimeoutException e) { + log.warn("[sendAndReceive][接收响应超时]"); + return null; + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotGatewaySubDeviceUdpProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotGatewaySubDeviceUdpProtocolIntegrationTest.java new file mode 100644 index 0000000000..fe7f7f8126 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotGatewaySubDeviceUdpProtocolIntegrationTest.java @@ -0,0 +1,181 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp; + +import cn.hutool.core.map.MapUtil; +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.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +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 协议集成测试(手动测试) + * + *

测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 + * + *

重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。 + *

网关设备转发子设备请求时,Token 使用子设备自己的信息。 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(UDP 端口 8093)
  2. + *
  3. 确保子设备已通过 {@link IotGatewayDeviceUdpProtocolIntegrationTest#testTopoAdd()} 绑定到网关
  4. + *
  5. 运行 {@link #testAuth()} 获取子设备 token,将返回的 token 粘贴到 {@link #TOKEN} 常量
  6. + *
  7. 运行以下测试方法: + *
      + *
    • {@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)
    • + *
    • {@link #testEventPost()} - 子设备事件上报(由网关代理转发)
    • + *
    + *
  8. + *
+ * + *

注意:UDP 协议是无状态的,每次请求需要在 params 中携带 token(与 HTTP 通过 Header 传递不同) + * + * @author 芋道源码 + */ +@Slf4j +@Disabled +public class IotGatewaySubDeviceUdpProtocolIntegrationTest { + + private static final String SERVER_HOST = "127.0.0.1"; + private static final int SERVER_PORT = 8093; + private static final int TIMEOUT_MS = 5000; + + // ===================== 序列化器 ===================== + + /** + * 消息序列化器 + */ + private static final IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + // ===================== 网关子设备信息(根据实际情况修改,从 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. 构建认证消息 + 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.requestOf("auth", authReqDTO); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testAuth][响应消息: {}]", response); + log.info("[testAuth][请将返回的 token 复制到 TOKEN 常量中]"); + } + + // ===================== 子设备属性上报测试 ===================== + + /** + * 子设备属性上报测试 + */ + @Test + public void testPropertyPost() throws Exception { + // 1. 构建属性上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), + withToken(IotDevicePropertyPostReqDTO.of(MapUtil.builder() + .put("power", 100) + .put("status", "online") + .put("temperature", 36.5) + .build()))); + log.info("[testPropertyPost][子设备属性上报 - 请求实际由 Gateway 代为转发]"); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testPropertyPost][响应消息: {}]", response); + } + + // ===================== 子设备事件上报测试 ===================== + + /** + * 子设备事件上报测试 + */ + @Test + public void testEventPost() throws Exception { + // 1. 构建事件上报消息 + IotDeviceMessage request = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.EVENT_POST.getMethod(), + withToken(IotDeviceEventPostReqDTO.of( + "alarm", + MapUtil.builder() + .put("level", "warning") + .put("message", "temperature too high") + .put("threshold", 40) + .put("current", 42) + .build(), + System.currentTimeMillis()))); + log.info("[testEventPost][子设备事件上报 - 请求实际由 Gateway 代为转发]"); + + // 2. 发送并接收响应 + IotDeviceMessage response = sendAndReceive(request); + log.info("[testEventPost][响应消息: {}]", response); + } + + // ===================== 辅助方法 ===================== + + /** + * 构建带 token 的 params + */ + private Map withToken(Object params) { + Map result = new HashMap<>(); + result.put("token", TOKEN); + result.put("body", params); + return result; + } + + /** + * 发送 UDP 消息并接收响应 + */ + private IotDeviceMessage sendAndReceive(IotDeviceMessage request) throws Exception { + byte[] payload = SERIALIZER.serialize(request); + log.info("[sendAndReceive][发送消息: {},数据长度: {} 字节]", request.getMethod(), payload.length); + + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(TIMEOUT_MS); + 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[] responseBytes = new byte[receivePacket.getLength()]; + System.arraycopy(receivePacket.getData(), 0, responseBytes, 0, receivePacket.getLength()); + return SERIALIZER.deserialize(responseBytes); + } catch (java.net.SocketTimeoutException e) { + log.warn("[sendAndReceive][接收响应超时]"); + return null; + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotDirectDeviceWebSocketProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotDirectDeviceWebSocketProtocolIntegrationTest.java new file mode 100644 index 0000000000..15eed61e2a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotDirectDeviceWebSocketProtocolIntegrationTest.java @@ -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.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +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 协议集成测试(手动测试) + * + *

测试场景:直连设备(IotProductDeviceTypeEnum 的 DIRECT 类型)通过 WebSocket 协议直接连接平台 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)
  2. + *
  3. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 设备认证
    • + *
    • {@link #testDeviceRegister()} - 设备动态注册(一型一密)
    • + *
    • {@link #testPropertyPost()} - 设备属性上报
    • + *
    • {@link #testEventPost()} - 设备事件上报
    • + *
    + *
  4. + *
+ * + *

注意: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 IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + // ===================== 直连设备信息(根据实际情况修改,从 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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testAuth][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 2.1 创建 WebSocket 连接(同步) + WebSocket ws = createWebSocketConnection(); + log.info("[testAuth][WebSocket 连接成功]"); + + // 2.2 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + + // 3. 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(StrUtil.utf8Bytes(response)); + log.info("[testAuth][响应消息: {}]", responseMessage); + } else { + log.warn("[testAuth][未收到响应]"); + } + + // 4. 关闭连接 + ws.close(); + } + + // ===================== 动态注册测试 ===================== + + /** + * 直连设备动态注册测试(一型一密) + *

+ * 使用产品密钥(productSecret)验证身份,成功后返回设备密钥(deviceSecret) + *

+ * 注意:此接口不需要认证 + */ + @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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testDeviceRegister][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 2.1 创建 WebSocket 连接(同步) + WebSocket ws = createWebSocketConnection(); + log.info("[testDeviceRegister][WebSocket 连接成功]"); + + // 2.2 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + + // 3. 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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.builder() + .put("width", 1) + .put("height", "2") + .build()), + null, null, null); + // 2.2 序列化 + byte[] payload = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testPropertyPost][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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.builder().put("rice", 3).build(), + System.currentTimeMillis()), + null, null, null); + // 2.2 序列化 + byte[] payload = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testEventPost][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[authenticate][发送认证请求: {}]", jsonMessage); + + String response = sendAndReceive(ws, jsonMessage); + if (response != null) { + return SERIALIZER.deserialize(StrUtil.utf8Bytes(response)); + } + return null; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewayDeviceWebSocketProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewayDeviceWebSocketProtocolIntegrationTest.java new file mode 100644 index 0000000000..20d66fa0a7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewayDeviceWebSocketProtocolIntegrationTest.java @@ -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.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +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 协议集成测试(手动测试) + * + *

测试场景:网关设备(IotProductDeviceTypeEnum 的 GATEWAY 类型)通过 WebSocket 协议管理子设备拓扑关系 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)
  2. + *
  3. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 网关设备认证
    • + *
    • {@link #testTopoAdd()} - 添加子设备拓扑关系
    • + *
    • {@link #testTopoDelete()} - 删除子设备拓扑关系
    • + *
    • {@link #testTopoGet()} - 获取子设备拓扑关系
    • + *
    • {@link #testSubDeviceRegister()} - 子设备动态注册
    • + *
    • {@link #testPropertyPackPost()} - 批量上报属性(网关 + 子设备)
    • + *
    + *
  4. + *
+ * + *

注意: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 IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + // ===================== 网关设备信息(根据实际情况修改,从 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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testAuth][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 2.1 创建 WebSocket 连接(同步) + WebSocket ws = createWebSocketConnection(); + log.info("[testAuth][WebSocket 连接成功]"); + + // 2.2 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + + // 3. 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testTopoAdd][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testTopoDelete][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testTopoGet][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testSubDeviceRegister][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 gatewayProperties = MapUtil.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 gatewayEvents = MapUtil.builder() + .put("statusReport", gatewayEvent) + .build(); + // 2.3 构建【网关子设备】属性 + Map subDeviceProperties = MapUtil.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 subDeviceEvents = MapUtil.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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testPropertyPackPost][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[authenticate][发送认证请求: {}]", jsonMessage); + + String response = sendAndReceive(ws, jsonMessage); + if (response != null) { + return SERIALIZER.deserialize(StrUtil.utf8Bytes(response)); + } + return null; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewaySubDeviceWebSocketProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewaySubDeviceWebSocketProtocolIntegrationTest.java new file mode 100644 index 0000000000..f792288fe3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotGatewaySubDeviceWebSocketProtocolIntegrationTest.java @@ -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.serialize.IotMessageSerializer; +import cn.iocoder.yudao.module.iot.gateway.serialize.json.IotJsonSerializer; +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 协议集成测试(手动测试) + * + *

测试场景:子设备(IotProductDeviceTypeEnum 的 SUB 类型)通过网关设备代理上报数据 + * + *

重要说明:子设备无法直接连接平台,所有请求均由网关设备(Gateway)代为转发。 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(WebSocket 端口 8094)
  2. + *
  3. 确保子设备已通过 {@link IotGatewayDeviceWebSocketProtocolIntegrationTest#testTopoAdd()} 绑定到网关
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 子设备认证
    • + *
    • {@link #testPropertyPost()} - 子设备属性上报(由网关代理转发)
    • + *
    • {@link #testEventPost()} - 子设备事件上报(由网关代理转发)
    • + *
    + *
  6. + *
+ * + *

注意: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 IotMessageSerializer SERIALIZER = new IotJsonSerializer(); + + // ===================== 网关子设备信息(根据实际情况修改,从 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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testAuth][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 2.1 创建 WebSocket 连接(同步) + WebSocket ws = createWebSocketConnection(); + log.info("[testAuth][WebSocket 连接成功]"); + + // 2.2 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + + // 3. 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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.builder() + .put("power", 100) + .put("status", "online") + .put("temperature", 36.5) + .build()), + null, null, null); + // 2.2 序列化 + byte[] payload = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testPropertyPost][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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.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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[testEventPost][Serialize: {}, 请求消息: {}]", SERIALIZER.getType(), request); + + // 3.1 发送并等待响应 + String response = sendAndReceive(ws, jsonMessage); + // 3.2 解码响应 + if (response != null) { + IotDeviceMessage responseMessage = SERIALIZER.deserialize(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 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 = SERIALIZER.serialize(request); + String jsonMessage = StrUtil.utf8Str(payload); + log.info("[authenticate][发送认证请求: {}]", jsonMessage); + + String response = sendAndReceive(ws, jsonMessage); + if (response != null) { + return SERIALIZER.deserialize(StrUtil.utf8Bytes(response)); + } + return null; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html deleted file mode 100644 index e0853ac6bf..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html +++ /dev/null @@ -1,888 +0,0 @@ - - - - - - MQTT WebSocket 测试客户端 - - - -

-
-

🚀 MQTT WebSocket 测试客户端

-

RuoYi-Vue-Pro IoT 模块 - MQTT over WebSocket 在线测试工具

-
- - -
-

📌 标准协议格式说明

-
    -
  • Topic 格式:/sys/{productKey}/{deviceName}/thing/property/post
  • -
  • Client ID 格式:{productKey}.{deviceName} 例如:zOXKLvHjUqTo7ipD.ceshi001 -
  • -
  • Username 格式:{deviceName}&{productKey} 例如:ceshi001&zOXKLvHjUqTo7ipD -
  • -
  • 消息格式(Alink 协议): -
    -{
    -  "id": "消息 ID(唯一标识)",
    -  "version": "1.0",
    -  "method": "thing.property.post",
    -  "params": {
    -    "temperature": 25.5,
    -    "humidity": 60
    -  }
    -}
    -
  • -
  • 常用 Topic(下行 - 服务端推送): -
      -
    • 属性设置:/sys/{pk}/{dn}/thing/property/set
    • -
    • 服务调用:/sys/{pk}/{dn}/thing/service/invoke
    • -
    • 配置推送:/sys/{pk}/{dn}/thing/config/push
    • -
    • OTA 升级:/sys/{pk}/{dn}/thing/ota/upgrade
    • -
    -
  • -
  • 常用 Topic(上行 - 设备上报): -
      -
    • 状态更新:/sys/{pk}/{dn}/thing/state/update
    • -
    • 属性上报:/sys/{pk}/{dn}/thing/property/post
    • -
    • 事件上报:/sys/{pk}/{dn}/thing/event/post
    • -
    • OTA 进度:/sys/{pk}/{dn}/thing/ota/progress
    • -
    -
  • -
-
- -
- -
-

📡 连接配置

- -
- ⚫ 未连接 -
- -
- - - WebSocket 地址,支持 ws:// 和 wss:// -
- -
- - - 格式:{productKey}.{deviceName} -
- -
- - - 格式:{deviceName}&{productKey} -
- -
- - - 设备的认证密钥(Device Secret) -
- -
- - - -
- - -
-
-
0
-
发送消息数
-
-
-
0
-
接收消息数
-
-
-
0
-
错误次数
-
-
-
- - -
-

📤 消息发布

- -
- - -
- -
- - - 标准格式:/sys/{productKey}/{deviceName}/thing/property/post -
- -
- - -
- -
- - - - Alink 协议格式:id(消息 ID)、version(协议版本)、method(方法)、params(参数) - -
- -
- - -
- -

📥 主题订阅

- -
- - -
- -
- - - 标准格式:/sys/{productKey}/{deviceName}/thing/method 或使用通配符 - /sys/+/+/# -
- -
- - -
- -
- - -
-
- - -
-

📝 日志输出

-
-
-
-
- - - - - - - - - diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-binary-packet-examples.md b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-binary-packet-examples.md deleted file mode 100644 index 8e2037892f..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-binary-packet-examples.md +++ /dev/null @@ -1,193 +0,0 @@ -# TCP 二进制协议数据包格式说明 - -## 1. 协议概述 - -TCP 二进制协议是一种高效的自定义协议格式,采用紧凑的二进制格式传输数据,适用于对带宽和性能要求较高的 IoT 场景。 - -### 1.1 协议特点 - -- **高效传输**:完全二进制格式,减少数据传输量 -- **版本控制**:内置协议版本号,支持协议升级 -- **类型安全**:明确的消息类型标识 -- **简洁设计**:去除冗余字段,协议更加精简 -- **兼容性**:与现有 `IotDeviceMessage` 接口完全兼容 - -## 2. 协议格式 - -### 2.1 整体结构 - -``` -+--------+--------+--------+---------------------------+--------+--------+ -| 魔术字 | 版本号 | 消息类型| 消息长度(4字节) | -+--------+--------+--------+---------------------------+--------+--------+ -| 消息 ID 长度(2字节) | 消息 ID (变长字符串) | -+--------+--------+--------+--------+--------+--------+--------+--------+ -| 方法名长度(2字节) | 方法名(变长字符串) | -+--------+--------+--------+--------+--------+--------+--------+--------+ -| 消息体数据(变长) | -+--------+--------+--------+--------+--------+--------+--------+--------+ -``` - -### 2.2 字段详细说明 - -| 字段 | 长度 | 类型 | 说明 | -|------|------|------|------| -| 魔术字 | 1字节 | byte | `0x7E` - 协议识别标识,用于数据同步 | -| 版本号 | 1字节 | byte | `0x01` - 协议版本号,支持版本控制 | -| 消息类型 | 1字节 | byte | `0x01`=请求, `0x02`=响应 | -| 消息长度 | 4字节 | int | 整个消息的总长度(包含头部) | -| 消息 ID 长度 | 2字节 | short | 消息 ID 字符串的字节长度 | -| 消息 ID | 变长 | string | 消息唯一标识符(UTF-8编码) | -| 方法名长度 | 2字节 | short | 方法名字符串的字节长度 | -| 方法名 | 变长 | string | 消息方法名(UTF-8编码) | -| 消息体 | 变长 | binary | 根据消息类型的不同数据格式 | - -**⚠️ 重要说明**:deviceId 不包含在协议中,由服务器根据连接上下文自动设置 - -### 2.3 协议常量定义 - -```java -// 协议标识 -private static final byte MAGIC_NUMBER = (byte) 0x7E; -private static final byte PROTOCOL_VERSION = (byte) 0x01; - -// 消息类型 -private static final byte REQUEST = (byte) 0x01; // 请求消息 -private static final byte RESPONSE = (byte) 0x02; // 响应消息 - -// 协议长度 -private static final int HEADER_FIXED_LENGTH = 7; // 固定头部长度 -private static final int MIN_MESSAGE_LENGTH = 11; // 最小消息长度 -``` - -## 3. 消息类型和格式 - -### 3.1 请求消息 (REQUEST - 0x01) - -请求消息用于设备向服务器发送数据或请求。 - -#### 3.1.1 消息体格式 -``` -消息体 = params 数据(JSON格式) -``` - -#### 3.1.2 示例:设备认证请求 - -**消息内容:** -- 消息 ID: `auth_1704067200000_123` -- 方法名: `auth` -- 参数: `{"clientId":"device_001","username":"productKey_deviceName","password":"device_password"}` - -**二进制数据包结构:** -``` -7E // 魔术字 (0x7E) -01 // 版本号 (0x01) -01 // 消息类型 (REQUEST) -00 00 00 89 // 消息长度 (137字节) -00 19 // 消息 ID 长度 (25字节) -61 75 74 68 5F 31 37 30 34 30 // 消息 ID: "auth_1704067200000_123" -36 37 32 30 30 30 30 30 5F 31 -32 33 -00 04 // 方法名长度 (4字节) -61 75 74 68 // 方法名: "auth" -7B 22 63 6C 69 65 6E 74 49 64 // JSON参数数据 -22 3A 22 64 65 76 69 63 65 5F // {"clientId":"device_001", -30 30 31 22 2C 22 75 73 65 72 // "username":"productKey_deviceName", -6E 61 6D 65 22 3A 22 70 72 6F // "password":"device_password"} -64 75 63 74 4B 65 79 5F 64 65 -76 69 63 65 4E 61 6D 65 22 2C -22 70 61 73 73 77 6F 72 64 22 -3A 22 64 65 76 69 63 65 5F 70 -61 73 73 77 6F 72 64 22 7D -``` - -#### 3.1.3 示例:属性数据上报 - -**消息内容:** -- 消息 ID: `property_1704067200000_456` -- 方法名: `thing.property.post` -- 参数: `{"temperature":25.5,"humidity":60.2,"pressure":1013.25}` - -### 3.2 响应消息 (RESPONSE - 0x02) - -响应消息用于服务器向设备回复请求结果。 - -#### 3.2.1 消息体格式 -``` -消息体 = 响应码(4字节) + 响应消息长度(2字节) + 响应消息(UTF-8) + 响应数据(JSON) -``` - -#### 3.2.2 字段说明 - -| 字段 | 长度 | 类型 | 说明 | -|------|------|------|------| -| 响应码 | 4字节 | int | HTTP状态码风格,0=成功,其他=错误 | -| 响应消息长度 | 2字节 | short | 响应消息字符串的字节长度 | -| 响应消息 | 变长 | string | 响应提示信息(UTF-8编码) | -| 响应数据 | 变长 | binary | JSON格式的响应数据(可选) | - -#### 3.2.3 示例:认证成功响应 - -**消息内容:** -- 消息 ID: `auth_response_1704067200000_123` -- 方法名: `auth` -- 响应码: `0` -- 响应消息: `认证成功` -- 响应数据: `{"success":true,"message":"认证成功"}` - -**二进制数据包结构:** -``` -7E // 魔术字 (0x7E) -01 // 版本号 (0x01) -02 // 消息类型 (RESPONSE) -00 00 00 A4 // 消息长度 (164字节) -00 22 // 消息 ID 长度 (34字节) -61 75 74 68 5F 72 65 73 70 6F // 消息 ID: "auth_response_1704067200000_123" -6E 73 65 5F 31 37 30 34 30 36 -37 32 30 30 30 30 30 5F 31 32 -33 -00 04 // 方法名长度 (4字节) -61 75 74 68 // 方法名: "auth" -00 00 00 00 // 响应码 (0 = 成功) -00 0C // 响应消息长度 (12字节) -E8 AE A4 E8 AF 81 E6 88 90 E5 // 响应消息: "认证成功" (UTF-8) -8A 9F -7B 22 73 75 63 63 65 73 73 22 // JSON响应数据 -3A 74 72 75 65 2C 22 6D 65 73 // {"success":true,"message":"认证成功"} -73 61 67 65 22 3A 22 E8 AE A4 -E8 AF 81 E6 88 90 E5 8A 9F 22 -7D -``` - -## 4. 编解码器标识 - -```java -public static final String TYPE = "TcpBinary"; -``` - -## 5. 协议优势 - -- **数据紧凑**:二进制格式,相比 JSON 减少 30-50% 的数据量 -- **解析高效**:直接二进制操作,减少字符串转换开销 -- **类型安全**:明确的消息类型和字段定义 -- **设计简洁**:去除冗余字段,协议更加精简高效 -- **版本控制**:内置版本号支持协议升级 - -## 6. 与 JSON 协议对比 - -| 特性 | 二进制协议 | JSON协议 | -|------|-------------|--------| -| 数据大小 | 小(节省30-50%) | 大 | -| 解析性能 | 高 | 中等 | -| 网络开销 | 低 | 高 | -| 可读性 | 差 | 优秀 | -| 调试难度 | 高 | 低 | -| 扩展性 | 良好 | 优秀 | - -**推荐场景**: -- ✅ **高频数据传输**:传感器数据实时上报 -- ✅ **带宽受限环境**:移动网络、卫星通信 -- ✅ **性能要求高**:需要低延迟、高吞吐的场景 -- ✅ **设备资源有限**:嵌入式设备、低功耗设备 -- ❌ **开发调试阶段**:调试困难,建议使用 JSON 协议 -- ❌ **快速原型开发**:开发效率低 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-json-packet-examples.md b/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-json-packet-examples.md deleted file mode 100644 index 5bfa42b4d0..0000000000 --- a/yudao-module-iot/yudao-module-iot-gateway/src/test/resources/tcp-json-packet-examples.md +++ /dev/null @@ -1,191 +0,0 @@ -# TCP JSON 格式协议说明 - -## 1. 协议概述 - -TCP JSON 格式协议采用纯 JSON 格式进行数据传输,具有以下特点: - -- **标准化**:使用标准 JSON 格式,易于解析和处理 -- **可读性**:人类可读,便于调试和维护 -- **扩展性**:可以轻松添加新字段,向后兼容 -- **跨平台**:JSON 格式支持所有主流编程语言 -- **安全优化**:移除冗余的 deviceId 字段,提高安全性 - -## 2. 消息格式 - -### 2.1 基础消息结构 - -```json -{ - "id": "消息唯一标识", - "method": "消息方法", - "params": { - // 请求参数 - }, - "data": { - // 响应数据 - }, - "code": 响应码, - "msg": "响应消息", - "timestamp": 时间戳 -} -``` - -**⚠️ 重要说明**: -- **不包含 deviceId 字段**:由服务器通过 TCP 连接上下文自动确定设备 ID -- **避免伪造攻击**:防止设备伪造其他设备的 ID 发送消息 - -### 2.2 字段详细说明 - -| 字段名 | 类型 | 必填 | 用途 | 说明 | -|--------|------|------|------|------| -| id | String | 是 | 所有消息 | 消息唯一标识 | -| method | String | 是 | 所有消息 | 消息方法,如 `auth`、`thing.property.post` | -| params | Object | 否 | 请求消息 | 请求参数,具体内容根据method而定 | -| data | Object | 否 | 响应消息 | 响应数据,服务器返回的结果数据 | -| code | Integer | 否 | 响应消息 | 响应码,0=成功,其他=错误 | -| msg | String | 否 | 响应消息 | 响应提示信息 | -| timestamp | Long | 是 | 所有消息 | 时间戳(毫秒),编码时自动生成 | - -### 2.3 消息分类 - -#### 2.3.1 请求消息(上行) -- **特征**:包含 `params` 字段,不包含 `code`、`msg` 字段 -- **方向**:设备 → 服务器 -- **用途**:设备认证、数据上报、状态更新等 - -#### 2.3.2 响应消息(下行) -- **特征**:包含 `code`、`msg` 字段,可能包含 `data` 字段 -- **方向**:服务器 → 设备 -- **用途**:认证结果、指令响应、错误提示等 - -## 3. 消息示例 - -### 3.1 设备认证 (auth) - -#### 认证请求格式 -**消息方向**:设备 → 服务器 - -```json -{ - "id": "auth_1704067200000_123", - "method": "auth", - "params": { - "clientId": "device_001", - "username": "productKey_deviceName", - "password": "设备密码" - }, - "timestamp": 1704067200000 -} -``` - -**认证参数说明:** - -| 字段名 | 类型 | 必填 | 说明 | -|--------|------|------|------| -| clientId | String | 是 | 客户端唯一标识,用于连接管理 | -| username | String | 是 | 设备用户名,格式为 `productKey_deviceName` | -| password | String | 是 | 设备密码,在设备管理平台配置 | - -#### 认证响应格式 -**消息方向**:服务器 → 设备 - -**认证成功响应:** -```json -{ - "id": "response_auth_1704067200000_123", - "method": "auth", - "data": { - "success": true, - "message": "认证成功" - }, - "code": 0, - "msg": "认证成功", - "timestamp": 1704067200001 -} -``` - -**认证失败响应:** -```json -{ - "id": "response_auth_1704067200000_123", - "method": "auth", - "data": { - "success": false, - "message": "认证失败:用户名或密码错误" - }, - "code": 401, - "msg": "认证失败", - "timestamp": 1704067200001 -} -``` - -### 3.2 属性数据上报 (thing.property.post) - -**消息方向**:设备 → 服务器 - -**示例:温度传感器数据上报** -```json -{ - "id": "property_1704067200000_456", - "method": "thing.property.post", - "params": { - "temperature": 25.5, - "humidity": 60.2, - "pressure": 1013.25, - "battery": 85, - "signal_strength": -65 - }, - "timestamp": 1704067200000 -} -``` - -### 3.3 设备状态更新 (thing.state.update) - -**消息方向**:设备 → 服务器 - -**示例:心跳请求** -```json -{ - "id": "heartbeat_1704067200000_321", - "method": "thing.state.update", - "params": { - "state": "online", - "uptime": 86400, - "memory_usage": 65.2, - "cpu_usage": 12.8 - }, - "timestamp": 1704067200000 -} -``` - -## 4. 编解码器标识 - -```java -public static final String TYPE = "TcpJson"; -``` - -## 5. 协议优势 - -- **开发效率高**:JSON 格式,开发和调试简单 -- **跨语言支持**:所有主流语言都支持 JSON -- **可读性优秀**:可以直接查看消息内容 -- **扩展性强**:可以轻松添加新字段 -- **安全性高**:移除 deviceId 字段,防止伪造攻击 - -## 6. 与二进制协议对比 - -| 特性 | JSON协议 | 二进制协议 | -|------|----------|------------| -| 开发难度 | 低 | 高 | -| 调试难度 | 低 | 高 | -| 可读性 | 优秀 | 差 | -| 数据大小 | 中等 | 小(节省30-50%) | -| 解析性能 | 中等 | 高 | -| 学习成本 | 低 | 高 | - -**推荐场景**: -- ✅ **开发调试阶段**:调试友好,开发效率高 -- ✅ **快速原型开发**:实现简单,快速迭代 -- ✅ **多语言集成**:广泛的语言支持 -- ❌ **高频数据传输**:建议使用二进制协议 -- ❌ **带宽受限环境**:建议使用二进制协议 \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java index 8ec1159244..2ab259a57f 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/ErrorCodeConstants.java @@ -41,6 +41,7 @@ public interface ErrorCodeConstants { ErrorCode ORDER_PICK_UP_FAIL_NOT_VERIFY_USER = new ErrorCode(1_011_000_036, "交易订单自提失败,原因:你没有核销该门店订单的权限"); ErrorCode ORDER_PICK_UP_FAIL_COMBINATION_NOT_SUCCESS = new ErrorCode(1_011_000_037, "交易订单自提失败,原因:商品拼团记录不是【成功】状态"); ErrorCode ORDER_CREATE_FAIL_INSUFFICIENT_USER_POINTS = new ErrorCode(1_011_000_038, "交易订单创建失败,原因:用户积分不足"); + ErrorCode ORDER_PICK_UP_FAIL_STATUS_NOT_UNDELIVERED = new ErrorCode(1_011_000_039, "交易订单自提失败,订单不是【待核销】状态"); // ========== After Sale 模块 1-011-000-100 ========== ErrorCode AFTER_SALE_NOT_FOUND = new ErrorCode(1_011_000_100, "售后单不存在"); diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserCreateReqVO.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserCreateReqVO.java index f93149afb8..7865725a72 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserCreateReqVO.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/user/BrokerageUserCreateReqVO.java @@ -13,6 +13,7 @@ public class BrokerageUserCreateReqVO { private Long userId; @Schema(description = "推广员编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4587") + @NotNull(message = "推广员编号不能为空") private Long bindUserId; } diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java index c90d9ed81a..0e2380a463 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/convert/order/TradeOrderConvert.java @@ -265,8 +265,7 @@ public interface TradeOrderConvert { ProductSpuRespDTO spu, ProductSkuRespDTO sku) { BrokerageAddReqBO bo = new BrokerageAddReqBO().setBizId(String.valueOf(item.getId())).setSourceUserId(item.getUserId()) .setBasePrice(item.getPayPrice()) - .setTitle(StrUtil.format("{}成功购买{}", user.getNickname(), item.getSpuName())) - .setFirstFixedPrice(0).setSecondFixedPrice(0); + .setTitle(StrUtil.format("{}成功购买{}", user.getNickname(), item.getSpuName())); if (BooleanUtil.isTrue(spu.getSubCommissionType())) { // 特殊:单独设置的佣金需要乘以购买数量。关联 https://gitee.com/yudaocode/yudao-mall-uniapp/issues/ICY7SJ bo.setFirstFixedPrice(sku.getFirstBrokeragePrice() * item.getCount()) diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/framework/order/core/aop/TradeOrderLogAspect.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/framework/order/core/aop/TradeOrderLogAspect.java index 5a8d26e944..74f2d42d59 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/framework/order/core/aop/TradeOrderLogAspect.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/framework/order/core/aop/TradeOrderLogAspect.java @@ -65,11 +65,17 @@ public class TradeOrderLogAspect { public void doAfterReturning(JoinPoint joinPoint, TradeOrderLog orderLog) { try { // 1.1 操作用户 - Integer userType = getUserType(); - Long userId = getUserId(); + Integer userType = USER_TYPE.get(); + if (ObjectUtil.isNull(userType)) { + userType = getUserType(); + } + Long userId = USER_ID.get(); + if (ObjectUtil.isNull(userId)) { + userId = getUserId(); + } // 1.2 订单信息 Long orderId = ORDER_ID.get(); - if (orderId == null) { // 如果未设置,只有注解,说明不需要记录日志 + if (ObjectUtil.isNull(orderId)) { // 如果未设置,只有注解,说明不需要记录日志 return; } Integer beforeStatus = BEFORE_STATUS.get(); @@ -136,4 +142,4 @@ public class TradeOrderLogAspect { EXTS.remove(); } -} +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java index 93f99c94fb..ed314c3ac0 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java @@ -142,7 +142,7 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { */ int calculatePrice(Integer basePrice, Integer percent, Integer fixedPrice) { // 1. 优先使用固定佣金 - if (fixedPrice != null && fixedPrice > 0) { + if (fixedPrice != null && fixedPrice >= 0) { return ObjectUtil.defaultIfNull(fixedPrice, 0); } // 2. 根据比例计算佣金 diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageAddReqBO.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageAddReqBO.java index fe7877961f..1f94cb2b9e 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageAddReqBO.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/bo/BrokerageAddReqBO.java @@ -31,7 +31,6 @@ public class BrokerageAddReqBO { /** * 一级佣金(固定) */ - @NotNull(message = "一级佣金(固定)不能为空") private Integer firstFixedPrice; /** * 二级佣金(固定) diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java index 690f3de52c..9cbeccf6a5 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java @@ -780,6 +780,9 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService { if (ObjUtil.notEqual(DeliveryTypeEnum.PICK_UP.getType(), order.getDeliveryType())) { throw exception(ORDER_RECEIVE_FAIL_DELIVERY_TYPE_NOT_PICK_UP); } + if (!TradeOrderStatusEnum.isUndelivered(order.getStatus())) { + throw exception(ORDER_PICK_UP_FAIL_STATUS_NOT_UNDELIVERED); + } // 情况一:如果是拼团订单,则校验拼团是否成功 if (TradeOrderTypeEnum.isCombination(order.getType())) { CombinationRecordRespDTO combinationRecord = combinationRecordApi.getCombinationRecordByOrderId( diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java index 58036eebc9..445f06a40f 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java @@ -174,7 +174,11 @@ public class PayWalletServiceImpl implements PayWalletService { } // 3. 生成钱包流水 - Integer afterBalance = payWallet.getBalance() - price; + // 情况一:充值退款:balance 在冻结时已扣,updateWhenRechargeRefund 只扣 freeze_price,所以 afterBalance 不变。https://t.zsxq.com/OJk9m + // 情况二:消费支付:updateWhenConsumption 从 balance 扣,所以 afterBalance = balance - price + Integer afterBalance = bizType == PayWalletBizTypeEnum.RECHARGE_REFUND + ? payWallet.getBalance() + : payWallet.getBalance() - price; WalletTransactionCreateReqBO bo = new WalletTransactionCreateReqBO().setWalletId(payWallet.getId()) .setPrice(-price).setBalance(afterBalance).setBizId(String.valueOf(bizId)) .setBizType(bizType.getType()).setTitle(bizType.getDescription()); diff --git a/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java b/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java index e5f63967d8..50673b57f5 100644 --- a/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java +++ b/yudao-module-report/src/main/java/cn/iocoder/yudao/module/report/framework/jmreport/core/service/JmReportTokenServiceImpl.java @@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.report.framework.jmreport.core.service; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; +import cn.iocoder.yudao.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO; import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils; @@ -10,9 +12,6 @@ import cn.iocoder.yudao.framework.security.core.LoginUser; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; -import cn.iocoder.yudao.framework.common.biz.system.oauth2.OAuth2TokenCommonApi; -import cn.iocoder.yudao.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO; -import cn.iocoder.yudao.module.system.api.permission.PermissionApi; import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -158,4 +157,33 @@ public class JmReportTokenServiceImpl implements JmReportTokenServiceI { return StrUtil.toStringOrNull(loginUser.getTenantId()); } + @Override + public String[] getPermissions(String token) { + // 设置租户上下文 + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return null; + } + TenantContextHolder.setTenantId(loginUser.getTenantId()); + + // 参见文档 https://help.jimureport.com/prodSafe/ 文档 + // 适配:如果是本系统的管理员,则返回积木报表(仪表盘/大屏设计器)的所有权限指令 + // 如果不处理,会碰到 https://t.zsxq.com/yzlkA 反馈的问题 + Long userId = SecurityFrameworkUtils.getLoginUserId(); + if (permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode())) { + return new String[]{ + "drag:datasource:testConnection", // 数据库连接测试 + "drag:datasource:saveOrUpate", // 数据源保存 + "drag:datasource:delete", // 数据源删除 + "drag:analysis:sql", // SQL解析 + "drag:design:getTotalData", // 展示Online表单数据 + "drag:dataset:save", // 数据集保存 + "drag:dataset:delete", // 数据集删除 + "onl:drag:clear:recovery", // 清空回收站 + "onl:drag:page:delete" // 数据删除 + }; + } + return null; + } + } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java index 59bb505891..ebb10800fd 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/MailSendApiImpl.java @@ -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()); } } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java index 2d67a78087..0b7b125f9a 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/mail/dto/MailSendSingleToUserReqDTO.java @@ -1,10 +1,10 @@ package cn.iocoder.yudao.module.system.api.mail.dto; -import lombok.Data; - import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.io.File; import java.util.List; import java.util.Map; @@ -47,4 +47,9 @@ public class MailSendSingleToUserReqDTO { */ private Map templateParams; + /** + * 附件 + */ + private File[] attachments; + } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginReqVO.java index a29660ec4e..4d7f31ac85 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginReqVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/auth/vo/AuthLoginReqVO.java @@ -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") diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java index 482d140533..f196e651d8 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/user/vo/user/UserSaveReqVO.java @@ -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; diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/convert/auth/AuthConvert.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/convert/auth/AuthConvert.java index 72c8cb187f..b89627573e 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/convert/auth/AuthConvert.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/convert/auth/AuthConvert.java @@ -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 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) { diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java index 03a4b7f198..dcf3588583 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/message/mail/MailSendMessage.java @@ -1,12 +1,11 @@ package cn.iocoder.yudao.module.system.mq.message.mail; -import lombok.Data; - import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import lombok.Data; +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; + } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java index 07aabb00a8..4173734a7b 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/mq/producer/mail/MailProducer.java @@ -1,16 +1,13 @@ package cn.iocoder.yudao.module.system.mq.producer.mail; import cn.iocoder.yudao.module.system.mq.message.mail.MailSendMessage; +import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; -import jakarta.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 toMails, Collection ccMails, Collection 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); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java index 1b600bc90c..c94de6d27e 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendService.java @@ -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 toMails, Collection ccMails, Collection bccMails, - String templateCode, Map templateParams) { + String templateCode, Map 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 toMails, Collection ccMails, Collection bccMails, - String templateCode, Map templateParams) { + String templateCode, Map 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 toMails, Collection ccMails, Collection bccMails, Long userId, Integer userType, - String templateCode, Map templateParams); + String templateCode, Map templateParams, + File... attachments); /** * 执行真正的邮件发送 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java index 682696f932..310ae7e282 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImpl.java @@ -20,6 +20,7 @@ import org.dromara.hutool.extra.mail.MailUtil; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; +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 toMails, Collection ccMails, Collection bccMails, Long userId, Integer userType, - String templateCode, Map templateParams) { + String templateCode, Map 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) { diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java index a35945ed77..f738f7420c 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java @@ -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); diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java index fc81be23b1..0a8e30d714 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java @@ -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; @@ -368,16 +381,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()); } } diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImplTest.java index 459568360d..9d67c844e1 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailSendServiceImplTest.java @@ -13,13 +13,15 @@ import cn.iocoder.yudao.module.system.mq.producer.mail.MailProducer; import cn.iocoder.yudao.module.system.service.member.MemberService; import cn.iocoder.yudao.module.system.service.user.AdminUserService; import org.assertj.core.util.Lists; -import org.dromara.hutool.extra.mail.*; +import org.dromara.hutool.extra.mail.MailAccount; +import org.dromara.hutool.extra.mail.MailUtil; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import java.io.File; import java.util.Collection; import java.util.HashMap; import java.util.Map; @@ -106,7 +108,7 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { // 调用 Long resultMailLogId = mailSendService.sendSingleMail(toMails, ccMails, bccMails, userId, - UserTypeEnum.ADMIN.getValue(), templateCode, templateParams); + UserTypeEnum.ADMIN.getValue(), templateCode, templateParams, (File[]) null); // 断言 assertEquals(mailLogId, resultMailLogId); // 断言调用 @@ -114,7 +116,7 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { argThat(toMailSet -> toMailSet.contains(user.getEmail()) && toMailSet.contains("admin@test.com")), argThat(ccMailSet -> ccMailSet.contains("cc@test.com")), argThat(bccMailSet -> bccMailSet.contains("bcc@test.com")), - eq(account.getId()), eq(template.getNickname()), eq(title), eq(content)); + eq(account.getId()), eq(template.getNickname()), eq(title), eq(content), isNull()); } /** @@ -156,7 +158,7 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { eq(account), eq(template), eq(content), eq(templateParams), eq(true))).thenReturn(mailLogId); // 调用 - Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams); + Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams, (java.io.File[]) null); // 断言 assertEquals(mailLogId, resultMailLogId); // 断言调用 @@ -164,7 +166,7 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { argThat(toMailSet -> toMailSet.contains(mail)), argThat(Collection::isEmpty), argThat(Collection::isEmpty), - eq(account.getId()), eq(template.getNickname()), eq(title), eq(content)); + eq(account.getId()), eq(template.getNickname()), eq(title), eq(content), isNull()); } /** @@ -206,12 +208,12 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { eq(account), eq(template), eq(content), eq(templateParams), eq(false))).thenReturn(mailLogId); // 调用 - Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams); + Long resultMailLogId = mailSendService.sendSingleMail(toMails, null, null, userId, userType, templateCode, templateParams, (java.io.File[]) null); // 断言 assertEquals(mailLogId, resultMailLogId); // 断言调用 verify(mailProducer, times(0)).sendMailSendMessage(anyLong(), any(), any(), any(), - anyLong(), anyString(), anyString(), anyString()); + anyLong(), anyString(), anyString(), anyString(), any()); } @Test @@ -261,7 +263,7 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { // 调用,并断言异常 assertServiceException(() -> mailSendService.sendSingleMail(toMails, null, null, userId, - UserTypeEnum.ADMIN.getValue(), templateCode, templateParams), + UserTypeEnum.ADMIN.getValue(), templateCode, templateParams, (java.io.File[]) null), MAIL_SEND_MAIL_NOT_EXISTS); } @@ -288,7 +290,7 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { assertEquals(account.getSslEnable(), mailAccount.isSslEnable()); return true; }), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()), - eq(message.getTitle()), eq(message.getContent()), eq(true))) + eq(message.getTitle()), eq(message.getContent()), eq(true), eq(message.getAttachments()))) .thenReturn(messageId); // 调用 @@ -311,16 +313,16 @@ public class MailSendServiceImplTest extends BaseMockitoUnitTest { // mock 方法(发送邮件) Exception e = new NullPointerException("啦啦啦"); mailUtilMock.when(() -> MailUtil.send(argThat(mailAccount -> { - assertEquals("芋艿 <7685@qq.com>", mailAccount.getFrom()); - assertTrue(mailAccount.isAuth()); - assertEquals(account.getUsername(), mailAccount.getUser()); - assertArrayEquals(account.getPassword().toCharArray(), mailAccount.getPass()); - assertEquals(account.getHost(), mailAccount.getHost()); - assertEquals(account.getPort(), mailAccount.getPort()); - assertEquals(account.getSslEnable(), mailAccount.isSslEnable()); - return true; - }), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()), - eq(message.getTitle()), eq(message.getContent()), eq(true))).thenThrow(e); + assertEquals("芋艿 <7685@qq.com>", mailAccount.getFrom()); + assertTrue(mailAccount.isAuth()); + assertEquals(account.getUsername(), mailAccount.getUser()); + assertArrayEquals(account.getPassword().toCharArray(), mailAccount.getPass()); + assertEquals(account.getHost(), mailAccount.getHost()); + assertEquals(account.getPort(), mailAccount.getPort()); + assertEquals(account.getSslEnable(), mailAccount.isSslEnable()); + return true; + }), eq(message.getToMails()), eq(message.getCcMails()), eq(message.getBccMails()), + eq(message.getTitle()), eq(message.getContent()), eq(true), same(message.getAttachments()))).thenThrow(e); // 调用 mailSendService.doSendMail(message); diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index dc001c27a7..f4e9da8495 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -68,13 +68,14 @@ spring: url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true username: root password: 123456 - tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) - 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 - druid: - validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL +# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) +# 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 +# druid: +# validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 data: