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
+
+
+ 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