소스 검색

Merge remote-tracking branch 'origin/2.0' into 2.0

zhouhao 2 년 전
부모
커밋
6dcb26c02a
95개의 변경된 파일5774개의 추가작업 그리고 696개의 파일을 삭제
  1. 27 0
      jetlinks-components/common-component/src/main/java/org/jetlinks/community/doc/QueryConditionOnly.java
  2. 19 0
      jetlinks-components/common-component/src/main/java/org/jetlinks/community/micrometer/MeterRegistryCustomizer.java
  3. 23 0
      jetlinks-components/common-component/src/main/java/org/jetlinks/community/micrometer/MeterRegistrySettings.java
  4. 2 0
      jetlinks-components/common-component/src/main/java/org/jetlinks/community/reference/DataReferenceManager.java
  5. 12 0
      jetlinks-components/common-component/src/main/java/org/jetlinks/community/web/ErrorControllerAdvice.java
  6. 2 1
      jetlinks-components/common-component/src/main/resources/i18n/common-component/messages_en.properties
  7. 1 1
      jetlinks-components/common-component/src/main/resources/i18n/common-component/messages_zh.properties
  8. 28 0
      jetlinks-components/notify-component/notify-voice/pom.xml
  9. 19 0
      jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/VoiceNotifierConfiguration.java
  10. 20 0
      jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/VoiceProvider.java
  11. 80 0
      jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/aliyun/AliyunNotifierProvider.java
  12. 116 0
      jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/aliyun/AliyunVoiceNotifier.java
  13. 68 0
      jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/aliyun/AliyunVoiceTemplate.java
  14. 20 0
      jetlinks-components/notify-component/notify-voice/src/test/java/org/jetlinks/community/notify/voice/VoiceNotifierConfigurationTest.java
  15. 90 0
      jetlinks-components/notify-component/notify-voice/src/test/java/org/jetlinks/community/notify/voice/aliyun/AliyunNotifierProviderTest.java
  16. 69 0
      jetlinks-components/notify-component/notify-voice/src/test/java/org/jetlinks/community/notify/voice/aliyun/AliyunVoiceNotifierTest.java
  17. 29 0
      jetlinks-components/notify-component/notify-webhook/pom.xml
  18. 21 0
      jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/WebHookProvider.java
  19. 96 0
      jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookNotifier.java
  20. 62 0
      jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookNotifierProvider.java
  21. 32 0
      jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookProperties.java
  22. 108 0
      jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookTemplate.java
  23. 49 0
      jetlinks-components/notify-component/notify-webhook/src/test/java/org/jetlinks/community/notify/webhook/http/HttpWebHookTemplateTest.java
  24. 2 0
      jetlinks-components/notify-component/pom.xml
  25. 2 2
      jetlinks-components/things-component/src/main/java/org/jetlinks/community/things/configuration/ThingsConfiguration.java
  26. 1 0
      jetlinks-components/things-component/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  27. 122 0
      jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/initialize/MenuAuthenticationInitializeService.java
  28. 4 3
      jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/web/MenuController.java
  29. 43 3
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/configuration/DeviceManagerConfiguration.java
  30. 0 30
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceCategory.java
  31. 79 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceCategoryEntity.java
  32. 34 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceLatestData.java
  33. 111 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceMetadataMappingEntity.java
  34. 26 1
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/ProtocolSupportEntity.java
  35. 1 1
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/excel/DeviceInstanceImportExportEntity.java
  36. 14 1
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/enums/DeviceFeature.java
  37. 16 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/events/DeviceDeployedEvent.java
  38. 20 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/events/DeviceUnregisterEvent.java
  39. 62 5
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/events/handler/DeviceProductDeployHandler.java
  40. 32 47
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/measurements/message/DeviceMessageMeasurement.java
  41. 1 2
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/measurements/message/DeviceMessageMeasurementProvider.java
  42. 107 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/relation/DeviceObjectProvider.java
  43. 11 6
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/response/DeviceDeployResult.java
  44. 50 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/response/DeviceDetail.java
  45. 9 4
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DefaultDeviceConfigMetadataManager.java
  46. 40 19
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DefaultDeviceConfigMetadataSupplier.java
  47. 80 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceCategoryService.java
  48. 3 4
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceConfigMetadataManager.java
  49. 133 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceEntityEventHandler.java
  50. 60 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceProductHandler.java
  51. 556 203
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalDeviceInstanceService.java
  52. 12 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalDeviceProductService.java
  53. 29 22
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalProtocolSupportService.java
  54. 73 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/ProtocolSupportHandler.java
  55. 651 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/DatabaseDeviceLatestDataService.java
  56. 4 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/DeviceDataService.java
  57. 138 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/DeviceLatestDataService.java
  58. 54 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/NonDeviceLatestDataService.java
  59. 38 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/StorageConstants.java
  60. 94 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/StorageDeviceConfigMetadataSupplier.java
  61. 11 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/spi/DeviceConfigMetadataSupplier.java
  62. 50 63
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceCategoryController.java
  63. 126 5
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java
  64. 107 5
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceProductController.java
  65. 59 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/ProtocolSupportController.java
  66. 321 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/excel/PropertyMetadataExcelInfo.java
  67. 56 0
      jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/excel/PropertyMetadataWrapper.java
  68. 4 4
      jetlinks-manager/device-manager/src/main/resources/i18n/device-manager/messages_zh.properties
  69. 113 0
      jetlinks-manager/network-manager/src/main/java/org/jetlinks/community/network/manager/service/NetworkEntityEventHandler.java
  70. 47 0
      jetlinks-manager/network-manager/src/main/java/org/jetlinks/community/network/manager/service/ProtocolDataReferenceProvider.java
  71. 13 0
      jetlinks-manager/notify-manager/pom.xml
  72. 10 0
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/Notify.java
  73. 6 0
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/SubscriberProvider.java
  74. 125 0
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/providers/AlarmProvider.java
  75. 0 74
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/providers/DeviceAlarmProvider.java
  76. 32 12
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/web/NotifierController.java
  77. 70 12
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/web/NotifierTemplateController.java
  78. 28 0
      jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/web/response/TemplateInfo.java
  79. 16 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/configuration/RuleEngineManagerConfiguration.java
  80. 21 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmDashboardDefinition.java
  81. 21 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmObjectDefinition.java
  82. 51 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmRecordMeasurementProvider.java
  83. 135 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmRecordRankMeasurement.java
  84. 101 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmRecordTrendMeasurement.java
  85. 23 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmTimeSeriesMetric.java
  86. 57 3
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/DeviceTrigger.java
  87. 21 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneActions.java
  88. 25 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneConditionAction.java
  89. 215 14
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneRule.java
  90. 4 1
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneTaskExecutorProvider.java
  91. 2 6
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/web/AlarmConfigController.java
  92. 0 141
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/web/RuleInstanceController.java
  93. 2 0
      jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/web/SceneController.java
  94. 191 0
      jetlinks-standalone/src/main/resources/application-dev.yml
  95. 6 1
      jetlinks-standalone/src/main/resources/application.yml

+ 27 - 0
jetlinks-components/common-component/src/main/java/org/jetlinks/community/doc/QueryConditionOnly.java

@@ -0,0 +1,27 @@
+package org.jetlinks.community.doc;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.ezorm.core.param.Term;
+
+import java.util.List;
+
+/**
+ * 文档专用,描述仅有查询功能的动态查询参数
+ *
+ * @author zhouhao
+ * @since 1.5
+ * @see org.hswebframework.web.api.crud.entity.QueryParamEntity
+ */
+@Getter
+@Setter
+public class QueryConditionOnly {
+
+    @Schema(description = "where条件表达式,与terms参数不能共存.语法: name = 张三 and age > 16")
+    private String where;
+
+    @Schema(description = "查询条件集合")
+    private List<Term> terms;
+
+}

+ 19 - 0
jetlinks-components/common-component/src/main/java/org/jetlinks/community/micrometer/MeterRegistryCustomizer.java

@@ -0,0 +1,19 @@
+package org.jetlinks.community.micrometer;
+
+/**
+ * 监控指标自定义注册接口,用于对指标进行自定义,如添加指标标签等操作
+ *
+ * @author zhouhao
+ * @since 1.11
+ */
+public interface MeterRegistryCustomizer {
+
+    /**
+     * 在指标首次初始化时调用,可以通过判断metric进行自定义标签
+     *
+     * @param metric   指标
+     * @param settings 自定义设置
+     */
+    void custom(String metric, MeterRegistrySettings settings);
+
+}

+ 23 - 0
jetlinks-components/common-component/src/main/java/org/jetlinks/community/micrometer/MeterRegistrySettings.java

@@ -0,0 +1,23 @@
+package org.jetlinks.community.micrometer;
+
+import org.jetlinks.core.metadata.DataType;
+
+import javax.annotation.Nonnull;
+
+/**
+ * 指标注册配置信息
+ *
+ * @author zhouhao
+ * @since 1.11
+ */
+public interface MeterRegistrySettings {
+
+    /**
+     * 给指标添加标签,用于自定义标签类型.在相应指标实现中会根据类型对数据进行存储
+     *
+     * @param tag  标签key
+     * @param type 类型
+     */
+    void addTag(@Nonnull String tag, @Nonnull DataType type);
+
+}

+ 2 - 0
jetlinks-components/common-component/src/main/java/org/jetlinks/community/reference/DataReferenceManager.java

@@ -24,6 +24,8 @@ public interface DataReferenceManager {
     String TYPE_NETWORK = "network";
     //数据类型:关系配置
     String TYPE_RELATION = "relation";
+    //数据类型:消息协议
+    String TYPE_PROTOCOL = "protocol";
 
     /**
      * 判断指定数据类型的数据是否已经被其他地方所引用

+ 12 - 0
jetlinks-components/common-component/src/main/java/org/jetlinks/community/web/ErrorControllerAdvice.java

@@ -6,6 +6,8 @@ import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException;
 import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata;
 import org.hswebframework.web.crud.web.ResponseMessage;
 import org.hswebframework.web.i18n.LocaleUtils;
+import org.jetlinks.community.reference.DataReferenceInfo;
+import org.jetlinks.community.reference.DataReferencedException;
 import org.jetlinks.core.enums.ErrorCode;
 import org.jetlinks.core.exception.DeviceOperationException;
 import org.springframework.core.Ordered;
@@ -102,5 +104,15 @@ public class ErrorControllerAdvice {
                 .body(ResponseMessage.error(status.value(), e.getCode().name().toLowerCase(), msg))
             );
     }
+    @ExceptionHandler
+    public Mono<ResponseMessage<List<DataReferenceInfo>>> handleException(DataReferencedException e) {
+        return e
+            .getLocalizedMessageReactive()
+            .map(msg -> {
+                return ResponseMessage
+                    .<List<DataReferenceInfo>>error(400,"error.data.referenced", msg)
+                    .result(e.getReferenceList());
+            });
+    }
 
 }

+ 2 - 1
jetlinks-components/common-component/src/main/resources/i18n/common-component/messages_en.properties

@@ -1,3 +1,4 @@
 message.device_message_handing=Message sent to device, processing...
 
-error.duplicate_key_detail=Duplicate Data:{0}
+error.duplicate_key_detail=Duplicate Data:{0}
+error.data.referenced=The data has been used elsewhere

+ 1 - 1
jetlinks-components/common-component/src/main/resources/i18n/common-component/messages_zh.properties

@@ -1,3 +1,3 @@
 message.device_message_handing=消息已发往设备,处理中...
-
+error.data.referenced=数据已经被其他地方使用
 error.duplicate_key_detail=重复的数据:{0}

+ 28 - 0
jetlinks-components/notify-component/notify-voice/pom.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>notify-component</artifactId>
+        <groupId>org.jetlinks.community</groupId>
+        <version>2.0.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>notify-voice</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>aliyun-java-sdk-core</artifactId>
+            <version>4.5.2</version>
+        </dependency>
+        <dependency>
+            <groupId>${project.groupId}</groupId>
+            <artifactId>notify-core</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+    </dependencies>
+
+
+</project>

+ 19 - 0
jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/VoiceNotifierConfiguration.java

@@ -0,0 +1,19 @@
+package org.jetlinks.community.notify.voice;
+
+import org.jetlinks.community.notify.template.TemplateManager;
+import org.jetlinks.community.notify.voice.aliyun.AliyunNotifierProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class VoiceNotifierConfiguration {
+
+
+    @Bean
+    @ConditionalOnBean(TemplateManager.class)
+    public AliyunNotifierProvider aliyunNotifierProvider(TemplateManager templateManager) {
+        return new AliyunNotifierProvider(templateManager);
+    }
+
+}

+ 20 - 0
jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/VoiceProvider.java

@@ -0,0 +1,20 @@
+package org.jetlinks.community.notify.voice;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.jetlinks.community.notify.Provider;
+
+@Getter
+@AllArgsConstructor
+public enum VoiceProvider implements Provider {
+
+    aliyun("阿里云")
+    ;
+
+    private final String name;
+
+    @Override
+    public String getId() {
+        return name();
+    }
+}

+ 80 - 0
jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/aliyun/AliyunNotifierProvider.java

@@ -0,0 +1,80 @@
+package org.jetlinks.community.notify.voice.aliyun;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.hswebframework.web.i18n.LocaleUtils;
+import org.jetlinks.community.notify.*;
+import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.core.metadata.DefaultConfigMetadata;
+import org.jetlinks.core.metadata.types.IntType;
+import org.jetlinks.core.metadata.types.StringType;
+import org.jetlinks.community.notify.template.TemplateManager;
+import org.jetlinks.community.notify.template.TemplateProperties;
+import org.jetlinks.community.notify.template.TemplateProvider;
+import org.jetlinks.community.notify.voice.VoiceProvider;
+import reactor.core.publisher.Mono;
+
+import javax.annotation.Nonnull;
+
+/**
+ * <a href="https://help.aliyun.com/document_detail/114035.html?spm=a2c4g.11186623.6.561.3d1b3c2dGMXAmk">
+ * 阿里云语音通知服务
+ * </a>
+ *
+ * @author zhouhao
+ * @since 1.0
+ */
+@Slf4j
+@AllArgsConstructor
+public class AliyunNotifierProvider implements NotifierProvider, TemplateProvider {
+
+    private TemplateManager templateManager;
+
+    @Nonnull
+    @Override
+    public Provider getProvider() {
+        return VoiceProvider.aliyun;
+    }
+
+    public static final DefaultConfigMetadata templateConfig = new DefaultConfigMetadata("阿里云语音模版",
+            "https://help.aliyun.com/document_detail/114035.html?spm=a2c4g.11186623.6.561.3d1b3c2dGMXAmk")
+            .add("ttsCode", "模版ID", "ttsCode", new StringType())
+            .add("calledShowNumbers", "被叫显号", "", new StringType())
+            .add("CalledNumber", "被叫号码", "", new StringType())
+            .add("PlayTimes", "播放次数", "", new IntType());
+
+    public static final DefaultConfigMetadata notifierConfig = new DefaultConfigMetadata("阿里云通知配置",
+            "https://help.aliyun.com/document_detail/114035.html?spm=a2c4g.11186623.6.561.3d1b3c2dGMXAmk")
+            .add("regionId", "regionId", "regionId", new StringType())
+            .add("accessKeyId", "accessKeyId", "", new StringType())
+            .add("secret", "secret", "", new StringType());
+
+    @Override
+    public ConfigMetadata getTemplateConfigMetadata() {
+        return templateConfig;
+    }
+
+    @Override
+    public ConfigMetadata getNotifierConfigMetadata() {
+        return notifierConfig;
+    }
+
+    @Override
+    public Mono<AliyunVoiceTemplate> createTemplate(TemplateProperties properties) {
+        return Mono.fromCallable(() -> new AliyunVoiceTemplate().with(properties).validate())
+            .as(LocaleUtils::transform);
+    }
+
+    @Nonnull
+    @Override
+    public NotifyType getType() {
+        return DefaultNotifyType.voice;
+    }
+
+    @Nonnull
+    @Override
+    public Mono<AliyunVoiceNotifier> createNotifier(@Nonnull NotifierProperties properties) {
+        return Mono.fromSupplier(() -> new AliyunVoiceNotifier(properties, templateManager))
+            .as(LocaleUtils::transform);
+    }
+}

+ 116 - 0
jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/aliyun/AliyunVoiceNotifier.java

@@ -0,0 +1,116 @@
+package org.jetlinks.community.notify.voice.aliyun;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.aliyuncs.CommonRequest;
+import com.aliyuncs.CommonResponse;
+import com.aliyuncs.DefaultAcsClient;
+import com.aliyuncs.IAcsClient;
+import com.aliyuncs.http.MethodType;
+import com.aliyuncs.profile.DefaultProfile;
+import com.aliyuncs.profile.IClientProfile;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.hswebframework.web.exception.BusinessException;
+import org.hswebframework.web.logger.ReactiveLogger;
+import org.jetlinks.community.notify.*;
+import org.jetlinks.community.notify.template.TemplateManager;
+import org.jetlinks.community.notify.voice.VoiceProvider;
+import org.jetlinks.core.Values;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+import javax.annotation.Nonnull;
+import java.util.Map;
+import java.util.Objects;
+
+@Slf4j
+public class AliyunVoiceNotifier extends AbstractNotifier<AliyunVoiceTemplate> {
+
+    private final IAcsClient client;
+    private final int connectTimeout = 1000;
+    private final int readTimeout = 5000;
+
+    @Getter
+    private String notifierId;
+
+    private String domain = "dyvmsapi.aliyuncs.com";
+    private String regionId = "cn-hangzhou";
+
+    public AliyunVoiceNotifier(NotifierProperties profile, TemplateManager templateManager) {
+        super(templateManager);
+        Map<String, Object> config = profile.getConfiguration();
+        DefaultProfile defaultProfile = DefaultProfile.getProfile(
+            this.regionId = (String) Objects.requireNonNull(config.get("regionId"), "regionId不能为空"),
+            (String) Objects.requireNonNull(config.get("accessKeyId"), "accessKeyId不能为空"),
+            (String) Objects.requireNonNull(config.get("secret"), "secret不能为空")
+        );
+        this.client = new DefaultAcsClient(defaultProfile);
+        this.domain = (String) config.getOrDefault("domain", "dyvmsapi.aliyuncs.com");
+        this.notifierId = profile.getId();
+    }
+
+    public AliyunVoiceNotifier(IClientProfile profile, TemplateManager templateManager) {
+        this(new DefaultAcsClient(profile), templateManager);
+    }
+
+    public AliyunVoiceNotifier(IAcsClient client, TemplateManager templateManager) {
+        super(templateManager);
+        this.client = client;
+    }
+
+    @Override
+    @Nonnull
+    public NotifyType getType() {
+        return DefaultNotifyType.voice;
+    }
+
+    @Nonnull
+    @Override
+    public Provider getProvider() {
+        return VoiceProvider.aliyun;
+    }
+
+    @Override
+    @Nonnull
+    public Mono<Void> send(@Nonnull AliyunVoiceTemplate template, @Nonnull Values context) {
+
+        return Mono.<Void>defer(() -> {
+            try {
+                CommonRequest request = new CommonRequest();
+                request.setSysMethod(MethodType.POST);
+                request.setSysDomain(domain);
+                request.setSysVersion("2017-05-25");
+                request.setSysAction("SingleCallByTts");
+                request.setSysConnectTimeout(connectTimeout);
+                request.setSysReadTimeout(readTimeout);
+                request.putQueryParameter("RegionId", regionId);
+                request.putQueryParameter("CalledShowNumber", template.getCalledShowNumbers());
+                request.putQueryParameter("CalledNumber", template.getCalledNumber(context.getAllValues()));
+                request.putQueryParameter("TtsCode", template.getTtsCode());
+                request.putQueryParameter("PlayTimes", String.valueOf(template.getPlayTimes()));
+                request.putQueryParameter("TtsParam", template.createTtsParam(context.getAllValues()));
+
+                CommonResponse response = client.getCommonResponse(request);
+
+                log.info("发起语音通知完成 {}:{}", response.getHttpResponse().getStatus(), response.getData());
+
+                JSONObject json = JSON.parseObject(response.getData());
+                if (!"ok".equalsIgnoreCase(json.getString("Code"))) {
+                    return Mono.error(new BusinessException(json.getString("Message"), json.getString("Code")));
+                }
+            } catch (Exception e) {
+                return Mono.error(e);
+            }
+            return Mono.empty();
+        }).doOnEach(ReactiveLogger.onError(err -> {
+            log.info("发起语音通知失败", err);
+        })).subscribeOn(Schedulers.boundedElastic());
+    }
+
+    @Override
+    @Nonnull
+    public Mono<Void> close() {
+        return Mono.fromRunnable(client::shutdown);
+    }
+}

+ 68 - 0
jetlinks-components/notify-component/notify-voice/src/main/java/org/jetlinks/community/notify/voice/aliyun/AliyunVoiceTemplate.java

@@ -0,0 +1,68 @@
+package org.jetlinks.community.notify.voice.aliyun;
+
+import com.alibaba.fastjson.JSON;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.jetlinks.community.notify.template.AbstractTemplate;
+import org.jetlinks.community.notify.template.Template;
+import org.jetlinks.community.notify.template.VariableDefinition;
+import org.springframework.util.StringUtils;
+
+import javax.annotation.Nonnull;
+import javax.validation.constraints.NotBlank;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 阿里云语音验证码通知模版
+ * <p>
+ * https://help.aliyun.com/document_detail/114035.html?spm=a2c4g.11186623.6.561.3d1b3c2dGMXAmk
+ */
+@Getter
+@Setter
+public class AliyunVoiceTemplate extends AbstractTemplate<AliyunVoiceTemplate> {
+    public static final String CALLED_NUMBER_KEY = "calledNumber";
+
+    @Schema(description = "通知模版ID")
+    @NotBlank(message = "[ttsCode]不能为空")
+    private String ttsCode;
+
+    private String calledShowNumbers;
+
+    @NotBlank(message = "[calledNumber]不能为空")
+    private String calledNumber;
+
+    @Schema(description = "通知播放次数")
+    private int playTimes = 1;
+
+    private Map<String, String> ttsParam;
+
+    public String createTtsParam(Map<String, Object> ctx) {
+
+        return JSON.toJSONString(ctx);
+    }
+
+    public String getCalledNumber(Map<String, Object> ctx) {
+        return get(CALLED_NUMBER_KEY, ctx, this::getCalledNumber);
+    }
+
+    @Nonnull
+    @Override
+    protected List<VariableDefinition> getEmbeddedVariables() {
+        //指定了固定的收信人
+        if (StringUtils.hasText(calledNumber)) {
+            return Collections.emptyList();
+        }
+        return Collections.singletonList(
+            VariableDefinition
+                .builder()
+                .id(CALLED_NUMBER_KEY)
+                .name("收信人")
+                .description("收信人手机号码")
+                .required(true)
+                .build()
+        );
+    }
+}

+ 20 - 0
jetlinks-components/notify-component/notify-voice/src/test/java/org/jetlinks/community/notify/voice/VoiceNotifierConfigurationTest.java

@@ -0,0 +1,20 @@
+package org.jetlinks.community.notify.voice;
+
+import org.jetlinks.community.notify.voice.aliyun.AliyunNotifierProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 输入描述.
+ *
+ * @author zhangji
+ * @version 1.11 2022/2/7
+ */
+public class VoiceNotifierConfigurationTest {
+    @Test
+    void test() {
+        VoiceNotifierConfiguration configuration = new VoiceNotifierConfiguration();
+        AliyunNotifierProvider provider = configuration.aliyunNotifierProvider(null);
+        Assertions.assertNotNull(provider);
+    }
+}

+ 90 - 0
jetlinks-components/notify-component/notify-voice/src/test/java/org/jetlinks/community/notify/voice/aliyun/AliyunNotifierProviderTest.java

@@ -0,0 +1,90 @@
+package org.jetlinks.community.notify.voice.aliyun;
+
+import com.alibaba.fastjson.JSONObject;
+import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.community.notify.DefaultNotifyType;
+import org.jetlinks.community.notify.NotifierProperties;
+import org.jetlinks.community.notify.template.TemplateProperties;
+import org.jetlinks.community.notify.voice.VoiceProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 输入描述.
+ *
+ * @author zhangji
+ * @version 1.11 2022/2/7
+ */
+public class AliyunNotifierProviderTest {
+    private AliyunNotifierProvider provider;
+    private TemplateProperties     templateProperties;
+    private NotifierProperties     notifierProperties;
+
+    private static final String TTS_CODE = "ttsCode";
+    private static final String NOTIFIER_ID = "notifier_id";
+
+    @BeforeEach
+    void init() {
+        provider = new AliyunNotifierProvider(null);
+
+        templateProperties = new TemplateProperties();
+        templateProperties.setId("test");
+        templateProperties.setType(DefaultNotifyType.voice.getId());
+        templateProperties.setProvider(VoiceProvider.aliyun.getId());
+        AliyunVoiceTemplate aliyunVoiceTemplate = new AliyunVoiceTemplate();
+        aliyunVoiceTemplate.setTtsCode(TTS_CODE);
+        aliyunVoiceTemplate.setCalledNumber("calledNumber");
+        templateProperties.setTemplate((JSONObject)JSONObject.toJSON(aliyunVoiceTemplate));
+
+        notifierProperties = new NotifierProperties();
+        notifierProperties.setId(NOTIFIER_ID);
+        notifierProperties.setType(DefaultNotifyType.voice.getId());
+        notifierProperties.setProvider(VoiceProvider.aliyun.getId());
+        Map<String, Object> config = new HashMap<>();
+        config.put("regionId", "regionId");
+        config.put("accessKeyId", "accessKeyId");
+        config.put("secret", "secret");
+        notifierProperties.setConfiguration(config);
+    }
+
+    @Test
+    void test() {
+        Assertions.assertEquals(VoiceProvider.aliyun, provider.getProvider());
+        Assertions.assertEquals(DefaultNotifyType.voice, provider.getType());
+    }
+
+    @Test
+    void getTemplateConfigMetadata() {
+        ConfigMetadata templateConfig = provider.getTemplateConfigMetadata();
+        Assertions.assertNotNull(templateConfig);
+        Assertions.assertEquals("阿里云语音模版", templateConfig.getName());
+    }
+
+    @Test
+    void getNotifierConfigMetadata() {
+        ConfigMetadata notifierConfig = provider.getNotifierConfigMetadata();
+        Assertions.assertNotNull(notifierConfig);
+        Assertions.assertEquals("阿里云通知配置", notifierConfig.getName());
+    }
+
+    @Test
+    void createTemplate() {
+        provider.createTemplate(templateProperties)
+            .as(StepVerifier::create)
+            .expectNextMatches(template -> template.getTtsCode().equals(TTS_CODE))
+            .verifyComplete();
+    }
+
+    @Test
+    void createNotifier() {
+        provider.createNotifier(notifierProperties)
+            .as(StepVerifier::create)
+            .expectNextMatches(notifier -> notifier.getNotifierId().equals(NOTIFIER_ID))
+            .verifyComplete();
+    }
+}

+ 69 - 0
jetlinks-components/notify-component/notify-voice/src/test/java/org/jetlinks/community/notify/voice/aliyun/AliyunVoiceNotifierTest.java

@@ -0,0 +1,69 @@
+package org.jetlinks.community.notify.voice.aliyun;
+
+import com.aliyuncs.CommonRequest;
+import com.aliyuncs.CommonResponse;
+import com.aliyuncs.IAcsClient;
+import com.aliyuncs.exceptions.ClientException;
+import com.aliyuncs.http.HttpResponse;
+import org.jetlinks.core.Values;
+import org.jetlinks.community.notify.DefaultNotifyType;
+import org.jetlinks.community.notify.voice.VoiceProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.http.HttpStatus;
+import reactor.test.StepVerifier;
+
+import java.util.HashMap;
+
+/**
+ * 输入描述.
+ *
+ * @author zhangji
+ * @version 1.11 2022/2/7
+ */
+public class AliyunVoiceNotifierTest {
+    private IAcsClient          client;
+    private AliyunVoiceNotifier notifier;
+
+    private static final String TTS_CODE    = "tts_code";
+
+    @BeforeEach
+    void init() throws ClientException {
+        client = Mockito.mock(IAcsClient.class);
+        CommonResponse response = new CommonResponse();
+        HttpResponse httpResponse = new HttpResponse();
+        httpResponse.setStatus(HttpStatus.OK.value());
+        response.setHttpResponse(httpResponse);
+        response.setData("{\"Code\":\"ok\"}");
+        Mockito.when(client.getCommonResponse(Mockito.any(CommonRequest.class))).thenReturn(response);
+
+        notifier = new AliyunVoiceNotifier(client, null);
+    }
+
+    @Test
+    void test() {
+        Assertions.assertNotNull(notifier);
+        Assertions.assertEquals(DefaultNotifyType.voice, notifier.getType());
+        Assertions.assertEquals(VoiceProvider.aliyun, notifier.getProvider());
+    }
+
+    @Test
+    void send() {
+        AliyunVoiceTemplate template = new AliyunVoiceTemplate();
+        template.setTtsCode(TTS_CODE);
+        template.setCalledNumber("calledNumber");
+
+        notifier.send(template, Values.of(new HashMap<>()))
+            .as(StepVerifier::create)
+            .verifyComplete();
+    }
+
+    @Test
+    void close() {
+        notifier.close()
+            .as(StepVerifier::create)
+            .verifyComplete();
+    }
+}

+ 29 - 0
jetlinks-components/notify-component/notify-webhook/pom.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>notify-component</artifactId>
+        <groupId>org.jetlinks.community</groupId>
+        <version>2.0.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>notify-webhook</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.jetlinks.community</groupId>
+            <artifactId>notify-core</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-webflux</artifactId>
+        </dependency>
+    </dependencies>
+
+
+
+
+</project>

+ 21 - 0
jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/WebHookProvider.java

@@ -0,0 +1,21 @@
+package org.jetlinks.community.notify.webhook;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.jetlinks.community.notify.Provider;
+
+@Getter
+@AllArgsConstructor
+public enum WebHookProvider implements Provider {
+
+    http("HTTP")
+    ;
+
+    private final String name;
+
+    @Override
+    public String getId() {
+        return name();
+    }
+}
+

+ 96 - 0
jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookNotifier.java

@@ -0,0 +1,96 @@
+package org.jetlinks.community.notify.webhook.http;
+
+import org.apache.commons.collections.CollectionUtils;
+import org.jetlinks.community.notify.AbstractNotifier;
+import org.jetlinks.community.notify.DefaultNotifyType;
+import org.jetlinks.community.notify.NotifyType;
+import org.jetlinks.community.notify.Provider;
+import org.jetlinks.community.notify.template.TemplateManager;
+import org.jetlinks.community.notify.webhook.WebHookProvider;
+import org.jetlinks.core.Values;
+import org.springframework.http.HttpMethod;
+import org.springframework.util.StringUtils;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+import javax.annotation.Nonnull;
+
+public class HttpWebHookNotifier extends AbstractNotifier<HttpWebHookTemplate> {
+    private final String id;
+
+    private final WebClient webClient;
+
+    private final HttpWebHookProperties properties;
+
+    public HttpWebHookNotifier(String id,
+                               HttpWebHookProperties properties,
+                               WebClient webClient,
+                               TemplateManager templateManager) {
+        super(templateManager);
+        this.id = id;
+        this.properties = properties;
+        this.webClient = webClient;
+    }
+
+    @Override
+    public String getNotifierId() {
+        return id;
+    }
+
+    @Nonnull
+    @Override
+    public NotifyType getType() {
+        return DefaultNotifyType.webhook;
+    }
+
+    @Nonnull
+    @Override
+    public Provider getProvider() {
+        return WebHookProvider.http;
+    }
+
+    @Nonnull
+    @Override
+    public Mono<Void> send(@Nonnull HttpWebHookTemplate template,
+                           @Nonnull Values context) {
+        HttpMethod method = template.getMethod();
+        WebClient.RequestBodyUriSpec bodyUriSpec = webClient
+            .method(template.getMethod());
+
+        if (StringUtils.hasText(template.getUrl())) {
+            bodyUriSpec.uri(template.getUrl());
+        }
+        if (method == HttpMethod.POST
+            || method == HttpMethod.PUT
+            || method == HttpMethod.PATCH) {
+            String body = template.resolveBody(context);
+            if (null != body) {
+                bodyUriSpec.bodyValue(body);
+            }
+        }
+
+        bodyUriSpec.headers(headers -> {
+            if (CollectionUtils.isNotEmpty(properties.getHeaders())) {
+                for (HttpWebHookProperties.Header header : properties.getHeaders()) {
+                    headers.add(header.getKey(), header.getValue());
+                }
+            }
+
+            if (CollectionUtils.isNotEmpty(template.getHeaders())) {
+                for (HttpWebHookProperties.Header header : template.getHeaders()) {
+                    headers.add(header.getKey(), header.getValue());
+                }
+            }
+        });
+
+        return bodyUriSpec
+            .retrieve()
+            .bodyToMono(Void.class);
+    }
+
+    @Nonnull
+    @Override
+    public Mono<Void> close() {
+        return Mono.empty();
+    }
+}

+ 62 - 0
jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookNotifierProvider.java

@@ -0,0 +1,62 @@
+package org.jetlinks.community.notify.webhook.http;
+
+import lombok.AllArgsConstructor;
+import org.hswebframework.web.bean.FastBeanCopier;
+import org.hswebframework.web.i18n.LocaleUtils;
+import org.hswebframework.web.validator.ValidatorUtils;
+import org.jetlinks.community.notify.*;
+import org.jetlinks.community.notify.template.Template;
+import org.jetlinks.community.notify.template.TemplateManager;
+import org.jetlinks.community.notify.template.TemplateProperties;
+import org.jetlinks.community.notify.template.TemplateProvider;
+import org.jetlinks.community.notify.webhook.WebHookProvider;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+import javax.annotation.Nonnull;
+
+@Component
+@AllArgsConstructor
+public class HttpWebHookNotifierProvider implements NotifierProvider, TemplateProvider {
+
+    private final TemplateManager templateManager;
+
+    private final WebClient.Builder builder;
+
+    @Nonnull
+    @Override
+    public NotifyType getType() {
+        return DefaultNotifyType.webhook;
+    }
+
+    @Nonnull
+    @Override
+    public Provider getProvider() {
+        return WebHookProvider.http;
+    }
+
+    @Override
+    public Mono<HttpWebHookTemplate> createTemplate(TemplateProperties properties) {
+        return Mono.just(new HttpWebHookTemplate().with(properties).validate())
+            .as(LocaleUtils::transform);
+    }
+
+    @Nonnull
+    @Override
+    public Mono<? extends Notifier<? extends Template>> createNotifier(@Nonnull NotifierProperties properties) {
+
+        HttpWebHookProperties hookProperties = FastBeanCopier.copy(properties.getConfiguration(),new HttpWebHookProperties());
+        ValidatorUtils.tryValidate(hookProperties);
+
+        WebClient.Builder client = builder.clone();
+
+        client.baseUrl(hookProperties.getUrl());
+
+        return Mono.just(new HttpWebHookNotifier(properties.getId(),
+                                                 hookProperties,
+                                                 client.build(),
+                                                 templateManager))
+            .as(LocaleUtils::transform);
+    }
+}

+ 32 - 0
jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookProperties.java

@@ -0,0 +1,32 @@
+package org.jetlinks.community.notify.webhook.http;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.hibernate.validator.constraints.URL;
+
+import javax.validation.constraints.NotBlank;
+import java.util.List;
+
+@Getter
+@Setter
+public class HttpWebHookProperties {
+    @Schema(description = "请求根地址,如: https://host/api")
+    @NotBlank
+    @URL
+    private String url;
+
+    @Schema(description = "请求头")
+    private List<Header> headers;
+
+    //todo 认证方式
+
+
+    @Getter
+    @Setter
+    public static class Header {
+        private String key;
+
+        private String value;
+    }
+}

+ 108 - 0
jetlinks-components/notify-component/notify-webhook/src/main/java/org/jetlinks/community/notify/webhook/http/HttpWebHookTemplate.java

@@ -0,0 +1,108 @@
+package org.jetlinks.community.notify.webhook.http;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONException;
+import com.google.common.collect.Maps;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.jetlinks.community.notify.template.AbstractTemplate;
+import org.jetlinks.core.Values;
+import org.springframework.http.HttpMethod;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Getter
+@Setter
+public class HttpWebHookTemplate extends AbstractTemplate<HttpWebHookTemplate> {
+
+    @Schema(description = "请求地址")
+    private String url = "";
+
+    @Schema(description = "请求头")
+    private List<HttpWebHookProperties.Header> headers;
+
+    @Schema(description = "请求方式,默认POST")
+    private HttpMethod method = HttpMethod.POST;
+
+    @Schema(description = "使用上下文作为请求体")
+    private boolean contextAsBody;
+
+    @Schema(description = "自定义请求体")
+    private String body;
+
+    @Hidden
+    private Boolean bodyIsJson;
+
+    //todo 增加认证类型, oauth2等
+
+    public String resolveBody(Values context) {
+        if (!StringUtils.hasText(body)) {
+            return body;
+        }
+        if (contextAsBody) {
+            return JSON.toJSONString(context.getAllValues());
+        }
+        Map<String, Object> contextVal = context.getAllValues();
+
+        try {
+            if (bodyIsJson == null || bodyIsJson) {
+                //解析为json再填充变量,防止变量值中含有",{等字符时导致json格式错误
+                String _body = JSON.toJSONString(
+                    resolveBody(JSON.parse(body), contextVal)
+                );
+                bodyIsJson = true;
+                return _body;
+            }
+        } catch (JSONException ignore) {
+
+        }
+        bodyIsJson = false;
+        return render(body, contextVal);
+    }
+
+
+    private Object resolveBody(Object val, Map<String, Object> context) {
+        //字符串,支持变量:${var}
+        if (val instanceof String) {
+            return render(String.valueOf(val), context);
+        }
+        if (val instanceof Map) {
+            return resolveBody(((Map<?, ?>) val), context);
+        }
+        if (val instanceof List) {
+            return resolveBody(((List<?>) val), context);
+        }
+        return val;
+    }
+
+    private Map<Object, Object> resolveBody(Map<?, ?> obj, Map<String, Object> context) {
+        Map<Object, Object> val = Maps.newLinkedHashMapWithExpectedSize(obj.size());
+
+        for (Map.Entry<?, ?> entry : obj.entrySet()) {
+            Object key = resolveBody(entry.getKey(), context);
+            //空key,忽略
+            if (ObjectUtils.isEmpty(key)) {
+                continue;
+            }
+            val.put(key, resolveBody(entry.getValue(), context));
+        }
+        return val;
+    }
+
+    private List<Object> resolveBody(List<?> obj, Map<String, Object> context) {
+        List<Object> array = new ArrayList<>(obj.size());
+        for (Object val : obj) {
+            array.add(resolveBody(val, context));
+        }
+        return array;
+    }
+
+
+
+}

+ 49 - 0
jetlinks-components/notify-component/notify-webhook/src/test/java/org/jetlinks/community/notify/webhook/http/HttpWebHookTemplateTest.java

@@ -0,0 +1,49 @@
+package org.jetlinks.community.notify.webhook.http;
+
+import org.jetlinks.community.notify.webhook.http.HttpWebHookTemplate;
+import org.jetlinks.core.Values;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Bean;
+
+import java.util.Collections;
+
+class HttpWebHookTemplateTest {
+
+
+    @Test
+    void testResolveBody() {
+        HttpWebHookTemplate template = new HttpWebHookTemplate();
+        template.setBody("{\"name\":\"${deviceName}\"}");
+
+        String body = template.resolveBody(Values.of(Collections.singletonMap("deviceName", "Test")));
+        System.out.println(body);
+        Assertions.assertEquals(
+            "{\"name\":\"Test\"}",
+            body);
+
+    }
+
+
+    @Test
+    void testResolveArrayBody() {
+        HttpWebHookTemplate template = new HttpWebHookTemplate();
+        template.setBody("[{\"name\":\"${deviceName}\"}]");
+
+        String body = template.resolveBody(Values.of(Collections.singletonMap("deviceName", "Test")));
+        System.out.println(body);
+        Assertions.assertEquals("[{\"name\":\"Test\"}]", body);
+
+    }
+
+    @Test
+    void testResolvePlainBody() {
+        HttpWebHookTemplate template = new HttpWebHookTemplate();
+        template.setBody("\"${deviceName}\"");
+
+        String body = template.resolveBody(Values.of(Collections.singletonMap("deviceName", "Test")));
+        System.out.println(body);
+        Assertions.assertEquals("\"Test\"", body);
+
+    }
+}

+ 2 - 0
jetlinks-components/notify-component/pom.xml

@@ -18,6 +18,8 @@
         <module>notify-email</module>
         <module>notify-wechat</module>
         <module>notify-dingtalk</module>
+        <module>notify-voice</module>
+        <module>notify-webhook</module>
     </modules>
 
 

+ 2 - 2
jetlinks-components/things-component/src/main/java/org/jetlinks/community/things/configuration/ThingsConfiguration.java

@@ -7,12 +7,12 @@ import org.jetlinks.core.device.DeviceRegistry;
 import org.jetlinks.core.event.EventBus;
 import org.jetlinks.core.things.ThingsRegistry;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Primary;
 
-@Configuration
+@AutoConfiguration
 @Generated
 public class ThingsConfiguration {
 

+ 1 - 0
jetlinks-components/things-component/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

@@ -0,0 +1 @@
+org.jetlinks.community.things.configuration.ThingsConfiguration

+ 122 - 0
jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/initialize/MenuAuthenticationInitializeService.java

@@ -0,0 +1,122 @@
+package org.jetlinks.community.auth.initialize;
+
+import lombok.AllArgsConstructor;
+import org.apache.commons.collections4.CollectionUtils;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
+import org.hswebframework.web.authorization.DefaultDimensionType;
+import org.hswebframework.web.authorization.Permission;
+import org.hswebframework.web.authorization.events.AuthorizationInitializeEvent;
+import org.hswebframework.web.authorization.simple.SimpleAuthentication;
+import org.hswebframework.web.authorization.simple.SimplePermission;
+import org.hswebframework.web.system.authorization.api.entity.ActionEntity;
+import org.hswebframework.web.system.authorization.api.entity.PermissionEntity;
+import org.hswebframework.web.system.authorization.defaults.service.DefaultPermissionService;
+import org.jetlinks.community.auth.entity.MenuEntity;
+import org.jetlinks.community.auth.entity.MenuView;
+import org.jetlinks.community.auth.service.DefaultMenuService;
+import org.jetlinks.community.auth.service.request.MenuGrantRequest;
+import org.jetlinks.community.auth.web.request.AuthorizationSettingDetail;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@AllArgsConstructor
+@Component
+public class MenuAuthenticationInitializeService {
+
+    private final DefaultMenuService menuService;
+
+    private final DefaultPermissionService permissionService;
+
+    /**
+     * 根据角色配置的菜单权限来重构权限信息
+     *
+     * @param event 权限初始化事件
+     */
+    @EventListener
+    public void refactorPermission(AuthorizationInitializeEvent event) {
+        if (event.getAuthentication().getDimensions().isEmpty()) {
+            return;
+        }
+        event.async(
+            Mono
+                .zip(
+                    // T1: 权限定义列表
+                    permissionService
+                        .createQuery()
+                        .where(PermissionEntity::getStatus, 1)
+                        .fetch()
+                        .collectMap(PermissionEntity::getId, Function.identity()),
+                    // T2: 菜单定义列表
+                    menuService
+                        .createQuery()
+                        .where(MenuEntity::getStatus, 1)
+                        .fetch()
+                        .collectList(),
+                    // T3: 角色赋予的菜单列表
+                    menuService
+                        .getGrantedMenus(QueryParamEntity.of(), event
+                            .getAuthentication()
+                            .getDimensions())
+                        .collectList()
+                        .filter(CollectionUtils::isNotEmpty)
+                )
+                .<Permission>flatMapIterable(tp3 -> {
+                    Map<String, PermissionEntity> permissions = tp3.getT1();
+                    List<MenuEntity> menus = tp3.getT2();
+                    List<MenuView> grantedMenus = tp3.getT3();
+                    MenuGrantRequest request = new MenuGrantRequest();
+                    request.setTargetType(DefaultDimensionType.role.getId());
+                    request.setTargetId("merge");
+                    request.setMenus(grantedMenus);
+                    AuthorizationSettingDetail detail = request.toAuthorizationSettingDetail(menus);
+                    return detail
+                        .getPermissionList()
+                        .stream()
+                        .map(per -> {
+                            PermissionEntity entity = permissions.get(per.getId());
+                            if (entity == null || per.getActions() == null) {
+                                return null;
+                            }
+
+                            Set<String> actions;
+                            if (CollectionUtils.isEmpty(entity.getActions())) {
+                                actions = new HashSet<>();
+                            } else {
+                                Set<String> defActions = entity
+                                    .getActions()
+                                    .stream()
+                                    .map(ActionEntity::getAction)
+                                    .collect(Collectors.toSet());
+                                actions = new HashSet<>(per.getActions());
+                                actions.retainAll(defActions);
+                            }
+
+                            return SimplePermission
+                                .builder()
+                                .id(entity.getId())
+                                .name(entity.getName())
+                                .options(entity.getProperties())
+                                .actions(actions)
+                                .build();
+                        })
+                        .filter(Objects::nonNull)
+                        .collect(Collectors.toList());
+                })
+                .collectList()
+                .filter(CollectionUtils::isNotEmpty)
+                .doOnNext(mapping -> {
+                    SimpleAuthentication authentication = new SimpleAuthentication();
+                    authentication.setUser(event.getAuthentication().getUser());
+                    authentication.setPermissions(mapping);
+                    event.setAuthentication(event.getAuthentication().merge(authentication));
+                })
+        );
+
+    }
+
+}

+ 4 - 3
jetlinks-manager/authentication-manager/src/main/java/org/jetlinks/community/auth/web/MenuController.java

@@ -247,15 +247,16 @@ public class MenuController implements ReactiveServiceCrudController<MenuEntity,
 
     }
 
-    @PatchMapping("/_all")
+    @PatchMapping("/{owner}/_all")
     @SaveAction
     @Transactional
-    @Operation(summary = "全量保存数据", description = "先删除旧数据,再新增数据")
-    public Mono<SaveResult> saveAll(@RequestBody Flux<MenuEntity> menus) {
+    @Operation(summary = "保存一个应用下的全量数据", description = "先应用下全部删除旧数据,再新增数据")
+    public Mono<SaveResult> saveOwnerAll(@PathVariable String owner, @RequestBody Flux<MenuEntity> menus) {
         return this
             .getService()
             .createDelete()
             .where(MenuEntity::getStatus, 1)
+            .and(MenuEntity::getOwner, owner)
             .execute()
             .then(
                 this.save(menus)

+ 43 - 3
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/configuration/DeviceManagerConfiguration.java

@@ -1,25 +1,29 @@
 package org.jetlinks.community.device.configuration;
 
 import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
+import org.hswebframework.ezorm.rdb.operator.DatabaseOperator;
+import org.jetlinks.community.buffer.BufferProperties;
 import org.jetlinks.community.device.entity.DeviceInstanceEntity;
 import org.jetlinks.community.device.function.ReactorQLDeviceSelectorBuilder;
 import org.jetlinks.community.device.function.RelationDeviceSelectorProvider;
 import org.jetlinks.community.device.message.DeviceMessageConnector;
 import org.jetlinks.community.device.message.writer.TimeSeriesMessageWriterConnector;
-import org.jetlinks.community.device.service.data.DeviceDataService;
-import org.jetlinks.community.device.service.data.DeviceDataStorageProperties;
+import org.jetlinks.community.device.service.data.*;
 import org.jetlinks.community.rule.engine.executor.DeviceSelectorBuilder;
 import org.jetlinks.community.rule.engine.executor.device.DeviceSelectorProvider;
 import org.jetlinks.core.device.DeviceRegistry;
 import org.jetlinks.core.device.session.DeviceSessionManager;
 import org.jetlinks.core.event.EventBus;
 import org.jetlinks.core.server.MessageHandler;
-import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
+import java.time.Duration;
+
 @Configuration
 @EnableConfigurationProperties(DeviceDataStorageProperties.class)
 public class DeviceManagerConfiguration {
@@ -51,4 +55,40 @@ public class DeviceManagerConfiguration {
     }
 
 
+    @Configuration
+    @ConditionalOnProperty(prefix = "jetlinks.device.storage", name = "enable-last-data-in-db", havingValue = "true")
+    static class DeviceLatestDataServiceConfiguration {
+
+        @Bean
+        @ConfigurationProperties(prefix = "jetlinks.device.storage.latest.buffer")
+        public BufferProperties deviceLatestDataServiceBufferProperties() {
+            BufferProperties bufferProperties = new BufferProperties();
+            bufferProperties.setFilePath("./data/device-latest-data-buffer");
+            bufferProperties.setSize(1000);
+            bufferProperties.setParallelism(1);
+            bufferProperties.setTimeout(Duration.ofSeconds(1));
+            return bufferProperties;
+        }
+
+        @Bean(destroyMethod = "destroy")
+        public DatabaseDeviceLatestDataService deviceLatestDataService(DatabaseOperator databaseOperator) {
+            return new DatabaseDeviceLatestDataService(databaseOperator,
+                                                       deviceLatestDataServiceBufferProperties());
+        }
+
+    }
+
+    @Bean
+    @ConditionalOnProperty(
+        prefix = "jetlinks.device.storage",
+        name = "enable-last-data-in-db",
+        havingValue = "false",
+        matchIfMissing = true)
+    @ConditionalOnMissingBean(DeviceLatestDataService.class)
+    public DeviceLatestDataService deviceLatestDataService() {
+        return new NonDeviceLatestDataService();
+    }
+
+
+
 }

+ 0 - 30
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceCategory.java

@@ -1,30 +0,0 @@
-package org.jetlinks.community.device.entity;
-
-import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.Getter;
-import lombok.Setter;
-import org.hswebframework.web.api.crud.entity.GenericTreeSortSupportEntity;
-
-import java.util.List;
-
-@Getter
-@Setter
-public class DeviceCategory extends GenericTreeSortSupportEntity<String> {
-
-    @Schema(description = "ID")
-    private String id;
-
-    @Schema(description = "标识")
-    private String key;
-
-    @Schema(description = "名称")
-    private String name;
-
-    @Schema(description = "父节点标识")
-    private String parentId;
-
-    @Schema(description = "子节点")
-    private List<DeviceCategory> children;
-
-
-}

+ 79 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceCategoryEntity.java

@@ -0,0 +1,79 @@
+package org.jetlinks.community.device.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.ezorm.rdb.mapping.annotation.ColumnType;
+import org.hswebframework.ezorm.rdb.mapping.annotation.Comment;
+import org.hswebframework.ezorm.rdb.mapping.annotation.DefaultValue;
+import org.hswebframework.web.api.crud.entity.GenericTreeSortSupportEntity;
+import org.hswebframework.web.api.crud.entity.RecordCreationEntity;
+import org.hswebframework.web.crud.annotation.EnableEntityEvent;
+import org.hswebframework.web.crud.generator.Generators;
+import org.hswebframework.web.validator.CreateGroup;
+
+import javax.persistence.Column;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Pattern;
+import java.sql.JDBCType;
+import java.util.List;
+
+@Getter
+@Setter
+@Table(name = "dev_product_category")
+@Comment("产品分类信息表")
+@EnableEntityEvent
+public class DeviceCategoryEntity extends GenericTreeSortSupportEntity<String> implements RecordCreationEntity {
+
+    @Override
+    @Id
+    @Column(length = 64, updatable = false)
+    @GeneratedValue(generator = Generators.SNOW_FLAKE)
+    @NotBlank(message = "ID不能为空", groups = CreateGroup.class)
+    @Pattern(regexp = "^[0-9a-zA-Z_\\-|]+$", message = "ID只能由数字,字母,下划线和中划线组成", groups = CreateGroup.class)
+    public String getId() {
+        return super.getId();
+    }
+
+    @Schema(description = "标识")
+    @Column(nullable = false,length = 64)
+    @NotBlank(message = "标识不能为空", groups = CreateGroup.class)
+    @GeneratedValue(generator = Generators.SNOW_FLAKE)
+    @Pattern(regexp = "^[0-9a-zA-Z_\\-]+$", message = "分类标识只能由数字,字母,下划线和中划线组成")
+    private String key;
+
+    @Schema(description = "名称")
+    @Column(nullable = false)
+    @NotBlank
+    private String name;
+
+    @Schema(description = "说明")
+    @Column
+    private String description;
+
+    @Schema(description = "子节点")
+    private List<DeviceCategoryEntity> children;
+
+    @Schema(description = "物模型")
+    @Column
+    @ColumnType(javaType = String.class, jdbcType = JDBCType.CLOB)
+    private String metadata;
+
+    @Column(updatable = false)
+    @Schema(
+        description = "创建者ID(只读)"
+        , accessMode = Schema.AccessMode.READ_ONLY
+    )
+    private String creatorId;
+
+    @Column(updatable = false)
+    @DefaultValue(generator = Generators.CURRENT_TIME)
+    @Schema(
+        description = "创建时间(只读)"
+        , accessMode = Schema.AccessMode.READ_ONLY
+    )
+    private Long createTime;
+}

+ 34 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceLatestData.java

@@ -0,0 +1,34 @@
+package org.jetlinks.community.device.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class DeviceLatestData extends HashMap<String,Object> {
+
+    public DeviceLatestData(int initialCapacity, float loadFactor) {
+        super(initialCapacity, loadFactor);
+    }
+
+    public DeviceLatestData(int initialCapacity) {
+        super(initialCapacity);
+    }
+
+    public DeviceLatestData() {
+    }
+
+    public DeviceLatestData(Map<? extends String, ?> m) {
+        super(m);
+    }
+
+    @Schema(description = "设备ID")
+    public String getDeviceId(){
+        return (String)get("deviceId");
+    }
+
+    @Schema(description = "设备名称")
+    public String getDeviceName(){
+        return (String)get("deviceName");
+    }
+}

+ 111 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceMetadataMappingEntity.java

@@ -0,0 +1,111 @@
+package org.jetlinks.community.device.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.ezorm.rdb.mapping.annotation.ColumnType;
+import org.hswebframework.ezorm.rdb.mapping.annotation.DefaultValue;
+import org.hswebframework.ezorm.rdb.mapping.annotation.EnumCodec;
+import org.hswebframework.ezorm.rdb.mapping.annotation.JsonCodec;
+import org.hswebframework.web.api.crud.entity.GenericEntity;
+import org.hswebframework.web.api.crud.entity.RecordCreationEntity;
+import org.hswebframework.web.crud.annotation.EnableEntityEvent;
+import org.hswebframework.web.crud.generator.Generators;
+import org.hswebframework.web.utils.DigestUtils;
+import org.hswebframework.web.validator.CreateGroup;
+import org.jetlinks.core.things.ThingMetadataType;
+import org.springframework.util.StringUtils;
+
+import javax.persistence.Column;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.sql.JDBCType;
+import java.util.Map;
+
+@Getter
+@Setter
+@Table(name = "dev_metadata_mapping", indexes = {
+    @Index(name = "idx_dev_mmp_did", columnList = "device_id"),
+    @Index(name = "idx_dev_mmp_pid", columnList = "product_id")
+})
+@Schema(description = "设备物模型映射")
+@EnableEntityEvent
+public class DeviceMetadataMappingEntity extends GenericEntity<String> implements RecordCreationEntity {
+
+    @Schema(description = "产品ID")
+    @Column(length = 64, nullable = false, updatable = false)
+    @NotBlank(groups = CreateGroup.class)
+    private String productId;
+
+    @Schema(description = "设备ID,为空时表示映射对产品下所有设备生效")
+    @Column(length = 64, updatable = false)
+    @NotBlank
+    private String deviceId;
+
+    @Schema(description = "物模型类型,如:property")
+    @Column(length = 32, nullable = false, updatable = false)
+    @DefaultValue("property")
+    @NotNull(groups = CreateGroup.class)
+    @EnumCodec
+    @ColumnType(javaType = String.class)
+    private ThingMetadataType metadataType;
+
+    @Schema(description = "物模型ID,如:属性ID")
+    @Column(length = 64, nullable = false, updatable = false)
+    @NotBlank(groups = CreateGroup.class)
+    private String metadataId;
+
+    @Schema(description = "原始物模型ID")
+    @Column(length = 64, nullable = false)
+    @NotBlank(groups = CreateGroup.class)
+    private String originalId;
+
+    @Schema(description = "其他配置")
+    @Column
+    @JsonCodec
+    @ColumnType(jdbcType = JDBCType.LONGVARCHAR)
+    private Map<String, Object> others;
+
+    @Schema(description = "说明")
+    @Column(length = 512)
+    private String description;
+
+    @Schema(description = "创建者ID", accessMode = Schema.AccessMode.READ_ONLY)
+    @Column(length = 64, updatable = false)
+    private String creatorId;
+
+    @Schema(description = "创建时间", accessMode = Schema.AccessMode.READ_ONLY)
+    @Column(updatable = false)
+    @DefaultValue(generator = Generators.CURRENT_TIME)
+    private Long createTime;
+
+    @Override
+    public String getId() {
+        if (super.getId() == null) {
+            generateId();
+        }
+        return super.getId();
+    }
+
+    public void generateId() {
+        if (StringUtils.hasText(deviceId)) {
+            setId(
+                generateIdByDevice(deviceId, metadataType, metadataId)
+            );
+        } else if (StringUtils.hasText(productId)) {
+            setId(
+                generateIdByProduct(productId, metadataType, metadataId)
+            );
+        }
+    }
+
+    public static String generateIdByProduct(String productId, ThingMetadataType type, String metadataId) {
+        return DigestUtils.md5Hex(String.join(":", "product", productId, type.name(), metadataId));
+    }
+
+    public static String generateIdByDevice(String deviceId, ThingMetadataType type, String metadataId) {
+        return DigestUtils.md5Hex(String.join(":", "device", deviceId, type.name(), metadataId));
+    }
+}

+ 26 - 1
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/ProtocolSupportEntity.java

@@ -1,10 +1,15 @@
 package org.jetlinks.community.device.entity;
 
+import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Getter;
 import lombok.Setter;
 import org.hswebframework.ezorm.rdb.mapping.annotation.ColumnType;
+import org.hswebframework.ezorm.rdb.mapping.annotation.DefaultValue;
 import org.hswebframework.ezorm.rdb.mapping.annotation.JsonCodec;
 import org.hswebframework.web.api.crud.entity.GenericEntity;
+import org.hswebframework.web.api.crud.entity.RecordCreationEntity;
+import org.hswebframework.web.crud.annotation.EnableEntityEvent;
+import org.hswebframework.web.crud.generator.Generators;
 import org.jetlinks.supports.protocol.management.ProtocolSupportDefinition;
 
 import javax.persistence.Column;
@@ -15,7 +20,8 @@ import java.util.Map;
 @Getter
 @Setter
 @Table(name = "dev_protocol")
-public class ProtocolSupportEntity extends GenericEntity<String> {
+@EnableEntityEvent
+public class ProtocolSupportEntity extends GenericEntity<String> implements RecordCreationEntity {
 
     @Column
     private String name;
@@ -27,8 +33,27 @@ public class ProtocolSupportEntity extends GenericEntity<String> {
     private String type;
 
     @Column
+    @Schema(description = "状态,1启用,0禁用")
+    @DefaultValue("1")
     private Byte state;
 
+
+    @Column(updatable = false)
+    @Schema(
+        description = "创建者ID(只读)"
+        , accessMode = Schema.AccessMode.READ_ONLY
+    )
+    private String creatorId;
+
+    @Column(updatable = false)
+    @DefaultValue(generator = Generators.CURRENT_TIME)
+    @Schema(
+        description = "创建时间(只读)"
+        , accessMode = Schema.AccessMode.READ_ONLY
+    )
+    private Long createTime;
+
+
     @Column
     @ColumnType(jdbcType = JDBCType.CLOB)
     @JsonCodec

+ 1 - 1
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/excel/DeviceInstanceImportExportEntity.java

@@ -12,7 +12,7 @@ public class DeviceInstanceImportExportEntity {
     @ExcelProperty("设备名称")
     private String name;
 
-    @ExcelProperty("设备型号")
+    @ExcelProperty("产品名称")
     private String productName;
 
     @ExcelProperty("描述")

+ 14 - 1
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/enums/DeviceFeature.java

@@ -1,14 +1,16 @@
 package org.jetlinks.community.device.enums;
 
 import lombok.AllArgsConstructor;
+import lombok.Generated;
 import lombok.Getter;
 import org.hswebframework.web.dict.Dict;
 import org.hswebframework.web.dict.EnumDict;
+import org.jetlinks.core.metadata.Feature;
 
 @Dict("device-feature")
 @Getter
 @AllArgsConstructor
-public enum DeviceFeature implements EnumDict<String> {
+public enum DeviceFeature implements EnumDict<String> , Feature {
     selfManageState("子设备自己管理状态")
 
 
@@ -16,7 +18,18 @@ public enum DeviceFeature implements EnumDict<String> {
     private final String text;
 
     @Override
+    @Generated
     public String getValue() {
         return name();
     }
+
+    @Override
+    public String getId() {
+        return getValue();
+    }
+
+    @Override
+    public String getName() {
+        return text;
+    }
 }

+ 16 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/events/DeviceDeployedEvent.java

@@ -0,0 +1,16 @@
+package org.jetlinks.community.device.events;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.hswebframework.web.event.DefaultAsyncEvent;
+import org.jetlinks.community.device.entity.DeviceInstanceEntity;
+
+import java.util.List;
+
+@Getter
+@AllArgsConstructor(staticName = "of")
+public class DeviceDeployedEvent extends DefaultAsyncEvent {
+
+    private final List<DeviceInstanceEntity> devices;
+
+}

+ 20 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/events/DeviceUnregisterEvent.java

@@ -0,0 +1,20 @@
+package org.jetlinks.community.device.events;
+
+import lombok.AllArgsConstructor;
+import lombok.Generated;
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.web.event.DefaultAsyncEvent;
+import org.jetlinks.community.device.entity.DeviceInstanceEntity;
+
+import java.util.List;
+
+@Getter
+@Setter
+@AllArgsConstructor(staticName = "of")
+@Generated
+public class DeviceUnregisterEvent extends DefaultAsyncEvent {
+
+    private List<DeviceInstanceEntity> devices;
+
+}

+ 62 - 5
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/events/handler/DeviceProductDeployHandler.java

@@ -4,6 +4,9 @@ import lombok.extern.slf4j.Slf4j;
 import org.jetlinks.community.device.events.DeviceProductDeployEvent;
 import org.jetlinks.community.device.service.LocalDeviceProductService;
 import org.jetlinks.community.device.service.data.DeviceDataService;
+import org.jetlinks.community.device.service.data.DeviceLatestDataService;
+import org.jetlinks.core.event.EventBus;
+import org.jetlinks.core.event.Subscription;
 import org.jetlinks.core.metadata.DeviceMetadataCodec;
 import org.jetlinks.supports.official.JetLinksDeviceMetadataCodec;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -11,8 +14,12 @@ import org.springframework.boot.CommandLineRunner;
 import org.springframework.context.event.EventListener;
 import org.springframework.core.annotation.Order;
 import org.springframework.stereotype.Component;
+import reactor.core.Disposable;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
+import javax.annotation.PreDestroy;
+
 /**
  * 处理设备产品发布事件
  *
@@ -31,33 +38,83 @@ public class DeviceProductDeployHandler implements CommandLineRunner {
 
     private final DeviceDataService dataService;
 
+    private final DeviceLatestDataService latestDataService;
+    private final EventBus eventBus;
+
+    private final Disposable disposable;
 
     @Autowired
     public DeviceProductDeployHandler(LocalDeviceProductService productService,
-                                      DeviceDataService dataService) {
+                                      DeviceDataService dataService,
+                                      EventBus eventBus,
+                                      DeviceLatestDataService latestDataService) {
         this.productService = productService;
         this.dataService = dataService;
+        this.eventBus = eventBus;
+        this.latestDataService = latestDataService;
+        //监听其他服务器上的物模型变更
+        disposable = eventBus
+            .subscribe(Subscription
+                           .builder()
+                           .subscriberId("product-metadata-upgrade")
+                           .topics("/_sys/product-upgrade")
+                           .justBroker()
+                           .build(), String.class)
+            .flatMap(id -> this
+                .reloadMetadata(id)
+                .onErrorResume((err) -> {
+                    log.warn("handle product upgrade event error", err);
+                    return Mono.empty();
+                }))
+            .subscribe();
     }
 
+    @PreDestroy
+    public void shutdown() {
+        disposable.dispose();
+    }
 
     @EventListener
     public void handlerEvent(DeviceProductDeployEvent event) {
         event.async(
             this
                 .doRegisterMetadata(event.getId(), event.getMetadata())
+                .then(
+                    eventBus.publish("/_sys/product-upgrade", event.getId())
+                )
         );
     }
 
-    private Mono<Void> doRegisterMetadata(String productId, String metadataString) {
+    protected Mono<Void> reloadMetadata(String productId) {
+        return productService
+            .findById(productId)
+            .flatMap(product -> doReloadMetadata(productId, product.getMetadata()))
+            .then();
+    }
+
+    protected Mono<Void> doReloadMetadata(String productId, String metadataString) {
+        return codec
+            .decode(metadataString)
+            .flatMap(metadata -> Flux
+                .mergeDelayError(2,
+                                 dataService.reloadMetadata(productId, metadata),
+                                 latestDataService.reloadMetadata(productId, metadata))
+                .then());
+    }
+
+    protected Mono<Void> doRegisterMetadata(String productId, String metadataString) {
         return codec
             .decode(metadataString)
-            .flatMap(metadata -> dataService.registerMetadata(productId, metadata));
+            .flatMap(metadata -> Flux
+                .mergeDelayError(2,
+                                 dataService.registerMetadata(productId, metadata),
+                                 latestDataService.upgradeMetadata(productId, metadata))
+                .then());
     }
 
 
     @Override
     public void run(String... args) {
-        //启动时发布物模型
         productService
             .createQuery()
             .fetch()
@@ -65,7 +122,7 @@ public class DeviceProductDeployHandler implements CommandLineRunner {
             .flatMap(deviceProductEntity -> this
                 .doRegisterMetadata(deviceProductEntity.getId(), deviceProductEntity.getMetadata())
                 .onErrorResume(err -> {
-                    log.warn("register product metadata error", err);
+                    log.warn("register product [{}] metadata error", deviceProductEntity.getId(), err);
                     return Mono.empty();
                 })
             )

+ 32 - 47
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/measurements/message/DeviceMessageMeasurement.java

@@ -1,5 +1,6 @@
 package org.jetlinks.community.device.measurements.message;
 
+import lombok.Generated;
 import org.jetlinks.community.Interval;
 import org.jetlinks.community.dashboard.*;
 import org.jetlinks.community.dashboard.supports.StaticMeasurement;
@@ -33,14 +34,11 @@ class DeviceMessageMeasurement extends StaticMeasurement {
 
     private final TimeSeriesManager timeSeriesManager;
 
-    private final DeviceRegistry deviceRegistry;
     static MeasurementDefinition definition = MeasurementDefinition.of("quantity", "设备消息量");
 
     public DeviceMessageMeasurement(EventBus eventBus,
-                                    DeviceRegistry registry,
                                     TimeSeriesManager timeSeriesManager) {
         super(definition);
-        this.deviceRegistry = registry;
         this.eventBus = eventBus;
         this.timeSeriesManager = timeSeriesManager;
         addDimension(new RealTimeMessageDimension());
@@ -53,21 +51,25 @@ class DeviceMessageMeasurement extends StaticMeasurement {
     class RealTimeMessageDimension implements MeasurementDimension {
 
         @Override
+        @Generated
         public DimensionDefinition getDefinition() {
             return CommonDimensionDefinition.realTime;
         }
 
         @Override
+        @Generated
         public DataType getValueType() {
             return IntType.GLOBAL;
         }
 
         @Override
+        @Generated
         public ConfigMetadata getParams() {
             return realTimeConfigMetadata;
         }
 
         @Override
+        @Generated
         public boolean isRealTime() {
             return true;
         }
@@ -86,7 +88,6 @@ class DeviceMessageMeasurement extends StaticMeasurement {
         }
     }
 
-
     static ConfigMetadata historyConfigMetadata = new DefaultConfigMetadata()
         .add("productId", "设备型号", "", new StringType())
         .add("time", "周期", "例如: 1h,10m,30s", new StringType())
@@ -100,78 +101,62 @@ class DeviceMessageMeasurement extends StaticMeasurement {
 
 
         @Override
+        @Generated
         public DimensionDefinition getDefinition() {
             return CommonDimensionDefinition.agg;
         }
 
         @Override
+        @Generated
         public DataType getValueType() {
             return IntType.GLOBAL;
         }
 
         @Override
+        @Generated
         public ConfigMetadata getParams() {
             return historyConfigMetadata;
         }
 
         @Override
+        @Generated
         public boolean isRealTime() {
             return false;
         }
 
-        private AggregationQueryParam createQueryParam(MeasurementParameter parameter) {
-            return AggregationQueryParam.of()
-//                .sum("count")
-                .groupBy(
-                    parameter.getInterval("time").orElse(Interval.ofHours(1)),
-                    parameter.getString("format").orElse("MM月dd日 HH时"))
-//                .filter(query ->
-//                    query
-//                        .where("name", "message-count")
-//                        .is("productId", parameter.getString("productId").orElse(null))
-//                        .is("msgType", parameter.getString("msgType").orElse(null))
-//                )
+        public AggregationQueryParam createQueryParam(MeasurementParameter parameter) {
+            return AggregationQueryParam
+                .of()
+                .sum("count")
+                .groupBy(parameter.getInterval("interval", parameter.getInterval("time", null)),
+                         parameter.getString("format").orElse("MM月dd日 HH时"))
+                .filter(query -> query
+                    .where("name", "message-count")
+                    .is("productId", parameter.getString("productId").orElse(null))
+                )
                 .limit(parameter.getInt("limit").orElse(1))
-                .from(parameter.getDate("from").orElseGet(() -> Date.from(LocalDateTime.now().plusDays(-1).atZone(ZoneId.systemDefault()).toInstant())))
+                .from(parameter
+                          .getDate("from")
+                          .orElseGet(() -> Date
+                              .from(LocalDateTime
+                                        .now()
+                                        .plusDays(-1)
+                                        .atZone(ZoneId.systemDefault())
+                                        .toInstant())))
                 .to(parameter.getDate("to").orElse(new Date()));
         }
 
-        private Mono<TimeSeriesMetric[]> getProductMetrics(List<String> productIdList) {
-            return Flux
-                .fromIterable(productIdList)
-                .flatMap(id -> deviceRegistry
-                    .getProduct(id)
-                    .flatMap(DeviceProductOperator::getMetadata)
-                    .onErrorResume(err -> Mono.empty())
-                    .flatMapMany(metadata -> Flux.fromIterable(metadata.getEvents())
-                        .map(event -> DeviceTimeSeriesMetric.deviceEventMetric(id, event.getId())))
-                    .concatWithValues(DeviceTimeSeriesMetric.devicePropertyMetric(id)))
-                .collectList()
-                .map(list -> list.toArray(new TimeSeriesMetric[0]));
-        }
-
         @Override
         public Flux<SimpleMeasurementValue> getValue(MeasurementParameter parameter) {
+            AggregationQueryParam param = createQueryParam(parameter);
 
-             return AggregationQueryParam.of()
-                .sum("count")
-                .groupBy(
-                    parameter.getInterval("time").orElse(Interval.ofHours(1)),
-                    parameter.getString("format").orElse("MM月dd日 HH时"))
-                .filter(query ->
-                    query.where("name", "message-count")
-                        .is("productId", parameter.getString("productId").orElse(null))
-                        .is("msgType", parameter.getString("msgType").orElse(null))
-                )
-                .limit(parameter.getInt("limit").orElse(1))
-                .from(parameter.getDate("from").orElseGet(() -> Date.from(LocalDateTime.now().plusDays(-1).atZone(ZoneId.systemDefault()).toInstant())))
-                .to(parameter.getDate("to").orElse(new Date()))
+            return Flux.defer(() -> param
                 .execute(timeSeriesManager.getService(DeviceTimeSeriesMetric.deviceMetrics())::aggregation)
                 .index((index, data) -> SimpleMeasurementValue.of(
-                    data.getInt("count").orElse(0),
+                    data.getLong("count",0),
                     data.getString("time").orElse(""),
-                    index))
-                .sort();
+                    index)))
+                .take(param.getLimit());
         }
     }
 

+ 1 - 2
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/measurements/message/DeviceMessageMeasurementProvider.java

@@ -21,14 +21,13 @@ public class DeviceMessageMeasurementProvider extends StaticMeasurementProvider
 
     public DeviceMessageMeasurementProvider(EventBus eventBus,
                                             MeterRegistryManager registryManager,
-                                            DeviceRegistry deviceRegistry,
                                             TimeSeriesManager timeSeriesManager) {
         super(DeviceDashboardDefinition.instance, DeviceObjectDefinition.message);
 
         registry = registryManager.getMeterRegister(DeviceTimeSeriesMetric.deviceMetrics().getId(),
             "target", "msgType", "productId");
 
-        addMeasurement(new DeviceMessageMeasurement(eventBus, deviceRegistry, timeSeriesManager));
+        addMeasurement(new DeviceMessageMeasurement(eventBus, timeSeriesManager));
 
     }
 

+ 107 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/relation/DeviceObjectProvider.java

@@ -0,0 +1,107 @@
+package org.jetlinks.community.device.relation;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import org.jetlinks.core.device.DeviceConfigKey;
+import org.jetlinks.core.device.DeviceOperator;
+import org.jetlinks.core.device.DeviceRegistry;
+import org.jetlinks.core.message.DeviceDataManager;
+import org.jetlinks.core.things.relation.ObjectType;
+import org.jetlinks.core.things.relation.PropertyOperation;
+import org.jetlinks.community.PropertyConstants;
+import org.jetlinks.community.device.service.LocalDeviceInstanceService;
+import org.jetlinks.community.relation.RelationObjectProvider;
+import org.jetlinks.community.relation.impl.SimpleObjectType;
+import org.jetlinks.community.relation.impl.property.PropertyOperationStrategy;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Mono;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@Component
+public class DeviceObjectProvider implements RelationObjectProvider {
+
+    private final DeviceDataManager deviceDataManager;
+
+    private final LocalDeviceInstanceService instanceService;
+
+    private final DeviceRegistry registry;
+
+    @Override
+    public String getTypeId() {
+        return RelationObjectProvider.TYPE_DEVICE;
+    }
+
+    @Override
+    public Mono<ObjectType> getType() {
+        return Mono.just(new SimpleObjectType(getTypeId(), "设备", "设备"));
+    }
+
+    @Override
+    public PropertyOperation properties(String id) {
+        return PropertyOperationStrategy
+            .composite(
+                PropertyOperationStrategy
+                    .simple(registry.getDevice(id),
+                            strategy -> strategy
+                                .addMapper("id", DeviceOperator::getDeviceId)
+                                .addAsyncMapper(PropertyConstants.deviceName, DeviceOperator::getSelfConfig)
+                                .addAsyncMapper(PropertyConstants.productName, DeviceOperator::getSelfConfig)
+                                .addAsyncMapper(PropertyConstants.productId, DeviceOperator::getSelfConfig)
+                                .addAsyncMapper(DeviceConfigKey.deviceType, DeviceOperator::getSelfConfig)
+                                .addAsyncMapper(DeviceConfigKey.parentGatewayId, DeviceOperator::getSelfConfig)
+                                .addAsyncMapper(DeviceConfigKey.firstPropertyTime, DeviceOperator::getSelfConfig)),
+                PropertyOperationStrategy
+                    .detect(strategy -> {
+                        // dev@device.property.temp
+                        // dev@device.property.temp.timestamp
+                        strategy
+                            .addOperation("property",
+                                          key -> getDeviceProperty(id, key))
+                            // dev@device.tag.tagKey
+                            .addOperation("tag",
+                                          key -> deviceDataManager
+                                              .getTags(id, key)
+                                              .map(DeviceDataManager.TagValue::getValue)
+                                              .singleOrEmpty()
+                            )
+                            // dev@device.config.key
+                            .addOperation("config",
+                                          key -> registry
+                                              .getDevice(id)
+                                              .flatMap(device -> device.getConfig(key))
+                            );
+                    })
+            );
+    }
+
+    protected Mono<Object> getDeviceProperty(String deviceId, String property) {
+        if (!property.contains(".")) {
+            return this
+                .getPropertyValue(deviceId, property)
+                .map(DeviceDataManager.PropertyValue::getValue);
+        }
+        String[] arr = property.split("[.]");
+        String propertyKey = arr[0];
+        String valueType = arr[1];
+        Mono<DeviceDataManager.PropertyValue> propertyValueMono = this.getPropertyValue(deviceId, propertyKey);
+
+        if ("timestamp".equals(valueType)) {
+            return propertyValueMono
+                .map(DeviceDataManager.PropertyValue::getTimestamp);
+        }
+        if ("state".equals(valueType)) {
+            return propertyValueMono
+                .mapNotNull(DeviceDataManager.PropertyValue::getState);
+        }
+        return propertyValueMono
+            .map(DeviceDataManager.PropertyValue::getValue);
+
+    }
+
+    Mono<DeviceDataManager.PropertyValue> getPropertyValue(String deviceId, String property) {
+        return deviceDataManager.getLastProperty(deviceId, property);
+    }
+}

+ 11 - 6
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/response/DeviceDeployResult.java

@@ -1,9 +1,6 @@
 package org.jetlinks.community.device.response;
 
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-import lombok.Setter;
+import lombok.*;
 
 @Getter
 @Setter
@@ -17,11 +14,19 @@ public class DeviceDeployResult {
 
     private String message;
 
+    //导致错误的源头
+    private Object source;
+
+    //导致错误的操作
+    private String operation;
+
+    @Generated
     public static DeviceDeployResult success(int total) {
-        return new DeviceDeployResult(total, true, null);
+        return new DeviceDeployResult(total, true, null, null, null);
     }
 
+    @Generated
     public static DeviceDeployResult error(String message) {
-        return new DeviceDeployResult(0, false, message);
+        return new DeviceDeployResult(0, false, message, null, null);
     }
 }

+ 50 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/response/DeviceDetail.java

@@ -7,12 +7,19 @@ import org.apache.commons.collections4.MapUtils;
 import org.jetlinks.community.device.entity.DeviceInstanceEntity;
 import org.jetlinks.community.device.entity.DeviceProductEntity;
 import org.jetlinks.community.device.entity.DeviceTagEntity;
+import org.jetlinks.community.device.enums.DeviceFeature;
 import org.jetlinks.community.device.enums.DeviceState;
 import org.jetlinks.community.device.enums.DeviceType;
+import org.jetlinks.community.relation.service.response.RelatedInfo;
+import org.jetlinks.core.ProtocolSupport;
 import org.jetlinks.core.Values;
+import org.jetlinks.core.device.DeviceConfigKey;
 import org.jetlinks.core.device.DeviceOperator;
+import org.jetlinks.core.device.DeviceProductOperator;
 import org.jetlinks.core.metadata.ConfigPropertyMetadata;
 import org.jetlinks.core.metadata.DeviceMetadata;
+import org.jetlinks.core.metadata.Feature;
+import org.jetlinks.core.metadata.SimpleFeature;
 import org.jetlinks.supports.official.JetLinksDeviceMetadataCodec;
 import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
@@ -126,6 +133,13 @@ public class DeviceDetail {
     @Schema(description = "设备描述")
     private String description;
 
+    @Schema(description = "关系信息")
+    private List<RelatedInfo> relations;
+
+    @Schema(description = "设备特性")
+    private List<Feature> features = new ArrayList<>();
+
+
     public DeviceDetail notActive() {
 
         state = DeviceState.notActive;
@@ -209,6 +223,11 @@ public class DeviceDetail {
         return this;
     }
 
+    public DeviceDetail withRelation(List<RelatedInfo> relations){
+        this.relations=relations;
+        return this;
+    }
+
     public DeviceDetail with(DeviceProductEntity productEntity) {
         if (productEntity == null) {
             return this;
@@ -237,6 +256,9 @@ public class DeviceDetail {
         setOrgId(device.getOrgId());
         setParentId(device.getParentId());
         setDescription(device.getDescribe());
+        if (device.getFeatures() != null) {
+            withFeatures(Arrays.asList(device.getFeatures()));
+        }
         Optional.ofNullable(device.getRegistryTime())
                 .ifPresent(this::setRegisterTime);
 
@@ -267,4 +289,32 @@ public class DeviceDetail {
         return this;
     }
 
+    public DeviceDetail withFeatures(Collection<? extends Feature> features) {
+        for (Feature feature : features) {
+            this.features.add(new SimpleFeature(feature.getId(), feature.getName()));
+        }
+        return this;
+    }
+
+    public Mono<DeviceDetail> with(DeviceProductOperator product) {
+        return Mono
+            .zip(
+                product
+                    .getProtocol()
+                    .mapNotNull(ProtocolSupport::getName)
+                    .defaultIfEmpty(""),
+                product
+                    .getConfig(DeviceConfigKey.metadata)
+                    .defaultIfEmpty(""))
+            .doOnNext(tp2 -> {
+                setProtocolName(tp2.getT1());
+                //物模型以产品缓存里的为准
+                if (!this.independentMetadata && StringUtils.hasText(tp2.getT2())) {
+                    setMetadata(tp2.getT2());
+                }
+            })
+            .thenReturn(this);
+    }
+
+
 }

+ 9 - 4
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DefaultDeviceConfigMetadataManager.java

@@ -1,10 +1,7 @@
 package org.jetlinks.community.device.service;
 
 import org.jetlinks.community.device.spi.DeviceConfigMetadataSupplier;
-import org.jetlinks.core.metadata.ConfigMetadata;
-import org.jetlinks.core.metadata.ConfigScope;
-import org.jetlinks.core.metadata.DeviceConfigScope;
-import org.jetlinks.core.metadata.DeviceMetadataType;
+import org.jetlinks.core.metadata.*;
 import org.springframework.beans.factory.config.BeanPostProcessor;
 import org.springframework.stereotype.Component;
 import org.springframework.util.CollectionUtils;
@@ -72,4 +69,12 @@ public class DefaultDeviceConfigMetadataManager implements DeviceConfigMetadataM
         }
         return bean;
     }
+
+    @Override
+    public Flux<Feature> getProductFeatures(String productId) {
+        return Flux
+            .fromIterable(suppliers)
+            .flatMap(supplier -> supplier.getProductFeatures(productId))
+            .distinct(Feature::getId);
+    }
 }

+ 40 - 19
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DefaultDeviceConfigMetadataSupplier.java

@@ -1,21 +1,25 @@
 package org.jetlinks.community.device.service;
 
 import lombok.AllArgsConstructor;
+import org.hswebframework.web.exception.BusinessException;
 import org.jetlinks.community.device.entity.DeviceInstanceEntity;
 import org.jetlinks.community.device.entity.DeviceProductEntity;
 import org.jetlinks.community.device.spi.DeviceConfigMetadataSupplier;
+import org.jetlinks.core.ProtocolSupport;
 import org.jetlinks.core.ProtocolSupports;
 import org.jetlinks.core.message.codec.Transport;
 import org.jetlinks.core.message.codec.Transports;
 import org.jetlinks.core.metadata.ConfigMetadata;
 import org.jetlinks.core.metadata.DeviceConfigScope;
 import org.jetlinks.core.metadata.DeviceMetadataType;
+import org.jetlinks.core.metadata.Feature;
 import org.springframework.stereotype.Component;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
+import java.util.function.BiFunction;
 import java.util.function.Function;
 
 @Component
@@ -62,15 +66,40 @@ public class DefaultDeviceConfigMetadataSupplier implements DeviceConfigMetadata
             .filter(metadata -> metadata.hasScope(DeviceConfigScope.product));
     }
 
-    @Override
-    @SuppressWarnings("all")
     public Flux<ConfigMetadata> getMetadataExpandsConfig(String productId,
                                                          DeviceMetadataType metadataType,
                                                          String metadataId,
                                                          String typeId) {
-        Assert.hasText(productId, "productId can not be empty");
-        Assert.notNull(metadataType, "metadataType can not be empty");
+        Assert.hasText(productId, "message.productId_cannot_be_empty");
+        Assert.notNull(metadataType, "message.metadataType_cannot_be_empty");
+
+        return this
+            .computeDeviceProtocol(productId, (protocol, transport) ->
+                protocol.getMetadataExpandsConfig(transport, metadataType, metadataId, typeId))
+            .flatMapMany(Function.identity());
+    }
+
+    private Flux<ConfigMetadata> getProductConfigMetadata0(String productId) {
+        return productService
+            .findById(productId)
+            .filter(product -> StringUtils.hasText(product.getMessageProtocol()))
+            .flatMapMany(product -> protocolSupports
+                .getProtocol(product.getMessageProtocol())
+                .onErrorMap(e -> new BusinessException("error.unable_to_load_protocol_by_access_id", 404, product.getMessageProtocol()))
+                .flatMap(support -> support.getConfigMetadata(Transport.of(product.getTransportProtocol()))));
+    }
+
+    @Override
+    public Flux<Feature> getProductFeatures(String productId) {
+        Assert.hasText(productId, "message.productId_cannot_be_empty");
+        return this
+            .computeDeviceProtocol(productId, ProtocolSupport::getFeatures)
+            .flatMapMany(Function.identity());
+    }
+
 
+    @SuppressWarnings("all")
+    protected <T> Mono<T> computeDeviceProtocol(String productId, BiFunction<ProtocolSupport, Transport, T> computer) {
         return productService
             .createQuery()
             .select(DeviceProductEntity::getMessageProtocol, DeviceProductEntity::getTransportProtocol)
@@ -80,22 +109,14 @@ public class DefaultDeviceConfigMetadataSupplier implements DeviceConfigMetadata
                 return Mono
                     .zip(
                         //消息协议
-                        protocolSupports.getProtocol(product.getMessageProtocol()),
+                        Mono.justOrEmpty(product.getMessageProtocol())
+                            .flatMap(protocolSupports::getProtocol)
+                            .onErrorMap(e -> new BusinessException("error.unable_to_load_protocol_by_access_id", 404, product.getMessageProtocol())),
                         //传输协议
-                        Mono.justOrEmpty(product.getTransportEnum(Transports.get())),
-                        (protocol, transport) -> {
-                            return protocol.getMetadataExpandsConfig(transport, metadataType, metadataId, typeId);
-                        }
+                        Mono.justOrEmpty(product.getTransportProtocol())
+                            .map(Transport::of),
+                        computer
                     );
-            })
-            .flatMapMany(Function.identity());
-    }
-
-    private Flux<ConfigMetadata> getProductConfigMetadata0(String productId) {
-        return productService
-            .findById(productId)
-            .flatMapMany(product -> protocolSupports
-                .getProtocol(product.getMessageProtocol())
-                .flatMap(support -> support.getConfigMetadata(Transport.of(product.getTransportProtocol()))));
+            });
     }
 }

+ 80 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceCategoryService.java

@@ -0,0 +1,80 @@
+package org.jetlinks.community.device.service;
+
+import com.alibaba.fastjson.JSON;
+import org.hswebframework.web.api.crud.entity.TreeSupportEntity;
+import org.hswebframework.web.crud.service.GenericReactiveTreeSupportCrudService;
+import org.hswebframework.web.id.IDGenerator;
+import org.jetlinks.community.device.entity.DeviceCategoryEntity;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StreamUtils;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+@Service
+public class DeviceCategoryService extends GenericReactiveTreeSupportCrudService<DeviceCategoryEntity, String> implements CommandLineRunner {
+
+    @Override
+    public IDGenerator<String> getIDGenerator() {
+        return IDGenerator.MD5;
+    }
+
+    private static final String category_splitter = "-";
+    @Override
+    public void setChildren(DeviceCategoryEntity entity, List<DeviceCategoryEntity> children) {
+        entity.setChildren(children);
+    }
+
+    @Override
+    public void run(String... args) {
+        this
+            .createQuery()
+            .fetchOne()
+            .switchIfEmpty(initDefaultData().then(Mono.empty()))
+            .subscribe();
+    }
+
+
+    static void rebuild(String parentId, List<DeviceCategoryEntity> children) {
+        if (children == null) {
+            return;
+        }
+        for (DeviceCategoryEntity child : children) {
+            String id = child.getId();
+            child.setId(parentId + category_splitter + id +category_splitter);
+            child.setParentId(parentId +category_splitter);
+            rebuild(parentId + category_splitter + id, child.getChildren());
+        }
+    }
+
+    private Mono<Void> initDefaultData() {
+        return Mono
+            .fromCallable(() -> {
+                ClassPathResource resource = new ClassPathResource("device-category.json");
+
+                try (InputStream stream = resource.getInputStream()) {
+                    String json = StreamUtils.copyToString(stream, StandardCharsets.UTF_8);
+
+                    List<DeviceCategoryEntity> all = JSON.parseArray(json, DeviceCategoryEntity.class);
+
+                    List<DeviceCategoryEntity> root = TreeSupportEntity.list2tree(all, DeviceCategoryEntity::setChildren);
+
+                    for (DeviceCategoryEntity category : root) {
+                        String id = category.getId();
+                        category.setId(category_splitter + id + category_splitter);
+                        category.setParentId(category_splitter + category.getParentId() + category_splitter);
+                        rebuild(category_splitter + id, category.getChildren());
+                    }
+                    return root;
+                }
+
+            })
+            .flatMap(all -> save(Flux.fromIterable(all)))
+            .then();
+    }
+}

+ 3 - 4
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceConfigMetadataManager.java

@@ -4,10 +4,7 @@ import org.jetlinks.community.device.entity.DeviceInstanceEntity;
 import org.jetlinks.community.device.entity.DeviceProductEntity;
 import org.jetlinks.community.device.spi.DeviceConfigMetadataSupplier;
 import org.jetlinks.core.message.codec.Transport;
-import org.jetlinks.core.metadata.ConfigMetadata;
-import org.jetlinks.core.metadata.ConfigPropertyMetadata;
-import org.jetlinks.core.metadata.ConfigScope;
-import org.jetlinks.core.metadata.DeviceMetadataType;
+import org.jetlinks.core.metadata.*;
 import reactor.core.publisher.Flux;
 
 
@@ -78,4 +75,6 @@ public interface DeviceConfigMetadataManager {
                                                   String typeId,
                                                   ConfigScope... scopes);
 
+    Flux<Feature> getProductFeatures(String productId);
+
 }

+ 133 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceEntityEventHandler.java

@@ -0,0 +1,133 @@
+package org.jetlinks.community.device.service;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.hswebframework.web.crud.events.EntityDeletedEvent;
+import org.hswebframework.web.crud.events.EntityModifyEvent;
+import org.hswebframework.web.crud.events.EntityPrepareCreateEvent;
+import org.hswebframework.web.crud.events.EntitySavedEvent;
+import org.hswebframework.web.exception.BusinessException;
+import org.jetlinks.core.ProtocolSupports;
+import org.jetlinks.core.device.DeviceRegistry;
+import org.jetlinks.core.message.codec.Transport;
+import org.jetlinks.community.PropertyConstants;
+import org.jetlinks.community.device.entity.DeviceCategoryEntity;
+import org.jetlinks.community.device.entity.DeviceInstanceEntity;
+import org.jetlinks.community.device.entity.DeviceProductEntity;
+import org.jetlinks.supports.official.JetLinksDeviceMetadataCodec;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Component
+@AllArgsConstructor
+@Slf4j
+public class DeviceEntityEventHandler {
+
+    private final LocalDeviceProductService productService;
+
+    private final DeviceRegistry registry;
+
+    private final ProtocolSupports supports;
+
+    @EventListener
+    public void handleDeviceEvent(EntitySavedEvent<DeviceInstanceEntity> event) {
+        //保存设备时,自动更新注册中心里的名称
+        event.first(
+            Flux.fromIterable(event.getEntity())
+                .filter(device -> StringUtils.hasText(device.getName()))
+                .flatMap(device -> registry
+                    .getDevice(device.getId())
+                    .flatMap(deviceOperator -> deviceOperator.setConfig(PropertyConstants.deviceName, device.getName())))
+        );
+    }
+
+    @EventListener
+    public void handleDeviceEvent(EntityModifyEvent<DeviceInstanceEntity> event) {
+        Map<String, DeviceInstanceEntity> olds = event
+            .getBefore()
+            .stream()
+            .filter(device -> StringUtils.hasText(device.getId()))
+            .collect(Collectors.toMap(DeviceInstanceEntity::getId, Function.identity()));
+
+        //更新设备时,自动更新注册中心里的名称
+        event.first(
+            Flux.fromIterable(event.getAfter())
+                .filter(device -> {
+                    DeviceInstanceEntity old = olds.get(device.getId());
+                    return old != null && !Objects.equals(device.getName(), old.getName());
+                })
+                .flatMap(device -> registry
+                    .getDevice(device.getId())
+                    .flatMap(deviceOperator -> deviceOperator.setConfig(PropertyConstants.deviceName, device.getName())))
+        );
+
+    }
+
+    @EventListener
+    public void handleProductDefaultMetadata(EntityPrepareCreateEvent<DeviceProductEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .flatMap(product -> {
+                    //新建产品时自动填充默认物模型
+                    if (product.getMetadata() == null &&
+                        StringUtils.hasText(product.getMessageProtocol()) &&
+                        StringUtils.hasText(product.getTransportProtocol())) {
+                        return supports
+                            .getProtocol(product.getMessageProtocol())
+                            .flatMapMany(support -> support
+                                .getDefaultMetadata(Transport.of(product.getTransportProtocol()))
+                                .flatMap(JetLinksDeviceMetadataCodec.getInstance()::encode)
+                                .doOnNext(product::setMetadata))
+                            .onErrorResume(err -> {
+                                log.warn("auto set product[{}] default metadata error", product.getName(), err);
+                                return Mono.empty();
+                            });
+                    }
+                    return Mono.empty();
+                })
+        );
+    }
+
+    @EventListener
+    public void handleCategoryDelete(EntityDeletedEvent<DeviceCategoryEntity> event) {
+        //禁止删除有产品使用的分类
+        event.async(
+            productService
+                .createQuery()
+                .in(DeviceProductEntity::getClassifiedId, event
+                    .getEntity()
+                    .stream()
+                    .map(DeviceCategoryEntity::getId)
+                    .collect(Collectors.toList()))
+                .count()
+                .doOnNext(i -> {
+                    if (i > 0) {
+                        throw new BusinessException("error.device_category_has_bean_use_by_product");
+                    }
+                })
+        );
+
+    }
+
+    //修改产品分类时,同步修改产品分类名称
+    @EventListener
+    public void handleCategorySave(EntitySavedEvent<DeviceCategoryEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .flatMap(category -> productService
+                    .createUpdate()
+                    .set(DeviceProductEntity::getClassifiedName, category.getName())
+                    .where(DeviceProductEntity::getClassifiedId, category.getId())
+                    .execute()
+                    .then())
+        );
+    }
+}

+ 60 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/DeviceProductHandler.java

@@ -0,0 +1,60 @@
+package org.jetlinks.community.device.service;
+
+import lombok.AllArgsConstructor;
+import org.hswebframework.web.bean.FastBeanCopier;
+import org.hswebframework.web.crud.events.EntityModifyEvent;
+import org.hswebframework.web.crud.events.EntitySavedEvent;
+import org.jetlinks.core.device.DeviceRegistry;
+import org.jetlinks.community.device.entity.DeviceProductEntity;
+import org.jetlinks.community.device.events.DeviceProductDeployEvent;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+/**
+ * @author bestfeng
+ */
+@AllArgsConstructor
+@Component
+public class DeviceProductHandler {
+
+    private final LocalDeviceProductService productService;
+
+    private final DeviceRegistry deviceRegistry;
+
+    private final ApplicationEventPublisher eventPublisher;
+
+    @EventListener
+    public void handleProductSaveEvent(EntitySavedEvent<DeviceProductEntity> event) {
+        event.async(
+            applyProductConfig(event.getEntity())
+        );
+    }
+
+    @EventListener
+    public void handleProductSaveEvent(EntityModifyEvent<DeviceProductEntity> event) {
+        event.async(
+            applyProductConfig(event.getBefore())
+        );
+    }
+
+    //已发布状态的产品配置更新后,重新应用配置
+    private Mono<Void> applyProductConfig(List<DeviceProductEntity> entities) {
+        return Flux
+            .fromIterable(entities)
+            .map(DeviceProductEntity::getId)
+            .as(productService::findById)
+            .filter(product -> product.getState() == 1)
+            .flatMap(product -> deviceRegistry
+                .register(product.toProductInfo())
+                .flatMap(i -> FastBeanCopier
+                    .copy(product, new DeviceProductDeployEvent())
+                    .publish(eventPublisher))
+            )
+            .then();
+    }
+}

+ 556 - 203
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalDeviceInstanceService.java

@@ -7,36 +7,51 @@ import org.apache.commons.collections4.MapUtils;
 import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
 import org.hswebframework.ezorm.rdb.mapping.ReactiveUpdate;
 import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult;
-import org.hswebframework.ezorm.rdb.operator.dml.Terms;
+import org.hswebframework.web.api.crud.entity.PagerResult;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
 import org.hswebframework.web.crud.events.EntityDeletedEvent;
 import org.hswebframework.web.crud.events.EntityEventHelper;
 import org.hswebframework.web.crud.service.GenericReactiveCrudService;
 import org.hswebframework.web.exception.BusinessException;
+import org.hswebframework.web.exception.I18nSupportException;
+import org.hswebframework.web.exception.TraceSourceException;
+import org.hswebframework.web.i18n.LocaleUtils;
 import org.hswebframework.web.id.IDGenerator;
-import org.jetlinks.community.device.entity.*;
-import org.jetlinks.community.device.enums.DeviceFeature;
-import org.jetlinks.community.device.enums.DeviceState;
-import org.jetlinks.community.device.response.DeviceDeployResult;
 import org.jetlinks.community.device.response.DeviceDetail;
-import org.jetlinks.community.utils.ErrorUtils;
 import org.jetlinks.core.device.DeviceConfigKey;
 import org.jetlinks.core.device.DeviceOperator;
+import org.jetlinks.core.device.DeviceProductOperator;
 import org.jetlinks.core.device.DeviceRegistry;
 import org.jetlinks.core.enums.ErrorCode;
 import org.jetlinks.core.exception.DeviceOperationException;
 import org.jetlinks.core.message.DeviceMessageReply;
 import org.jetlinks.core.message.FunctionInvokeMessageSender;
+import org.jetlinks.core.message.ReadPropertyMessageSender;
 import org.jetlinks.core.message.WritePropertyMessageSender;
+import org.jetlinks.core.message.codec.Transport;
 import org.jetlinks.core.message.function.FunctionInvokeMessageReply;
 import org.jetlinks.core.message.property.ReadPropertyMessageReply;
 import org.jetlinks.core.message.property.WritePropertyMessageReply;
 import org.jetlinks.core.metadata.ConfigMetadata;
-import org.jetlinks.core.metadata.PropertyMetadata;
-import org.jetlinks.core.metadata.types.StringType;
+import org.jetlinks.core.metadata.DeviceMetadata;
+import org.jetlinks.core.metadata.MergeOption;
 import org.jetlinks.core.utils.CyclicDependencyChecker;
+import org.jetlinks.community.device.entity.*;
+import org.jetlinks.community.device.enums.DeviceState;
+import org.jetlinks.community.device.events.DeviceDeployedEvent;
+import org.jetlinks.community.device.events.DeviceUnregisterEvent;
+import org.jetlinks.community.device.response.DeviceDeployResult;
+import org.jetlinks.community.relation.RelationObjectProvider;
+import org.jetlinks.community.relation.service.RelationService;
+import org.jetlinks.community.relation.service.response.RelatedInfo;
+import org.jetlinks.community.utils.ErrorUtils;
+import org.jetlinks.reactor.ql.utils.CastUtils;
+import org.jetlinks.supports.official.JetLinksDeviceMetadataCodec;
 import org.reactivestreams.Publisher;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.event.EventListener;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.reactive.TransactionalOperator;
 import org.springframework.util.StringUtils;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
@@ -46,6 +61,7 @@ import reactor.util.function.Tuple3;
 import reactor.util.function.Tuples;
 
 import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
@@ -57,31 +73,54 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
 
     private final LocalDeviceProductService deviceProductService;
 
+    private final ReactiveRepository<DeviceTagEntity, String> tagRepository;
+
+    private final ApplicationEventPublisher eventPublisher;
+
     private final DeviceConfigMetadataManager metadataManager;
 
-    @SuppressWarnings("all")
-    private final ReactiveRepository<DeviceTagEntity, String> tagRepository;
+    private final RelationService relationService;
+
+    private final TransactionalOperator transactionalOperator;
 
     public LocalDeviceInstanceService(DeviceRegistry registry,
                                       LocalDeviceProductService deviceProductService,
-                                      DeviceConfigMetadataManager metadataManager,
                                       @SuppressWarnings("all")
-                                      ReactiveRepository<DeviceTagEntity, String> tagRepository) {
+                                      ReactiveRepository<DeviceTagEntity, String> tagRepository,
+                                      ApplicationEventPublisher eventPublisher,
+                                      DeviceConfigMetadataManager metadataManager,
+                                      RelationService relationService,
+                                      TransactionalOperator transactionalOperator) {
         this.registry = registry;
         this.deviceProductService = deviceProductService;
-        this.metadataManager = metadataManager;
         this.tagRepository = tagRepository;
+        this.eventPublisher = eventPublisher;
+        this.metadataManager = metadataManager;
+        this.relationService = relationService;
+        this.transactionalOperator = transactionalOperator;
     }
 
-
     @Override
     public Mono<SaveResult> save(Publisher<DeviceInstanceEntity> entityPublisher) {
-        return Flux.from(entityPublisher)
-                   .doOnNext(instance -> instance.setState(null))
-                   .as(super::save);
+        return Flux
+            .from(entityPublisher)
+            .flatMap(instance -> {
+                instance.setState(null);
+                if (StringUtils.isEmpty(instance.getId())) {
+                    return handleCreateBefore(instance);
+                }
+                return registry
+                    .getDevice(instance.getId())
+                    .flatMap(DeviceOperator::getState)
+                    .map(DeviceState::of)
+                    .onErrorReturn(DeviceState.offline)
+                    .defaultIfEmpty(DeviceState.notActive)
+                    .doOnNext(instance::setState)
+                    .thenReturn(instance);
+            })
+            .as(super::save);
     }
 
-
     /**
      * 重置设备配置
      *
@@ -105,19 +144,24 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
                                        .forEach(device.getConfiguration()::remove);
                             }
                             //重置注册中心里的配置
-                            return registry.getDevice(deviceId)
-                                           .flatMap(opts -> opts.removeConfigs(product.getConfiguration().keySet()))
-                                           .then();
+                            return registry
+                                .getDevice(deviceId)
+                                .flatMap(opts -> opts.removeConfigs(product.getConfiguration().keySet()))
+                                .then();
                         }
                         return Mono.empty();
-                    }).then(
-                        //更新数据库
-                        createUpdate()
-                            .set(device::getConfiguration)
-                            .where(device::getId)
-                            .execute()
+                    })
+                    .then(
+                        Mono.defer(() -> {
+                            //更新数据库
+                            return createUpdate()
+                                .when(device.getConfiguration() != null, update -> update.set(device::getConfiguration))
+                                .when(device.getConfiguration() == null, update -> update.setNull(DeviceInstanceEntity::getConfiguration))
+                                .where(device::getId)
+                                .execute();
+                        })
                     )
-                    .thenReturn(device.getConfiguration());
+                    .then(Mono.fromSupplier(device::getConfiguration));
             })
             .defaultIfEmpty(Collections.emptyMap())
             ;
@@ -132,22 +176,48 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
     public Mono<DeviceDeployResult> deploy(String id) {
         return findById(id)
             .flux()
-            .as(this::deploy)
+            .as(flux -> deploy(flux, Mono::error))
             .singleOrEmpty();
     }
 
+
     /**
-     * 批量发布设备到设备注册中心
+     * 批量发布设备到设备注册中心,并异常返回空
      *
      * @param flux 设备实例流
      * @return 发布数量
      */
     public Flux<DeviceDeployResult> deploy(Flux<DeviceInstanceEntity> flux) {
+        return this
+            .deploy(flux, err -> Mono.empty());
+//            .contextWrite(TraceSourceException.deepTraceContext());
+    }
+
+    /**
+     * 批量发布设备到设备注册中心并指定异常
+     *
+     * @param flux 设备实例流
+     * @return 发布数量
+     */
+    public Flux<DeviceDeployResult> deploy(Flux<DeviceInstanceEntity> flux, Function<Throwable, Mono<Void>> fallback) {
+        //设备回滚 key: deviceId value: 操作
+        Map<String, Mono<Void>> rollback = new ConcurrentHashMap<>();
+
         return flux
+            //添加回滚操作,用于再触发DeviceDeployedEvent事件执行失败时进行回滚.
+            .flatMap(device -> registry
+                .getDevice(device.getId())
+                .switchIfEmpty(Mono.fromRunnable(() -> {
+                    //设备之前没有注册的回滚操作(注销)
+                    rollback.put(device.getId(), registry.unregisterDevice(device.getId()));
+                }))
+                .thenReturn(device))
+            //发布到注册中心
             .flatMap(instance -> registry
                 .register(instance.toDeviceInfo())
                 .flatMap(deviceOperator -> deviceOperator
-                    .getState()
+                    .checkState()//激活时检查设备状态
+                    .onErrorReturn(org.jetlinks.core.device.DeviceState.offline)
                     .flatMap(r -> {
                         if (r.equals(org.jetlinks.core.device.DeviceState.unknown) ||
                             r.equals(org.jetlinks.core.device.DeviceState.noActive)) {
@@ -157,12 +227,14 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
                         instance.setState(DeviceState.of(r));
                         return Mono.just(true);
                     })
-                    .flatMap(success -> success ? Mono.just(deviceOperator) : Mono.empty())
-                )
-                .thenReturn(instance))
-            .buffer(50)
+                    .flatMap(success -> success ? Mono.just(deviceOperator) : Mono.empty()))
+                .thenReturn(instance)
+                //激活失败,忽略错误,继续处理其他设备
+                .onErrorResume(e -> fallback.apply(e).then(Mono.empty()))
+            )
+            .buffer(200)//每200条数据批量更新
             .publishOn(Schedulers.single())
-            .flatMap(all -> Flux
+            .concatMap(all -> Flux
                 .fromIterable(all)
                 .groupBy(DeviceInstanceEntity::getState)
                 .flatMap(group -> group
@@ -171,30 +243,38 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
                     .flatMap(list -> createUpdate()
                         .where()
                         .set(DeviceInstanceEntity::getState, group.key())
-                        .set(DeviceInstanceEntity::getRegistryTime, new Date())
+                        .set(DeviceInstanceEntity::getRegistryTime, System.currentTimeMillis())
                         .in(DeviceInstanceEntity::getId, list)
+                        .is(DeviceInstanceEntity::getState, DeviceState.notActive)
                         .execute()
-                        .map(r -> DeviceDeployResult.success(list.size()))
-                        .onErrorResume(err -> Mono.just(DeviceDeployResult.error(err.getMessage()))))))
+                        .map(r -> DeviceDeployResult.success(list.size()))))
+                //推送激活事件
+                .flatMap(res -> DeviceDeployedEvent.of(all).publish(eventPublisher).thenReturn(res))
+                //传递国际化上下文
+                .as(LocaleUtils::transform)
+                .as(transactionalOperator::transactional)
+                .onErrorResume(err -> Flux
+                    .fromIterable(all)
+                    .mapNotNull(device -> rollback.get(device.getId()))
+                    .flatMap(Function.identity())
+                    .then(
+                        Mono.zip(
+                            I18nSupportException.tryGetLocalizedMessageReactive(err),
+                            TraceSourceException.tryGetOperationLocalizedReactive(err).defaultIfEmpty(""),
+                            (msg, opt) -> new DeviceDeployResult(all.size(),
+                                                                 false,
+                                                                 msg,
+                                                                 TraceSourceException.tryGetSource(err),
+                                                                 opt))
+                    )
+                    .flatMap(res -> fallback.apply(err).thenReturn(res))
+                )
+            )
+            //激活时不触发事件,单独处理DeviceDeployedEvent
+            .as(EntityEventHelper::setDoNotFireEvent)
             ;
     }
 
-    /**
-     * 取消发布(取消激活),取消后,设备无法再连接到服务. 注册中心也无法再获取到该设备信息.
-     *
-     * @param id 设备ID
-     * @return 取消结果
-     */
-    public Mono<Integer> cancelDeploy(String id) {
-        return findById(Mono.just(id))
-            .flatMap(product -> registry
-                .unregisterDevice(id)
-                .then(createUpdate()
-                          .set(DeviceInstanceEntity::getState, DeviceState.notActive.getValue())
-                          .where(DeviceInstanceEntity::getId, id)
-                          .execute()));
-    }
-
     /**
      * 注销设备,取消后,设备无法再连接到服务. 注册中心也无法再获取到该设备信息.
      *
@@ -202,13 +282,9 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
      * @return 注销结果
      */
     public Mono<Integer> unregisterDevice(String id) {
-        return this.findById(Mono.just(id))
-                   .flatMap(device -> registry
-                       .unregisterDevice(id)
-                       .then(createUpdate()
-                                 .set(DeviceInstanceEntity::getState, DeviceState.notActive.getValue())
-                                 .where(DeviceInstanceEntity::getId, id)
-                                 .execute()));
+        return this
+            .unregisterDevice(Mono.just(id))
+            .thenReturn(1);
     }
 
     /**
@@ -218,25 +294,187 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
      * @return 注销结果
      */
     public Mono<Integer> unregisterDevice(Publisher<String> ids) {
-        return Flux.from(ids)
-                   .flatMap(id -> registry.unregisterDevice(id).thenReturn(id))
+        return Flux
+            .from(ids)
+            .buffer(200)
+            //先修改状态
+            .flatMap(list -> this
+                .findById(list)
+                .collectList()
+                .flatMap(devices -> DeviceUnregisterEvent.of(devices).publish(eventPublisher))
+                .then(this
+                          .createUpdate()
+                          .set(DeviceInstanceEntity::getState, DeviceState.notActive.getValue())
+                          .where().in(DeviceInstanceEntity::getId, list)
+                          .execute()
+                          .thenReturn(list)
+                ))
+            .flatMapIterable(Function.identity())
+            //再注销
+            .flatMap(id -> registry
+                .getDevice(id)
+                .flatMap(DeviceOperator::disconnect)
+                .onErrorResume(err -> Mono.empty())
+                .then(registry.unregisterDevice(id))
+                .onErrorResume(err -> Mono.empty())
+                .thenReturn(id))
+            .count()
+            .map(Long::intValue)
+            //注销不触发事件,单独处理DeviceDeployedEvent
+            .as(EntityEventHelper::setDoNotFireEvent);
+    }
+
+    @Override
+    public Mono<Integer> deleteById(Publisher<String> idPublisher) {
+        return Flux.from(idPublisher)
                    .collectList()
-                   .flatMap(list -> createUpdate()
-                       .set(DeviceInstanceEntity::getState, DeviceState.notActive.getValue())
-                       .where().in(DeviceInstanceEntity::getId, list)
+                   .flatMap(list -> createDelete()
+                       .where()
+                       .in(DeviceInstanceEntity::getId, list)
+                       .and(DeviceInstanceEntity::getState, DeviceState.notActive)
                        .execute());
     }
 
-    protected Mono<DeviceDetail> createDeviceDetail(DeviceProductEntity product,
-                                                    DeviceInstanceEntity device,
-                                                    List<DeviceTagEntity> tags) {
+    private boolean hasContext(QueryParamEntity param, String key) {
+        return param
+            .getContext(key)
+            .map(CastUtils::castBoolean)
+            .orElse(true);
+    }
+
+    //分页查询设备详情列表
+    public Mono<PagerResult<DeviceDetail>> queryDeviceDetail(QueryParamEntity entity) {
+
+        return this
+            .queryPager(entity)
+            .filter(e -> CollectionUtils.isNotEmpty(e.getData()))
+            .flatMap(result -> this
+                .convertDeviceInstanceToDetail(result.getData(),
+                                               hasContext(entity, "includeTags"),
+                                               hasContext(entity, "includeBind"),
+                                               hasContext(entity, "includeRelations"),
+                                               hasContext(entity, "includeFirmwareInfos"))
+                .collectList()
+                .map(detailList -> PagerResult.of(result.getTotal(), detailList, entity)))
+            .defaultIfEmpty(PagerResult.empty());
+    }
+
+    //查询设备详情列表
+    public Flux<DeviceDetail> queryDeviceDetailList(QueryParamEntity entity) {
+        return this
+            .query(entity)
+            .collectList()
+            .flatMapMany(list -> this
+                .convertDeviceInstanceToDetail(list,
+                                               hasContext(entity, "includeTags"),
+                                               hasContext(entity, "includeBind"),
+                                               hasContext(entity, "includeRelations"),
+                                               hasContext(entity, "includeFirmwareInfos")));
+    }
+
+    private Mono<Map<String, List<DeviceTagEntity>>> queryDeviceTagGroup(Collection<String> deviceIdList) {
+        return tagRepository
+            .createQuery()
+            .where()
+            .in(DeviceTagEntity::getDeviceId, deviceIdList)
+            .fetch()
+            .collect(Collectors.groupingBy(DeviceTagEntity::getDeviceId))
+            .defaultIfEmpty(Collections.emptyMap());
+    }
+
+    private Flux<DeviceDetail> convertDeviceInstanceToDetail(List<DeviceInstanceEntity> instanceList,
+                                                             boolean includeTag,
+                                                             boolean includeBinds,
+                                                             boolean includeRelations,
+                                                             boolean includeFirmwareInfos) {
+        if (CollectionUtils.isEmpty(instanceList)) {
+            return Flux.empty();
+        }
+        List<String> deviceIdList = new ArrayList<>(instanceList.size());
+        //按设备产品分组
+        Map<String, List<DeviceInstanceEntity>> productGroup = instanceList
+            .stream()
+            .peek(device -> deviceIdList.add(device.getId()))
+            .collect(Collectors.groupingBy(DeviceInstanceEntity::getProductId));
+        //标签
+        Mono<Map<String, List<DeviceTagEntity>>> tags = includeTag
+            ? this.queryDeviceTagGroup(deviceIdList)
+            : Mono.just(Collections.emptyMap());
+
+        //关系信息
+        Mono<Map<String, List<RelatedInfo>>> relations = includeRelations ? relationService
+            .getRelationInfo(RelationObjectProvider.TYPE_DEVICE, deviceIdList)
+            .collect(Collectors.groupingBy(RelatedInfo::getObjectId))
+            .defaultIfEmpty(Collections.emptyMap())
+            : Mono.just(Collections.emptyMap());
+
 
-        DeviceDetail detail = new DeviceDetail().with(product).with(device).with(tags);
         return Mono
             .zip(
+                //T1:查询出所有设备的产品信息
+                deviceProductService
+                    .findById(productGroup.keySet())
+                    .collect(Collectors.toMap(DeviceProductEntity::getId, Function.identity())),
+                //T2:查询出标签并按设备ID分组
+                tags,
+                //T3: 关系信息
+                relations
+            )
+            .flatMapMany(tp5 -> Flux
+                //遍历设备,将设备信息转为详情.
+                .fromIterable(instanceList)
+                .flatMap(instance -> this
+                    .createDeviceDetail(
+                        // 设备
+                        instance
+                        //产品
+                        , tp5.getT1().get(instance.getProductId())
+                        //标签
+                        , tp5.getT2().get(instance.getId())
+                        //关系信息
+                        , tp5.getT3().get(instance.getId())
+                    )
+                ))
+            //createDeviceDetail是异步操作,可能导致顺序错乱.进行重新排序.
+            .sort(Comparator.comparingInt(detail -> deviceIdList.indexOf(detail.getId())))
+            ;
+    }
+
+    private Mono<DeviceDetail> createDeviceDetail(DeviceInstanceEntity device,
+                                                  DeviceProductEntity product,
+                                                  List<DeviceTagEntity> tags,
+                                                  List<RelatedInfo> relations) {
+        if (product == null) {
+            log.warn("device [{}] product [{}] does not exists", device.getId(), device.getProductId());
+            return Mono.empty();
+        }
+        DeviceDetail detail = new DeviceDetail()
+            .with(product)
+            .with(device)
+            .with(tags)
+            .withRelation(relations);
+
+        return Mono
+            .zip(
+                //产品注册信息
+                registry
+                    .getProduct(product.getId()),
+                //feature信息
+                metadataManager
+                    .getProductFeatures(product.getId())
+                    .collectList())
+            .flatMap(t2 -> {
+                //填充产品中feature信息
+                detail.withFeatures(t2.getT2());
+                //填充注册中心里的产品信息
+                return detail.with(t2.getT1());
+            })
+            .then(Mono.zip(
                 //设备信息
                 registry
                     .getDevice(device.getId())
+                    //先刷新配置缓存
+                    .flatMap(operator -> operator.refreshAllConfig().thenReturn(operator))
                     .flatMap(operator -> operator
                         //检查设备的真实状态,可能出现设备已经离线,但是数据库状态未及时更新的.
                         .checkState()
@@ -255,11 +493,11 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
                 metadataManager
                     .getDeviceConfigMetadata(device.getId())
                     .flatMapIterable(ConfigMetadata::getProperties)
-                    .collectList(),
-                detail::with
-            )
+                    .collectList()
+            ))
             //填充详情信息
-            .flatMap(Function.identity())
+            .flatMap(tp2 -> detail
+                .with(tp2.getT1(), tp2.getT2()))
             .switchIfEmpty(
                 Mono.defer(() -> {
                     //如果设备注册中心里没有设备信息,并且数据库里的状态不是未激活.
@@ -277,39 +515,168 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
                 log.warn("get device detail error", err);
                 return Mono.just(detail);
             });
-
     }
 
     public Mono<DeviceDetail> getDeviceDetail(String deviceId) {
+
         return this
             .findById(deviceId)
-            .zipWhen(device -> deviceProductService.findById(device.getProductId()))//合并型号
-            .zipWith(tagRepository
-                         .createQuery()
-                         .where(DeviceTagEntity::getDeviceId, deviceId)
-                         .fetch()
-                         .collectList()
-                         .defaultIfEmpty(Collections.emptyList()) //合并标签
-                , (left, right) -> Tuples.of(left.getT2(), left.getT1(), right))
-            .flatMap(tp3 -> createDeviceDetail(tp3.getT1(), tp3.getT2(), tp3.getT3()));
+            .map(Collections::singletonList)
+            .flatMapMany(list -> convertDeviceInstanceToDetail(list, true, true, true, true))
+            .next();
     }
 
     public Mono<DeviceState> getDeviceState(String deviceId) {
+        return registry.getDevice(deviceId)
+                       .flatMap(DeviceOperator::checkState)
+                       .flatMap(state -> {
+                           DeviceState deviceState = DeviceState.of(state);
+                           return createUpdate()
+                               .set(DeviceInstanceEntity::getState, deviceState)
+                               .where(DeviceInstanceEntity::getId, deviceId)
+                               .execute()
+                               .thenReturn(deviceState);
+                       })
+                       .defaultIfEmpty(DeviceState.notActive);
+    }
+
+    //获取设备属性
+    @SneakyThrows
+    public Mono<Map<String, Object>> readProperty(String deviceId,
+                                                  String property) {
         return registry
             .getDevice(deviceId)
-            .flatMap(DeviceOperator::checkState)
-            .flatMap(state -> {
-                DeviceState deviceState = DeviceState.of(state);
-                return this
-                    .createUpdate()
-                    .set(DeviceInstanceEntity::getState, deviceState)
-                    .where(DeviceInstanceEntity::getId, deviceId)
-                    .execute()
-                    .thenReturn(deviceState);
-            })
-            .defaultIfEmpty(DeviceState.notActive);
+            .switchIfEmpty(ErrorUtils.notFound("error.device_not_found_or_not_activated"))
+            .map(DeviceOperator::messageSender)//发送消息到设备
+            .map(sender -> sender.readProperty(property).messageId(IDGenerator.SNOW_FLAKE_STRING.generate()))
+            .flatMapMany(ReadPropertyMessageSender::send)
+            .flatMap(mapReply(ReadPropertyMessageReply::getProperties))
+            .reduceWith(LinkedHashMap::new, (main, map) -> {
+                main.putAll(map);
+                return main;
+            });
+
+    }
+
+    //获取标准设备属性
+    @SneakyThrows
+    public Mono<DeviceProperty> readAndConvertProperty(String deviceId,
+                                                       String property) {
+        return registry
+            .getDevice(deviceId)
+            .switchIfEmpty(ErrorUtils.notFound("error.device_not_found_or_not_activated"))
+            .flatMap(deviceOperator -> deviceOperator
+                .messageSender()
+                .readProperty(property)
+                .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
+                .send()
+                .flatMap(mapReply(ReadPropertyMessageReply::getProperties))
+                .reduceWith(LinkedHashMap::new, (main, map) -> {
+                    main.putAll(map);
+                    return main;
+                })
+                .flatMap(map -> {
+                    Object value = map.get(property);
+                    return deviceOperator
+                        .getMetadata()
+                        .map(deviceMetadata -> DeviceProperty.of(value, deviceMetadata.getPropertyOrNull(property)));
+                }));
+
+    }
+
+    //设置设备属性
+    @SneakyThrows
+    public Mono<Map<String, Object>> writeProperties(String deviceId,
+                                                     Map<String, Object> properties) {
+
+        return registry
+            .getDevice(deviceId)
+            .switchIfEmpty(ErrorUtils.notFound("error.device_not_found_or_not_activated"))
+            .flatMap(operator -> operator
+                .messageSender()
+                .writeProperty()
+                .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
+                .write(properties)
+                .validate()
+            )
+            .flatMapMany(WritePropertyMessageSender::send)
+            .flatMap(mapReply(WritePropertyMessageReply::getProperties))
+            .reduceWith(LinkedHashMap::new, (main, map) -> {
+                main.putAll(map);
+                return main;
+            });
+    }
+
+    //设备功能调用
+    @SneakyThrows
+    public Flux<?> invokeFunction(String deviceId,
+                                  String functionId,
+                                  Map<String, Object> properties) {
+        return invokeFunction(deviceId, functionId, properties, true);
+    }
+
+    //设备功能调用
+    @SneakyThrows
+    public Flux<?> invokeFunction(String deviceId,
+                                  String functionId,
+                                  Map<String, Object> properties,
+                                  boolean convertReply) {
+        return registry
+            .getDevice(deviceId)
+            .switchIfEmpty(ErrorUtils.notFound("error.device_not_found_or_not_activated"))
+            .flatMap(operator -> operator
+                .messageSender()
+                .invokeFunction(functionId)
+                .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
+                .setParameter(properties)
+                .validate()
+            )
+            .flatMapMany(FunctionInvokeMessageSender::send)
+            .flatMap(convertReply ? mapReply(FunctionInvokeMessageReply::getOutput) : Mono::just);
+
+
+    }
+
+    //获取设备所有属性
+    @SneakyThrows
+    public Mono<Map<String, Object>> readProperties(String deviceId, List<String> properties) {
+
+        return registry.getDevice(deviceId)
+                       .switchIfEmpty(ErrorUtils.notFound("error.device_not_found_or_not_activated"))
+                       .map(DeviceOperator::messageSender)
+                       .flatMapMany((sender) -> sender.readProperty()
+                                                      .read(properties)
+                                                      .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
+                                                      .send())
+                       .flatMap(mapReply(ReadPropertyMessageReply::getProperties))
+                       .reduceWith(LinkedHashMap::new, (main, map) -> {
+                           main.putAll(map);
+                           return main;
+                       });
+    }
+
+    private static <R extends DeviceMessageReply, T> Function<R, Mono<T>> mapReply(Function<R, T> function) {
+        return reply -> {
+            if (ErrorCode.REQUEST_HANDLING.name().equals(reply.getCode())) {
+                throw new DeviceOperationException(ErrorCode.REQUEST_HANDLING, reply.getMessage());
+            }
+            if (!reply.isSuccess()) {
+                if (StringUtils.isEmpty(reply.getMessage())) {
+                    throw new BusinessException("error.reply_is_error");
+                }
+                throw new BusinessException(reply.getMessage(), reply.getCode());
+            }
+            return Mono.justOrEmpty(function.apply(reply));
+        };
     }
 
+    /**
+     * 批量同步设备状态
+     *
+     * @param batch 设备状态ID流
+     * @param force 是否强制获取设备状态,强制获取会去设备连接到服务器检查设备是否真实在线
+     * @return 同步数量
+     */
     public Flux<List<DeviceStateInfo>> syncStateBatch(Flux<List<String>> batch, boolean force) {
 
         return batch
@@ -363,92 +730,108 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
             .as(EntityEventHelper::setDoNotFireEvent);
     }
 
-    private static <R extends DeviceMessageReply, T> Function<R, Mono<T>> mapReply(Function<R, T> function) {
-        return reply -> {
-            if (ErrorCode.REQUEST_HANDLING.name().equals(reply.getCode())) {
-                throw new DeviceOperationException(ErrorCode.REQUEST_HANDLING, reply.getMessage());
-            }
-            if (!reply.isSuccess()) {
-                throw new BusinessException(reply.getMessage(), reply.getCode());
-            }
-            return Mono.justOrEmpty(function.apply(reply));
-        };
-    }
 
-    //获取标准设备属性
-    @SneakyThrows
-    public Mono<DevicePropertiesEntity> readAndConvertProperty(String deviceId,
-                                                               String property) {
-        return registry
-            .getDevice(deviceId)
-            .switchIfEmpty(ErrorUtils.notFound("设备不存在"))
-            .flatMap(deviceOperator -> deviceOperator
-                .messageSender()
-                .readProperty(property)
-                .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
-                .send()
-                .flatMap(mapReply(ReadPropertyMessageReply::getProperties))
-                .reduceWith(LinkedHashMap::new, (main, map) -> {
-                    main.putAll(map);
-                    return main;
-                })
-                .flatMap(map -> {
-                    Object value = map.get(property);
-                    return deviceOperator
-                        .getMetadata()
-                        .map(deviceMetadata -> deviceMetadata
-                            .getProperty(property)
-                            .map(PropertyMetadata::getValueType)
-                            .orElse(new StringType()))
-                        .map(dataType -> DevicePropertiesEntity
-                            .builder()
-                            .deviceId(deviceId)
-                            .productId(property)
-                            .build()
-                            .withValue(dataType, value));
-                }));
+    public Mono<Void> mergeMetadata(String deviceId, DeviceMetadata metadata, MergeOption... options) {
 
+        return Mono
+            .zip(this.findById(deviceId)
+                     .flatMap(device -> {
+                         if (StringUtils.hasText(device.getDeriveMetadata())) {
+                             return Mono.just(device.getDeriveMetadata());
+                         } else {
+                             return deviceProductService
+                                 .findById(device.getProductId())
+                                 .map(DeviceProductEntity::getMetadata);
+                         }
+                     })
+                     .flatMap(JetLinksDeviceMetadataCodec.getInstance()::decode),
+                 Mono.just(metadata),
+                 (older, newer) -> older.merge(newer, options)
+            )
+            .flatMap(JetLinksDeviceMetadataCodec.getInstance()::encode)
+            .flatMap(newMetadata -> createUpdate()
+                .set(DeviceInstanceEntity::getDeriveMetadata, newMetadata)
+                .where(DeviceInstanceEntity::getId, deviceId)
+                .execute()
+                .then(
+                    registry
+                        .getDevice(deviceId)
+                        .flatMap(device -> device.updateMetadata(newMetadata))
+                ))
+            .then();
     }
 
-    //设置设备属性
-    @SneakyThrows
-    public Mono<Map<String, Object>> writeProperties(String deviceId,
-                                                     Map<String, Object> properties) {
+    public Flux<DeviceTagEntity> queryDeviceTag(String deviceId, String... tags) {
+        return tagRepository
+            .createQuery()
+            .where(DeviceTagEntity::getDeviceId, deviceId)
+            .when(tags.length > 0, q -> q.in(DeviceTagEntity::getKey, Arrays.asList(tags)))
+            .fetch();
+    }
 
-        return registry
-            .getDevice(deviceId)
-            .switchIfEmpty(ErrorUtils.notFound("设备不存在"))
-            .map(operator -> operator
-                .messageSender()
-                .writeProperty()
-                .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
-                .write(properties)
+    //删除设备时,删除设备标签
+    @EventListener
+    public void handleDeviceDelete(EntityDeletedEvent<DeviceInstanceEntity> event) {
+        event.async(
+            Flux.concat(
+                Flux
+                    .fromIterable(event.getEntity())
+                    .flatMap(device -> registry
+                        .unregisterDevice(device.getId())
+                        .onErrorResume(err -> Mono.empty())
+                    )
+                    .then(),
+                tagRepository
+                    .createDelete()
+                    .where()
+                    .in(DeviceTagEntity::getDeviceId, event
+                        .getEntity()
+                        .stream()
+                        .map(DeviceInstanceEntity::getId)
+                        .collect(Collectors.toSet()))
+                    .execute()
             )
-            .flatMapMany(WritePropertyMessageSender::send)
-            .flatMap(mapReply(WritePropertyMessageReply::getProperties))
-            .reduceWith(LinkedHashMap::new, (main, map) -> {
-                main.putAll(map);
-                return main;
-            });
+        );
     }
 
-    //设备功能调用
-    @SneakyThrows
-    public Flux<?> invokeFunction(String deviceId,
-                                  String functionId,
-                                  Map<String, Object> properties) {
-        return registry
-            .getDevice(deviceId)
-            .switchIfEmpty(ErrorUtils.notFound("设备不存在"))
-            .flatMap(operator -> operator
-                .messageSender()
-                .invokeFunction(functionId)
-                .messageId(IDGenerator.SNOW_FLAKE_STRING.generate())
-                .setParameter(properties)
-                .validate()
+    @Override
+    public Mono<Integer> insert(DeviceInstanceEntity data) {
+        return this
+            .handleCreateBefore(data)
+            .flatMap(super::insert);
+    }
+
+    @Override
+    public Mono<Integer> insert(Publisher<DeviceInstanceEntity> entityPublisher) {
+        return super.insert(Flux.from(entityPublisher).flatMap(this::handleCreateBefore));
+    }
+
+    @Override
+    public Mono<Integer> insertBatch(Publisher<? extends Collection<DeviceInstanceEntity>> entityPublisher) {
+        return Flux.from(entityPublisher)
+                   .flatMapIterable(Function.identity())
+                   .as(this::insert);
+    }
+
+    private Mono<DeviceInstanceEntity> handleCreateBefore(DeviceInstanceEntity instanceEntity) {
+        return Mono
+            .zip(
+                deviceProductService.findById(instanceEntity.getProductId()),
+                registry
+                    .getProduct(instanceEntity.getProductId())
+                    .flatMap(DeviceProductOperator::getProtocol),
+                (product, protocol) -> protocol.doBeforeDeviceCreate(Transport.of(product.getTransportProtocol()), instanceEntity
+                    .toDeviceInfo())
             )
-            .flatMapMany(FunctionInvokeMessageSender::send)
-            .flatMap(mapReply(FunctionInvokeMessageReply::getOutput));
+            .flatMap(Function.identity())
+            .doOnNext(info -> {
+                if (StringUtils.isEmpty(instanceEntity.getId())) {
+                    instanceEntity.setId(info.getId());
+                }
+                instanceEntity.mergeConfiguration(info.getConfiguration());
+            })
+            .thenReturn(instanceEntity);
+
     }
 
     private final CyclicDependencyChecker<DeviceInstanceEntity, Void> checker = CyclicDependencyChecker
@@ -495,34 +878,4 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
 
     }
 
-    /**
-     * 删除设备后置处理,解绑子设备和网关,并在注册中心取消激活已激活设备.
-     */
-    private Flux<Void> deletedHandle(Flux<DeviceInstanceEntity> devices) {
-        return devices.filter(device -> !StringUtils.isEmpty(device.getParentId()))
-            .groupBy(DeviceInstanceEntity::getParentId)
-            .flatMap(group -> {
-                String parentId = group.key();
-                return group.flatMap(child -> registry.getDevice(child.getId())
-                            .flatMap(device -> device.removeConfig(DeviceConfigKey.parentGatewayId.getKey()).thenReturn(device))
-                    )
-                    .as(childrenDeviceOp -> registry.getDevice(parentId)
-                        .flatMap(gwOperator -> gwOperator.getProtocol()
-                            .flatMap(protocolSupport -> protocolSupport.onChildUnbind(gwOperator, childrenDeviceOp))
-                        )
-                    );
-            })
-            // 取消激活
-            .thenMany(
-                devices.filter(device -> device.getState() != DeviceState.notActive)
-                    .flatMap(device -> registry.unregisterDevice(device.getId()))
-            );
-    }
-
-    @EventListener
-    public void deletedHandle(EntityDeletedEvent<DeviceInstanceEntity> event) {
-        event.async(
-            this.deletedHandle(Flux.fromIterable(event.getEntity())).then()
-        );
-    }
 }

+ 12 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalDeviceProductService.java

@@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
 import org.hswebframework.web.bean.FastBeanCopier;
 import org.hswebframework.web.crud.service.GenericReactiveCrudService;
+import org.hswebframework.web.exception.BusinessException;
 import org.jetlinks.community.device.entity.DeviceInstanceEntity;
 import org.jetlinks.community.device.entity.DeviceProductEntity;
 import org.jetlinks.community.device.enums.DeviceProductState;
@@ -13,6 +14,7 @@ import org.reactivestreams.Publisher;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
@@ -29,10 +31,13 @@ public class LocalDeviceProductService extends GenericReactiveCrudService<Device
     @Autowired
     private ReactiveRepository<DeviceInstanceEntity, String> instanceRepository;
 
+
     public Mono<Integer> deploy(String id) {
         return findById(Mono.just(id))
+            .doOnNext(this::validateDeviceProduct)
             .flatMap(product -> registry
                 .register(product.toProductInfo())
+                .onErrorMap(e -> new BusinessException("error.unable_to_load_protocol_by_access_id", 404, product.getMessageProtocol()))
                 .then(
                     createUpdate()
                         .set(DeviceProductEntity::getState, DeviceProductState.registered.getValue())
@@ -46,6 +51,13 @@ public class LocalDeviceProductService extends GenericReactiveCrudService<Device
             );
     }
 
+    private void validateDeviceProduct(DeviceProductEntity product) {
+        // 设备接入ID不能为空
+//        Assert.hasText(product.getAccessId(), "error.access_id_can_not_be_empty");
+        // 发布前,必须填写消息协议
+        Assert.hasText(product.getMessageProtocol(), "error.please_select_the_access_mode_first");
+    }
+
 
     public Mono<Integer> cancelDeploy(String id) {
         return createUpdate()

+ 29 - 22
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalProtocolSupportService.java

@@ -1,48 +1,55 @@
 package org.jetlinks.community.device.service;
 
+import lombok.extern.slf4j.Slf4j;
 import org.hswebframework.web.crud.service.GenericReactiveCrudService;
-import org.hswebframework.web.exception.BusinessException;
 import org.hswebframework.web.exception.NotFoundException;
 import org.jetlinks.community.device.entity.ProtocolSupportEntity;
-import org.jetlinks.supports.protocol.management.ProtocolSupportLoader;
+import org.jetlinks.community.reference.DataReferenceManager;
 import org.jetlinks.supports.protocol.management.ProtocolSupportManager;
+import org.reactivestreams.Publisher;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 @Service
+@Slf4j
 public class LocalProtocolSupportService extends GenericReactiveCrudService<ProtocolSupportEntity, String> {
 
     @Autowired
     private ProtocolSupportManager supportManager;
 
     @Autowired
-    private ProtocolSupportLoader loader;
+    private DataReferenceManager referenceManager;
+
+    @Override
+    public Mono<Integer> deleteById(Publisher<String> idPublisher) {
+        return Flux.from(idPublisher)
+                   .flatMap(id -> supportManager.remove(id).thenReturn(id))
+                   .as(super::deleteById);
+    }
 
     public Mono<Boolean> deploy(String id) {
         return findById(Mono.just(id))
-                .switchIfEmpty(Mono.error(NotFoundException::new))
-                .map(ProtocolSupportEntity::toDeployDefinition)
-                .flatMap(def->loader.load(def).thenReturn(def))
-                .onErrorMap(err->new BusinessException("无法加载协议:"+err.getMessage(),err))
-                .flatMap(supportManager::save)
-                .flatMap(r -> createUpdate()
-                        .set(ProtocolSupportEntity::getState, 1)
-                        .where(ProtocolSupportEntity::getId, id)
-                        .execute())
-                .map(i -> i > 0);
+            .switchIfEmpty(Mono.error(NotFoundException::new))
+            .flatMap(r -> createUpdate()
+                .set(ProtocolSupportEntity::getState, 1)
+                .where(ProtocolSupportEntity::getId, id)
+                .execute())
+            .map(i -> i > 0);
     }
 
     public Mono<Boolean> unDeploy(String id) {
-        return findById(Mono.just(id))
-                .switchIfEmpty(Mono.error(NotFoundException::new))
-                .map(ProtocolSupportEntity::toUnDeployDefinition)
-                .flatMap(supportManager::save)
-                .flatMap(r -> createUpdate()
-                        .set(ProtocolSupportEntity::getState, 0)
-                        .where(ProtocolSupportEntity::getId, id)
-                        .execute())
-                .map(i -> i > 0);
+        // 消息协议被使用时,不能禁用
+        return referenceManager
+            .assertNotReferenced(DataReferenceManager.TYPE_PROTOCOL, id)
+            .then(findById(Mono.just(id)))
+            .switchIfEmpty(Mono.error(NotFoundException::new))
+            .flatMap(r -> createUpdate()
+                .set(ProtocolSupportEntity::getState, 0)
+                .where(ProtocolSupportEntity::getId, id)
+                .execute())
+            .map(i -> i > 0);
     }
 
 }

+ 73 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/ProtocolSupportHandler.java

@@ -0,0 +1,73 @@
+package org.jetlinks.community.device.service;
+
+import lombok.AllArgsConstructor;
+import org.hswebframework.web.crud.events.EntityBeforeDeleteEvent;
+import org.hswebframework.web.crud.events.EntityCreatedEvent;
+import org.hswebframework.web.crud.events.EntityModifyEvent;
+import org.hswebframework.web.crud.events.EntitySavedEvent;
+import org.hswebframework.web.exception.BusinessException;
+import org.jetlinks.community.device.entity.ProtocolSupportEntity;
+import org.jetlinks.community.reference.DataReferenceManager;
+import org.jetlinks.core.ProtocolSupport;
+import org.jetlinks.supports.protocol.management.ProtocolSupportLoader;
+import org.jetlinks.supports.protocol.management.ProtocolSupportManager;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.Collection;
+
+/**
+ * 协议事件处理类.
+ *
+ * @author zhangji 2022/4/1
+ */
+@Component
+@AllArgsConstructor
+public class ProtocolSupportHandler {
+    private final DataReferenceManager referenceManager;
+    private       ProtocolSupportLoader  loader;
+    private       ProtocolSupportManager supportManager;
+
+    //禁止删除已有网关使用的协议
+    @EventListener
+    public void handleProtocolDelete(EntityBeforeDeleteEvent<ProtocolSupportEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .flatMap(protocol -> referenceManager
+                    .assertNotReferenced(DataReferenceManager.TYPE_PROTOCOL, protocol.getId()))
+        );
+    }
+
+    @EventListener
+    public void handleCreated(EntityCreatedEvent<ProtocolSupportEntity> event) {
+        event.async(reloadProtocol(event.getEntity()));
+    }
+
+    @EventListener
+    public void handleSaved(EntitySavedEvent<ProtocolSupportEntity> event) {
+        event.async(reloadProtocol(event.getEntity()));
+    }
+
+    @EventListener
+    public void handleModify(EntityModifyEvent<ProtocolSupportEntity> event) {
+        event.async(reloadProtocol(event.getAfter()));
+    }
+
+    // 重新加载协议
+    private Mono<Void> reloadProtocol(Collection<ProtocolSupportEntity> protocol) {
+        return Flux
+            .fromIterable(protocol)
+            .filter(entity -> entity.getState() != null)
+            .map(entity -> entity.getState() == 1 ? entity.toDeployDefinition() : entity.toUnDeployDefinition())
+            .flatMap(def -> loader
+                //加载一下检验是否正确,然后就卸载
+                .load(def)
+                .doOnNext(ProtocolSupport::dispose)
+                .thenReturn(def))
+            .onErrorMap(err -> new BusinessException("error.unable_to_load_protocol", 500, err.getMessage()))
+            .flatMap(supportManager::save)
+            .then();
+    }
+}

+ 651 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/DatabaseDeviceLatestDataService.java

@@ -0,0 +1,651 @@
+package org.jetlinks.community.device.service.data;
+
+import com.google.common.collect.Maps;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.hswebframework.ezorm.core.ValueCodec;
+import org.hswebframework.ezorm.rdb.codec.ClobValueCodec;
+import org.hswebframework.ezorm.rdb.codec.DateTimeCodec;
+import org.hswebframework.ezorm.rdb.codec.JsonValueCodec;
+import org.hswebframework.ezorm.rdb.codec.NumberValueCodec;
+import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrappers;
+import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
+import org.hswebframework.ezorm.rdb.mapping.defaults.record.Record;
+import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata;
+import org.hswebframework.ezorm.rdb.metadata.RDBSchemaMetadata;
+import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata;
+import org.hswebframework.ezorm.rdb.operator.DatabaseOperator;
+import org.hswebframework.ezorm.rdb.operator.ddl.TableBuilder;
+import org.hswebframework.ezorm.rdb.operator.dml.SelectColumnSupplier;
+import org.hswebframework.ezorm.rdb.operator.dml.query.Selects;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
+import org.hswebframework.web.exception.ValidationException;
+import org.jetlinks.core.event.Subscription;
+import org.jetlinks.core.message.DeviceMessage;
+import org.jetlinks.core.message.event.EventMessage;
+import org.jetlinks.core.metadata.DataType;
+import org.jetlinks.core.metadata.DeviceMetadata;
+import org.jetlinks.core.metadata.EventMetadata;
+import org.jetlinks.core.metadata.PropertyMetadata;
+import org.jetlinks.core.metadata.types.*;
+import org.jetlinks.core.utils.Reactors;
+import org.jetlinks.core.utils.SerializeUtils;
+import org.jetlinks.core.utils.StringBuilderUtils;
+import org.jetlinks.community.ConfigMetadataConstants;
+import org.jetlinks.community.buffer.BufferProperties;
+import org.jetlinks.community.buffer.BufferSettings;
+import org.jetlinks.community.buffer.PersistenceBuffer;
+import org.jetlinks.community.device.entity.DeviceLatestData;
+import org.jetlinks.community.gateway.DeviceMessageUtils;
+import org.jetlinks.community.gateway.annotation.Subscribe;
+import org.jetlinks.community.timeseries.query.Aggregation;
+import org.jetlinks.community.timeseries.query.AggregationColumn;
+import org.jetlinks.reactor.ql.utils.CastUtils;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+import reactor.math.MathFlux;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
+
+import java.io.Externalizable;
+import java.io.IOException;
+import java.io.ObjectInput;
+import java.io.ObjectOutput;
+import java.sql.JDBCType;
+import java.time.Duration;
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 设备最新数据服务,用于保存设备最新的相关数据到关系型数据库中,可以使用动态条件进行查询相关数据
+ *
+ * @author zhouhao
+ * @since 1.5.0
+ */
+@Slf4j
+public class DatabaseDeviceLatestDataService implements DeviceLatestDataService {
+
+    private final DatabaseOperator databaseOperator;
+
+    private final BufferProperties buffer;
+
+    private PersistenceBuffer<Buffer> writer;
+
+    public DatabaseDeviceLatestDataService(DatabaseOperator databaseOperator, BufferProperties properties) {
+        this.databaseOperator = databaseOperator;
+        this.buffer = properties;
+        init();
+    }
+
+    public static String getLatestTableTableName(String productId) {
+        return StringBuilderUtils.buildString(productId, (p, b) -> {
+            b.append("dev_lst_");
+            for (char c : productId.toCharArray()) {
+                if (c == '-' || c == '.') {
+                    b.append('_');
+                } else {
+                    b.append(Character.toLowerCase(c));
+                }
+            }
+        });
+    }
+
+    private String getEventColumn(String event, String property) {
+        return event + "_" + property;
+    }
+
+    private Mono<Boolean> doWrite(Flux<Buffer> flux) {
+        return flux
+            .groupBy(Buffer::getTable, Integer.MAX_VALUE)
+            .concatMap(group -> group
+                .groupBy(Buffer::getDeviceId, Integer.MAX_VALUE)
+                .flatMap(sameDevice -> sameDevice.reduce(Buffer::merge))
+                .buffer(200)
+                //批量更新
+                .flatMap(sameTableData -> {
+                    Buffer first = sameTableData.get(0);
+                    List<Map<String, Object>> data = sameTableData
+                        .stream()
+                        .map(Buffer::getProperties)
+                        .collect(Collectors.toList());
+                    return this
+                        .doUpdateLatestData(first.table, data)
+                        .onErrorResume((err) -> {
+                            log.error("save device latest data error", err);
+                            return Mono.empty();
+                        });
+                }))
+            .then(Reactors.ALWAYS_FALSE);
+
+    }
+
+    public void init() {
+
+        writer = new PersistenceBuffer<>(
+                BufferSettings.create("./data/buffer", buffer),
+                Buffer::new,
+                this::doWrite)
+            .name("device-latest-data")
+            //最大缓冲10万条数据
+            .settings(setting -> setting.bufferSize(10_0000));
+
+        writer.start();
+
+    }
+
+    public void destroy() {
+        writer.dispose();
+    }
+
+    static GeoCodec geoCodec = new GeoCodec();
+
+    static StringCodec stringCodec = new StringCodec();
+
+    static class GeoCodec implements ValueCodec<String, GeoPoint> {
+
+        @Override
+        public String encode(Object value) {
+            return String.valueOf(value);
+        }
+
+        @Override
+        public GeoPoint decode(Object data) {
+            return GeoPoint.of(data);
+        }
+    }
+
+    static class StringCodec implements ValueCodec<String, String> {
+
+        @Override
+        public String encode(Object value) {
+            return String.valueOf(value);
+        }
+
+        @Override
+        public String decode(Object data) {
+            return String.valueOf(data);
+        }
+    }
+
+    Class<?> getJavaType(DataType dataType) {
+        if (null == dataType) {
+            return Map.class;
+        }
+        switch (dataType.getType()) {
+            case IntType.ID:
+                return Integer.class;
+            case LongType.ID:
+                return Long.class;
+            case FloatType.ID:
+                return Float.class;
+            case DoubleType.ID:
+                return Double.class;
+            case BooleanType.ID:
+                return Boolean.class;
+            case DateTimeType.ID:
+                return Date.class;
+            case ArrayType.ID:
+                return List.class;
+            case GeoType.ID:
+            case ObjectType.ID:
+                return Map.class;
+            default:
+                return String.class;
+        }
+    }
+
+    RDBColumnMetadata convertColumn(PropertyMetadata metadata) {
+        RDBColumnMetadata column = new RDBColumnMetadata();
+        column.setName(metadata.getId());
+        column.setComment(metadata.getName());
+        DataType type = metadata.getValueType();
+        if (type instanceof NumberType) {
+            column.setLength(32);
+            column.setPrecision(32);
+            if (type instanceof DoubleType) {
+                column.setScale(Optional.ofNullable(((DoubleType) type).getScale()).orElse(2));
+                column.setValueCodec(new NumberValueCodec(Double.class));
+                column.setJdbcType(JDBCType.NUMERIC, Double.class);
+            } else if (type instanceof FloatType) {
+                column.setScale(Optional.ofNullable(((FloatType) type).getScale()).orElse(2));
+                column.setValueCodec(new NumberValueCodec(Float.class));
+                column.setJdbcType(JDBCType.NUMERIC, Float.class);
+            } else if (type instanceof LongType) {
+                column.setValueCodec(new NumberValueCodec(Long.class));
+                column.setJdbcType(JDBCType.NUMERIC, Long.class);
+            } else {
+                column.setValueCodec(new NumberValueCodec(IntType.class));
+                column.setJdbcType(JDBCType.NUMERIC, Integer.class);
+            }
+        } else if (type instanceof ObjectType) {
+            column.setJdbcType(JDBCType.CLOB, String.class);
+            column.setValueCodec(JsonValueCodec.of(Map.class));
+        } else if (type instanceof ArrayType) {
+            column.setJdbcType(JDBCType.CLOB, String.class);
+            ArrayType arrayType = ((ArrayType) type);
+            column.setValueCodec(JsonValueCodec.ofCollection(ArrayList.class, getJavaType(arrayType.getElementType())));
+        } else if (type instanceof DateTimeType) {
+            column.setJdbcType(JDBCType.TIMESTAMP, Long.class);
+            String format = ((DateTimeType) type).getFormat();
+            if (DateTimeType.TIMESTAMP_FORMAT.equals(format)) {
+                format = "yyyy-MM-dd HH:mm:ss";
+            }
+            column.setValueCodec(new DateTimeCodec(format, Long.class));
+        } else if (type instanceof GeoType) {
+            column.setJdbcType(JDBCType.VARCHAR, String.class);
+            column.setValueCodec(geoCodec);
+            column.setLength(128);
+        } else if (type instanceof EnumType) {
+            column.setJdbcType(JDBCType.VARCHAR, String.class);
+            column.setValueCodec(stringCodec);
+            column.setLength(64);
+        } else {
+            int len = type
+                .getExpand(ConfigMetadataConstants.maxLength.getKey())
+                .filter(o -> !StringUtils.isEmpty(o))
+                .map(CastUtils::castNumber)
+                .map(Number::intValue)
+                .orElse(255);
+            if (len > 2048) {
+                column.setJdbcType(JDBCType.LONGVARBINARY, String.class);
+                column.setValueCodec(ClobValueCodec.INSTANCE);
+            } else {
+                column.setJdbcType(JDBCType.VARCHAR, String.class);
+                column.setLength(len);
+                column.setValueCodec(stringCodec);
+            }
+        }
+
+        return column;
+    }
+
+
+    public Mono<Void> reloadMetadata(String productId, DeviceMetadata metadata) {
+        return Mono
+            .defer(() -> {
+                String tableName = getLatestTableTableName(productId);
+                log.debug("reload product[{}] metadata,table name:[{}] ", productId, tableName);
+                RDBSchemaMetadata schema = databaseOperator.getMetadata()
+                                                           .getCurrentSchema();
+
+                RDBTableMetadata table = schema.newTable(tableName);
+
+                RDBColumnMetadata id = table.newColumn();
+                id.setName("id");
+                id.setLength(64);
+                id.setPrimaryKey(true);
+                id.setJdbcType(JDBCType.VARCHAR, String.class);
+                table.addColumn(id);
+
+                RDBColumnMetadata deviceName = table.newColumn();
+                deviceName.setLength(128);
+                deviceName.setName("device_name");
+                deviceName.setAlias("deviceName");
+                deviceName.setJdbcType(JDBCType.VARCHAR, String.class);
+                table.addColumn(deviceName);
+
+                for (PropertyMetadata property : metadata.getProperties()) {
+                    table.addColumn(convertColumn(property));
+                }
+                for (EventMetadata event : metadata.getEvents()) {
+                    DataType type = event.getType();
+                    if (type instanceof ObjectType) {
+                        for (PropertyMetadata property : ((ObjectType) type).getProperties()) {
+                            RDBColumnMetadata column = convertColumn(property);
+                            column.setName(getEventColumn(event.getId(), property.getId()));
+                            table.addColumn(column);
+                        }
+                    }
+                }
+
+                return schema
+                    .getTableReactive(tableName, false)
+                    .doOnNext(oldTable -> oldTable.replace(table))
+                    .switchIfEmpty(Mono.fromRunnable(() -> schema.addTable(table)))
+                    .then();
+            });
+    }
+
+    @Transactional(propagation = Propagation.NEVER)
+    public Mono<Void> upgradeMetadata(String productId, DeviceMetadata metadata, boolean ddl) {
+        return Mono
+            .defer(() -> {
+                String tableName = getLatestTableTableName(productId);
+                log.debug("upgrade product[{}] metadata,table name:[{}] ", productId, tableName);
+                TableBuilder builder = databaseOperator
+                    .ddl()
+                    .createOrAlter(tableName)
+                    .addColumn("id").primaryKey().varchar(64).commit()
+                    .addColumn("device_name").alias("deviceName").varchar(128).notNull().commit()
+                    .merge(true)
+                    .allowAlter(ddl);
+
+                for (PropertyMetadata property : metadata.getProperties()) {
+                    builder.addColumn(convertColumn(property));
+                }
+                for (EventMetadata event : metadata.getEvents()) {
+                    DataType type = event.getType();
+                    if (type instanceof ObjectType) {
+                        for (PropertyMetadata property : ((ObjectType) type).getProperties()) {
+                            RDBColumnMetadata column = convertColumn(property);
+                            column.setName(getEventColumn(event.getId(), property.getId()));
+                            builder.addColumn(column);
+                        }
+                    }
+                }
+                return builder
+                    .commit()
+                    .reactive()
+                    .subscribeOn(Schedulers.boundedElastic())
+                    .then();
+            });
+    }
+
+    public Mono<Void> upgradeMetadata(String productId, DeviceMetadata metadata) {
+        return upgradeMetadata(productId, metadata, true);
+    }
+
+    @Subscribe(topics = "/device/**", features = Subscription.Feature.local)
+    public void save(DeviceMessage message) {
+        try {
+            Map<String, Object> properties = DeviceMessageUtils
+                .tryGetProperties(message)
+                .orElseGet(() -> {
+                    //事件
+                    if (message instanceof EventMessage) {
+                        Object data = ((EventMessage) message).getData();
+                        String event = ((EventMessage) message).getEvent();
+                        if (data instanceof Map) {
+                            Map<?, ?> mapValue = (Map<?, ?>) data;
+                            Map<String, Object> val = Maps.newHashMapWithExpectedSize(mapValue.size());
+                            ((Map<?, ?>) data).forEach((k, v) -> val.put(getEventColumn(event, String.valueOf(k)), v));
+                            return val;
+                        }
+                        return Collections.singletonMap(getEventColumn(event, "value"), data);
+                    }
+                    return null;
+                });
+            if (CollectionUtils.isEmpty(properties)) {
+                return;
+            }
+            String productId = message.getHeader("productId").map(String::valueOf).orElse("null");
+            String deviceName = message.getHeader("deviceName").map(String::valueOf).orElse(message.getDeviceId());
+            String tableName = getLatestTableTableName(productId);
+            Map<String, Object> prob = new HashMap<>(properties);
+            prob.put("id", message.getDeviceId());
+            prob.put("deviceName", deviceName);
+
+            Buffer buffer = Buffer.of(tableName, message.getDeviceId(), deviceName, prob, message.getTimestamp());
+            writer.write(buffer);
+        } catch (Exception e) {
+            log.error(e.getMessage(), e);
+        }
+    }
+
+    @Getter
+    private static class Buffer implements Externalizable {
+        //有效期
+        private final static long expires = Duration.ofSeconds(30).toMillis();
+
+        private String table;
+
+        private String deviceId;
+
+        private String deviceName;
+
+        private Map<String, Object> properties;
+
+        private long timestamp;
+
+
+        public Buffer() {
+        }
+
+        public boolean isEffective() {
+            return System.currentTimeMillis() - timestamp < expires;
+        }
+
+        public static Buffer of(String table,
+                                String deviceId,
+                                String deviceName,
+                                Map<String, Object> properties,
+                                long timestamp) {
+            Buffer buffer = new Buffer();
+            buffer.table = table;
+            buffer.deviceId = deviceId;
+            buffer.deviceName = deviceName;
+            buffer.properties = properties;
+            buffer.timestamp = timestamp;
+            return buffer;
+        }
+
+        public Buffer merge(Buffer buffer) {
+
+            //以比较新的数据为准
+            if (buffer.timestamp > this.timestamp) {
+                return buffer.merge(this);
+            }
+            //合并
+            buffer.properties.forEach(properties::putIfAbsent);
+            return this;
+        }
+
+        int size() {
+            return properties == null ? 0 : properties.size();
+        }
+
+        @Override
+        public void writeExternal(ObjectOutput out) throws IOException {
+            out.writeUTF(table);
+            out.writeUTF(deviceId);
+            out.writeUTF(deviceName);
+            out.writeLong(timestamp);
+            SerializeUtils.writeObject(properties, out);
+        }
+
+        @Override
+        @SuppressWarnings("all")
+        public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
+            table = in.readUTF();
+            deviceId = in.readUTF();
+            deviceName = in.readUTF();
+            timestamp = in.readLong();
+            properties = (Map<String, Object>) SerializeUtils.readObject(in);
+        }
+    }
+
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public Mono<Void> doUpdateLatestData(String table,
+                                         List<Map<String, Object>> properties) {
+        return databaseOperator
+            .getMetadata()
+            .getCurrentSchema()
+            .getTableReactive(table, false)
+            .flatMap(ignore -> {
+                //没有deviceName,说明可能在同步表结构的时候发生了错误。
+                if (!ignore.getColumn("deviceName").isPresent()) {
+                    log.warn("设备最新数据表[{}]结构错误", table);
+                    return Mono.empty();
+                }
+                return databaseOperator
+                    .dml()
+                    .upsert(table)
+                    .ignoreUpdate("id")
+                    .values(properties)
+                    .execute()
+                    .reactive()
+                    .then();
+            });
+    }
+
+    public ReactiveRepository<Record, String> getRepository(String productId) {
+        return databaseOperator
+            .dml()
+            .createReactiveRepository(getLatestTableTableName(productId));
+    }
+
+    @Override
+    public Flux<DeviceLatestData> query(String productId, QueryParamEntity param) {
+        return getRepository(productId)
+            .createQuery()
+            .setParam(param)
+            .fetch()
+            .map(DeviceLatestData::new);
+    }
+
+    @Override
+    public Mono<DeviceLatestData> queryDeviceData(String productId, String deviceId) {
+        return getRepository(productId)
+            .findById(deviceId)
+            .map(DeviceLatestData::new);
+    }
+
+    @Override
+    public Mono<Integer> count(String productId, QueryParamEntity param) {
+        return getRepository(productId)
+            .createQuery()
+            .setParam(param)
+            .count();
+    }
+
+    private SelectColumnSupplier createAggColumn(AggregationColumn column) {
+        switch (column.getAggregation()) {
+            case COUNT:
+                return Selects.count(column.getProperty()).as(column.getAlias());
+            case AVG:
+                return Selects.avg(column.getProperty()).as(column.getAlias());
+            case MAX:
+                return Selects.max(column.getProperty()).as(column.getAlias());
+            case MIN:
+                return Selects.min(column.getProperty()).as(column.getAlias());
+            case SUM:
+                return Selects.sum(column.getProperty()).as(column.getAlias());
+            default:
+                throw new UnsupportedOperationException("unsupported agg:" + column.getAggregation());
+        }
+    }
+
+    private SelectColumnSupplier[] createAggColumns(List<AggregationColumn> columns) {
+        return columns
+            .stream()
+            .map(this::createAggColumn)
+            .toArray(SelectColumnSupplier[]::new);
+    }
+
+    @Override
+    public Mono<Map<String, Object>> aggregation(String productId,
+                                                 List<AggregationColumn> columns,
+                                                 QueryParamEntity paramEntity) {
+        if (CollectionUtils.isEmpty(columns)) {
+            return Mono.error(new ValidationException("columns", "error.aggregate_column_cannot_be_empty"));
+        }
+        String table = getLatestTableTableName(productId);
+
+        return databaseOperator
+            .getMetadata()
+            .getTableReactive(table)
+            .flatMap(tableMetadata ->
+                     {
+                         List<String> illegals = new ArrayList<>();
+
+                         List<AggregationColumn> columnList = columns
+                             .stream()
+                             .filter(column -> {
+                                 if (tableMetadata
+                                     .getColumn(column.getProperty())
+                                     .isPresent()) {
+                                     return true;
+                                 }
+                                 illegals.add(column.getProperty());
+                                 return false;
+                             })
+                             .collect(Collectors.toList());
+                         if (CollectionUtils.isEmpty(columnList)) {
+                             return Mono.error(new ValidationException("columns", "error.invalid_product_attribute_or_event", productId, illegals));
+                         }
+                         return databaseOperator
+                             .dml()
+                             .query(table)
+                             .select(createAggColumns(columnList))
+                             .setParam(paramEntity.clone().noPaging())
+                             .fetch(ResultWrappers.map())
+                             .reactive()
+                             .take(1)
+                             .singleOrEmpty()
+                             .doOnNext(map -> {
+                                 for (AggregationColumn column : columns) {
+                                     map.putIfAbsent(column.getAlias(), 0);
+                                 }
+                             })
+                             //表不存在
+                             .onErrorReturn(e -> StringUtils.hasText(e.getMessage()) && e
+                                 .getMessage()
+                                 .contains("doesn't exist "), Collections.emptyMap());
+                     }
+            );
+
+    }
+
+    @Override
+    public Flux<Map<String, Object>> aggregation(Flux<QueryProductLatestDataRequest> param,
+                                                 boolean merge) {
+        Flux<QueryProductLatestDataRequest> cached = param.cache();
+        return cached
+            .flatMap(request -> this
+                .aggregation(request.getProductId(), request.getColumns(), request.getQuery())
+                .doOnNext(map -> {
+                    if (!merge) {
+                        map.put("productId", request.getProductId());
+                    }
+                }))
+            .as(flux -> {
+                if (!merge) {
+                    return flux;
+                }
+                //合并所有产品的字段到一条数据中,合并时,使用第一个聚合字段使用的聚合类型
+                return cached
+                    .take(1)
+                    .flatMapIterable(QueryLatestDataRequest::getColumns)
+                    .collectMap(AggregationColumn::getAlias, agg -> aggMappers.getOrDefault(agg.getAggregation(), sum))
+                    .flatMap(mappers -> flux
+                        .flatMapIterable(Map::entrySet)
+                        .groupBy(Map.Entry::getKey, Integer.MAX_VALUE)
+                        .flatMap(group -> mappers
+                            .getOrDefault(group.key(), sum)
+                            .apply(group.map(Map.Entry::getValue))
+                            .map(val -> Tuples.of(String.valueOf(group.key()), (Object) val)))
+                        .collectMap(Tuple2::getT1, Tuple2::getT2)).flux();
+            });
+    }
+
+
+    static Map<Aggregation, Function<Flux<Object>, Mono<? extends Number>>> aggMappers = new HashMap<>();
+
+    static Function<Flux<Object>, Mono<? extends Number>> avg = flux -> MathFlux.averageDouble(flux
+                                                                                                   .map(CastUtils::castNumber)
+                                                                                                   .map(Number::doubleValue));
+    static Function<Flux<Object>, Mono<? extends Number>> max = flux -> MathFlux.max(flux
+                                                                                         .map(CastUtils::castNumber)
+                                                                                         .map(Number::doubleValue));
+    static Function<Flux<Object>, Mono<? extends Number>> min = flux -> MathFlux.min(flux
+                                                                                         .map(CastUtils::castNumber)
+                                                                                         .map(Number::doubleValue));
+    static Function<Flux<Object>, Mono<? extends Number>> sum = flux -> MathFlux.sumDouble(flux
+                                                                                               .map(CastUtils::castNumber)
+                                                                                               .map(Number::doubleValue));
+
+    static {
+        aggMappers.put(Aggregation.AVG, avg);
+        aggMappers.put(Aggregation.MAX, max);
+        aggMappers.put(Aggregation.MIN, min);
+        aggMappers.put(Aggregation.SUM, sum);
+        aggMappers.put(Aggregation.COUNT, sum);
+    }
+
+}

+ 4 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/DeviceDataService.java

@@ -11,6 +11,7 @@ import org.jetlinks.community.device.entity.DeviceOperationLogEntity;
 import org.jetlinks.community.device.entity.DeviceProperty;
 import org.jetlinks.community.timeseries.query.Aggregation;
 import org.jetlinks.community.timeseries.query.AggregationData;
+import org.jetlinks.core.config.ConfigKey;
 import org.jetlinks.core.message.DeviceMessage;
 import org.jetlinks.core.metadata.DeviceMetadata;
 import org.jetlinks.core.metadata.EventMetadata;
@@ -33,6 +34,9 @@ import java.util.Map;
  */
 public interface DeviceDataService {
 
+
+    ConfigKey<String> STORE_POLICY_CONFIG_KEY = ConfigKey.of("storePolicy", "存储策略", String.class);
+
     /**
      * 注册设备物模型信息
      *

+ 138 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/DeviceLatestDataService.java

@@ -0,0 +1,138 @@
+package org.jetlinks.community.device.service.data;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.web.api.crud.entity.PagerResult;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
+import org.jetlinks.core.message.DeviceMessage;
+import org.jetlinks.core.metadata.DeviceMetadata;
+import org.jetlinks.community.device.entity.DeviceLatestData;
+import org.jetlinks.community.doc.QueryConditionOnly;
+import org.jetlinks.community.timeseries.query.AggregationColumn;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 设备最新数据服务,用于保存设备最新的相关数据到关系型数据库中,可以使用动态条件进行查询相关数据
+ *
+ * @author zhouhao
+ * @since 1.5.0
+ */
+public interface DeviceLatestDataService {
+
+    /**
+     * 根据物模型更新产品表结构信息
+     *
+     * @param productId 产品ID
+     * @param metadata  物模型
+     * @return void
+     */
+    Mono<Void> upgradeMetadata(String productId, DeviceMetadata metadata);
+
+    /**
+     * 重新加载物模型信息
+     *
+     * @param productId 产品ID
+     * @param metadata  物模型
+     * @return void
+     */
+    Mono<Void> reloadMetadata(String productId, DeviceMetadata metadata);
+
+    /**
+     * 保存消息数据
+     *
+     * @param message 设备消息
+     */
+    void save(DeviceMessage message);
+
+    /**
+     * 根据产品ID 查询最新的数据
+     *
+     * @param productId 产品ID
+     * @param param     查询参数
+     * @return 数据列表
+     */
+    Flux<DeviceLatestData> query(String productId, QueryParamEntity param);
+
+    /**
+     * 查询设备最新属性数据
+     *
+     * @param productId 产品ID
+     * @param deviceId  设备ID
+     * @return 属性数据
+     */
+    Mono<DeviceLatestData> queryDeviceData(String productId, String deviceId);
+
+    /**
+     * 根据产品ID查询数量
+     *
+     * @param productId 产品ID
+     * @param param     参数
+     * @return 查询数量
+     */
+    Mono<Integer> count(String productId, QueryParamEntity param);
+
+    /**
+     * 根据产品ID分页查询数据
+     *
+     * @param productId 产品ID
+     * @param param     查询条件参数
+     * @return 分页结果数据
+     */
+    default Mono<PagerResult<DeviceLatestData>> queryPager(String productId, QueryParamEntity param) {
+        return Mono
+            .zip(
+                query(productId, param).collectList(),
+                count(productId, param),
+                (data, total) -> PagerResult.of(total, data, param)
+            )
+            .defaultIfEmpty(PagerResult.empty());
+    }
+
+    /**
+     * 根据产品ID聚合查询数据
+     *
+     * @param productId   产品ID
+     * @param columns     聚合列
+     * @param paramEntity 查询条件参数
+     * @return 聚合结果
+     */
+    Mono<Map<String, Object>> aggregation(String productId,
+                                          List<AggregationColumn> columns,
+                                          QueryParamEntity paramEntity);
+
+    /**
+     * 聚合查询多个产品下设备最新的数据
+     *
+     * @param param 参数
+     * @param merge 是否将所有数据合并在一起
+     * @return 查询结果
+     */
+    Flux<Map<String, Object>> aggregation(Flux<QueryProductLatestDataRequest> param,
+                                          boolean merge);
+
+
+    @Getter
+    @Setter
+    class QueryProductLatestDataRequest extends QueryLatestDataRequest {
+        @NotBlank
+        @Schema(defaultValue = "产品ID")
+        private String productId;
+    }
+
+    @Getter
+    @Setter
+    class QueryLatestDataRequest {
+        @NotNull
+        private List<AggregationColumn> columns;
+
+        @Schema(implementation = QueryConditionOnly.class)
+        private QueryParamEntity query = QueryParamEntity.of();
+    }
+}

+ 54 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/NonDeviceLatestDataService.java

@@ -0,0 +1,54 @@
+package org.jetlinks.community.device.service.data;
+
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
+import org.jetlinks.core.message.DeviceMessage;
+import org.jetlinks.core.metadata.DeviceMetadata;
+import org.jetlinks.community.device.entity.DeviceLatestData;
+import org.jetlinks.community.timeseries.query.AggregationColumn;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+import java.util.Map;
+
+public class NonDeviceLatestDataService implements DeviceLatestDataService {
+    @Override
+    public Mono<Void> upgradeMetadata(String productId, DeviceMetadata metadata) {
+        return Mono.empty();
+    }
+
+    @Override
+    public Mono<Void> reloadMetadata(String productId, DeviceMetadata metadata) {
+        return Mono.empty();
+    }
+
+    @Override
+    public void save(DeviceMessage message) {
+
+    }
+
+    @Override
+    public Flux<DeviceLatestData> query(String productId, QueryParamEntity param) {
+        return Flux.empty();
+    }
+
+    @Override
+    public Mono<DeviceLatestData> queryDeviceData(String productId, String deviceId) {
+        return Mono.empty();
+    }
+
+    @Override
+    public Mono<Integer> count(String productId, QueryParamEntity param) {
+        return Mono.empty();
+    }
+
+    @Override
+    public Mono<Map<String, Object>> aggregation(String productId, List<AggregationColumn> columns, QueryParamEntity paramEntity) {
+        return Mono.empty();
+    }
+
+    @Override
+    public Flux<Map<String, Object>> aggregation(Flux<QueryProductLatestDataRequest> param, boolean merge) {
+        return Flux.empty();
+    }
+}

+ 38 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/StorageConstants.java

@@ -0,0 +1,38 @@
+package org.jetlinks.community.device.service.data;
+
+import org.jetlinks.core.metadata.PropertyMetadata;
+
+public interface StorageConstants {
+    String storePolicyConfigKey = "storePolicy";
+
+    String propertyStorageType = "storageType";
+    String propertyStorageTypeJson = "json-string";
+    String propertyStorageTypeIgnore = "ignore";
+
+    /**
+     * 判断属性是否使用json字符串来存储
+     *
+     * @param metadata 属性物模型
+     * @return 是否使用json字符串存储
+     */
+    static boolean propertyIsJsonStringStorage(PropertyMetadata metadata) {
+        return metadata
+            .getExpand(propertyStorageType)
+            .map(propertyStorageTypeJson::equals)
+            .orElse(false);
+    }
+
+    /**
+     * 判断属性是否忽略存储
+     *
+     * @param metadata 属性物模型
+     * @return 属性是否忽略存储
+     */
+    static boolean propertyIsIgnoreStorage(PropertyMetadata metadata) {
+        return metadata
+            .getExpand(propertyStorageType)
+            .map(propertyStorageTypeIgnore::equals)
+            .orElse(false);
+    }
+
+}

+ 94 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/data/StorageDeviceConfigMetadataSupplier.java

@@ -0,0 +1,94 @@
+package org.jetlinks.community.device.service.data;
+
+import lombok.AllArgsConstructor;
+import org.jetlinks.core.Value;
+import org.jetlinks.core.device.DeviceRegistry;
+import org.jetlinks.core.metadata.*;
+import org.jetlinks.core.metadata.types.ArrayType;
+import org.jetlinks.core.metadata.types.EnumType;
+import org.jetlinks.core.metadata.types.ObjectType;
+import org.jetlinks.community.device.spi.DeviceConfigMetadataSupplier;
+import org.jetlinks.community.things.data.ThingsDataRepositoryStrategies;
+import org.jetlinks.community.things.data.ThingsDataRepositoryStrategy;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+@Component
+@AllArgsConstructor
+public class StorageDeviceConfigMetadataSupplier implements DeviceConfigMetadataSupplier {
+    private final DeviceRegistry registry;
+
+    private final DeviceDataStorageProperties properties;
+
+    private final ConfigMetadata objectConf = new DefaultConfigMetadata("存储配置", "")
+        .scope(DeviceConfigScope.product)
+        .add(StorageConstants.propertyStorageType, "存储方式", new EnumType()
+            .addElement(EnumType.Element.of("direct", "直接存储", "直接存储上报的数据"))
+            .addElement(EnumType.Element.of(StorageConstants.propertyStorageTypeIgnore, "不存储", "不存储此属性值"))
+            .addElement(EnumType.Element.of(StorageConstants.propertyStorageTypeJson, "JSON字符", "将数据序列话为JSON字符串进行存储"))
+        );
+
+    private final ConfigMetadata anotherConf = new DefaultConfigMetadata("存储配置", "")
+        .scope(DeviceConfigScope.product)
+        .add(StorageConstants.propertyStorageType, "存储方式", new EnumType()
+            .addElement(EnumType.Element.of("direct", "存储", "将上报的属性值保存到配置到存储策略中"))
+            .addElement(EnumType.Element.of(StorageConstants.propertyStorageTypeIgnore, "不存储", "不存储此属性值"))
+        );
+
+
+    @Override
+    public Flux<ConfigMetadata> getDeviceConfigMetadata(String deviceId) {
+        return Flux.empty();
+    }
+
+    @Override
+    public Flux<ConfigMetadata> getDeviceConfigMetadataByProductId(String productId) {
+        return Flux.empty();
+    }
+
+    @Override
+    public Flux<ConfigMetadata> getProductConfigMetadata(String productId) {
+        return Flux.empty();
+    }
+
+    @Override
+    public Flux<Feature> getProductFeatures(String productId) {
+        return registry
+            .getProduct(productId)
+            .flatMap(prod -> prod.getConfig(DeviceDataService.STORE_POLICY_CONFIG_KEY))
+            .defaultIfEmpty(properties.getDefaultPolicy())
+            .flatMap(this::getStoragePolicy)
+            .flatMapMany(strategy -> strategy
+                .opsForSave(ThingsDataRepositoryStrategy.OperationsContext.DEFAULT)
+                .getFeatures());
+    }
+
+    private Mono<ThingsDataRepositoryStrategy> getStoragePolicy(String policy) {
+        return Mono.justOrEmpty(ThingsDataRepositoryStrategies.getStrategy(policy));
+    }
+
+    @Override
+    public Flux<ConfigMetadata> getMetadataExpandsConfig(String productId,
+                                                         DeviceMetadataType metadataType,
+                                                         String metadataId,
+                                                         String typeId) {
+        if (metadataType == DeviceMetadataType.property) {
+            if ((ObjectType.ID.equals(typeId) || ArrayType.ID.equals(typeId))) {
+                return registry
+                    .getProduct(productId)
+                    .flatMap(prod -> prod
+                        .getConfig(StorageConstants.storePolicyConfigKey)
+                        .map(Value::asString))
+                    .defaultIfEmpty(properties.getDefaultPolicy())
+                    .filter(policy -> policy.startsWith("default-"))
+                    .map(ignore -> objectConf)
+                    .flux();
+            }
+            return Flux.just(anotherConf);
+        }
+
+        return Flux.empty();
+
+    }
+}

+ 11 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/spi/DeviceConfigMetadataSupplier.java

@@ -1,7 +1,9 @@
 package org.jetlinks.community.device.spi;
 
+import lombok.Generated;
 import org.jetlinks.core.metadata.ConfigMetadata;
 import org.jetlinks.core.metadata.DeviceMetadataType;
+import org.jetlinks.core.metadata.Feature;
 import reactor.core.publisher.Flux;
 
 /**
@@ -38,4 +40,13 @@ public interface DeviceConfigMetadataSupplier {
                                                           String typeId) {
         return Flux.empty();
     }
+
+
+    /**
+     * @see org.jetlinks.community.device.service.DeviceConfigMetadataManager#getProductFeatures(String)
+     */
+    @Generated
+    default Flux<Feature> getProductFeatures(String productId){
+        return Flux.empty();
+    }
 }

+ 50 - 63
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceCategoryController.java

@@ -1,85 +1,72 @@
 package org.jetlinks.community.device.web;
 
-import com.alibaba.fastjson.JSON;
-import io.swagger.v3.oas.annotations.Operation;
+
+import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.hswebframework.web.api.crud.entity.QueryNoPagingOperation;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
 import org.hswebframework.web.api.crud.entity.TreeSupportEntity;
-import org.jetlinks.community.device.entity.DeviceCategory;
-import org.springframework.core.io.ClassPathResource;
-import org.springframework.util.StreamUtils;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.hswebframework.web.authorization.annotation.Authorize;
+import org.hswebframework.web.authorization.annotation.Resource;
+import org.hswebframework.web.crud.service.ReactiveCrudService;
+import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
+import org.jetlinks.community.device.entity.DeviceCategoryEntity;
+import org.jetlinks.community.device.service.DeviceCategoryService;
+import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
-
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
+import reactor.core.publisher.Mono;
 
 @RestController
 @RequestMapping("/device/category")
 @Slf4j
-@Tag(name = "设备分类目录")
-public class DeviceCategoryController {
+@Tag(name = "产品分类管理")
+@AllArgsConstructor
+@Resource(id="device-category",name = "产品分类")
+public class DeviceCategoryController implements ReactiveServiceCrudController<DeviceCategoryEntity,String> {
 
 
-    static List<DeviceCategory> statics;
+    private final DeviceCategoryService categoryService;
 
-
-    static void rebuild(String parentId, List<DeviceCategory> children) {
-        if (children == null) {
-            return;
-        }
-        for (DeviceCategory child : children) {
-            String id = child.getId();
-            child.setId(parentId + "|" + id + "|");
-            child.setParentId(parentId + "|");
-            rebuild(parentId + "|" + id, child.getChildren());
-        }
+    @GetMapping
+    @QueryNoPagingOperation(summary = "获取全部分类")
+    @Authorize(merge = false)
+    public Flux<DeviceCategoryEntity> getAllCategory(@Parameter(hidden = true) QueryParamEntity query) {
+        return this
+            .categoryService
+            .createQuery()
+            .setParam(query)
+            .fetch();
     }
 
-    static {
-        try {
-            ClassPathResource resource = new ClassPathResource("device-category.json");
-            String json = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
-
-            List<DeviceCategory> all = JSON.parseArray(json, DeviceCategory.class);
-
-            List<DeviceCategory> root = TreeSupportEntity.list2tree(all, DeviceCategory::setChildren);
-
-            for (DeviceCategory category : root) {
-                String id = category.getId();
-
-                category.setId("|" + id + "|");
-                category.setParentId("|" + category.getParentId() + "|");
-                rebuild("|" + id, category.getChildren());
-            }
-
-            statics = all;
-
-        } catch (Exception e) {
-            statics = new ArrayList<>();
-            DeviceCategoryController.log.error(e.getMessage(), e);
-        }
+    @GetMapping("/_tree")
+    @QueryNoPagingOperation(summary = "获取全部分类(树结构)")
+    @Authorize(merge = false)
+    public Flux<DeviceCategoryEntity> getAllCategoryTree(@Parameter(hidden = true) QueryParamEntity query) {
+        return this
+            .categoryService
+            .createQuery()
+            .setParam(query)
+            .fetch()
+            .collectList()
+            .flatMapMany(all-> Flux.fromIterable(TreeSupportEntity.list2tree(all, DeviceCategoryEntity::setChildren)));
     }
 
-    @GetMapping
-    @Operation(summary = "获取全部分类目录")
-    public Flux<DeviceCategory> getAllCategory() {
-        return Flux.fromIterable(statics);
-    }
 
-    @GetMapping("/_query/no-paging")
-    @Operation(summary = "获取全部分类目录")
-    public Flux<DeviceCategory> getAllCategory2() {
-        return Flux.fromIterable(statics);
+    @PostMapping("/_tree")
+    @QueryNoPagingOperation(summary = "获取全部分类(树结构)")
+    @Authorize(merge = false)
+    public Flux<DeviceCategoryEntity> getAllCategoryTreeByQueryParam(@RequestBody Mono<QueryParamEntity> query) {
+        return this
+            .categoryService
+            .query(query)
+            .collectList()
+            .flatMapMany(all-> Flux.fromIterable(TreeSupportEntity.list2tree(all, DeviceCategoryEntity::setChildren)));
     }
 
-
-    @GetMapping("/_tree")
-    @Operation(summary = "获取全部分类目录(树结构)")
-    public Flux<DeviceCategory> getAllCategoryTree() {
-        return Flux.fromIterable(TreeSupportEntity.list2tree(statics, DeviceCategory::setChildren));
+    @Override
+    public ReactiveCrudService<DeviceCategoryEntity, String> getService() {
+        return categoryService;
     }
 }

+ 126 - 5
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java

@@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.Getter;
 import lombok.SneakyThrows;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
 import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException;
 import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
 import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult;
@@ -22,6 +23,7 @@ import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
 import org.hswebframework.web.exception.BusinessException;
 import org.hswebframework.web.exception.NotFoundException;
 import org.hswebframework.web.exception.ValidationException;
+import org.hswebframework.web.i18n.LocaleUtils;
 import org.hswebframework.web.id.IDGenerator;
 import org.jetlinks.community.device.entity.*;
 import org.jetlinks.community.device.enums.DeviceState;
@@ -34,10 +36,16 @@ import org.jetlinks.community.device.service.LocalDeviceProductService;
 import org.jetlinks.community.device.service.data.DeviceDataService;
 import org.jetlinks.community.device.web.excel.DeviceExcelInfo;
 import org.jetlinks.community.device.web.excel.DeviceWrapper;
+import org.jetlinks.community.device.web.excel.PropertyMetadataExcelInfo;
+import org.jetlinks.community.device.web.excel.PropertyMetadataWrapper;
 import org.jetlinks.community.device.web.request.AggRequest;
 import org.jetlinks.community.io.excel.ImportExportService;
 import org.jetlinks.community.io.utils.FileUtils;
+import org.jetlinks.community.relation.RelationObjectProvider;
+import org.jetlinks.community.relation.service.RelationService;
+import org.jetlinks.community.relation.service.request.SaveRelationRequest;
 import org.jetlinks.community.timeseries.query.AggregationData;
+import org.jetlinks.community.web.response.ValidationResult;
 import org.jetlinks.core.Values;
 import org.jetlinks.core.device.*;
 import org.jetlinks.core.device.manager.DeviceBindHolder;
@@ -48,6 +56,7 @@ import org.jetlinks.core.message.Message;
 import org.jetlinks.core.message.MessageType;
 import org.jetlinks.core.message.RepayableDeviceMessage;
 import org.jetlinks.core.metadata.*;
+import org.jetlinks.supports.official.JetLinksDeviceMetadataCodec;
 import org.springframework.core.io.buffer.DataBufferFactory;
 import org.springframework.core.io.buffer.DefaultDataBufferFactory;
 import org.springframework.data.util.Lazy;
@@ -73,7 +82,7 @@ import java.util.Map;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
-import static org.hswebframework.reactor.excel.ReactorExcel.*;
+import static org.hswebframework.reactor.excel.ReactorExcel.read;
 
 @RestController
 @RequestMapping({"/device-instance", "/device/instance"})
@@ -99,6 +108,8 @@ public class DeviceInstanceController implements
 
     private final DeviceConfigMetadataManager metadataManager;
 
+    private final RelationService relationService;
+
     @SuppressWarnings("all")
     public DeviceInstanceController(LocalDeviceInstanceService service,
                                     DeviceRegistry registry,
@@ -106,7 +117,8 @@ public class DeviceInstanceController implements
                                     ImportExportService importExportService,
                                     ReactiveRepository<DeviceTagEntity, String> tagRepository,
                                     DeviceDataService deviceDataService,
-                                    DeviceConfigMetadataManager metadataManager) {
+                                    DeviceConfigMetadataManager metadataManager,
+                                    RelationService relationService) {
         this.service = service;
         this.registry = registry;
         this.productService = productService;
@@ -114,6 +126,7 @@ public class DeviceInstanceController implements
         this.tagRepository = tagRepository;
         this.deviceDataService = deviceDataService;
         this.metadataManager = metadataManager;
+        this.relationService = relationService;
     }
 
 
@@ -125,6 +138,17 @@ public class DeviceInstanceController implements
         return service.getDeviceDetail(id);
     }
 
+    //读取设备属性
+    @PostMapping("/{deviceId:.+}/properties/_read")
+    @QueryAction
+    @Operation(summary = "发送读取属性指令到设备", description = "请求示例: [\"属性ID\"]")
+    public Mono<?> readProperties(@PathVariable @Parameter(description = "设备ID") String deviceId,
+                                  @RequestBody Mono<List<String>> properties) {
+        return properties.flatMap(props -> service.readProperties(deviceId, props));
+    }
+
+
+
     //获取设备详情
     @GetMapping("/{id:.+}/config-metadata")
     @QueryAction
@@ -314,12 +338,22 @@ public class DeviceInstanceController implements
     //查询设备日志
     @GetMapping("/{deviceId:.+}/logs")
     @QueryAction
-    @QueryOperation(summary = "查询设备日志数据")
+    @QueryOperation(summary = "(GET)查询设备日志数据")
     public Mono<PagerResult<DeviceOperationLogEntity>> queryDeviceLog(@PathVariable @Parameter(description = "设备ID") String deviceId,
                                                                       @Parameter(hidden = true) QueryParamEntity entity) {
         return deviceDataService.queryDeviceMessageLog(deviceId, entity);
     }
 
+    //查询设备日志
+    @PostMapping("/{deviceId:.+}/logs")
+    @QueryAction
+    @Operation(summary = "(POST)查询设备日志数据")
+    public Mono<PagerResult<DeviceOperationLogEntity>> queryDeviceLog(@PathVariable @Parameter(description = "设备ID") String deviceId,
+                                                                      @RequestBody @Parameter(hidden = true) Mono<QueryParamEntity> queryParam) {
+        return queryParam.flatMap(param -> deviceDataService.queryDeviceMessageLog(deviceId, param));
+    }
+
+
     //删除标签
     @DeleteMapping("/{deviceId}/tag/{tagId:.+}")
     @SaveAction
@@ -366,8 +400,7 @@ public class DeviceInstanceController implements
      * 批量激活设备
      *
      * @param idList ID列表
-     * @return 被注销的数量
-     * @since 1.1
+     * @return 被激活的数量
      */
     @PutMapping("/batch/_deploy")
     @SaveAction
@@ -844,4 +877,92 @@ public class DeviceInstanceController implements
     }
 
 
+    @GetMapping("/{id:.+}/exists")
+    @QueryAction
+    @Operation(summary = "验证设备ID是否存在")
+    public Mono<Boolean> deviceIdValidate(@PathVariable @Parameter(description = "设备ID") String id) {
+        return service.findById(id)
+                      .hasElement();
+    }
+
+    @GetMapping("/id/_validate")
+    @QueryAction
+    @Operation(summary = "验证设备ID是否合法")
+    public Mono<ValidationResult> deviceIdValidate2(@RequestParam @Parameter(description = "设备ID") String id) {
+        return LocaleUtils.currentReactive()
+                          .flatMap(locale -> {
+                              DeviceInstanceEntity entity = new DeviceInstanceEntity();
+                              entity.setId(id);
+                              entity.validateId();
+
+                              return service.findById(id)
+                                            .map(device -> ValidationResult.error(
+                                                LocaleUtils.resolveMessage("error.device_ID_already_exists", locale)))
+                                            .defaultIfEmpty(ValidationResult.success());
+                          })
+                          .onErrorResume(ValidationException.class, e -> Mono.just(e.getI18nCode())
+                                                                             .map(ValidationResult::error));
+    }
+
+
+    //解析文件为属性物模型
+    @PostMapping(value = "/{productId}/property-metadata/import")
+    @SaveAction
+    @Operation(summary = "解析文件为属性物模型")
+    public Mono<String> importPropertyMetadata(@PathVariable @Parameter(description = "产品ID") String productId,
+                                               @RequestParam @Parameter(description = "文件地址,支持csv,xlsx文件格式") String fileUrl) {
+        return metadataManager
+            .getMetadataExpandsConfig(productId, DeviceMetadataType.property, "*", "*", DeviceConfigScope.device)
+            .collectList()
+            .map(PropertyMetadataWrapper::new)
+            //解析数据并转为物模型
+            .flatMap(wrapper -> importExportService
+                .getInputStream(fileUrl)
+                .flatMapMany(inputStream -> read(inputStream, FileUtils.getExtension(fileUrl), wrapper))
+                .map(PropertyMetadataExcelInfo::toMetadata)
+                .collectList())
+            .filter(CollectionUtils::isNotEmpty)
+            .map(list -> {
+                SimpleDeviceMetadata metadata = new SimpleDeviceMetadata();
+                list.forEach(metadata::addProperty);
+                return JetLinksDeviceMetadataCodec.getInstance().doEncode(metadata);
+            });
+    }
+
+    //获取物模型属性导入模块
+    @GetMapping("/{deviceId}/property-metadata/template.{format}")
+    @QueryAction
+    @Operation(summary = "下载设备物模型属性导入模块")
+    public Mono<Void> downloadMetadataExportTemplate(@PathVariable @Parameter(description = "设备ID") String deviceId,
+                                                     ServerHttpResponse response,
+                                                     @PathVariable @Parameter(description = "文件格式,支持csv,xlsx") String format) throws IOException {
+        response.getHeaders().set(HttpHeaders.CONTENT_DISPOSITION,
+                                  "attachment; filename=".concat(URLEncoder.encode("物模型导入模块." + format, StandardCharsets.UTF_8
+                                      .displayName())));
+
+        return metadataManager
+            .getMetadataExpandsConfig(deviceId, DeviceMetadataType.property, "*", "*", DeviceConfigScope.device)
+            .collectList()
+            .map(PropertyMetadataExcelInfo::getTemplateHeaderMapping)
+            .flatMapMany(headers ->
+                             ReactorExcel.<DeviceExcelInfo>writer(format)
+                                 .headers(headers)
+                                 .converter(DeviceExcelInfo::toMap)
+                                 .writeBuffer(Flux.empty()))
+            .doOnError(err -> log.error(err.getMessage(), err))
+            .map(bufferFactory::wrap)
+            .as(response::writeWith)
+            ;
+    }
+
+
+    @PatchMapping("/{deviceId}/relations")
+    @Operation(summary = "保存设备的关系信息")
+    @SaveAction
+    public Mono<Void> saveRelation(@PathVariable String deviceId,
+                                   @RequestBody Flux<SaveRelationRequest> requestFlux) {
+        return relationService.saveRelated(RelationObjectProvider.TYPE_DEVICE, deviceId, requestFlux);
+    }
+
+
 }

+ 107 - 5
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceProductController.java

@@ -7,34 +7,51 @@ import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
+import org.hswebframework.reactor.excel.ReactorExcel;
 import org.hswebframework.web.authorization.annotation.QueryAction;
 import org.hswebframework.web.authorization.annotation.Resource;
 import org.hswebframework.web.authorization.annotation.SaveAction;
 import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
+import org.hswebframework.web.exception.ValidationException;
+import org.hswebframework.web.i18n.LocaleUtils;
 import org.jetlinks.community.device.entity.DeviceProductEntity;
 import org.jetlinks.community.device.service.DeviceConfigMetadataManager;
 import org.jetlinks.community.device.service.LocalDeviceProductService;
 import org.jetlinks.community.device.service.data.DeviceDataService;
+import org.jetlinks.community.device.web.excel.PropertyMetadataExcelInfo;
+import org.jetlinks.community.device.web.excel.PropertyMetadataWrapper;
 import org.jetlinks.community.device.web.request.AggRequest;
+import org.jetlinks.community.io.excel.ImportExportService;
+import org.jetlinks.community.io.utils.FileUtils;
 import org.jetlinks.community.things.data.ThingsDataRepositoryStrategy;
 import org.jetlinks.community.timeseries.query.AggregationData;
-import org.jetlinks.core.metadata.ConfigMetadata;
-import org.jetlinks.core.metadata.DeviceConfigScope;
-import org.jetlinks.core.metadata.DeviceMetadataCodec;
-import org.jetlinks.core.metadata.DeviceMetadataType;
+import org.jetlinks.community.web.response.ValidationResult;
+import org.jetlinks.core.metadata.*;
 import org.jetlinks.supports.official.JetLinksDeviceMetadataCodec;
 import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.core.io.buffer.DefaultDataBufferFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.server.reactive.ServerHttpResponse;
 import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Map;
 
+import static org.hswebframework.reactor.excel.ReactorExcel.read;
+
 @RestController
 @RequestMapping({"/device-product","/device/product"})
 @Resource(id = "device-product", name = "设备产品")
 @Tag(name = "设备产品接口")
+@Slf4j
 public class DeviceProductController implements ReactiveServiceCrudController<DeviceProductEntity, String> {
 
     private final LocalDeviceProductService productService;
@@ -48,16 +65,23 @@ public class DeviceProductController implements ReactiveServiceCrudController<De
 
     private final DeviceMetadataCodec defaultCodec = new JetLinksDeviceMetadataCodec();
 
+
+    private final DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
+
+    private final ImportExportService importExportService;
+
     public DeviceProductController(LocalDeviceProductService productService,
                                    List<ThingsDataRepositoryStrategy> policies,
                                    DeviceDataService deviceDataService,
                                    DeviceConfigMetadataManager configMetadataManager,
-                                   ObjectProvider<DeviceMetadataCodec> metadataCodecs) {
+                                   ObjectProvider<DeviceMetadataCodec> metadataCodecs,
+                                   ImportExportService importExportService) {
         this.productService = productService;
         this.policies = policies;
         this.deviceDataService = deviceDataService;
         this.configMetadataManager = configMetadataManager;
         this.metadataCodecs = metadataCodecs;
+        this.importExportService = importExportService;
     }
 
     @Override
@@ -177,4 +201,82 @@ public class DeviceProductController implements ReactiveServiceCrudController<De
         }
     }
 
+    @GetMapping("/{id:.+}/exists")
+    @QueryAction
+    @Operation(summary = "验证产品ID是否存在")
+    public Mono<Boolean> deviceIdValidate(@PathVariable @Parameter(description = "产品ID") String id) {
+        return productService.findById(id)
+                             .hasElement();
+    }
+
+    @GetMapping("/id/_validate")
+    @QueryAction
+    @Operation(summary = "验证产品ID是否合法")
+    public Mono<ValidationResult> deviceIdValidate2(@RequestParam @Parameter(description = "产品ID") String id) {
+        return LocaleUtils.currentReactive()
+                          .flatMap(locale -> {
+                              DeviceProductEntity entity = new DeviceProductEntity();
+                              entity.setId(id);
+                              entity.validateId();
+
+                              return productService.findById(id)
+                                                   .map(product -> ValidationResult.error(
+                                                       LocaleUtils.resolveMessage("error.product_ID_already_exists", locale)))
+                                                   .defaultIfEmpty(ValidationResult.success());
+                          })
+                          .onErrorResume(ValidationException.class, e -> Mono.just(e.getI18nCode())
+                                                                             .map(ValidationResult::error));
+    }
+
+
+    //获取产品物模型属性导入模块
+    @GetMapping("/{productId}/property-metadata/template.{format}")
+    @QueryAction
+    @Operation(summary = "下载产品物模型属性导入模块")
+    public Mono<Void> downloadExportPropertyMetadataTemplate(@PathVariable @Parameter(description = "产品ID") String productId,
+                                                             ServerHttpResponse response,
+                                                             @PathVariable @Parameter(description = "文件格式,支持csv,xlsx") String format) throws IOException {
+        response.getHeaders().set(HttpHeaders.CONTENT_DISPOSITION,
+                                  "attachment; filename=".concat(URLEncoder.encode("物模型导入模块." + format, StandardCharsets.UTF_8
+                                      .displayName())));
+
+        return configMetadataManager
+            .getMetadataExpandsConfig(productId, DeviceMetadataType.property, "*", "*", DeviceConfigScope.product)
+            .collectList()
+            .map(PropertyMetadataExcelInfo::getTemplateHeaderMapping)
+            .flatMapMany(headers -> ReactorExcel
+                .<PropertyMetadataExcelInfo>writer(format)
+                .headers(headers)
+                .converter(PropertyMetadataExcelInfo::toMap)
+                .writeBuffer(PropertyMetadataExcelInfo.getTemplateContentMapping()))
+            .doOnError(err -> log.error(err.getMessage(), err))
+            .map(bufferFactory::wrap)
+            .as(response::writeWith)
+            ;
+    }
+
+    //解析文件为属性物模型
+    @PostMapping(value = "/{productId}/property-metadata/import")
+    @SaveAction
+    @Operation(summary = "解析文件为属性物模型")
+    public Mono<String> importPropertyMetadata(@PathVariable @Parameter(description = "产品ID") String productId,
+                                               @RequestParam @Parameter(description = "文件地址,支持csv,xlsx文件格式") String fileUrl) {
+        return configMetadataManager
+            .getMetadataExpandsConfig(productId, DeviceMetadataType.property, "*", "*", DeviceConfigScope.product)
+            .collectList()
+            .map(PropertyMetadataWrapper::new)
+            //解析数据并转为物模型
+            .flatMap(wrapper -> importExportService
+                .getInputStream(fileUrl)
+                .flatMapMany(inputStream -> read(inputStream, FileUtils.getExtension(fileUrl), wrapper))
+                .map(PropertyMetadataExcelInfo::toMetadata)
+                .collectList())
+            .filter(CollectionUtils::isNotEmpty)
+            .map(list -> {
+                SimpleDeviceMetadata metadata = new SimpleDeviceMetadata();
+                list.forEach(metadata::addProperty);
+                return JetLinksDeviceMetadataCodec.getInstance().doEncode(metadata);
+            });
+    }
+
 }

+ 59 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/ProtocolSupportController.java

@@ -7,11 +7,13 @@ import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.Getter;
 import org.hswebframework.utils.StringUtils;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
 import org.hswebframework.web.authorization.annotation.Authorize;
 import org.hswebframework.web.authorization.annotation.QueryAction;
 import org.hswebframework.web.authorization.annotation.Resource;
 import org.hswebframework.web.authorization.annotation.SaveAction;
 import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
+import org.hswebframework.web.exception.BusinessException;
 import org.jetlinks.community.device.entity.ProtocolSupportEntity;
 import org.jetlinks.community.device.service.LocalProtocolSupportService;
 import org.jetlinks.community.device.web.protocol.ProtocolDetail;
@@ -19,6 +21,7 @@ import org.jetlinks.community.device.web.protocol.ProtocolInfo;
 import org.jetlinks.community.device.web.protocol.TransportInfo;
 import org.jetlinks.community.device.web.request.ProtocolDecodeRequest;
 import org.jetlinks.community.device.web.request.ProtocolEncodeRequest;
+import org.jetlinks.community.protocol.TransportDetail;
 import org.jetlinks.core.ProtocolSupport;
 import org.jetlinks.core.ProtocolSupports;
 import org.jetlinks.core.message.codec.Transport;
@@ -32,7 +35,10 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
 
+import java.util.Comparator;
 import java.util.List;
 
 @RestController
@@ -174,4 +180,57 @@ public class ProtocolSupportController
     public Flux<ValueUnit> allUnits() {
         return Flux.fromIterable(ValueUnits.getAllUnit());
     }
+
+
+    @GetMapping("/supports/{transport}")
+    @Authorize(merge = false)
+    @Operation(summary = "获取支持指定传输协议的消息协议")
+    public Flux<ProtocolInfo> getSupportTransportProtocols(@PathVariable String transport,
+                                                           @Parameter(hidden = true) QueryParamEntity query) {
+        return protocolSupports
+            .getProtocols()
+            .collectMap(ProtocolSupport::getId)
+            .flatMapMany(protocols -> service.createQuery()
+                                             .setParam(query)
+                                             .fetch()
+                                             .index()
+                                             .flatMap(tp2 -> Mono
+                                                 .justOrEmpty(protocols.get(tp2.getT2().getId()))
+                                                 .filterWhen(support -> support
+                                                     .getSupportedTransport()
+                                                     .filter(t -> t.isSame(transport))
+                                                     .hasElements())
+                                                 .map(ProtocolInfo::of)
+                                                 .map(protocolInfo -> Tuples.of(tp2.getT1(), protocolInfo))))
+            .sort(Comparator.comparingLong(Tuple2::getT1))
+            .map(Tuple2::getT2);
+    }
+
+    @GetMapping("/{id}/transport/{transport}")
+    @Authorize(merge = false)
+    @Operation(summary = "获取消息协议对应的传输协议信息")
+    public Mono<TransportDetail> getTransportDetail(@PathVariable @Parameter(description = "协议ID") String id,
+                                                    @PathVariable @Parameter(description = "传输协议") String transport) {
+        return protocolSupports
+            .getProtocol(id)
+            .onErrorMap(e -> new BusinessException("error.unable_to_load_protocol_by_access_id", 404, id))
+            .flatMapMany(protocol -> protocol
+                .getSupportedTransport()
+                .filter(trans -> trans.isSame(transport))
+                .distinct()
+                .flatMap(_transport -> TransportDetail.of(protocol, _transport)))
+            .singleOrEmpty();
+    }
+
+
+    @PostMapping("/{id}/detail")
+    @QueryAction
+    @Operation(summary = "获取协议详情")
+    public Mono<ProtocolDetail> protocolDetail(@PathVariable String id) {
+        return protocolSupports
+            .getProtocol(id)
+            .onErrorMap(e -> new BusinessException("error.unable_to_load_protocol_by_access_id", 404, id))
+            .flatMap(ProtocolDetail::of);
+    }
+
 }

+ 321 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/excel/PropertyMetadataExcelInfo.java

@@ -0,0 +1,321 @@
+package org.jetlinks.community.device.web.excel;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.google.common.collect.Lists;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.hswebframework.reactor.excel.CellDataType;
+import org.hswebframework.reactor.excel.ExcelHeader;
+import org.hswebframework.web.bean.FastBeanCopier;
+import org.hswebframework.web.dict.EnumDict;
+import org.hswebframework.web.exception.BusinessException;
+import org.hswebframework.web.exception.ValidationException;
+import org.hswebframework.web.validator.ValidatorUtils;
+import org.jetlinks.core.metadata.*;
+import org.jetlinks.core.metadata.types.*;
+import org.jetlinks.core.metadata.unit.ValueUnit;
+import org.jetlinks.core.metadata.unit.ValueUnits;
+import org.jetlinks.supports.official.JetLinksDataTypeCodecs;
+import org.springframework.util.CollectionUtils;
+import reactor.core.publisher.Flux;
+
+import javax.validation.constraints.NotBlank;
+import java.util.*;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+@Getter
+@Setter
+@Slf4j
+public class PropertyMetadataExcelInfo {
+
+    @NotBlank(message = "属性ID不能为空")
+    private String property;
+
+    @NotBlank(message = "属性名称不能为空")
+    private String name;
+
+    private String valueType;
+
+    private Map<String, Object> expands;
+    //数据类型
+    @NotBlank(message = "数据类型不能为空")
+    private String dataType;
+    //单位
+    private String unit;
+    //精度
+    private String scale;
+    //来源
+    @NotBlank(message = "来源不能为空")
+    private String source;
+
+    private String description;
+
+    private String storageType;
+
+    private long rowNumber;
+    //读写类型
+    private List<String> type;
+
+    /**
+     * 单位
+     */
+    private static final List<ValueUnit> idList = ValueUnits.getAllUnit();
+
+    /**
+     * 所有数据类型
+     */
+    private static final List<String> DATA_TYPES = Lists.newArrayList(ArrayType.ID, BooleanType.ID,
+        DateTimeType.ID, DoubleType.ID, EnumType.ID, FloatType.ID, IntType.ID, LongType.ID,
+        ObjectType.ID, StringType.ID, GeoType.ID, FileType.ID, PasswordType.ID, GeoShapeType.ID);
+
+    private static final List<String> OBJECT_NOT_HAVE = Lists.newArrayList(DateTimeType.ID, FileType.ID, ObjectType.ID, PasswordType.ID);
+    /**
+     * 简单模板支持类型
+     */
+    private static final List<String> SIMPLE = Lists.newArrayList(IntType.ID, FloatType.ID, DoubleType.ID, LongType.ID);
+
+    public void with(String key, Object value) {
+        FastBeanCopier.copy(Collections.singletonMap(key, value), this);
+    }
+
+    public PropertyMetadata toMetadata() {
+        SimplePropertyMetadata metadata = new SimplePropertyMetadata();
+        try {
+            ValidatorUtils.tryValidate(this);
+            if (CollectionUtils.isEmpty(type) || type.size() == 1 && StringUtils.isEmpty(type.get(0))) {
+                throw new ValidationException("读写类型不能为空");
+            }
+            metadata.setId(property);
+            metadata.setName(name);
+            metadata.setValueType(parseDataType());
+            metadata.setExpands(parseExpands());
+            metadata.setDescription(description);
+            return metadata;
+        } catch (Throwable e) {
+            throw new BusinessException("第" + this.getRowNumber() + "行错误:" + e.getMessage());
+        }
+    }
+
+    public static List<ExcelHeader> getTemplateHeaderMapping(List<ConfigMetadata> configMetadataList) {
+        List<ExcelHeader> arr = new ArrayList<>(Arrays.asList(
+            new ExcelHeader("property", "属性ID", CellDataType.STRING),
+            new ExcelHeader("name", "属性名称", CellDataType.STRING),
+            new ExcelHeader("dataType", "数据类型", CellDataType.STRING),
+            new ExcelHeader("unit", "单位", CellDataType.STRING),
+            new ExcelHeader("scale", "精度", CellDataType.STRING),
+            new ExcelHeader("valueType", "数据类型配置", CellDataType.STRING),
+            new ExcelHeader("source", "来源", CellDataType.STRING),
+            new ExcelHeader("description", "属性说明", CellDataType.STRING),
+            new ExcelHeader("type", "读写类型", CellDataType.STRING)
+        ));
+
+        Set<String> expandsKeys = new HashSet<>();
+        for (ConfigMetadata configMetadata : configMetadataList) {
+            for (ConfigPropertyMetadata property : configMetadata.getProperties()) {
+                String header = property.getName();
+                if (expandsKeys.contains(header)) {
+                    header = configMetadata.getName() + "-" + header;
+                }
+                arr.add(new ExcelHeader("expands." + property.getProperty(), header, CellDataType.STRING));
+                expandsKeys.add(property.getName());
+            }
+        }
+
+        return arr;
+    }
+
+    protected DataType parseDataType() {
+        JSONObject dataTypeJson = new JSONObject();
+        //默认先使用json格式的数据解析物模型,没有json则使用简单模板,只支持int long double float
+        if (!StringUtils.isEmpty(this.valueType)) {
+            dataTypeJson = JSON.parseObject(this.valueType);
+            this.dataType = dataTypeJson.getString("type");
+        } else {
+            dataTypeJson.put("type", this.dataType);
+            dataTypeJson.put("unit", this.unit);
+            dataTypeJson.put("scale", this.scale);
+        }
+        DataType dataType = Optional.ofNullable(this.dataType)
+            .map(DataTypes::lookup)
+            .map(Supplier::get)
+            .orElseThrow(() -> new BusinessException("error.unknown_data_type" ,500, this, getDataType()));
+        JSONObject finalDataTypeJson = dataTypeJson;
+        JetLinksDataTypeCodecs
+            .getCodec(dataType.getId())
+            .ifPresent(codec -> codec.decode(dataType, finalDataTypeJson));
+        return dataType;
+    }
+
+    protected Map<String, Object> parseExpands() {
+        Map<String, Object> map = new HashMap<>(4);
+        map.put("source", PropertySource.getValue(source));
+        map.put("storageType", PropertyStorage.getValue(storageType));
+        map.put("tags", "");
+        map.put("type", type.stream().map(PropertyType::getValue).collect(Collectors.toList()));
+        return map;
+    }
+
+    public static Flux<PropertyMetadataExcelInfo> getTemplateContentMapping() {
+        return Flux.fromIterable(DATA_TYPES)
+            .flatMap(dt -> {
+                PropertyMetadataExcelInfo excelInfo = new PropertyMetadataExcelInfo();
+                DataType dataType = DataTypes.lookup(dt).get();
+                excelInfo.setProperty(dataType.getType() + "_id");
+                excelInfo.setName(dataType.getType() + "类型属性示例");
+                excelInfo.setDataType(dataType.getId());
+                excelInfo.setUnit("");
+                excelInfo.setScale("");
+                Random random = new Random();
+                excelInfo.setStorageType(random.nextBoolean() ? "direct" : "ignore");
+                excelInfo.setSource(random.nextInt(2) == 1 ? "manual" : random.nextInt(2) < 1 ? "device" : "rule");
+                excelInfo.setDescription(excelInfo.getName() + "的说明");
+                if (SIMPLE.contains(dt)) {
+                    excelInfo.setUnit(idList.get(0).getId());
+                    excelInfo.setDataType(dt);
+                    excelInfo.setScale(String.valueOf(random.nextInt(2)));
+                    excelInfo.setDescription(excelInfo.getName() + "的说明,优先使用json数据类型配置,没有则使用简单模板,仅支持int double float long四种");
+                }
+                Map<String, Object> valueType = JetLinksDataTypeCodecs.encode(buildValueType(dataType, random)).orElse(Collections.emptyMap());
+                excelInfo.setValueType(JSONObject.toJSONString(valueType));
+                excelInfo.setExpands(Collections.singletonMap("storageType", excelInfo.getStorageType()));
+                excelInfo.setType(Arrays.asList("read", "write", "report"));
+                return Flux.just(excelInfo);
+            }).doOnError(e -> {
+                log.error("填充模板异常:", e);
+            });
+    }
+
+    private static DataType buildValueType(DataType dataType, Random random) {
+        switch (dataType.getId()) {
+            case ArrayType.ID:
+                ((ArrayType) dataType).elementType(new IntType().unit(idList.get(random.nextInt(idList.size() - 1))));
+                break;
+            case DoubleType.ID:
+                ((DoubleType) dataType).scale(random.nextInt(10)).unit(idList.get(random.nextInt(idList.size() - 1)));
+                break;
+            case FloatType.ID:
+                ((FloatType) dataType).scale(random.nextInt(10)).unit(idList.get(random.nextInt(idList.size() - 1)));
+                break;
+            case EnumType.ID:
+                dataType = new EnumType();
+                for (int i = 0; i < random.nextInt(5); i++) {
+                    ((EnumType) dataType).addElement(EnumType.Element.of("枚举值" + i, String.valueOf(i), "枚举说明" + i));
+                }
+                break;
+            case IntType.ID:
+                dataType = new IntType();
+                ((IntType) dataType).unit(idList.get(random.nextInt(idList.size() - 1)));
+                break;
+            case LongType.ID:
+                ((LongType) dataType).unit(idList.get(random.nextInt(idList.size() - 1)));
+                break;
+            case FileType.ID:
+                ((FileType) dataType).bodyType(FileType.BodyType.url);
+                break;
+            case ObjectType.ID:
+                int i = 1;
+                List<String> objectParam = Lists.newCopyOnWriteArrayList(DATA_TYPES);
+                dataType = new ObjectType();
+                objectParam.removeAll(OBJECT_NOT_HAVE);
+                for (String id : objectParam) {
+                    ((ObjectType) dataType).addProperty("param" + i, "参数" + i, buildValueType(DataTypes.lookup(id).get(), random));
+                    i++;
+                }
+                break;
+            case StringType.ID:
+                ((StringType) dataType).expand("maxLength", random.nextInt(2000));
+                break;
+            case PasswordType.ID:
+                ((PasswordType) dataType).expand("maxLength", random.nextInt(30));
+                break;
+            default:
+                break;
+        }
+        return dataType;
+    }
+
+
+    public Map<String, Object> toMap() {
+        setSource(PropertySource.getText(source));
+        setStorageType(PropertyStorage.getText(storageType));
+        setExpands(Collections.singletonMap("storageType", storageType));
+        Map<String, Object> map = FastBeanCopier.copy(this, new HashMap<>(8));
+        map.put("type", type.stream()
+            .map(PropertyType::getText)
+            .collect(Collectors.joining(",")));
+        return map;
+    }
+
+    @AllArgsConstructor
+    @Getter
+    private enum PropertySource implements EnumDict<String> {
+        device("设备"),
+        manual("手动"),
+        rule("规则");
+
+        private String text;
+
+        @Override
+        public String getValue() {
+            return name();
+        }
+
+        public static String getText(String value) {
+            return EnumDict.findByValue(PropertySource.class, value).map(PropertySource::getText).orElse("");
+        }
+
+        public static String getValue(String text) {
+            return EnumDict.findByText(PropertySource.class, text).map(PropertySource::getValue).orElse("");
+        }
+    }
+
+    @AllArgsConstructor
+    @Getter
+    private enum PropertyType implements EnumDict<String> {
+        read("读"),
+        write("写"),
+        report("上报");
+
+        private String text;
+
+        @Override
+        public String getValue() {
+            return name();
+        }
+
+        public static String getText(String value) {
+            return EnumDict.findByValue(PropertyType.class, value).map(PropertyType::getText).orElse("");
+        }
+
+        public static String getValue(String text) {
+            return EnumDict.findByText(PropertyType.class, text).map(PropertyType::getValue).orElse("");
+        }
+    }
+
+    @AllArgsConstructor
+    @Getter
+    private enum PropertyStorage implements EnumDict<String> {
+        direct("存储"),
+        ignore("不存储");
+
+        private String text;
+
+        @Override
+        public String getValue() {
+            return name();
+        }
+
+        public static String getText(String value) {
+            return EnumDict.findByValue(PropertyStorage.class, value).map(PropertyStorage::getText).orElse("");
+        }
+
+        public static String getValue(String text) {
+            return EnumDict.findByText(PropertyStorage.class, text).map(PropertyStorage::getValue).orElse("");
+        }
+    }
+}

+ 56 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/excel/PropertyMetadataWrapper.java

@@ -0,0 +1,56 @@
+package org.jetlinks.community.device.web.excel;
+
+import org.hswebframework.reactor.excel.Cell;
+import org.hswebframework.reactor.excel.converter.RowWrapper;
+import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.core.metadata.ConfigPropertyMetadata;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class PropertyMetadataWrapper extends RowWrapper<PropertyMetadataExcelInfo> {
+
+    private final Map<String, String> propertyMapping = new HashMap<>();
+
+    public PropertyMetadataWrapper(List<ConfigMetadata> expands) {
+        propertyMapping.put("属性ID", "property");
+        propertyMapping.put("属性名称", "name");
+        propertyMapping.put("数据类型", "dataType");
+        propertyMapping.put("单位", "unit");
+        propertyMapping.put("精度", "scale");
+        propertyMapping.put("数据类型配置", "valueType");
+        propertyMapping.put("来源", "source");
+        propertyMapping.put("属性说明", "description");
+        propertyMapping.put("读写类型", "type");
+        for (ConfigMetadata expand : expands) {
+            for (ConfigPropertyMetadata property : expand.getProperties()) {
+                propertyMapping.put(expand.getName() + "-" + property.getName(), property.getProperty());
+                propertyMapping.put(property.getName(), property.getProperty());
+            }
+        }
+    }
+
+    @Override
+    protected PropertyMetadataExcelInfo newInstance() {
+        return new PropertyMetadataExcelInfo();
+    }
+
+    @Override
+    protected PropertyMetadataExcelInfo wrap(PropertyMetadataExcelInfo instance, Cell header, Cell dataCell) {
+        String headerText = header.valueAsText().orElse("null");
+        Object value = dataCell.valueAsText().orElse("");
+        if (propertyMapping.containsKey(headerText)) {
+            instance.with(propertyMapping.get(headerText), propertyTypeToLowerCase(headerText, value));
+        }
+        instance.setRowNumber(dataCell.getRowIndex() + 1);
+        return instance;
+    }
+
+    private Object propertyTypeToLowerCase(String headerText, Object value) {
+        if ("类型".equals(headerText)) {
+            return value.toString().toLowerCase();
+        }
+        return value;
+    }
+}

+ 4 - 4
jetlinks-manager/device-manager/src/main/resources/i18n/device-manager/messages_zh.properties

@@ -28,8 +28,8 @@ org.jetlinks.community.device.enums.TaskState.sendError=\u53D1\u9001\u5931\u8D25
 org.jetlinks.community.device.enums.FirmwareUpgradeState.waiting=\u7B49\u5F85\u5347\u7EA7
 org.jetlinks.community.device.enums.FirmwareUpgradeState.processing=\u5347\u7EA7\u4E2D
 org.jetlinks.community.device.enums.FirmwareUpgradeState.failed=\u5347\u7EA7\u5931\u8D25
-org.jetlinks.community.device.enums.FirmwareUpgradeState.success=\u5347\u7ea7\u5b8c\u6210
-org.jetlinks.community.device.enums.FirmwareUpgradeState.canceled=\u5df2\u505c\u6b62
+org.jetlinks.community.device.enums.FirmwareUpgradeState.success=\u5347\u7EA7\u5B8C\u6210
+org.jetlinks.community.device.enums.FirmwareUpgradeState.canceled=\u5DF2\u505C\u6B62
 
 
 #message
@@ -69,7 +69,7 @@ error.product_does_not_exist=\u4EA7\u54C1{0}\u4E0D\u5B58\u5728
 error.reply_is_error=\u8BBE\u5907\u54CD\u5E94\u9519\u8BEF
 error.cannot_deleted_because_device_is_associated_with_it=\u5B58\u5728\u5173\u8054\u8BBE\u5907,\u65E0\u6CD5\u5220\u9664!
 error.unable_to_load_protocol=\u65E0\u6CD5\u52A0\u8F7D\u534F\u8BAE:{0}
-error.unable_to_load_protocol_by_access_id=\u627e\u4e0d\u5230\u5f53\u524d\u63a5\u5165\u65b9\u5f0f\u4e2d\u7684\u534f\u8bae:{0}
+error.unable_to_load_protocol_by_access_id=\u627E\u4E0D\u5230\u5F53\u524D\u63A5\u5165\u65B9\u5F0F\u4E2D\u7684\u534F\u8BAE:{0}
 error.unknown_data_type=\u672A\u77E5\u7684\u6570\u636E\u7C7B\u578B:{0}
 error.unrecognized_message=\u65E0\u6CD5\u8BC6\u522B\u7684\u6D88\u606F
 error.device_ID_already_exists=\u8BBE\u5907ID\u5DF2\u5B58\u5728
@@ -81,7 +81,7 @@ error.product_ID_cannot_be_empty=\u4EA7\u54C1ID\u4E0D\u80FD\u4E3A\u7A7A
 error.message_format=\u6D88\u606F\u683C\u5F0F\u9519\u8BEF
 error.gateway_cannot_be_bound_as_a_child_device=\u4E0D\u80FD\u7ED1\u5B9A\u7F51\u5173\u81EA\u8EAB\u4E3A\u5B50\u8BBE\u5907
 error.device_not_found_or_not_activated=\u8BBE\u5907\u4E0D\u5B58\u5728\u6216\u672A\u542F\u7528
-error.device_category_has_bean_use_by_product=\u8BBE\u5907\u5206\u7C7B\u5DF2\u7ECF\u88AB\u5176\u4ED6\u4EA7\u54C1\u4F7F\u7528
+error.device_category_has_bean_use_by_product=\u4EA7\u54C1\u5206\u7C7B\u5DF2\u7ECF\u88AB\u5176\u4ED6\u4EA7\u54C1\u4F7F\u7528
 error.storage_policy_unsupported_operation=\u5B58\u50A8\u7B56\u7565\u4E0D\u652F\u6301\u6B64\u64CD\u4F5C
 error.message_protocol_can_not_be_empty=\u6D88\u606F\u534F\u8BAE\u4E0D\u80FD\u4E3A\u7A7A
 error.please_select_the_access_mode_first=\u8BF7\u5148\u9009\u62E9\u63A5\u5165\u65B9\u5F0F

+ 113 - 0
jetlinks-manager/network-manager/src/main/java/org/jetlinks/community/network/manager/service/NetworkEntityEventHandler.java

@@ -0,0 +1,113 @@
+package org.jetlinks.community.network.manager.service;
+
+import lombok.AllArgsConstructor;
+import org.hswebframework.web.crud.events.EntityBeforeDeleteEvent;
+import org.hswebframework.web.crud.events.EntityCreatedEvent;
+import org.hswebframework.web.crud.events.EntityModifyEvent;
+import org.hswebframework.web.crud.events.EntitySavedEvent;
+import org.hswebframework.web.exception.BusinessException;
+import org.jetlinks.community.network.NetworkManager;
+import org.jetlinks.community.network.NetworkProperties;
+import org.jetlinks.community.network.manager.entity.CertificateEntity;
+import org.jetlinks.community.network.manager.entity.NetworkConfigEntity;
+import org.jetlinks.community.network.manager.enums.NetworkConfigState;
+import org.jetlinks.community.reference.DataReferenceManager;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.Collection;
+
+@Component
+@AllArgsConstructor
+public class NetworkEntityEventHandler {
+
+    private final NetworkConfigService networkService;
+
+    private final DataReferenceManager referenceManager;
+
+    private final NetworkManager networkManager;
+
+    //禁止删除已有网络组件使用的证书
+    @EventListener
+    public void handleCertificateDelete(EntityBeforeDeleteEvent<CertificateEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .flatMap(e -> networkService
+                    .createQuery()
+                    // FIXME: 2021/9/13 由于网络组件没有直接记录证书,还有更好的处理办法?
+                    .$like$(NetworkConfigEntity::getConfiguration, e.getId())
+                    .or()
+                    .$like$(NetworkConfigEntity::getCluster, e.getId())
+                    .count()
+                    .doOnNext(i -> {
+                        if (i > 0) {
+                            throw new BusinessException("error.certificate_has_bean_use_by_network");
+                        }
+                    })
+                )
+        );
+    }
+
+    //禁止删除已有网关使用的网络组件
+    @EventListener
+    public void handleNetworkDelete(EntityBeforeDeleteEvent<NetworkConfigEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .flatMap(e -> referenceManager.assertNotReferenced(DataReferenceManager.TYPE_NETWORK, e.getId()))
+        );
+
+    }
+
+
+    @EventListener
+    public void handleNetworkCreated(EntityCreatedEvent<NetworkConfigEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .flatMapIterable(NetworkConfigEntity::toNetworkPropertiesList)
+                .flatMap(this::networkConfigValidate)
+                .then(handleEvent(event.getEntity()))
+        );
+    }
+
+    @EventListener
+    public void handleNetworkSaved(EntitySavedEvent<NetworkConfigEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getEntity())
+                .filter(conf -> conf.getConfiguration() != null || conf.getCluster() != null)
+                .flatMapIterable(NetworkConfigEntity::toNetworkPropertiesList)
+                .flatMap(this::networkConfigValidate)
+                .then(handleEvent(event.getEntity()))
+        );
+    }
+
+    @EventListener
+    public void handleNetworkModify(EntityModifyEvent<NetworkConfigEntity> event) {
+        event.async(
+            Flux.fromIterable(event.getAfter())
+                .filter(conf -> conf.getConfiguration() != null || conf.getCluster() != null)
+                .flatMapIterable(NetworkConfigEntity::toNetworkPropertiesList)
+                .flatMap(this::networkConfigValidate)
+                .then(handleEvent(event.getAfter()))
+        );
+    }
+
+
+    //网络组件配置验证
+    private Mono<Void> networkConfigValidate(NetworkProperties properties) {
+        return Mono.justOrEmpty(networkManager.getProvider(properties.getType()))
+                   .flatMap(networkProvider -> networkProvider.createConfig(properties))
+                   .then();
+    }
+
+    private Mono<Void> handleEvent(Collection<NetworkConfigEntity> entities) {
+        return Flux
+            .fromIterable(entities)
+            .filter(conf -> conf.getState() == NetworkConfigState.enabled)
+            .flatMap(conf -> networkManager.reload(conf.lookupNetworkType(), conf.getId()))
+            .then();
+    }
+
+
+}

+ 47 - 0
jetlinks-manager/network-manager/src/main/java/org/jetlinks/community/network/manager/service/ProtocolDataReferenceProvider.java

@@ -0,0 +1,47 @@
+package org.jetlinks.community.network.manager.service;
+
+import lombok.AllArgsConstructor;
+import org.jetlinks.community.network.manager.entity.DeviceGatewayEntity;
+import org.jetlinks.community.reference.DataReferenceInfo;
+import org.jetlinks.community.reference.DataReferenceManager;
+import org.jetlinks.community.reference.DataReferenceProvider;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+
+/**
+ * 消息协议的数据引用提供商.
+ *
+ * 返回被设备接入网关使用的消息协议
+ *
+ * @author zhangji 2022/4/12
+ */
+@Component
+@AllArgsConstructor
+public class ProtocolDataReferenceProvider implements DataReferenceProvider {
+    private final DeviceGatewayService deviceGatewayService;
+
+    @Override
+    public String getId() {
+        return DataReferenceManager.TYPE_PROTOCOL;
+    }
+
+    @Override
+    public Flux<DataReferenceInfo> getReference(String protocolId) {
+        return deviceGatewayService
+            .createQuery()
+            .where()
+            .is(DeviceGatewayEntity::getProtocol, protocolId)
+            .fetch()
+            .map(e -> DataReferenceInfo.of(e.getId(),DataReferenceManager.TYPE_PROTOCOL, e.getProtocol(), e.getName()));
+    }
+
+    @Override
+    public Flux<DataReferenceInfo> getReferences() {
+        return deviceGatewayService
+            .createQuery()
+            .where()
+            .notNull(DeviceGatewayEntity::getChannelId)
+            .fetch()
+            .map(e -> DataReferenceInfo.of(e.getId(),DataReferenceManager.TYPE_PROTOCOL, e.getChannelId(), e.getName()));
+    }
+}

+ 13 - 0
jetlinks-manager/notify-manager/pom.xml

@@ -76,6 +76,19 @@
             <artifactId>notify-wechat</artifactId>
             <version>${project.version}</version>
         </dependency>
+
+        <dependency>
+            <groupId>${project.groupId}</groupId>
+            <artifactId>notify-webhook</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>${project.groupId}</groupId>
+            <artifactId>notify-voice</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
         <dependency>
             <groupId>org.jetlinks.community</groupId>
             <artifactId>notify-core</artifactId>

+ 10 - 0
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/Notify.java

@@ -15,4 +15,14 @@ public class Notify {
     private String dataId;
 
     private long notifyTime;
+
+
+    private String code;
+
+    private Object detail;
+
+
+    public static Notify of(String message, String dataId, long timestamp) {
+        return new Notify(message, dataId, timestamp, null,null);
+    }
 }

+ 6 - 0
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/SubscriberProvider.java

@@ -2,6 +2,8 @@ package org.jetlinks.community.notify.manager.subscriber;
 
 import org.hswebframework.web.authorization.Authentication;
 import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.core.metadata.PropertyMetadata;
+import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
 import java.util.Map;
@@ -13,5 +15,9 @@ public interface SubscriberProvider {
 
     Mono<Subscriber> createSubscriber(String id, Authentication authentication, Map<String, Object> config);
 
+    default Flux<PropertyMetadata> getDetailProperties(Map<String, Object> config) {
+        return Flux.empty();
+    }
+
     ConfigMetadata getConfigMetadata();
 }

+ 125 - 0
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/providers/AlarmProvider.java

@@ -0,0 +1,125 @@
+package org.jetlinks.community.notify.manager.subscriber.providers;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.hswebframework.web.authorization.Authentication;
+import org.hswebframework.web.i18n.LocaleUtils;
+import org.jetlinks.community.ValueObject;
+import org.jetlinks.community.notify.manager.subscriber.Notify;
+import org.jetlinks.community.notify.manager.subscriber.Subscriber;
+import org.jetlinks.community.notify.manager.subscriber.SubscriberProvider;
+import org.jetlinks.community.topic.Topics;
+import org.jetlinks.core.event.EventBus;
+import org.jetlinks.core.event.Subscription;
+import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.core.metadata.DefaultConfigMetadata;
+import org.jetlinks.core.metadata.PropertyMetadata;
+import org.jetlinks.core.metadata.SimplePropertyMetadata;
+import org.jetlinks.core.metadata.types.StringType;
+import org.springframework.stereotype.Component;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.util.Map;
+import java.util.Objects;
+
+@Component
+public class AlarmProvider implements SubscriberProvider {
+
+    private final EventBus eventBus;
+
+    public AlarmProvider(EventBus eventBus) {
+        this.eventBus = eventBus;
+    }
+
+    @Override
+    public String getId() {
+        return "alarm";
+    }
+
+    @Override
+    public String getName() {
+        return "告警";
+    }
+
+    @Override
+    public ConfigMetadata getConfigMetadata() {
+        return new DefaultConfigMetadata()
+            .add("alarmConfigId", "告警规则", "告警规则,支持通配符:*", StringType.GLOBAL);
+    }
+
+    @Override
+    public Mono<Subscriber> createSubscriber(String id, Authentication authentication, Map<String, Object> config) {
+        ValueObject configs = ValueObject.of(config);
+
+        String alarmId = configs.getString("alarmConfigId").orElse("*");
+
+        String topic = Topics.alarm("*", "*", alarmId);
+        return Mono.justOrEmpty(()-> createSubscribe(id, new String[]{topic}));
+
+    }
+
+    private Flux<Notify> createSubscribe(String id,
+                                         String[] topics) {
+        Subscription.Feature[] features = new Subscription.Feature[]{Subscription.Feature.local};
+        return Flux
+            .defer(() -> this
+                .eventBus
+                .subscribe(Subscription.of("alarm:" + id, topics, features))
+                .map(msg -> {
+                    JSONObject json = msg.bodyToJson();
+                    return Notify.of(
+                        getNotifyMessage(json),
+                        //告警记录ID
+                        json.getString("id"),
+                        System.currentTimeMillis(),
+                        "alarm",
+                        json
+                    );
+                }));
+    }
+
+    private static String getNotifyMessage(JSONObject json) {
+
+        String message;
+        TargetType targetType = TargetType.of(json.getString("targetType"));
+        String targetName = json.getString("targetName");
+        String alarmName = json.getString("alarmName");
+        if (targetType == TargetType.other) {
+            message = String.format("[%s]发生告警:[%s]!", targetName, alarmName);
+        } else {
+            message = String.format("%s[%s]发生告警:[%s]!", targetType.getText(), targetName, alarmName);
+        }
+        return LocaleUtils.resolveMessage("message.alarm.notify." + targetType.name(), message, targetName, alarmName);
+    }
+
+    @Override
+    public Flux<PropertyMetadata> getDetailProperties(Map<String, Object> config) {
+        //todo 根据配置来获取输出数据
+        return Flux.just(
+            SimplePropertyMetadata.of("targetType", "告警类型", StringType.GLOBAL),
+            SimplePropertyMetadata.of("alarmName", "告警名称", StringType.GLOBAL),
+            SimplePropertyMetadata.of("targetName", "目标名称", StringType.GLOBAL)
+        );
+    }
+
+    @AllArgsConstructor
+    @Getter
+    enum TargetType {
+        device("设备"),
+        product("产品"),
+        other("其它");
+
+        private final String text;
+
+        public static TargetType of(String name) {
+            for (TargetType value : TargetType.values()) {
+                if (Objects.equals(value.name(), name)) {
+                    return value;
+                }
+            }
+            return TargetType.other;
+        }
+    }
+}

+ 0 - 74
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/subscriber/providers/DeviceAlarmProvider.java

@@ -1,74 +0,0 @@
-package org.jetlinks.community.notify.manager.subscriber.providers;
-
-import com.alibaba.fastjson.JSONObject;
-import org.hswebframework.web.authorization.Authentication;
-import org.jetlinks.community.ValueObject;
-import org.jetlinks.community.notify.manager.subscriber.Notify;
-import org.jetlinks.community.notify.manager.subscriber.Subscriber;
-import org.jetlinks.community.notify.manager.subscriber.SubscriberProvider;
-import org.jetlinks.core.event.EventBus;
-import org.jetlinks.core.event.Subscription;
-import org.jetlinks.core.metadata.ConfigMetadata;
-import org.jetlinks.core.metadata.DefaultConfigMetadata;
-import org.jetlinks.core.metadata.types.StringType;
-import org.springframework.stereotype.Component;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-
-import java.util.Map;
-
-@Component
-public class DeviceAlarmProvider implements SubscriberProvider {
-
-    private final EventBus eventBus;
-
-    public DeviceAlarmProvider(EventBus eventBus) {
-        this.eventBus = eventBus;
-    }
-
-    @Override
-    public String getId() {
-        return "device_alarm";
-    }
-
-    @Override
-    public String getName() {
-        return "设备告警";
-    }
-
-    @Override
-    public ConfigMetadata getConfigMetadata() {
-        return new DefaultConfigMetadata()
-            .add("productId", "产品ID", "产品ID,支持通配符:*", StringType.GLOBAL)
-            .add("deviceId", "设备ID", "设备ID,支持通配符:*", StringType.GLOBAL)
-            .add("productId", "告警ID", "告警ID,支持通配符:*", StringType.GLOBAL)
-            ;
-    }
-
-    @Override
-    public Mono<Subscriber> createSubscriber(String id, Authentication authentication, Map<String, Object> config) {
-        ValueObject configs = ValueObject.of(config);
-
-        String productId = configs.getString("productId").orElse("*");
-        String deviceId = configs.getString("deviceId").orElse("*");
-        String alarmId = configs.getString("alarmId").orElse("*");
-
-        Flux<Notify> flux = eventBus
-            .subscribe(Subscription.of("device-alarm:" + id,
-                String.format("/rule-engine/device/alarm/%s/%s/%s", productId, deviceId, alarmId),
-                Subscription.Feature.local
-            ))
-            .map(msg -> {
-                JSONObject json = msg.bodyToJson(true);
-
-                return Notify.of(
-                    String.format("设备[%s]发生告警:[%s]!", json.getString("deviceName"), json.getString("alarmName")),
-                    json.getString("alarmId"),
-                    System.currentTimeMillis()
-                );
-
-            });
-
-        return Mono.just(() -> flux);
-    }
-}

+ 32 - 12
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/web/NotifierController.java

@@ -4,15 +4,16 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.AllArgsConstructor;
 import lombok.Getter;
 import lombok.Setter;
 import org.hswebframework.web.authorization.annotation.Resource;
 import org.hswebframework.web.authorization.annotation.ResourceAction;
 import org.hswebframework.web.exception.NotFoundException;
-import org.jetlinks.community.notify.DefaultNotifyType;
 import org.jetlinks.community.notify.NotifierManager;
 import org.jetlinks.community.notify.NotifyType;
 import org.jetlinks.community.notify.manager.entity.NotifyTemplateEntity;
+import org.jetlinks.community.notify.manager.service.NotifyConfigService;
 import org.jetlinks.community.notify.template.TemplateManager;
 import org.jetlinks.core.Values;
 import org.springframework.web.bind.annotation.*;
@@ -27,17 +28,15 @@ import java.util.function.Function;
 @RequestMapping("/notifier")
 @Resource(id = "notifier", name = "通知管理")
 @Tag(name = "消息通知管理")
+@AllArgsConstructor
 public class NotifierController {
 
+    private final NotifyConfigService configService;
 
     private final NotifierManager notifierManager;
 
     private final TemplateManager templateManager;
 
-    public NotifierController(NotifierManager notifierManager, TemplateManager templateManager) {
-        this.notifierManager = notifierManager;
-        this.templateManager = templateManager;
-    }
 
     /**
      * 指定通知器以及模版.发送通知.
@@ -52,16 +51,37 @@ public class NotifierController {
     public Mono<Void> sendNotify(@PathVariable @Parameter(description = "通知配置ID") String notifierId,
                                  @RequestBody Mono<SendNotifyRequest> mono) {
         return mono.flatMap(tem -> {
-            NotifyType type = DefaultNotifyType.valueOf(tem.getTemplate().getType());
-            return Mono.zip(
-                notifierManager.getNotifier(type, notifierId)
-                    .switchIfEmpty(Mono.error(() -> new NotFoundException("通知器[" + notifierId + "]不存在"))),
-                templateManager.createTemplate(type, tem.getTemplate().toTemplateProperties()),
-                (notifier, template) -> notifier.send(template, Values.of(tem.getContext())))
+            NotifyType type = NotifyType.of(tem.getTemplate().getType());
+            return Mono
+                .zip(
+                    notifierManager
+                        .getNotifier(type, notifierId)
+                        .switchIfEmpty(Mono.error(() -> new NotFoundException("error.notifier_does_not_exist", notifierId))),
+                    templateManager.createTemplate(type, tem.getTemplate().toTemplateProperties()),
+                    (notifier, template) -> notifier.send(template, Values.of(tem.getContext())))
                 .flatMap(Function.identity());
         });
     }
 
+    @PostMapping("/{notifierId}/{templateId}/_send")
+    @ResourceAction(id = "send", name = "发送通知")
+    @Operation(summary = "根据配置和模版ID发送消息通知")
+    public Mono<Void> sendNotify(@PathVariable @Parameter(description = "通知配置ID") String notifierId,
+                                 @PathVariable @Parameter(description = "通知模版ID") String templateId,
+                                 @RequestBody Mono<Map<String, Object>> contextMono) {
+        return configService
+            .findById(notifierId)
+            .flatMap(conf -> Mono
+                .zip(
+                    notifierManager
+                        .getNotifier(NotifyType.of(conf.getType()), notifierId)
+                        .switchIfEmpty(Mono.error(() -> new NotFoundException("error.notifier_does_not_exist", notifierId))),
+                    contextMono,
+                    (notifier, contextMap) -> notifier.send(templateId, Values.of(contextMap))
+                )
+                .flatMap(Function.identity()));
+    }
+
     @Getter
     @Setter
     public static class SendNotifyRequest {
@@ -74,4 +94,4 @@ public class NotifierController {
         private Map<String, Object> context = new HashMap<>();
     }
 
-}
+}

+ 70 - 12
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/web/NotifierTemplateController.java

@@ -1,23 +1,26 @@
 package org.jetlinks.community.notify.manager.web;
 
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.hswebframework.web.api.crud.entity.QueryParamEntity;
 import org.hswebframework.web.authorization.annotation.Authorize;
 import org.hswebframework.web.authorization.annotation.QueryAction;
 import org.hswebframework.web.authorization.annotation.Resource;
 import org.hswebframework.web.crud.service.ReactiveCrudService;
 import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
+import org.hswebframework.web.exception.ValidationException;
 import org.jetlinks.community.notify.manager.entity.NotifyTemplateEntity;
+import org.jetlinks.community.notify.manager.service.NotifyConfigService;
 import org.jetlinks.community.notify.manager.service.NotifyTemplateService;
+import org.jetlinks.community.notify.manager.web.response.TemplateInfo;
 import org.jetlinks.community.notify.template.TemplateProvider;
 import org.jetlinks.core.metadata.ConfigMetadata;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 
+import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -35,11 +38,14 @@ public class NotifierTemplateController implements ReactiveServiceCrudController
     private final NotifyTemplateService templateService;
 
     private final List<TemplateProvider> providers;
+    private final NotifyConfigService configService;
 
-
-    public NotifierTemplateController(NotifyTemplateService templateService, List<TemplateProvider> providers) {
+    public NotifierTemplateController(NotifyTemplateService templateService,
+                                      NotifyConfigService configService,
+                                      List<TemplateProvider> providers) {
         this.templateService = templateService;
         this.providers = providers;
+        this.configService = configService;
     }
 
     @Override
@@ -47,17 +53,69 @@ public class NotifierTemplateController implements ReactiveServiceCrudController
         return templateService;
     }
 
+    @PostMapping("/{configId}/_query")
+    @QueryAction
+    @Operation(summary = "根据配置ID查询通知模版列表")
+    public Flux<NotifyTemplateEntity> queryTemplatesByConfigId(@PathVariable
+                                                               @Parameter(description = "配置ID") String configId,
+                                                               @RequestBody Mono<QueryParamEntity> query) {
+        return configService
+            .findById(configId)
+            .flatMapMany(conf -> query
+                .flatMapMany(param -> param
+                    .toNestQuery(nest -> nest
+                        //where type = ? and provider = ? and (config_id = ? or config_id is null or config_id = '')
+                        .is(NotifyTemplateEntity::getType, conf.getType())
+                        .is(NotifyTemplateEntity::getProvider, conf.getProvider())
+                        .nest()
+                        /**/.is(NotifyTemplateEntity::getConfigId, configId)
+                        /*  */.or()
+                        /**/.isNull(NotifyTemplateEntity::getConfigId)
+                        .isEmpty(NotifyTemplateEntity::getConfigId)
+                    )
+                    .noPaging()
+                    .execute(templateService::query)));
+
+    }
+
+    @GetMapping("/{templateId}/detail")
+    @QueryAction
+    @Operation(summary = "获取模版详情信息")
+    public Mono<TemplateInfo> getTemplateDetail(@PathVariable
+                                                @Parameter(description = "模版ID") String templateId) {
+        return templateService
+            .findById(templateId)
+            .flatMap(e -> {
+                TemplateInfo info = new TemplateInfo();
+                info.setId(e.getId());
+                info.setName(e.getName());
+                return this
+                    .getProvider(e.getType(), e.getProvider())
+                    .createTemplate(e.toTemplateProperties())
+                    .doOnNext(t -> info.setVariableDefinitions(new ArrayList<>(t.getVariables().values())))
+                    .thenReturn(info);
+            });
+    }
 
 
     @GetMapping("/{type}/{provider}/config/metadata")
     @QueryAction
     @Operation(summary = "获取指定类型和服务商所需模版配置定义")
-    public Mono<ConfigMetadata> getAllTypes(@PathVariable String type,
-                                            @PathVariable String provider) {
-        return Flux.fromIterable(providers)
-                .filter(prov -> prov.getType().getId().equalsIgnoreCase(type) && prov.getProvider().getId().equalsIgnoreCase(provider))
-                .flatMap(prov -> Mono.justOrEmpty(prov.getTemplateConfigMetadata()))
-                .next();
+    public Mono<ConfigMetadata> getConfigMetadata(@PathVariable @Parameter(description = "通知类型ID") String type,
+                                                  @PathVariable @Parameter(description = "服务商ID") String provider) {
+        return Mono.justOrEmpty(getProvider(type, provider).getTemplateConfigMetadata());
+    }
+
+    public TemplateProvider getProvider(String type, String provider) {
+        for (TemplateProvider prov : providers) {
+            if (prov.getType().getId().equalsIgnoreCase(type) && prov
+                .getProvider()
+                .getId()
+                .equalsIgnoreCase(provider)) {
+                return prov;
+            }
+        }
+        throw new ValidationException("error.unsupported_notify_provider");
     }
 
 }

+ 28 - 0
jetlinks-manager/notify-manager/src/main/java/org/jetlinks/community/notify/manager/web/response/TemplateInfo.java

@@ -0,0 +1,28 @@
+package org.jetlinks.community.notify.manager.web.response;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.jetlinks.community.notify.template.VariableDefinition;
+
+import java.util.List;
+
+@Getter
+@Setter
+@AllArgsConstructor
+@NoArgsConstructor
+public class TemplateInfo {
+
+    @Schema(description = "模版ID")
+    private String id;
+
+    @Schema(description = "模版名称")
+    private String name;
+
+    @Schema(description = "变量定义信息")
+    private List<VariableDefinition> variableDefinitions;
+
+
+}

+ 16 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/configuration/RuleEngineManagerConfiguration.java

@@ -1,11 +1,16 @@
 package org.jetlinks.community.rule.engine.configuration;
 
+import org.jetlinks.community.elastic.search.index.ElasticSearchIndexManager;
+import org.jetlinks.community.elastic.search.service.ElasticSearchService;
 import org.jetlinks.community.rule.engine.scene.SceneFilter;
 import org.jetlinks.community.rule.engine.scene.SceneTaskExecutorProvider;
+import org.jetlinks.community.rule.engine.service.ElasticSearchAlarmHistoryService;
 import org.jetlinks.core.event.EventBus;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
 
 @AutoConfiguration
 public class RuleEngineManagerConfiguration {
@@ -17,4 +22,15 @@ public class RuleEngineManagerConfiguration {
         return new SceneTaskExecutorProvider(eventBus,
                                              SceneFilter.composite(filters));
     }
+
+    @Configuration(proxyBeanMethods = false)
+    @ConditionalOnClass(ElasticSearchService.class)
+    static class ElasticSearchAlarmHistoryConfiguration {
+
+        @Bean(initMethod = "init")
+        public ElasticSearchAlarmHistoryService alarmHistoryService(ElasticSearchService elasticSearchService,
+                                                                    ElasticSearchIndexManager indexManager) {
+            return new ElasticSearchAlarmHistoryService(indexManager, elasticSearchService);
+        }
+    }
 }

+ 21 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmDashboardDefinition.java

@@ -0,0 +1,21 @@
+package org.jetlinks.community.rule.engine.measurement;
+
+import lombok.AllArgsConstructor;
+import lombok.Generated;
+import lombok.Getter;
+import org.jetlinks.community.dashboard.DashboardDefinition;
+
+/**
+ * @author bestfeng
+ */
+@Getter
+@AllArgsConstructor
+@Generated
+public enum AlarmDashboardDefinition implements DashboardDefinition {
+
+    alarm("alarm","告警信息");
+
+    private String id;
+
+    private String name;
+}

+ 21 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmObjectDefinition.java

@@ -0,0 +1,21 @@
+package org.jetlinks.community.rule.engine.measurement;
+
+import lombok.AllArgsConstructor;
+import lombok.Generated;
+import lombok.Getter;
+import org.jetlinks.community.dashboard.ObjectDefinition;
+
+@Getter
+@AllArgsConstructor
+@Generated
+public enum AlarmObjectDefinition implements ObjectDefinition {
+
+    record("告警记录");
+
+    @Override
+    public String getId() {
+        return name();
+    }
+
+    private String name;
+}

+ 51 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmRecordMeasurementProvider.java

@@ -0,0 +1,51 @@
+package org.jetlinks.community.rule.engine.measurement;
+
+import com.google.common.collect.Maps;
+import io.micrometer.core.instrument.MeterRegistry;
+import org.jetlinks.community.PropertyConstants;
+import org.jetlinks.community.dashboard.supports.StaticMeasurementProvider;
+import org.jetlinks.community.micrometer.MeterRegistryManager;
+import org.jetlinks.community.rule.engine.entity.AlarmHistoryInfo;
+import org.jetlinks.community.timeseries.TimeSeriesManager;
+import org.jetlinks.community.utils.ConverterUtils;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * @author bestfeng
+ */
+@Component
+public class AlarmRecordMeasurementProvider extends StaticMeasurementProvider {
+
+    MeterRegistry registry;
+
+    public AlarmRecordMeasurementProvider(MeterRegistryManager registryManager,
+                                          TimeSeriesManager timeSeriesManager) {
+        super(AlarmDashboardDefinition.alarm, AlarmObjectDefinition.record);
+
+        registry = registryManager.getMeterRegister(AlarmTimeSeriesMetric.alarmStreamMetrics().getId());
+        addMeasurement(new AlarmRecordTrendMeasurement(timeSeriesManager));
+        addMeasurement(new AlarmRecordRankMeasurement(timeSeriesManager));
+
+    }
+
+    @EventListener
+    public void aggAlarmRecord(AlarmHistoryInfo info) {
+        registry
+            .counter("record-agg", getTags(info))
+            .increment();
+    }
+
+
+
+    public String[] getTags(AlarmHistoryInfo info) {
+        Map<String, Object> tagMap = Maps.newLinkedHashMap();
+        tagMap.put("targetId", info.getTargetId());
+        tagMap.put("targetType", info.getTargetType());
+        tagMap.put("targetName", info.getTargetName());
+        tagMap.put("alarmConfigId", info.getAlarmConfigId());
+        return ConverterUtils.convertMapToTags(tagMap);
+    }
+}

+ 135 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmRecordRankMeasurement.java

@@ -0,0 +1,135 @@
+package org.jetlinks.community.rule.engine.measurement;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.jetlinks.community.dashboard.*;
+import org.jetlinks.community.dashboard.supports.StaticMeasurement;
+import org.jetlinks.community.timeseries.TimeSeriesManager;
+import org.jetlinks.community.timeseries.query.Aggregation;
+import org.jetlinks.community.timeseries.query.AggregationData;
+import org.jetlinks.community.timeseries.query.AggregationQueryParam;
+import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.core.metadata.DataType;
+import org.jetlinks.core.metadata.DefaultConfigMetadata;
+import org.jetlinks.core.metadata.types.IntType;
+import org.jetlinks.core.metadata.types.StringType;
+import reactor.core.publisher.Flux;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * @author bestfeng
+ */
+public class AlarmRecordRankMeasurement extends StaticMeasurement {
+
+    TimeSeriesManager timeSeriesManager;
+
+    public AlarmRecordRankMeasurement(TimeSeriesManager timeSeriesManager) {
+        super(MeasurementDefinition.of("rank", "告警记录排名"));
+        this.timeSeriesManager = timeSeriesManager;
+        addDimension(new AggRecordRankDimension());
+    }
+
+
+    static ConfigMetadata aggConfigMetadata = new DefaultConfigMetadata()
+        .add("time", "周期", "例如: 1h,10m,30s", StringType.GLOBAL)
+        .add("agg", "聚合类型", "count,sum,avg,max,min", StringType.GLOBAL)
+        .add("format", "时间格式", "如: MM-dd:HH", StringType.GLOBAL)
+        .add("limit", "最大数据量", "", StringType.GLOBAL)
+        .add("from", "时间从", "", StringType.GLOBAL)
+        .add("to", "时间至", "", StringType.GLOBAL);
+
+
+    class AggRecordRankDimension implements MeasurementDimension {
+
+        @Override
+        public DimensionDefinition getDefinition() {
+            return CommonDimensionDefinition.agg;
+        }
+
+        @Override
+        public DataType getValueType() {
+            return IntType.GLOBAL;
+        }
+
+        @Override
+        public ConfigMetadata getParams() {
+            return aggConfigMetadata;
+        }
+
+        @Override
+        public boolean isRealTime() {
+            return false;
+        }
+
+        public AggregationQueryParam createQueryParam(MeasurementParameter parameter) {
+            return AggregationQueryParam
+                .of()
+                .groupBy(parameter.getString("group", "targetId"))
+                .sum("count", "count")
+                .agg("targetId", Aggregation.TOP)
+                .filter(query -> query
+                    .where("name", "record-agg")
+                    .where("targetType", parameter.getString("targetType", null))
+                )
+                .limit(parameter.getInt("limit").orElse(1))
+                .from(parameter
+                          .getDate("from")
+                          .orElseGet(() -> Date
+                              .from(LocalDateTime
+                                        .now()
+                                        .plusDays(-1)
+                                        .atZone(ZoneId.systemDefault())
+                                        .toInstant())))
+                .to(parameter.getDate("to").orElse(new Date()));
+        }
+
+        @Override
+        public Flux<SimpleMeasurementValue> getValue(MeasurementParameter parameter) {
+
+            Comparator<AggregationData> comparator;
+            if (Objects.equals(parameter.getString("order",""), "asc")){
+                 comparator = Comparator.comparingInt(d-> d.getInt("count", 0));
+            }else {
+                comparator = Comparator.<AggregationData>comparingInt(d-> d.getInt("count", 0)).reversed();
+            }
+
+            AggregationQueryParam param = createQueryParam(parameter);
+
+            return Flux.defer(() -> param
+                .execute(timeSeriesManager.getService(AlarmTimeSeriesMetric.alarmStreamMetrics())::aggregation)
+                .groupBy(a -> a.getString("targetId", null))
+                .flatMap(fluxGroup -> fluxGroup.reduce(AggregationData::merge))
+                .sort(comparator)
+                .map(data -> SimpleMeasurementValue.of(new SimpleResult(data), 0))
+            )
+                .take(param.getLimit());
+        }
+
+        @Getter
+        @Setter
+        @AllArgsConstructor
+        @NoArgsConstructor
+        class SimpleResult {
+
+            private String targetId;
+
+            private String targetName;
+
+            private Integer count;
+
+            public SimpleResult(AggregationData data) {
+                String targetId = data.getString("targetId", "");
+                this.setCount(data.getInt("count", 0));
+                this.setTargetName(data.getString("targetName", targetId));
+                this.setTargetId(data.getString("targetId", ""));
+            }
+        }
+    }
+}

+ 101 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmRecordTrendMeasurement.java

@@ -0,0 +1,101 @@
+package org.jetlinks.community.rule.engine.measurement;
+
+import org.jetlinks.community.dashboard.*;
+import org.jetlinks.community.dashboard.supports.StaticMeasurement;
+import org.jetlinks.community.timeseries.TimeSeriesManager;
+import org.jetlinks.community.timeseries.query.AggregationQueryParam;
+import org.jetlinks.core.metadata.ConfigMetadata;
+import org.jetlinks.core.metadata.DataType;
+import org.jetlinks.core.metadata.DefaultConfigMetadata;
+import org.jetlinks.core.metadata.types.IntType;
+import org.jetlinks.core.metadata.types.StringType;
+import reactor.core.publisher.Flux;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Date;
+
+/**
+ * @author bestfeng
+ */
+public class AlarmRecordTrendMeasurement extends StaticMeasurement {
+
+    TimeSeriesManager timeSeriesManager;
+
+    public AlarmRecordTrendMeasurement(TimeSeriesManager timeSeriesManager) {
+        super(MeasurementDefinition.of("trend", "告警记录趋势"));
+        this.timeSeriesManager = timeSeriesManager;
+        addDimension(new AggRecordTrendDimension());
+    }
+
+
+    static ConfigMetadata aggConfigMetadata = new DefaultConfigMetadata()
+        .add("alarmConfigId", "告警配置Id", "", StringType.GLOBAL)
+        .add("time", "周期", "例如: 1h,10m,30s", StringType.GLOBAL)
+        .add("agg", "聚合类型", "count,sum,avg,max,min", StringType.GLOBAL)
+        .add("format", "时间格式", "如: MM-dd:HH", StringType.GLOBAL)
+        .add("limit", "最大数据量", "", StringType.GLOBAL)
+        .add("from", "时间从", "", StringType.GLOBAL)
+        .add("to", "时间至", "", StringType.GLOBAL);
+
+
+
+    class AggRecordTrendDimension implements MeasurementDimension{
+
+        @Override
+        public DimensionDefinition getDefinition() {
+            return CommonDimensionDefinition.agg;
+        }
+
+        @Override
+        public DataType getValueType() {
+            return IntType.GLOBAL;
+        }
+
+        @Override
+        public ConfigMetadata getParams() {
+            return aggConfigMetadata;
+        }
+
+        @Override
+        public boolean isRealTime() {
+            return false;
+        }
+
+        public AggregationQueryParam createQueryParam(MeasurementParameter parameter) {
+            return AggregationQueryParam
+                .of()
+                .groupBy(parameter.getInterval("time", null),
+                         parameter.getString("format").orElse("MM月dd日 HH时"))
+                .sum("count", "count")
+                .filter(query -> query
+                    .where("name", "record-agg")
+                    .and("targetType",parameter.getString("targetType").orElse(null))
+                    .and("targetId",parameter.getString("targetId").orElse(null))
+                    .is("alarmConfigId", parameter.getString("alarmConfigId").orElse(null))
+                )
+                .limit(parameter.getInt("limit").orElse(1))
+                .from(parameter
+                          .getDate("from")
+                          .orElseGet(() -> Date
+                              .from(LocalDateTime
+                                        .now()
+                                        .plusDays(-1)
+                                        .atZone(ZoneId.systemDefault())
+                                        .toInstant())))
+                .to(parameter.getDate("to").orElse(new Date()));
+        }
+
+        @Override
+        public Flux<SimpleMeasurementValue> getValue(MeasurementParameter parameter) {
+            AggregationQueryParam param = createQueryParam(parameter);
+            return Flux.defer(()-> param
+                .execute(timeSeriesManager.getService(AlarmTimeSeriesMetric.alarmStreamMetrics())::aggregation)
+                .index((index, data) -> SimpleMeasurementValue.of(
+                    data.getLong("count",0),
+                    data.getString("time",""),
+                    index)))
+                .take(param.getLimit());
+        }
+    }
+}

+ 23 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/measurement/AlarmTimeSeriesMetric.java

@@ -0,0 +1,23 @@
+package org.jetlinks.community.rule.engine.measurement;
+
+import org.jetlinks.community.timeseries.TimeSeriesMetric;
+
+/**
+ * 媒体时序数据度量标识
+ *
+ * @author bestfeng
+ *
+ * @see org.jetlinks.pro.timeseries.TimeSeriesService
+ * @see TimeSeriesMetric
+ */
+public interface AlarmTimeSeriesMetric {
+
+    /**
+     * 告警监控指标,用于对告警进行进行监控
+     *
+     * @return 度量标识
+     */
+    static TimeSeriesMetric alarmStreamMetrics() {
+        return TimeSeriesMetric.of("alarm_metrics");
+    }
+}

+ 57 - 3
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/DeviceTrigger.java

@@ -16,6 +16,7 @@ import org.hswebframework.web.i18n.LocaleUtils;
 import org.hswebframework.web.validator.ValidatorUtils;
 import org.jetlinks.core.device.DeviceRegistry;
 import org.jetlinks.core.metadata.DeviceMetadata;
+import org.jetlinks.core.utils.Reactors;
 import org.jetlinks.community.TimerSpec;
 import org.jetlinks.community.rule.engine.executor.DeviceMessageSendTaskExecutorProvider;
 import org.jetlinks.community.rule.engine.executor.device.DeviceSelectorSpec;
@@ -24,9 +25,12 @@ import org.jetlinks.community.rule.engine.scene.term.TermColumn;
 import org.jetlinks.community.rule.engine.scene.term.TermTypeSupport;
 import org.jetlinks.community.rule.engine.scene.term.TermTypes;
 import org.jetlinks.community.rule.engine.scene.value.TermValue;
+import org.jetlinks.reactor.ql.ReactorQL;
+import org.jetlinks.reactor.ql.ReactorQLContext;
 import org.jetlinks.rule.engine.api.model.RuleModel;
 import org.jetlinks.rule.engine.api.model.RuleNodeModel;
 import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
@@ -53,6 +57,10 @@ public class DeviceTrigger extends DeviceSelectorSpec implements Serializable {
     private DeviceOperation operation;
 
     public SqlRequest createSql(List<Term> terms) {
+        return createSql(terms, true);
+    }
+
+    public SqlRequest createSql(List<Term> terms, boolean hasWhere) {
 
         Map<String, Term> termsMap = SceneUtils.expandTerm(terms);
         // select * from (
@@ -78,6 +86,8 @@ public class DeviceTrigger extends DeviceSelectorSpec implements Serializable {
         selectColumns.add("this.headers._uid \"_uid\"");
         //维度绑定信息,如部门等
         selectColumns.add("this.headers.bindings \"_bindings\"");
+        //链路追踪ID
+        selectColumns.add("this.headers.traceparent \"traceparent\"");
 
         switch (this.operation.getOperator()) {
             case readProperty:
@@ -133,14 +143,56 @@ public class DeviceTrigger extends DeviceSelectorSpec implements Serializable {
         builder.append("\t\nfrom ").append(createFromTable());
         builder.append("\n) t \n");
 
+        if (hasWhere) {
+            SqlFragments fragments = terms == null ? EmptySqlFragments.INSTANCE : termBuilder.createTermFragments(this, terms);
+            if (!fragments.isEmpty()) {
+                SqlRequest request = fragments.toRequest();
+                builder.append("where ").append(request.getSql());
+            }
+            return PrepareSqlRequest.of(builder.toString(), fragments.getParameters().toArray());
+        }
+
+        return PrepareSqlRequest.of(builder.toString(), new Object[0]);
+
+    }
 
-        SqlFragments fragments = terms == null ? EmptySqlFragments.INSTANCE : termBuilder.createTermFragments(this, terms);
+    String createFilterDescription(List<Term> terms) {
+        SqlFragments fragments = CollectionUtils.isEmpty(terms) ? EmptySqlFragments.INSTANCE : termBuilder.createTermFragments(this, terms);
+        return fragments.isEmpty() ? "true" : fragments.toRequest().toNativeSql();
+    }
+
+    Function<Map<String, Object>, Mono<Boolean>> createFilter(List<Term> terms) {
+        SqlFragments fragments = CollectionUtils.isEmpty(terms) ? EmptySqlFragments.INSTANCE : termBuilder.createTermFragments(this, terms);
         if (!fragments.isEmpty()) {
             SqlRequest request = fragments.toRequest();
-            builder.append("where ").append(request.getSql());
+            String sql = "select 1 from t where " + request.getSql();
+            ReactorQL ql = ReactorQL
+                .builder()
+                .sql(sql)
+                .build();
+            Object[] args = request.getParameters();
+            String sqlString = request.toNativeSql();
+            return new Function<Map<String, Object>, Mono<Boolean>>() {
+                @Override
+                public Mono<Boolean> apply(Map<String, Object> map) {
+                    ReactorQLContext context = ReactorQLContext.ofDatasource((t) -> Flux.just(map));
+                    for (Object arg : args) {
+                        context.bind(arg);
+                    }
+
+                    return ql
+                        .start(context)
+                        .hasElements();
+                }
+
+                @Override
+                public String toString() {
+                    return sqlString;
+                }
+            };
         }
 
-        return PrepareSqlRequest.of(builder.toString(), fragments.getParameters().toArray());
+        return ignore -> Reactors.ALWAYS_TRUE;
 
     }
 
@@ -363,6 +415,8 @@ public class DeviceTrigger extends DeviceSelectorSpec implements Serializable {
         timerNode.setId("scene:device:timer");
         timerNode.setName("定时下发指令");
         timerNode.setExecutor("timer");
+        //使用最小负载节点来执行定时
+        // timerNode.setSchedulingRule(SchedulerSelectorStrategy.minimumLoad());
         timerNode.setConfiguration(FastBeanCopier.copy(timer, new HashMap<>()));
         model.getNodes().add(timerNode);
 

+ 21 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneActions.java

@@ -0,0 +1,21 @@
+package org.jetlinks.community.rule.engine.scene;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Getter
+@Setter
+public class SceneActions implements Serializable {
+
+    @Schema(description = "是否并行执行动作")
+    private boolean parallel;
+
+    @Schema(description = "执行动作")
+    private List<SceneAction> actions;
+
+
+}

+ 25 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneConditionAction.java

@@ -0,0 +1,25 @@
+package org.jetlinks.community.rule.engine.scene;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.ezorm.core.param.Term;
+import org.jetlinks.community.rule.engine.commons.ShakeLimit;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Getter
+@Setter
+public class SceneConditionAction implements Serializable {
+
+    @Schema(description = "条件")
+    private List<Term> when;
+
+    @Schema(description = "防抖配置")
+    private ShakeLimit shakeLimit;
+
+    @Schema(description = "满足条件时执行的动作")
+    private SceneActions then;
+
+}

+ 215 - 14
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneRule.java

@@ -13,21 +13,28 @@ import org.hswebframework.web.i18n.LocaleUtils;
 import org.hswebframework.web.validator.ValidatorUtils;
 import org.jetlinks.core.device.DeviceRegistry;
 import org.jetlinks.core.metadata.types.DateTimeType;
+import org.jetlinks.core.utils.Reactors;
+import org.jetlinks.community.rule.engine.commons.ShakeLimit;
 import org.jetlinks.community.rule.engine.commons.TermsConditionEvaluator;
 import org.jetlinks.community.rule.engine.scene.term.TermColumn;
+import org.jetlinks.community.rule.engine.scene.term.limit.ShakeLimitGrouping;
 import org.jetlinks.rule.engine.api.model.RuleLink;
 import org.jetlinks.rule.engine.api.model.RuleModel;
 import org.jetlinks.rule.engine.api.model.RuleNodeModel;
 import org.jetlinks.rule.engine.defaults.AbstractExecutionContext;
+import reactor.core.Disposable;
+import reactor.core.Disposables;
 import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.util.concurrent.Queues;
 
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
 import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
+import java.util.*;
+import java.util.function.BiFunction;
+import java.util.function.Function;
 
 @Getter
 @Setter
@@ -45,11 +52,6 @@ public class SceneRule implements Serializable {
     @NotNull(message = "error.scene_rule_trigger_cannot_be_null")
     private Trigger trigger;
 
-    /**
-     * @see TermColumn
-     * @see org.jetlinks.community.rule.engine.scene.term.TermType
-     * @see org.jetlinks.community.rule.engine.scene.value.TermValue
-     */
     @Schema(description = "触发条件")
     private List<Term> terms;
 
@@ -59,17 +61,42 @@ public class SceneRule implements Serializable {
     @Schema(description = "执行动作")
     private List<SceneAction> actions;
 
+    @Schema(description = "动作分支")
+    private List<SceneConditionAction> branches;
+
     @Schema(description = "说明")
     private String description;
 
-    public SqlRequest createSql() {
+    public SqlRequest createSql(boolean hasWhere) {
         if (trigger != null && trigger.getType() == TriggerType.device) {
-            return trigger.getDevice().createSql(terms);
+            return trigger.getDevice().createSql(terms, hasWhere);
         }
 
         return EmptySqlRequest.INSTANCE;
     }
 
+    public Function<Map<String, Object>, Mono<Boolean>> createFilter(List<Term> terms) {
+        if (trigger != null && trigger.getType() == TriggerType.device) {
+            return trigger.getDevice().createFilter(terms);
+        }
+
+        return ignore -> Reactors.ALWAYS_TRUE;
+    }
+
+    String createFilterDescription(List<Term> terms) {
+        if (trigger != null && trigger.getType() == TriggerType.device) {
+            return trigger.getDevice().createFilterDescription(terms);
+        }
+
+        return "true";
+    }
+
+    public ShakeLimitGrouping<Map<String, Object>> createGrouping() {
+        //todo 其他分组方式实现
+        return flux -> flux
+            .groupBy(map -> map.getOrDefault("deviceId", "null"), Integer.MAX_VALUE);
+    }
+
     private Flux<Variable> createSceneVariables(List<TermColumn> columns) {
         return LocaleUtils
             .currentReactive()
@@ -87,8 +114,8 @@ public class SceneRule implements Serializable {
                             List<Variable> termVar = SceneUtils.parseVariable(terms, columns);
                             List<Variable> variables = new ArrayList<>(defaultVariables.size() + termVar.size());
 
-                            //设备触发但是没有指定条件,以下是内置的输出参数
-                            if (CollectionUtils.isEmpty(termVar) && trigger.getType() == TriggerType.device) {
+                            //设备触发但是没有指定条件,或者其它触发类型,以下是内置的输出参数
+                            if (trigger.getType() != TriggerType.device || CollectionUtils.isEmpty(termVar)) {
                                 variables.add(Variable
                                                   .of("_now",
                                                       LocaleUtils.resolveMessage(
@@ -113,22 +140,153 @@ public class SceneRule implements Serializable {
     }
 
     public Flux<Variable> createVariables(List<TermColumn> columns,
+                                          Integer branchIndex,
                                           Integer actionIndex,
                                           DeviceRegistry registry) {
         Flux<Variable> variables = createSceneVariables(columns);
 
         //执行动作会输出的变量,串行执行才会生效
-        if (!parallel && actionIndex != null && CollectionUtils.isNotEmpty(actions)) {
+        if (branchIndex == null && !parallel && actionIndex != null && CollectionUtils.isNotEmpty(actions)) {
 
             for (int i = 0; i < Math.min(actions.size(), actionIndex + 1); i++) {
                 variables = variables.concatWith(actions.get(i).createVariables(registry, i));
             }
+        }
+        //分支条件
+        if (branchIndex != null && CollectionUtils.isNotEmpty(branches) && branches.size() > branchIndex) {
+            SceneConditionAction branch = branches.get(branchIndex);
+            List<SceneAction> actionList;
+            if (branch.getThen() != null && !branch.getThen().isParallel() &&
+
+                CollectionUtils.isNotEmpty(actionList = branch.getThen().getActions())) {
 
+                for (int i = 0; i < Math.min(actionList.size(), actionIndex + 1); i++) {
+                    variables = variables.concatWith(actionList.get(i).createVariables(registry, i));
+                }
+
+            }
         }
+
         return variables
             .doOnNext(Variable::refactorPrefix);
     }
 
+    public Disposable createBranchHandler(Flux<Map<String, Object>> sourceData,
+                                          BiFunction<String, Map<String, Object>, Mono<Void>> output) {
+        if (CollectionUtils.isEmpty(branches)) {
+            return Disposables.disposed();
+        }
+
+        Function<Map<String, Object>, Mono<Boolean>> last = null;
+
+        Disposable.Composite disposable = Disposables.composite();
+        int branchIndex = 0;
+        for (SceneConditionAction branch : branches) {
+            int _branchIndex = ++branchIndex;
+            //执行条件
+            Function<Map<String, Object>, Mono<Boolean>> filter = createFilter(branch.getWhen());
+            //满足条件后的输出操作
+            Function<Map<String, Object>, Mono<Void>> out;
+
+            SceneActions then = branch.getThen();
+            //执行动作
+            if (then != null && CollectionUtils.isNotEmpty(then.getActions())) {
+
+                int size = then.getActions().size();
+                //串行,只传递到第一个动作
+                if (!then.isParallel() || size == 1) {
+                    String nodeId = "branch_" + _branchIndex + "_action_1";
+                    out = data -> output.apply(nodeId, data);
+                } else {
+                    //多个并行执行动作
+                    String[] nodeIds = new String[size];
+                    for (int i = 0; i < nodeIds.length; i++) {
+                        nodeIds[0] = "branch_" + _branchIndex + "_action_" + (i + 1);
+                    }
+                    Flux<String> nodeIdFlux = Flux.fromArray(nodeIds);
+                    //并行
+                    out = data -> nodeIdFlux
+                        .flatMap(nodeId -> output.apply(nodeId, data))
+                        .then();
+                }
+                //防抖
+                ShakeLimit shakeLimit = branch.getShakeLimit();
+                if (shakeLimit != null && shakeLimit.isEnabled()) {
+
+                    Sinks.Many<Map<String, Object>> sinks = Sinks
+                        .many()
+                        .unicast()
+                        .onBackpressureBuffer(Queues.<Map<String, Object>>unboundedMultiproducer().get());
+
+                    //分组方式,比如设备触发时,应该按设备分组,每个设备都走独立的防抖策略
+                    ShakeLimitGrouping<Map<String, Object>> grouping = createGrouping();
+
+                    Function<Map<String, Object>, Mono<Void>> handler = out;
+
+                    disposable.add(
+                        shakeLimit
+                            .transfer(sinks.asFlux(),
+                                      (duration, stream) ->
+                                          grouping
+                                              .group(stream)//先按自定义分组再按事件窗口进行分组
+                                              .flatMap(group -> group.window(duration), Integer.MAX_VALUE),
+                                      (map, total) -> map.put("_total", total))
+                            .flatMap(handler)
+                            .subscribe()
+                    );
+                    //输出到sink进行防抖控制
+                    out = data -> {
+                        sinks.emitNext(data, Reactors.emitFailureHandler());
+                        return Mono.empty();
+                    };
+                }
+            } else {
+                out = ignore -> Mono.empty();
+            }
+
+            Function<Map<String, Object>, Mono<Void>> fOut = out;
+
+
+            Function<Map<String, Object>, Mono<Boolean>> handler =
+                data -> filter
+                    .apply(data)
+                    .flatMap(match -> {
+                        // 满足条件后执行输出
+                        if (match) {
+                            return fOut.apply(data).thenReturn(true);
+                        }
+                        return Reactors.ALWAYS_FALSE;
+                    });
+
+            if (last == null) {
+                last = handler;
+            } else {
+                Function<Map<String, Object>, Mono<Boolean>> _last = last;
+
+                last = data -> _last
+                    .apply(data)
+                    .flatMap(match -> {
+                        //上一个分支满足了则返回,不执行此分支逻辑
+                        if (match) {
+                            return Reactors.ALWAYS_FALSE;
+                        }
+                        return handler.apply(data);
+                    });
+            }
+        }
+        //never happen
+        if (last == null) {
+            disposable.dispose();
+            throw new IllegalArgumentException();
+        }
+
+        disposable.add(
+            sourceData.flatMap(last).subscribe()
+        );
+
+        return disposable;
+    }
+
     public List<Variable> createDefaultVariable() {
         return trigger != null
             ? trigger.createDefaultVariable()
@@ -199,6 +357,49 @@ public class SceneRule implements Serializable {
             }
         }
 
+        //使用分支条件时
+        if (CollectionUtils.isNotEmpty(branches)) {
+            int branchIndex = 0;
+            for (SceneConditionAction branch : branches) {
+                branchIndex++;
+
+                SceneActions actions = branch.getThen();
+                if (actions != null && CollectionUtils.isNotEmpty(actions.getActions())) {
+                    int actionIndex = 1;
+                    RuleNodeModel preNode = null;
+                    SceneAction preAction = null;
+                    for (SceneAction action : actions.getActions()) {
+                        RuleNodeModel actionNode = new RuleNodeModel();
+                        actionNode.setId("branch_" + branchIndex + "_action_" + actionIndex);
+                        actionNode.setName("条件_" + branchIndex + "_动作_" + actionIndex);
+
+                        action.applyNode(actionNode);
+                        //串行
+                        if (!actions.isParallel()) {
+                            //串行的时候 标记记录每一个动作的数据到header中,用于进行条件判断或者数据引用
+                            actionNode.addConfiguration(AbstractExecutionContext.RECORD_DATA_TO_HEADER, true);
+                            actionNode.addConfiguration(AbstractExecutionContext.RECORD_DATA_TO_HEADER_KEY, actionNode.getId());
+
+                            if (preNode != null) {
+                                //上一个节点->当前动作节点
+                                RuleLink link = model.link(preNode, actionNode);
+                                //设置上一个节点到此节点的输出条件
+                                if (CollectionUtils.isNotEmpty(preAction.getTerms())) {
+                                    link.setCondition(TermsConditionEvaluator.createCondition(preAction.getTerms()));
+                                }
+                            }
+
+                            preNode = actionNode;
+                        }
+
+                        model.getNodes().add(actionNode);
+                        preAction = action;
+                        actionIndex++;
+                    }
+                }
+            }
+        }
+
         return model;
 
     }

+ 4 - 1
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/scene/SceneTaskExecutorProvider.java

@@ -2,6 +2,7 @@ package org.jetlinks.community.rule.engine.scene;
 
 import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.CollectionUtils;
 import org.hswebframework.ezorm.rdb.executor.SqlRequest;
 import org.hswebframework.web.bean.FastBeanCopier;
 import org.hswebframework.web.id.IDGenerator;
@@ -89,7 +90,9 @@ public class SceneTaskExecutorProvider implements TaskExecutorProvider {
             if (disposable != null) {
                 disposable.dispose();
             }
-            SqlRequest request = rule.createSql();
+            boolean useBranch = CollectionUtils.isNotEmpty(rule.getBranches());
+
+            SqlRequest request = rule.createSql(!useBranch);
 
             //不是通过SQL来处理数据
             if (request.isEmpty()) {

+ 2 - 6
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/web/AlarmConfigController.java

@@ -3,15 +3,13 @@ package org.jetlinks.community.rule.engine.web;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import lombok.AllArgsConstructor;
-import lombok.Getter;
-import lombok.Setter;
 import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
 import org.hswebframework.web.authorization.annotation.Authorize;
 import org.hswebframework.web.authorization.annotation.QueryAction;
 import org.hswebframework.web.authorization.annotation.Resource;
 import org.hswebframework.web.authorization.annotation.SaveAction;
 import org.hswebframework.web.crud.service.ReactiveCrudService;
-import org.hswebframework.web.crud.web.reactive.ReactiveServiceQueryController;
+import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
 import org.jetlinks.community.rule.engine.alarm.AlarmLevelInfo;
 import org.jetlinks.community.rule.engine.alarm.AlarmTargetSupplier;
 import org.jetlinks.community.rule.engine.entity.AlarmConfigEntity;
@@ -29,8 +27,7 @@ import reactor.core.publisher.Mono;
 @Authorize
 @Tag(name = "告警配置")
 @AllArgsConstructor
-public class AlarmConfigController implements ReactiveServiceQueryController<AlarmConfigEntity, String> {
-
+public class AlarmConfigController implements ReactiveServiceCrudController<AlarmConfigEntity, String> {
     private final AlarmConfigService alarmConfigService;
 
     private final ReactiveRepository<AlarmLevelEntity, String> alarmLevelRepository;
@@ -89,5 +86,4 @@ public class AlarmConfigController implements ReactiveServiceQueryController<Ala
     public Mono<AlarmLevelEntity> queryAlarmLevel() {
         return alarmLevelRepository.findById(AlarmLevelService.DEFAULT_ALARM_ID);
     }
-
 }

+ 0 - 141
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/web/RuleInstanceController.java

@@ -1,141 +0,0 @@
-package org.jetlinks.community.rule.engine.web;
-
-import io.swagger.v3.oas.annotations.Operation;
-import io.swagger.v3.oas.annotations.Parameter;
-import io.swagger.v3.oas.annotations.tags.Tag;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-import lombok.Setter;
-import org.hswebframework.web.api.crud.entity.PagerResult;
-import org.hswebframework.web.api.crud.entity.QueryOperation;
-import org.hswebframework.web.api.crud.entity.QueryParamEntity;
-import org.hswebframework.web.authorization.annotation.QueryAction;
-import org.hswebframework.web.authorization.annotation.Resource;
-import org.hswebframework.web.authorization.annotation.ResourceAction;
-import org.hswebframework.web.crud.service.ReactiveCrudService;
-import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
-import org.hswebframework.web.exception.NotFoundException;
-import org.jetlinks.community.rule.engine.entity.RuleEngineExecuteEventInfo;
-import org.jetlinks.community.rule.engine.entity.RuleEngineExecuteLogInfo;
-import org.jetlinks.community.rule.engine.entity.RuleInstanceEntity;
-import org.jetlinks.community.rule.engine.service.RuleInstanceService;
-import org.jetlinks.rule.engine.api.RuleData;
-import org.jetlinks.rule.engine.api.RuleEngine;
-import org.jetlinks.rule.engine.api.model.RuleEngineModelParser;
-import org.jetlinks.rule.engine.api.task.Task;
-import org.jetlinks.rule.engine.api.task.TaskSnapshot;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.util.StringUtils;
-import org.springframework.web.bind.annotation.*;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-
-@RestController
-@RequestMapping("rule-engine/instance")
-@Resource(id = "rule-instance", name = "规则引擎-实例")
-@Tag(name = "规则实例")
-public class RuleInstanceController implements ReactiveServiceCrudController<RuleInstanceEntity, String> {
-
-    @Autowired
-    private RuleInstanceService instanceService;
-
-    @Autowired
-    private RuleEngine ruleEngine;
-
-    @Autowired
-    private RuleEngineModelParser modelParser;
-
-    //获取全部支持的执行器
-    @GetMapping("/{instanceId}/tasks")
-    @QueryAction
-    @Operation(summary = "获取执行中规则的任务信息")
-    public Flux<TaskSnapshot> getTasks(@PathVariable String instanceId) {
-        return ruleEngine
-            .getTasks(instanceId)
-            .flatMap(Task::dump);
-    }
-
-    /**
-     * 查看规则实例的节点
-     *
-     * @param instanceId 实例ID
-     * @return 节点信息
-     */
-    @GetMapping("/{instanceId}/nodes")
-    @QueryAction
-    @Operation(summary = "获取规则实例的节点信息")
-    public Flux<RuleNodeInfo> getRuleNodeList(@PathVariable String instanceId) {
-        return instanceService
-            .findById(instanceId)
-            .flatMapIterable(instance -> modelParser
-                .parse(instance.getModelType(), instance.getModelMeta())
-                .getNodes())
-            .map(model -> new RuleNodeInfo(model.getId(), StringUtils.hasLength(model.getName()) ? model.getName() : model.getExecutor()))
-            .onErrorResume(err -> Mono.empty());
-    }
-
-    @Getter
-    @Setter
-    @AllArgsConstructor
-    public static class RuleNodeInfo {
-        private String id;
-
-        private String name;
-    }
-
-    @PostMapping("/{id}/_start")
-    @ResourceAction(id = "start", name = "启动")
-    @Operation(summary = "启动规则")
-    public Mono<Boolean> start(@PathVariable String id) {
-        return instanceService.start(id)
-            .thenReturn(true);
-    }
-
-    @PostMapping("/{id}/_stop")
-    @ResourceAction(id = "stop", name = "停止")
-    @Operation(summary = "停止规则")
-    public Mono<Boolean> stop(@PathVariable String id) {
-        return instanceService.stop(id)
-            .thenReturn(true);
-    }
-
-
-    @GetMapping("/{id}/logs")
-    @QueryAction
-    @QueryOperation(summary = "查询规则日志")
-    public Mono<PagerResult<RuleEngineExecuteLogInfo>> queryLog(@PathVariable String id,
-                                                                @Parameter(hidden = true) QueryParamEntity paramEntity) {
-        return paramEntity.toQuery()
-            .is("instanceId", id)
-            .execute(instanceService::queryExecuteLog);
-    }
-
-    @GetMapping("/{id}/events")
-    @QueryAction
-    @QueryOperation(summary = "查询规则事件")
-    public Mono<PagerResult<RuleEngineExecuteEventInfo>> queryEvents(@PathVariable String id,
-                                                                     @Parameter(hidden = true) QueryParamEntity paramEntity) {
-        return paramEntity.toQuery()
-            .is("instanceId", id)
-            .execute(instanceService::queryExecuteEvent);
-
-    }
-
-    @PostMapping("/{id}/{taskId}/_execute")
-    @ResourceAction(id = "execute", name = "执行")
-    @QueryOperation(summary = "执行规则")
-    public Mono<Void> execute(@PathVariable @Parameter(description = "规则ID") String id,
-                              @PathVariable @Parameter(description = "任务ID") String taskId,
-                              @RequestBody @Parameter(description = "规则数据") Mono<RuleData> payload) {
-        return payload.flatMap(data -> ruleEngine
-            .getTasks(id)
-            .filter(task -> task.getId().equals(taskId))
-            .switchIfEmpty(Mono.error(NotFoundException::new))
-            .flatMap(task -> task.execute(data)).then());
-    }
-
-    @Override
-    public ReactiveCrudService<RuleInstanceEntity, String> getService() {
-        return instanceService;
-    }
-}

+ 2 - 0
jetlinks-manager/rule-engine-manager/src/main/java/org/jetlinks/community/rule/engine/web/SceneController.java

@@ -110,6 +110,7 @@ public class SceneController implements ReactiveServiceQueryController<SceneEnti
     @Operation(summary = "解析规则中输出的变量")
     @QueryAction
     public Flux<Variable> parseVariables(@RequestBody Mono<SceneRule> ruleMono,
+                                         @RequestParam(required = false) Integer branch,
                                          @RequestParam(required = false) Integer action) {
         Mono<SceneRule> cache = ruleMono.cache();
         return Mono
@@ -123,6 +124,7 @@ public class SceneController implements ReactiveServiceQueryController<SceneEnti
                                                     .filter(column -> column.hasColumn(terms.keySet()))
                                                     .map(column -> column.copyColumn(terms::containsKey))
                                                     .collect(Collectors.toList()),
+                                                branch,
                                                 action,
                                                 deviceRegistry);
                 })

+ 191 - 0
jetlinks-standalone/src/main/resources/application-dev.yml

@@ -0,0 +1,191 @@
+server:
+  port: 8850
+
+spring:
+  redis:
+    host: 127.0.0.1
+    port: 6380
+    lettuce:
+      pool:
+        max-active: 1024
+    timeout: 20s
+    serializer: jdk # 设置fst时,redis key使用string序列化,value使用 fst序列化.
+#    database: 3
+  #        max-wait: 10s
+  r2dbc:
+    # 需要手动创建数据库,启动会自动创建表,修改了配置easyorm相关配置也要修改
+    url: r2dbc:postgresql://localhost:5433/jetlinks
+#    url: r2dbc:mysql://localhost:3306/jetlinks?ssl=false&serverZoneId=Asia/Shanghai # 修改了配置easyorm相关配置也要修改
+    username: postgres
+    password: jetlinks
+    pool:
+      max-size: 32
+      max-idle-time: 2m # 值不能大于mysql server的wait_timeout配置
+      max-life-time: 10m
+      acquire-retry: 3
+  reactor:
+    debug-agent:
+      enabled: false
+  elasticsearch:
+    uris: localhost:9201
+    socket-timeout: 10s
+    connection-timeout: 15s
+    webclient:
+      max-in-memory-size: 100MB
+easyorm:
+  default-schema: public # 数据库默认的schema
+  dialect: postgres #数据库方言
+elasticsearch:
+  embedded:
+    enabled: false # 为true时使用内嵌的elasticsearch,不建议在生产环境中使用
+    data-path: ./data/elasticsearch
+    port: 9201
+    host: 0.0.0.0
+  index:
+    default-strategy: time-by-month #默认es的索引按月进行分表, direct则为直接操作索引.
+    settings:
+      number-of-shards: 1 # es 分片数量
+      number-of-replicas: 0 # 副本数量
+device:
+  message:
+    writer:
+      time-series:
+        enabled: true #写出设备消息数据到elasticsearch
+captcha:
+  enabled: false # 开启验证码
+  ttl: 2m #验证码过期时间,2分钟
+hsweb:
+  cors:
+    enable: true
+    configs:
+      - path: /**
+        allowed-headers: "*"
+        allowed-methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
+        allowed-origins: ["*"]
+#        allow-credentials: true
+        max-age: 1800
+  dict:
+    enum-packages: org.jetlinks
+  file:
+    upload:
+      static-file-path: ./static/upload
+      static-location: http://192.168.32.65:8850/upload
+  webflux:
+    response-wrapper:
+      enabled: true #开启响应包装器(将返回值包装为ResponseMessage)
+      excludes: # 这下包下的接口不包装
+        - org.springdoc
+  #  auth:   #默认的用户配置
+  #    users:
+  #      admin:
+  #        username: admin
+  #        password: admin
+  #        name: 超级管理员
+  authorize:
+    auto-parse: true
+  permission:
+    filter:
+      enabled: true # 设置为true开启权限过滤,赋权时,不能赋予比自己多的权限.
+      exclude-username: admin # admin用户不受上述限制
+      un-auth-strategy: ignore # error表示:发生越权时,抛出403错误. ignore表示会忽略越权的赋权.
+  cache:
+    type: none
+    redis:
+      local-cache-type: guava
+file:
+  manager:
+    storage-base-path: ./data/files
+api:
+   # 访问api接口的根地址
+  base-path: http://127.0.0.1:${server.port}
+
+jetlinks:
+  server-id: ${spring.application.name}:${server.port} #设备服务网关服务ID,不同服务请设置不同的ID
+  logging:
+    system:
+      context:
+        server: ${spring.application.name}
+  protocol:
+    spi:
+      enabled: true # 为true时开启自动加载通过依赖引入的协议包
+logging:
+  level:
+    org.jetlinks: debug
+    rule.engine: debug
+    org.hswebframework: debug
+    org.springframework.transaction: debug
+    org.springframework.data.r2dbc.connectionfactory: warn
+    io.micrometer: warn
+    org.hswebframework.expands: error
+    system: debug
+    org.jetlinks.rule.engine: warn
+    org.jetlinks.supports.event: warn
+    org.springframework: warn
+    org.jetlinks.community.device.message.writer: warn
+    org.jetlinks.community.timeseries.micrometer: warn
+    org.jetlinks.community.elastic.search.service.reactive: trace
+    org.jetlinks.community.network: warn
+    io.vertx.mqtt.impl: warn
+    org.jetlinks.supports.scalecube.rpc: warn
+    "org.jetlinks.community.buffer": debug
+    #    org.springframework.data.elasticsearch.client: trace
+    #    org.elasticsearch: error
+    org.elasticsearch: error
+    org.elasticsearch.deprecation.search.aggregations.bucket.histogram: error
+  config: classpath:logback-spring.xml
+vertx:
+  max-event-loop-execute-time-unit: seconds
+  max-event-loop-execute-time: 30
+  max-worker-execute-time-unit: seconds
+  max-worker-execute-time: 30
+  prefer-native-transport: true
+micrometer:
+  time-series:
+    tags:
+      server: ${spring.application.name}
+    metrics:
+      default:
+        step: 30s
+management:
+  health:
+    elasticsearch:
+      enabled: false  # 关闭elasticsearch健康检查
+springdoc:
+  swagger-ui:
+    path: /swagger-ui.html
+  #  packages-to-scan: org.jetlinks
+  group-configs:
+    - group: 设备管理相关接口
+      packages-to-scan:
+        - org.jetlinks.community.device
+      paths-to-exclude:
+        - /device-instance/**
+        - /device-product/**
+        - /protocol/**
+    - group: 规则引擎相关接口
+      packages-to-scan: org.jetlinks.community.rule.engine.web
+      paths-to-exclude: /api/**
+    - group: 通知管理相关接口
+      packages-to-scan: org.jetlinks.community.notify.manager.web
+    - group: 设备接入相关接口
+      packages-to-scan:
+        - org.jetlinks.community.network.manager.web
+        - org.jetlinks.community.device.web
+      paths-to-match:
+        - /gateway/**
+        - /network/**
+        - /protocol/**
+    - group: 系统管理相关接口
+      packages-to-scan:
+        - org.jetlinks.community.auth
+        - org.hswebframework.web.system.authorization.defaults.webflux
+        - org.hswebframework.web.file
+        - org.hswebframework.web.authorization.basic.web
+        - org.jetlinks.community.logging.controller
+  cache:
+    disabled: false
+network:
+  resources:
+    - 2883-2890
+    - 18800-18810
+    - 15060-15061

+ 6 - 1
jetlinks-standalone/src/main/resources/application.yml

@@ -196,4 +196,9 @@ springdoc:
         - org.hswebframework.web.authorization.basic.web
         - org.jetlinks.community.logging.controller
   cache:
-    disabled: false
+    disabled: false
+network:
+  resources:
+    - 1883-1890
+    - 8800-8810
+    - 5060-5061