瀏覽代碼

增加设备标签,优化设备导入导出

zhou-hao 5 年之前
父節點
當前提交
5b9afd135c

+ 12 - 0
jetlinks-components/io-component/pom.xml

@@ -13,6 +13,18 @@
     <artifactId>io-component</artifactId>
 
     <dependencies>
+        <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <version>2.6</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.hswebframework</groupId>
+            <artifactId>reactor-excel</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>com.alibaba</groupId>
             <artifactId>easyexcel</artifactId>

+ 3 - 3
jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/excel/DefaultImportExportService.java

@@ -18,10 +18,10 @@ import java.io.InputStream;
 @Component
 public class DefaultImportExportService implements ImportExportService {
 
-    private final WebClient.Builder builder;
+    private WebClient client;
 
     public DefaultImportExportService(WebClient.Builder builder) {
-        this.builder = builder;
+        client = builder.build();
     }
 
     public <T> Flux<RowResult<T>> doImport(Class<T> clazz, String fileUrl) {
@@ -38,7 +38,7 @@ public class DefaultImportExportService implements ImportExportService {
 
         return Mono.defer(() -> {
             if (fileUrl.startsWith("http")) {
-                return builder.build()
+                return client
                     .get()
                     .uri(fileUrl)
                     .accept(MediaType.APPLICATION_OCTET_STREAM)

+ 3 - 0
jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/excel/ImportExportService.java

@@ -2,6 +2,7 @@ package org.jetlinks.community.io.excel;
 
 
 import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
 
 import java.io.InputStream;
 
@@ -16,4 +17,6 @@ public interface ImportExportService {
 
     <T> Flux<RowResult<T>> doImport(Class<T> clazz, InputStream stream);
 
+    Mono<InputStream> getInputStream(String fileUrl);
+
 }

+ 21 - 0
jetlinks-components/io-component/src/main/java/org/jetlinks/community/io/utils/FileUtils.java

@@ -0,0 +1,21 @@
+package org.jetlinks.community.io.utils;
+
+import lombok.SneakyThrows;
+import org.apache.commons.io.FilenameUtils;
+
+import java.net.URLDecoder;
+
+public class FileUtils {
+
+    @SneakyThrows
+    public static String getExtension(String url) {
+        url = URLDecoder.decode(url, "utf8");
+        if (url.contains("?")) {
+            url = url.substring(0,url.lastIndexOf("?"));
+        }
+        if (url.contains("#")) {
+            url = url.substring(0,url.lastIndexOf("#"));
+        }
+        return FilenameUtils.getExtension(url);
+    }
+}

+ 60 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/entity/DeviceTagEntity.java

@@ -0,0 +1,60 @@
+package org.jetlinks.community.device.entity;
+
+
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.ezorm.rdb.mapping.annotation.DefaultValue;
+import org.hswebframework.web.api.crud.entity.GenericEntity;
+import org.hswebframework.web.crud.generator.Generators;
+import org.jetlinks.core.metadata.PropertyMetadata;
+
+import javax.persistence.Column;
+import javax.persistence.Index;
+import javax.persistence.Table;
+import javax.validation.constraints.NotBlank;
+import java.util.Date;
+
+@Getter
+@Setter
+@Table(name = "dev_device_tags", indexes = {
+    @Index(name = "dev_dev_id_idx", columnList = "device_id"),
+    @Index(name = "dev_tag_idx", columnList = "device_id,key,value")
+})
+public class DeviceTagEntity extends GenericEntity<String> {
+
+    @Column(length = 32, nullable = false, updatable = false)
+    @NotBlank(message = "[deviceId]不能为空")
+    private String deviceId;
+
+    @Column(length = 32, updatable = false, nullable = false)
+    @NotBlank(message = "[key]不能为空")
+    private String key;
+
+    @Column
+    private String name;
+
+    @Column(length = 256, nullable = false)
+    @NotBlank(message = "[value]不能为空")
+    private String value;
+
+    @Column(length = 32, nullable = false)
+    @DefaultValue("string")
+    private String type;
+
+    @Column(updatable = false)
+    @DefaultValue(Generators.CURRENT_TIME)
+    private Date createTime;
+
+    @Column
+    private String description;
+
+    public static DeviceTagEntity of(PropertyMetadata property){
+        DeviceTagEntity entity=new DeviceTagEntity();
+        entity.setKey(property.getId());
+        entity.setName(property.getName());
+        entity.setType(property.getValueType().getId());
+        entity.setDescription(property.getDescription());
+        entity.setCreateTime(new Date());
+        return entity;
+    }
+}

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

@@ -0,0 +1,144 @@
+package org.jetlinks.community.device.response;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.jetlinks.core.device.DeviceOperator;
+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.DeviceState;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+import reactor.core.publisher.Mono;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@Getter
+@Setter
+public class DeviceDetail {
+
+    //设备ID
+    private String id;
+
+    //设备名称
+    private String name;
+
+    //消息协议标识
+    private String protocol;
+
+    //通信协议
+    private String transport;
+
+    //所属机构ID
+    private String orgId;
+
+    //所属机构名称
+    private String orgName;
+
+    //型号ID
+    private String productId;
+
+    //型号名称
+    private String productName;
+
+    //设备状态
+    private DeviceState state;
+
+    //客户端地址 /id:port
+    private String address;
+
+    //上线时间
+    private long onlineTime;
+
+    //离线时间
+    private long offlineTime;
+
+    //创建时间
+    private long createTime;
+
+    //注册时间
+    private long registerTime;
+
+    //设备元数据
+    private String metadata;
+
+    //设备配置信息
+    private Map<String, Object> configuration;
+
+    //标签
+    private List<DeviceTagEntity> tags = new ArrayList<>();
+
+    public Mono<DeviceDetail> with(DeviceOperator operator) {
+        return Mono.zip(
+            operator.getAddress().defaultIfEmpty("/"),
+            operator.getOnlineTime().defaultIfEmpty(0L),
+            operator.getOfflineTime().defaultIfEmpty(0L),
+            operator.getMetadata()
+        ).doOnNext(tp -> {
+            setOnlineTime(tp.getT2());
+            setOfflineTime(tp.getT3());
+            setAddress(tp.getT1());
+            with(tp.getT4()
+                .getTags()
+                .stream()
+                .map(DeviceTagEntity::of)
+                .collect(Collectors.toList()));
+        }).thenReturn(this);
+    }
+
+    public synchronized DeviceDetail with(List<DeviceTagEntity> tags) {
+
+        Map<String, DeviceTagEntity> map = Stream
+            .concat(tags.stream(), this.tags.stream())
+            .collect(
+                Collectors.toMap(
+                    DeviceTagEntity::getKey,
+                    Function.identity(),
+                    (_1, _2) -> StringUtils.hasText(_1.getValue()) ? _1 : _2));
+
+        this.tags = new ArrayList<>(map.values());
+        this.tags.sort(Comparator.comparing(DeviceTagEntity::getCreateTime));
+
+        return this;
+    }
+
+    public DeviceDetail with(DeviceProductEntity productEntity) {
+        if (StringUtils.isEmpty(metadata)) {
+            setMetadata(productEntity.getMetadata());
+        }
+        if (CollectionUtils.isEmpty(configuration)) {
+            setConfiguration(productEntity.getConfiguration());
+        }
+        setProtocol(productEntity.getMessageProtocol());
+        setTransport(productEntity.getTransportProtocol());
+
+        setProductId(productEntity.getId());
+        setProductName(productEntity.getName());
+        return this;
+    }
+
+    public DeviceDetail with(DeviceInstanceEntity device) {
+
+        setId(device.getId());
+        setName(device.getName());
+        setState(device.getState());
+        setRegisterTime(device.getRegistryTime());
+        setCreateTime(device.getCreateTime());
+
+        if (!CollectionUtils.isEmpty(device.getConfiguration())) {
+            setConfiguration(device.getConfiguration());
+        }
+        if (StringUtils.hasText(device.getDeriveMetadata())) {
+            setMetadata(device.getDeriveMetadata());
+        }
+
+        return this;
+    }
+
+}

+ 38 - 9
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/LocalDeviceInstanceService.java

@@ -10,6 +10,7 @@ import org.apache.commons.collections4.CollectionUtils;
 import org.hswebframework.ezorm.core.dsl.Query;
 import org.hswebframework.ezorm.core.param.QueryParam;
 import org.hswebframework.ezorm.core.param.TermType;
+import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
 import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult;
 import org.hswebframework.web.api.crud.entity.PagerResult;
 import org.hswebframework.web.api.crud.entity.QueryParamEntity;
@@ -18,7 +19,7 @@ import org.hswebframework.web.crud.service.GenericReactiveCrudService;
 import org.hswebframework.web.exception.BusinessException;
 import org.hswebframework.web.exception.NotFoundException;
 import org.hswebframework.web.logger.ReactiveLogger;
-import org.jetlinks.community.device.entity.DeviceOperationLogEntity;
+import org.jetlinks.community.device.entity.*;
 import org.jetlinks.community.device.message.DeviceMessageUtils;
 import org.jetlinks.community.gateway.Subscription;
 import org.jetlinks.community.gateway.annotation.Subscribe;
@@ -32,9 +33,6 @@ import org.jetlinks.core.metadata.EventMetadata;
 import org.jetlinks.core.metadata.Metadata;
 import org.jetlinks.core.metadata.types.ObjectType;
 import org.jetlinks.core.utils.FluxUtils;
-import org.jetlinks.community.device.entity.DeviceInstanceEntity;
-import org.jetlinks.community.device.entity.DeviceProductEntity;
-import org.jetlinks.community.device.entity.DevicePropertiesEntity;
 import org.jetlinks.community.device.entity.excel.DeviceInstanceImportExportEntity;
 import org.jetlinks.community.device.enums.DeviceState;
 import org.jetlinks.community.device.response.*;
@@ -85,15 +83,16 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
     @Autowired
     private LocalDeviceProductService deviceProductService;
 
-    @Autowired
-    private ImportExportService importExportService;
-
     @Autowired
     private MessageGateway messageGateway;
 
     @Autowired
     private TimeSeriesManager timeSeriesManager;
 
+    @Autowired
+    @SuppressWarnings("all")
+    private ReactiveRepository<DeviceTagEntity, String> tagRepository;
+
 
     @Override
     public Mono<SaveResult> save(Publisher<DeviceInstanceEntity> entityPublisher) {
@@ -108,6 +107,7 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
      * @param id 设备ID
      * @return 设备详情信息
      */
+    @Deprecated
     public Mono<DeviceAllInfoResponse> getDeviceAllInfo(String id) {
 
         return findById(id)//设备信息
@@ -167,7 +167,7 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
     public Flux<DeviceDeployResult> deploy(Flux<DeviceInstanceEntity> flux) {
         return flux
             .flatMap(instance ->
-                registry.registry(org.jetlinks.core.device.DeviceInfo.builder()
+                registry.register(org.jetlinks.core.device.DeviceInfo.builder()
                     .id(instance.getId())
                     .productId(instance.getProductId())
                     .build()
@@ -212,17 +212,46 @@ public class LocalDeviceInstanceService extends GenericReactiveCrudService<Devic
     public Mono<Integer> cancelDeploy(String id) {
         return findById(Mono.just(id))
             .flatMap(product -> registry
-                .unRegistry(id)
+                .unregisterDevice(id)
                 .then(createUpdate()
                     .set(DeviceInstanceEntity::getState, DeviceState.notActive.getValue())
                     .where(DeviceInstanceEntity::getId, id)
                     .execute()));
     }
 
+    public Mono<DeviceDetail> getDeviceDetail(String deviceId) {
+        return this.findById(deviceId)
+            .zipWhen(device -> deviceProductService.findById(device.getProductId()),
+                (device, product) -> new DeviceDetail().with(device).with(product))
+            .flatMap(detail -> registry.getDevice(deviceId).flatMap(detail::with))
+            .flatMap(detail -> tagRepository
+                .createQuery()
+                .where(DeviceTagEntity::getDeviceId, deviceId)
+                .fetch()
+                .collectList()
+                .map(detail::with)
+                .defaultIfEmpty(detail));
+    }
+
+    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);
+    }
+
+    @Deprecated
     public Mono<DeviceRunInfo> getDeviceRunInfo(String deviceId) {
         return getDeviceRunRealInfo(deviceId);
     }
 
+    @Deprecated
     private Mono<DeviceRunInfo> getDeviceRunRealInfo(String deviceId) {
         return registry.getDevice(deviceId)
             .flatMap(deviceOperator -> Mono.zip(

+ 51 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/service/term/DeviceTagTerm.java

@@ -0,0 +1,51 @@
+package org.jetlinks.community.device.service.term;
+
+import org.apache.commons.collections.CollectionUtils;
+import org.hswebframework.ezorm.core.param.Term;
+import org.hswebframework.ezorm.rdb.metadata.RDBColumnMetadata;
+import org.hswebframework.ezorm.rdb.operator.builder.fragments.EmptySqlFragments;
+import org.hswebframework.ezorm.rdb.operator.builder.fragments.PrepareSqlFragments;
+import org.hswebframework.ezorm.rdb.operator.builder.fragments.SqlFragments;
+import org.hswebframework.ezorm.rdb.operator.builder.fragments.term.AbstractTermFragmentBuilder;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * where("id$dev-tag$location","重庆")
+ */
+@Component
+public class DeviceTagTerm extends AbstractTermFragmentBuilder {
+    public DeviceTagTerm() {
+        super("dev-tag", "根据设备标签查询设备");
+    }
+
+    @Override
+    public SqlFragments createFragments(String columnFullName, RDBColumnMetadata column, Term term) {
+        List<Object> values = convertList(column, term);
+        if (values.isEmpty()) {
+            return EmptySqlFragments.INSTANCE;
+        }
+
+        PrepareSqlFragments fragments = PrepareSqlFragments.of();
+
+        List<String> opts = term.getOptions();
+
+        fragments.addSql("exists(select 1 from dev_device_tags d where d.device_id = ", columnFullName);
+
+        if (CollectionUtils.isNotEmpty(opts)) {
+            fragments.addSql("and d.key=?").addParameter(opts.get(0));
+        }
+        if (values.size() == 1) {
+            fragments.addSql("and d.value like ?)")
+                .addParameter(values);
+        } else {
+            fragments.addSql("and d.value in(",
+                values.stream().map(r -> "?").collect(Collectors.joining(",")), "))")
+                .addParameter(values);
+        }
+
+        return fragments;
+    }
+}

+ 205 - 19
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/DeviceInstanceController.java

@@ -8,8 +8,10 @@ import lombok.Getter;
 import lombok.SneakyThrows;
 import lombok.extern.slf4j.Slf4j;
 import org.hswebframework.ezorm.core.param.QueryParam;
-import org.hswebframework.ezorm.core.param.TermType;
 import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException;
+import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository;
+import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult;
+import org.hswebframework.reactor.excel.ReactorExcel;
 import org.hswebframework.web.api.crud.entity.PagerResult;
 import org.hswebframework.web.api.crud.entity.QueryParamEntity;
 import org.hswebframework.web.authorization.Authentication;
@@ -21,23 +23,21 @@ import org.hswebframework.web.authorization.annotation.SaveAction;
 import org.hswebframework.web.bean.FastBeanCopier;
 import org.hswebframework.web.crud.web.reactive.ReactiveServiceCrudController;
 import org.hswebframework.web.exception.BusinessException;
-import org.jetlinks.community.device.entity.DeviceProductEntity;
+import org.jetlinks.community.device.entity.*;
 import org.jetlinks.community.device.entity.excel.DeviceInstanceImportExportEntity;
 import org.jetlinks.community.device.enums.DeviceState;
 import org.jetlinks.community.device.service.LocalDeviceProductService;
+import org.jetlinks.community.device.web.excel.DeviceExcelInfo;
+import org.jetlinks.community.device.web.excel.DeviceWrapper;
 import org.jetlinks.community.io.excel.ImportExportService;
-import org.jetlinks.core.device.DeviceConfigKey;
+import org.jetlinks.community.io.utils.FileUtils;
 import org.jetlinks.core.device.DeviceOperator;
+import org.jetlinks.core.device.DeviceProductOperator;
 import org.jetlinks.core.device.DeviceRegistry;
-import org.jetlinks.community.device.entity.DeviceInstanceEntity;
-import org.jetlinks.community.device.entity.DeviceOperationLogEntity;
-import org.jetlinks.community.device.entity.DevicePropertiesEntity;
 import org.jetlinks.community.device.response.*;
 import org.jetlinks.community.device.service.LocalDeviceInstanceService;
-import org.jetlinks.community.device.timeseries.DeviceTimeSeriesMetric;
 import org.jetlinks.community.timeseries.TimeSeriesManager;
 import org.jetlinks.community.timeseries.TimeSeriesMetric;
-import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.core.io.buffer.DataBufferFactory;
 import org.springframework.core.io.buffer.DefaultDataBufferFactory;
 import org.springframework.http.HttpHeaders;
@@ -48,13 +48,16 @@ import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
 import reactor.core.scheduler.Schedulers;
+import reactor.util.function.Tuple2;
+import reactor.util.function.Tuples;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.OutputStream;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
+import java.util.Collections;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 @RestController
@@ -76,37 +79,57 @@ public class DeviceInstanceController implements
 
     private final ImportExportService importExportService;
 
-    public DeviceInstanceController(LocalDeviceInstanceService service, TimeSeriesManager timeSeriesManager, DeviceRegistry registry, LocalDeviceProductService productService, ImportExportService importExportService) {
+    private final ReactiveRepository<DeviceTagEntity, String> tagRepository;
+
+    @SuppressWarnings("all")
+    public DeviceInstanceController(LocalDeviceInstanceService service,
+                                    TimeSeriesManager timeSeriesManager,
+                                    DeviceRegistry registry,
+                                    LocalDeviceProductService productService,
+                                    ImportExportService importExportService,
+                                    ReactiveRepository<DeviceTagEntity, String> tagRepository) {
         this.service = service;
         this.timeSeriesManager = timeSeriesManager;
         this.registry = registry;
         this.productService = productService;
         this.importExportService = importExportService;
+        this.tagRepository = tagRepository;
     }
 
-    @GetMapping({
-        "/all-info/{id:.+}", //todo 即将删除
-        "/{id:.+}/detail"
-    })
+
+    //获取设备详情
+    @GetMapping("/{id:.+}/detail")
+    @QueryAction
+    public Mono<DeviceDetail> getDeviceDetailInfo(@PathVariable String id) {
+        return service.getDeviceDetail(id);
+    }
+
+    //获取设备运行状态
+    @GetMapping("/{id:.+}/state")
     @QueryAction
-    public Mono<DeviceAllInfoResponse> getDeviceAllInfo(@PathVariable String id) {
-        return service.getDeviceAllInfo(id);
+    public Mono<DeviceState> getDeviceState(@PathVariable String id) {
+        return service.getDeviceState(id);
     }
 
+    //已弃用 下一个版本删除
     @GetMapping("/info/{id:.+}")
     @QueryAction
+    @Deprecated
     public Mono<DeviceInfo> getDeviceInfoById(@PathVariable String id) {
         return service.getDeviceInfoById(id);
     }
 
+    //已弃用 下一个版本删除
     @GetMapping("/run-info/{id:.+}")
     @QueryAction
+    @Deprecated
     public Mono<DeviceRunInfo> getRunDeviceInfoById(@PathVariable String id) {
         return service.getDeviceRunInfo(id);
     }
 
+
     @PostMapping({
-        "/deploy/{deviceId:.+}",//todo 即将移
+        "/deploy/{deviceId:.+}",//todo 已弃用 下一个版本删
         "/{deviceId:.+}/deploy"
     })
     @SaveAction
@@ -115,7 +138,7 @@ public class DeviceInstanceController implements
     }
 
     @PostMapping({
-        "/cancelDeploy/{deviceId:.+}", //todo 即将移
+        "/cancelDeploy/{deviceId:.+}", //todo 已弃用 下一个版本删
         "/{deviceId:.+}/undeploy"
     })
     @SaveAction
@@ -123,7 +146,7 @@ public class DeviceInstanceController implements
         return service.cancelDeploy(deviceId);
     }
 
-
+    //断开连接
     @PostMapping("/{deviceId:.+}/disconnect")
     @SaveAction
     public Mono<Boolean> disconnect(@PathVariable String deviceId) {
@@ -133,6 +156,7 @@ public class DeviceInstanceController implements
             .singleOrEmpty();
     }
 
+    //添加设备
     @PostMapping
     public Mono<DeviceInstanceEntity> add(@RequestBody Mono<DeviceInstanceEntity> payload) {
         return payload.flatMap(entity -> service.insert(Mono.just(entity))
@@ -140,6 +164,7 @@ public class DeviceInstanceController implements
             .thenReturn(entity));
     }
 
+    //批量发布,激活设备
     @GetMapping(value = "/deploy", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
     @SaveAction
     public Flux<DeviceDeployResult> deployAll(QueryParamEntity query) {
@@ -175,18 +200,21 @@ public class DeviceInstanceController implements
         return service.getDeviceLatestProperties(deviceId);
     }
 
+    //获取最新的设备属性
     @GetMapping("/{deviceId:.+}/properties/latest")
     @QueryAction
     public Flux<DevicePropertiesEntity> getDeviceLatestProperties(@PathVariable String deviceId) {
         return service.getDeviceLatestProperties(deviceId);
     }
 
+    //获取单个最新的设备属性
     @GetMapping("/{deviceId:.+}/property/{property:.+}")
     @QueryAction
     public Mono<DevicePropertiesEntity> getDeviceLatestProperty(@PathVariable String deviceId, @PathVariable String property) {
         return service.getDeviceLatestProperty(deviceId, property);
     }
 
+    //获取设备事件数据
     @GetMapping("/{deviceId:.+}/event/{eventId}")
     @QueryAction
     public Mono<PagerResult<Map<String, Object>>> queryPagerByDeviceEvent(QueryParamEntity queryParam,
@@ -209,6 +237,59 @@ public class DeviceInstanceController implements
         return service.queryDeviceLog(deviceId, entity);
     }
 
+    //删除标签
+    @DeleteMapping("/{deviceId}/tag/{tagId:.+}")
+    @SaveAction
+    public Mono<Void> deleteDeviceTag(@PathVariable String deviceId,
+                                      @PathVariable String tagId) {
+        return tagRepository.createDelete()
+            .where(DeviceTagEntity::getDeviceId, deviceId)
+            .and(DeviceTagEntity::getId, tagId)
+            .execute()
+            .then();
+    }
+
+    /**
+     * 获取设备全部标签
+     * <pre>
+     *     GET /device/instance/{deviceId}/tags
+     *
+     *     [
+     *      {
+     *          "id":"id",
+     *          "key":"",
+     *          "value":"",
+     *          "name":""
+     *      }
+     *     ]
+     * </pre>
+     *
+     * @param deviceId 设备ID
+     * @return 设备标签列表
+     */
+    @GetMapping("/{deviceId}/tags")
+    @SaveAction
+    public Flux<DeviceTagEntity> getDeviceTags(@PathVariable String deviceId) {
+        return tagRepository.createQuery()
+            .where(DeviceTagEntity::getDeviceId, deviceId)
+            .fetch();
+    }
+
+    //保存设备标签
+    @PatchMapping("/{deviceId}/tag")
+    @SaveAction
+    public Mono<Void> saveDeviceTag(@PathVariable String deviceId,
+                                    @RequestBody Flux<DeviceTagEntity> tags) {
+        return tags
+            .doOnNext(tag -> {
+                tag.setId(deviceId.concat(":").concat(tag.getKey()));
+                tag.setDeviceId(deviceId);
+                tag.tryValidate();
+            })
+            .as(tagRepository::save)
+            .then();
+    }
+
     //已废弃
     @GetMapping("/operation/log")
     @QueryAction
@@ -265,6 +346,111 @@ public class DeviceInstanceController implements
 
     DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
 
+    //按产品型号导入数据
+    @GetMapping(value = "/{productId}/import", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    @SaveAction
+    public Flux<ImportDeviceInstanceResult> doBatchImportByProduct(@PathVariable String productId,
+                                                                   @RequestParam String fileUrl) {
+
+        return registry.getProduct(productId)
+            .flatMap(DeviceProductOperator::getMetadata)
+            .map(metadata -> new DeviceWrapper(metadata.getTags()))
+            .defaultIfEmpty(DeviceWrapper.empty)
+            .flatMapMany(wrapper -> importExportService
+                .getInputStream(fileUrl)
+                .flatMapMany(inputStream -> ReactorExcel.read(inputStream, FileUtils.getExtension(fileUrl), wrapper)))
+            .map(info -> {
+                DeviceInstanceEntity entity = FastBeanCopier.copy(info, new DeviceInstanceEntity());
+                entity.setProductId(productId);
+                if (StringUtils.isEmpty(entity.getId())) {
+                    throw new BusinessException("设备ID不能为空");
+                }
+                return Tuples.of(entity, info.getTags());
+            })
+            .buffer(100)//每100条数据保存一次
+            .publishOn(Schedulers.single())
+            .concatMap(buffer ->
+                Mono.zip(
+                    service.save(Flux.fromIterable(buffer).map(Tuple2::getT1)),
+                    tagRepository
+                        .save(Flux.fromIterable(buffer).flatMapIterable(Tuple2::getT2))
+                        .defaultIfEmpty(SaveResult.of(0, 0))
+                ))
+            .map(res -> ImportDeviceInstanceResult.success(res.getT1()))
+            .onErrorResume(err -> Mono.just(ImportDeviceInstanceResult.error(err)));
+    }
+
+    //获取导入模版
+    @GetMapping("/{productId}/template.{format}")
+    @QueryAction
+    public Mono<Void> downloadExportTemplate(ServerHttpResponse response,
+                                             QueryParamEntity parameter,
+                                             @PathVariable String format,
+                                             @PathVariable String productId) throws IOException {
+        response.getHeaders().set(HttpHeaders.CONTENT_DISPOSITION,
+            "attachment; filename=".concat(URLEncoder.encode("设备导入模版." + format, StandardCharsets.UTF_8.displayName())));
+        return Authentication
+            .currentReactive()
+            .flatMap(auth -> {
+                parameter.setPaging(false);
+                parameter.toNestQuery(q -> q.is(DeviceInstanceEntity::getProductId, productId));
+                return registry.getProduct(productId)
+                    .flatMap(DeviceProductOperator::getMetadata)
+                    .map(meta -> DeviceExcelInfo.getTemplateHeaderMapping(meta.getTags()))
+                    .defaultIfEmpty(DeviceExcelInfo.getTemplateHeaderMapping(Collections.emptyList()))
+                    .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);
+            });
+    }
+
+    //按照型号导出数据
+    @GetMapping("/{productId}/export.{format}")
+    @QueryAction
+    public Mono<Void> export(ServerHttpResponse response,
+                             QueryParamEntity parameter,
+                             @PathVariable String format,
+                             @PathVariable String productId) throws IOException {
+        response.getHeaders().set(HttpHeaders.CONTENT_DISPOSITION,
+            "attachment; filename=".concat(URLEncoder.encode("设备实例." + format, StandardCharsets.UTF_8.displayName())));
+        parameter.setPaging(false);
+        parameter.toNestQuery(q -> q.is(DeviceInstanceEntity::getProductId, productId));
+        return registry.getProduct(productId)
+            .flatMap(DeviceProductOperator::getMetadata)
+            .map(meta -> DeviceExcelInfo.getExportHeaderMapping(meta.getTags()))
+            .defaultIfEmpty(DeviceExcelInfo.getExportHeaderMapping(Collections.emptyList()))
+            .flatMapMany(headers ->
+                ReactorExcel.<DeviceExcelInfo>writer(format)
+                    .headers(headers)
+                    .converter(DeviceExcelInfo::toMap)
+                    .writeBuffer(
+                        service.query(parameter)
+                            .map(entity -> FastBeanCopier.copy(entity, new DeviceExcelInfo()))
+                            .buffer(200)
+                            .flatMap(list -> {
+                                Map<String, DeviceExcelInfo> importInfo = list
+                                    .stream()
+                                    .collect(Collectors.toMap(DeviceExcelInfo::getId, Function.identity()));
+                                return tagRepository.createQuery()
+                                    .where()
+                                    .in(DeviceTagEntity::getDeviceId, importInfo.keySet())
+                                    .fetch()
+                                    .collect(Collectors.groupingBy(DeviceTagEntity::getDeviceId))
+                                    .flatMapIterable(Map::entrySet)
+                                    .doOnNext(entry -> importInfo.get(entry.getKey()).setTags(entry.getValue()))
+                                    .thenMany(Flux.fromIterable(list));
+                            })
+                        , 512 * 1024))//缓冲512k
+            .doOnError(err -> log.error(err.getMessage(), err))
+            .map(bufferFactory::wrap)
+            .as(response::writeWith);
+    }
+
     @PostMapping("/export")
     @QueryAction
     @SneakyThrows

+ 0 - 1
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/GatewayDeviceController.java

@@ -173,5 +173,4 @@ public class GatewayDeviceController {
                 .flatMap(operator -> operator.removeConfig(DeviceConfigKey.parentGatewayId.getKey())))
             .then(getGatewayInfo(gatewayId));
     }
-
 }

+ 103 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/excel/DeviceExcelInfo.java

@@ -0,0 +1,103 @@
+package org.jetlinks.community.device.web.excel;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.hswebframework.reactor.excel.CellDataType;
+import org.hswebframework.reactor.excel.ExcelHeader;
+import org.hswebframework.web.bean.FastBeanCopier;
+import org.jetlinks.community.device.entity.DeviceTagEntity;
+import org.jetlinks.core.metadata.PropertyMetadata;
+import org.springframework.util.StringUtils;
+
+import javax.validation.constraints.NotBlank;
+import java.util.*;
+
+@Getter
+@Setter
+public class DeviceExcelInfo {
+
+    @NotBlank(message = "设备ID不能为空")
+    private String id;
+
+    @NotBlank(message = "设备名称不能为空")
+    private String name;
+
+    private String orgId;
+
+    private String productName;
+
+    private String parentId;
+
+    private List<DeviceTagEntity> tags = new ArrayList<>();
+
+    public void tag(String key, String name, Object value) {
+        if (value == null) {
+            return;
+        }
+        DeviceTagEntity entity = new DeviceTagEntity();
+        entity.setKey(key);
+        entity.setValue(String.valueOf(value));
+        entity.setName(name);
+        entity.setId(String.valueOf(id).concat(":").concat(key));
+        tags.add(entity);
+    }
+
+    public void setId(String id) {
+        this.id = id;
+        for (DeviceTagEntity tag : tags) {
+            tag.setId(String.valueOf(id).concat(":").concat(tag.getKey()));
+        }
+    }
+
+    public void with(String key, Object value) {
+        FastBeanCopier.copy(Collections.singletonMap(key, value), this);
+    }
+
+    public Map<String,Object> toMap(){
+        Map<String,Object> val = FastBeanCopier.copy(this,new HashMap<>());
+        for (DeviceTagEntity tag : tags) {
+            val.put(tag.getKey(),tag.getValue());
+        }
+        return val;
+    }
+
+    public static List<ExcelHeader> getTemplateHeaderMapping(List<PropertyMetadata> tags) {
+        List<ExcelHeader> arr = new ArrayList<>(Arrays.asList(
+            new ExcelHeader("id", "设备ID", CellDataType.STRING),
+            new ExcelHeader("name", "设备名称", CellDataType.STRING),
+            new ExcelHeader("orgId", "所属机构ID", CellDataType.STRING),
+            new ExcelHeader("parentId", "父设备ID", CellDataType.STRING)
+        ));
+        for (PropertyMetadata tag : tags) {
+            arr.add(new ExcelHeader(tag.getId(), StringUtils.isEmpty(tag.getName()) ? tag.getId() : tag.getName(), CellDataType.STRING));
+        }
+        return arr;
+    }
+
+    public static List<ExcelHeader> getExportHeaderMapping(List<PropertyMetadata> tags) {
+        List<ExcelHeader> arr = new ArrayList<>(Arrays.asList(
+            new ExcelHeader("id", "设备ID", CellDataType.STRING),
+            new ExcelHeader("name", "设备名称", CellDataType.STRING),
+            new ExcelHeader("productName", "设备型号", CellDataType.STRING),
+            new ExcelHeader("orgId", "所属机构ID", CellDataType.STRING),
+            new ExcelHeader("parentId", "父设备ID", CellDataType.STRING)
+        ));
+        for (PropertyMetadata tag : tags) {
+            arr.add(new ExcelHeader(tag.getId(), StringUtils.isEmpty(tag.getName()) ? tag.getId() : tag.getName(), CellDataType.STRING));
+        }
+        return arr;
+    }
+
+    public static Map<String, String> getImportHeaderMapping() {
+        Map<String, String> mapping = new HashMap<>();
+
+        mapping.put("设备ID", "id");
+        mapping.put("设备名称", "name");
+        mapping.put("名称", "name");
+
+        mapping.put("所属机构", "orgId");
+        mapping.put("父设备ID", "parentId");
+
+        return mapping;
+    }
+}

+ 50 - 0
jetlinks-manager/device-manager/src/main/java/org/jetlinks/community/device/web/excel/DeviceWrapper.java

@@ -0,0 +1,50 @@
+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.PropertyMetadata;
+import org.springframework.util.StringUtils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 设备数据导入包装器
+ *
+ * @author zhouhao
+ * @see 1.0
+ */
+public class DeviceWrapper extends RowWrapper<DeviceExcelInfo> {
+
+    Map<String, String> tagMapping = new HashMap<>();
+    static Map<String, String> headerMapping = DeviceExcelInfo.getImportHeaderMapping();
+
+    public static DeviceWrapper empty = new DeviceWrapper(Collections.emptyList());
+
+    public DeviceWrapper(List<PropertyMetadata> tags) {
+        for (PropertyMetadata tag : tags) {
+            tagMapping.put(tag.getName(), tag.getId());
+        }
+    }
+
+    @Override
+    protected DeviceExcelInfo newInstance() {
+        return new DeviceExcelInfo();
+    }
+
+    @Override
+    protected DeviceExcelInfo wrap(DeviceExcelInfo deviceExcelInfo, Cell header, Cell cell) {
+        String headerText = header.valueAsText().orElse("null");
+
+        String maybeTag = tagMapping.get(headerText);
+        if (StringUtils.hasText(maybeTag)) {
+            deviceExcelInfo.tag(maybeTag, headerText, cell.value().orElse(null));
+        } else {
+            deviceExcelInfo.with(headerMapping.getOrDefault(headerText, headerText), cell.value().orElse(null));
+        }
+
+        return deviceExcelInfo;
+    }
+}

+ 8 - 0
pom.xml

@@ -26,6 +26,7 @@
         <vertx.version>3.8.5</vertx.version>
         <netty.version>4.1.46.Final</netty.version>
         <elasticsearch.version>6.8.6</elasticsearch.version>
+        <reactor.excel.version>1.0-BUILD-SNAPSHOT</reactor.excel.version>
     </properties>
 
     <build>
@@ -150,7 +151,14 @@
     </build>
 
     <dependencyManagement>
+
         <dependencies>
+            <dependency>
+                <groupId>org.hswebframework</groupId>
+                <artifactId>reactor-excel</artifactId>
+                <version>${reactor.excel.version}</version>
+            </dependency>
+
             <dependency>
                 <groupId>io.vavr</groupId>
                 <artifactId>vavr</artifactId>