【同步】BOOT 和 CLOUD 的功能
This commit is contained in:
@@ -124,6 +124,17 @@
|
||||
<artifactId>yudao-spring-boot-starter-excel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OkHttp -->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 消息队列相关 -->
|
||||
<!-- TODO @芋艿:临时打开 -->
|
||||
<dependency>
|
||||
|
||||
@@ -7,6 +7,10 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
@@ -19,6 +23,8 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
@@ -57,4 +63,18 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi {
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/register")
|
||||
@PermitAll
|
||||
public CommonResult<IotDeviceRegisterRespDTO> registerDevice(@RequestBody IotDeviceRegisterReqDTO reqDTO) {
|
||||
return success(deviceService.registerDevice(reqDTO));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/register-sub")
|
||||
@PermitAll
|
||||
public CommonResult<List<IotSubDeviceRegisterRespDTO>> registerSubDevices(@RequestBody IotSubDeviceRegisterFullReqDTO reqDTO) {
|
||||
return success(deviceService.registerSubDevices(reqDTO));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.Parameters;
|
||||
@@ -23,13 +26,12 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
|
||||
|
||||
@Tag(name = "管理后台 - IoT 设备")
|
||||
@RestController
|
||||
@@ -39,6 +41,8 @@ public class IotDeviceController {
|
||||
|
||||
@Resource
|
||||
private IotDeviceService deviceService;
|
||||
@Resource
|
||||
private IotProductService productService;
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建设备")
|
||||
@@ -47,6 +51,7 @@ public class IotDeviceController {
|
||||
return success(deviceService.createDevice(createReqVO));
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新设备")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:update')")
|
||||
@@ -55,7 +60,57 @@ public class IotDeviceController {
|
||||
return success(true);
|
||||
}
|
||||
|
||||
// TODO @芋艿:参考阿里云:1)绑定网关;2)解绑网关
|
||||
@PutMapping("/bind-gateway")
|
||||
@Operation(summary = "绑定子设备到网关")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:update')")
|
||||
public CommonResult<Boolean> bindDeviceGateway(@Valid @RequestBody IotDeviceBindGatewayReqVO reqVO) {
|
||||
deviceService.bindDeviceGateway(reqVO.getSubIds(), reqVO.getGatewayId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@PutMapping("/unbind-gateway")
|
||||
@Operation(summary = "解绑子设备与网关")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:update')")
|
||||
public CommonResult<Boolean> unbindDeviceGateway(@Valid @RequestBody IotDeviceUnbindGatewayReqVO reqVO) {
|
||||
deviceService.unbindDeviceGateway(reqVO.getSubIds(), reqVO.getGatewayId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/sub-device-list")
|
||||
@Operation(summary = "获取网关的子设备列表")
|
||||
@Parameter(name = "gatewayId", description = "网关设备编号", required = true, example = "1")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:query')")
|
||||
public CommonResult<List<IotDeviceRespVO>> getSubDeviceList(@RequestParam("gatewayId") Long gatewayId) {
|
||||
List<IotDeviceDO> list = deviceService.getDeviceListByGatewayId(gatewayId);
|
||||
if (CollUtil.isEmpty(list)) {
|
||||
return success(Collections.emptyList());
|
||||
}
|
||||
|
||||
// 补充产品名称
|
||||
Map<Long, IotProductDO> productMap = convertMap(productService.getProductList(), IotProductDO::getId);
|
||||
return success(convertList(list, device -> {
|
||||
IotDeviceRespVO respVO = BeanUtils.toBean(device, IotDeviceRespVO.class);
|
||||
MapUtils.findAndThen(productMap, device.getProductId(),
|
||||
product -> respVO.setProductName(product.getName()));
|
||||
return respVO;
|
||||
}));
|
||||
}
|
||||
|
||||
@GetMapping("/unbound-sub-device-page")
|
||||
@Operation(summary = "获取未绑定网关的子设备分页")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:query')")
|
||||
public CommonResult<PageResult<IotDeviceRespVO>> getUnboundSubDevicePage(@Valid IotDevicePageReqVO pageReqVO) {
|
||||
PageResult<IotDeviceDO> pageResult = deviceService.getUnboundSubDevicePage(pageReqVO);
|
||||
if (CollUtil.isEmpty(pageResult.getList())) {
|
||||
return success(PageResult.empty());
|
||||
}
|
||||
|
||||
// 补充产品名称
|
||||
Map<Long, IotProductDO> productMap = convertMap(productService.getProductList(), IotProductDO::getId);
|
||||
PageResult<IotDeviceRespVO> result = BeanUtils.toBean(pageResult, IotDeviceRespVO.class, device ->
|
||||
MapUtils.findAndThen(productMap, device.getProductId(), product -> device.setProductName(product.getName())));
|
||||
return success(result);
|
||||
}
|
||||
|
||||
@PutMapping("/update-group")
|
||||
@Operation(summary = "更新设备分组")
|
||||
@@ -136,6 +191,26 @@ public class IotDeviceController {
|
||||
.setProductId(device.getProductId()).setState(device.getState())));
|
||||
}
|
||||
|
||||
@GetMapping("/location-list")
|
||||
@Operation(summary = "获取设备位置列表", description = "获取有经纬度信息的设备列表,用于地图展示")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:query')")
|
||||
public CommonResult<List<IotDeviceRespVO>> getDeviceLocationList() {
|
||||
// 1. 获取有位置信息的设备列表
|
||||
List<IotDeviceDO> devices = deviceService.getDeviceListByHasLocation();
|
||||
if (CollUtil.isEmpty(devices)) {
|
||||
return success(Collections.emptyList());
|
||||
}
|
||||
|
||||
// 2. 转换并返回
|
||||
Map<Long, IotProductDO> productMap = convertMap(productService.getProductList(), IotProductDO::getId);
|
||||
return success(convertList(devices, device -> {
|
||||
IotDeviceRespVO respVO = BeanUtils.toBean(device, IotDeviceRespVO.class);
|
||||
MapUtils.findAndThen(productMap, device.getProductId(),
|
||||
product -> respVO.setProductName(product.getName()));
|
||||
return respVO;
|
||||
}));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "导入设备")
|
||||
@PreAuthorize("@ss.hasPermission('iot:device:import')")
|
||||
@@ -153,10 +228,9 @@ public class IotDeviceController {
|
||||
// 手动创建导出 demo
|
||||
List<IotDeviceImportExcelVO> list = Arrays.asList(
|
||||
IotDeviceImportExcelVO.builder().deviceName("温度传感器001").parentDeviceName("gateway110")
|
||||
.productKey("1de24640dfe").groupNames("灰度分组,生产分组")
|
||||
.locationType(IotLocationTypeEnum.IP.getType()).build(),
|
||||
.productKey("1de24640dfe").groupNames("灰度分组,生产分组").build(),
|
||||
IotDeviceImportExcelVO.builder().deviceName("biubiu").productKey("YzvHxd4r67sT4s2B")
|
||||
.groupNames("").locationType(IotLocationTypeEnum.MANUAL.getType()).build());
|
||||
.groupNames("").build());
|
||||
// 输出
|
||||
ExcelUtils.write(response, "设备导入模板.xls", "数据", IotDeviceImportExcelVO.class, list);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 设备绑定网关 Request VO")
|
||||
@Data
|
||||
public class IotDeviceBindGatewayReqVO {
|
||||
|
||||
@Schema(description = "子设备编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3")
|
||||
@NotEmpty(message = "子设备编号列表不能为空")
|
||||
private Set<Long> subIds;
|
||||
|
||||
@Schema(description = "网关设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@NotNull(message = "网关设备编号不能为空")
|
||||
private Long gatewayId;
|
||||
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -35,9 +32,4 @@ public class IotDeviceImportExcelVO {
|
||||
@ExcelProperty("设备分组")
|
||||
private String groupNames;
|
||||
|
||||
@ExcelProperty("上报方式(1:IP 定位, 2:设备上报,3:手动定位)")
|
||||
@NotNull(message = "上报方式不能为空")
|
||||
@InEnum(IotLocationTypeEnum.class)
|
||||
private Integer locationType;
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
|
||||
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
|
||||
import cn.iocoder.yudao.module.iot.enums.DictTypeConstants;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -45,6 +44,9 @@ public class IotDeviceRespVO {
|
||||
@ExcelProperty("产品编号")
|
||||
private Long productId;
|
||||
|
||||
@Schema(description = "产品名称", example = "温湿度传感器")
|
||||
private String productName; // 只有部分接口返回,例如 getDeviceLocationList
|
||||
|
||||
@Schema(description = "产品标识", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@ExcelProperty("产品 Key")
|
||||
private String productKey;
|
||||
@@ -77,18 +79,9 @@ public class IotDeviceRespVO {
|
||||
@ExcelProperty("设备密钥")
|
||||
private String deviceSecret;
|
||||
|
||||
@Schema(description = "认证类型(如一机一密、动态注册)", example = "2")
|
||||
@ExcelProperty("认证类型(如一机一密、动态注册)")
|
||||
private String authType;
|
||||
|
||||
@Schema(description = "设备配置", example = "{\"abc\": \"efg\"}")
|
||||
private String config;
|
||||
|
||||
@Schema(description = "定位方式", example = "2")
|
||||
@ExcelProperty(value = "定位方式", converter = DictConvert.class)
|
||||
@DictFormat(DictTypeConstants.LOCATION_TYPE)
|
||||
private Integer locationType;
|
||||
|
||||
@Schema(description = "设备位置的纬度", example = "45.000000")
|
||||
private BigDecimal latitude;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.DecimalMax;
|
||||
import jakarta.validation.constraints.DecimalMin;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -39,14 +39,14 @@ public class IotDeviceSaveReqVO {
|
||||
@Schema(description = "设备配置", example = "{\"abc\": \"efg\"}")
|
||||
private String config;
|
||||
|
||||
@Schema(description = "定位类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
@InEnum(value = IotLocationTypeEnum.class, message = "定位方式必须是 {value}")
|
||||
private Integer locationType;
|
||||
|
||||
@Schema(description = "设备位置的纬度", example = "16380")
|
||||
@Schema(description = "设备位置的纬度", example = "39.915")
|
||||
@DecimalMin(value = "-90", message = "纬度范围为 -90 到 90")
|
||||
@DecimalMax(value = "90", message = "纬度范围为 -90 到 90")
|
||||
private BigDecimal latitude;
|
||||
|
||||
@Schema(description = "设备位置的经度", example = "16380")
|
||||
@Schema(description = "设备位置的经度", example = "116.404")
|
||||
@DecimalMin(value = "-180", message = "经度范围为 -180 到 180")
|
||||
@DecimalMax(value = "180", message = "经度范围为 -180 到 180")
|
||||
private BigDecimal longitude;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Schema(description = "管理后台 - IoT 设备解绑网关 Request VO")
|
||||
@Data
|
||||
public class IotDeviceUnbindGatewayReqVO {
|
||||
|
||||
@Schema(description = "子设备编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3")
|
||||
@NotEmpty(message = "子设备编号列表不能为空")
|
||||
private Set<Long> subIds;
|
||||
|
||||
@Schema(description = "网关设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "网关设备编号不能为空")
|
||||
private Long gatewayId;
|
||||
|
||||
}
|
||||
@@ -143,11 +143,13 @@ public class IotProductController {
|
||||
|
||||
@GetMapping("/simple-list")
|
||||
@Operation(summary = "获取产品的精简信息列表", description = "主要用于前端的下拉选项")
|
||||
public CommonResult<List<IotProductRespVO>> getProductSimpleList() {
|
||||
List<IotProductDO> list = productService.getProductList();
|
||||
return success(convertList(list, product -> // 只返回 id、name 字段
|
||||
@Parameter(name = "deviceType", description = "设备类型", example = "1")
|
||||
public CommonResult<List<IotProductRespVO>> getProductSimpleList(
|
||||
@RequestParam(value = "deviceType", required = false) Integer deviceType) {
|
||||
List<IotProductDO> list = productService.getProductList(deviceType);
|
||||
return success(convertList(list, product -> // 只返回 id、name、productKey 字段
|
||||
new IotProductRespVO().setId(product.getId()).setName(product.getName()).setStatus(product.getStatus())
|
||||
.setDeviceType(product.getDeviceType()).setLocationType(product.getLocationType())));
|
||||
.setDeviceType(product.getDeviceType()).setProductKey(product.getProductKey())));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
|
||||
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
|
||||
import cn.iocoder.yudao.module.iot.enums.DictTypeConstants;
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -27,6 +27,12 @@ public class IotProductRespVO {
|
||||
@ExcelProperty("产品标识")
|
||||
private String productKey;
|
||||
|
||||
@Schema(description = "产品密钥", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String productSecret;
|
||||
|
||||
@Schema(description = "是否开启动态注册", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
|
||||
private Boolean registerEnabled;
|
||||
|
||||
@Schema(description = "产品分类编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long categoryId;
|
||||
|
||||
@@ -61,11 +67,6 @@ public class IotProductRespVO {
|
||||
@DictFormat(DictTypeConstants.NET_TYPE)
|
||||
private Integer netType;
|
||||
|
||||
@Schema(description = "定位方式", example = "2")
|
||||
@ExcelProperty(value = "定位方式", converter = DictConvert.class)
|
||||
@DictFormat(DictTypeConstants.LOCATION_TYPE)
|
||||
private Integer locationType;
|
||||
|
||||
@Schema(description = "数据格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
|
||||
@ExcelProperty(value = "数据格式", converter = DictConvert.class)
|
||||
@DictFormat(DictTypeConstants.CODEC_TYPE)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotNetTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -45,12 +44,12 @@ public class IotProductSaveReqVO {
|
||||
@InEnum(value = IotNetTypeEnum.class, message = "联网方式必须是 {value}")
|
||||
private Integer netType;
|
||||
|
||||
@Schema(description = "定位类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
@InEnum(value = IotLocationTypeEnum.class, message = "定位方式必须是 {value}")
|
||||
private Integer locationType;
|
||||
|
||||
@Schema(description = "数据格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
|
||||
@NotEmpty(message = "数据格式不能为空")
|
||||
private String codecType;
|
||||
|
||||
@Schema(description = "是否开启动态注册", example = "false")
|
||||
@NotNull(message = "是否开启动态注册不能为空")
|
||||
private Boolean registerEnabled;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package cn.iocoder.yudao.module.iot.core.mq.message;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* IoT 设备消息
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class IotDeviceMessage {
|
||||
|
||||
/**
|
||||
* 【消息总线】应用的设备消息 Topic,由 iot-gateway 发给 iot-biz 进行消费
|
||||
*/
|
||||
public static final String MESSAGE_BUS_DEVICE_MESSAGE_TOPIC = "iot_device_message";
|
||||
|
||||
/**
|
||||
* 【消息总线】设备消息 Topic,由 iot-biz 发送给 iot-gateway 的某个 "server"(protocol) 进行消费
|
||||
*
|
||||
* 其中,%s 就是该"server"(protocol) 的标识
|
||||
*/
|
||||
public static final String MESSAGE_BUS_GATEWAY_DEVICE_MESSAGE_TOPIC = MESSAGE_BUS_DEVICE_MESSAGE_TOPIC + "_%s";
|
||||
|
||||
/**
|
||||
* 消息编号
|
||||
*
|
||||
* 由后端生成,通过 {@link IotDeviceMessageUtils#generateMessageId()}
|
||||
*/
|
||||
private String id;
|
||||
/**
|
||||
* 上报时间
|
||||
*
|
||||
* 由后端生成,当前时间
|
||||
*/
|
||||
private LocalDateTime reportTime;
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
private Long deviceId;
|
||||
/**
|
||||
* 租户编号
|
||||
*/
|
||||
private Long tenantId;
|
||||
|
||||
/**
|
||||
* 服务编号,该消息由哪个 server 发送
|
||||
*/
|
||||
private String serverId;
|
||||
|
||||
// ========== codec(编解码)字段 ==========
|
||||
|
||||
/**
|
||||
* 请求编号
|
||||
*
|
||||
* 由设备生成,对应阿里云 IoT 的 Alink 协议中的 id、华为云 IoTDA 协议的 request_id
|
||||
*/
|
||||
private String requestId;
|
||||
/**
|
||||
* 请求方法
|
||||
*
|
||||
* 枚举 {@link IotDeviceMessageMethodEnum}
|
||||
* 例如说:thing.property.report 属性上报
|
||||
*/
|
||||
private String method;
|
||||
/**
|
||||
* 请求参数
|
||||
*
|
||||
* 例如说:属性上报的 properties、事件上报的 params
|
||||
*/
|
||||
private Object params;
|
||||
/**
|
||||
* 响应结果
|
||||
*/
|
||||
private Object data;
|
||||
/**
|
||||
* 响应错误码
|
||||
*/
|
||||
private Integer code;
|
||||
/**
|
||||
* 返回结果信息
|
||||
*/
|
||||
private String msg;
|
||||
|
||||
// ========== 基础方法:只传递"codec(编解码)字段" ==========
|
||||
|
||||
public static IotDeviceMessage requestOf(String method) {
|
||||
return requestOf(null, method, null);
|
||||
}
|
||||
|
||||
public static IotDeviceMessage requestOf(String method, Object params) {
|
||||
return requestOf(null, method, params);
|
||||
}
|
||||
|
||||
public static IotDeviceMessage requestOf(String requestId, String method, Object params) {
|
||||
return of(requestId, method, params, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建设备请求消息(包含设备信息)
|
||||
*
|
||||
* @param deviceId 设备编号
|
||||
* @param tenantId 租户编号
|
||||
* @param serverId 服务标识
|
||||
* @param method 消息方法
|
||||
* @param params 消息参数
|
||||
* @return 消息对象
|
||||
*/
|
||||
public static IotDeviceMessage requestOf(Long deviceId, Long tenantId, String serverId,
|
||||
String method, Object params) {
|
||||
IotDeviceMessage message = of(null, method, params, null, null, null);
|
||||
return message.setId(IotDeviceMessageUtils.generateMessageId())
|
||||
.setDeviceId(deviceId).setTenantId(tenantId).setServerId(serverId);
|
||||
}
|
||||
|
||||
public static IotDeviceMessage replyOf(String requestId, String method,
|
||||
Object data, Integer code, String msg) {
|
||||
if (code == null) {
|
||||
code = GlobalErrorCodeConstants.SUCCESS.getCode();
|
||||
msg = GlobalErrorCodeConstants.SUCCESS.getMsg();
|
||||
}
|
||||
return of(requestId, method, null, data, code, msg);
|
||||
}
|
||||
|
||||
public static IotDeviceMessage of(String requestId, String method,
|
||||
Object params, Object data, Integer code, String msg) {
|
||||
// 通用参数
|
||||
IotDeviceMessage message = new IotDeviceMessage()
|
||||
.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now());
|
||||
// 当前参数
|
||||
message.setRequestId(requestId).setMethod(method).setParams(params)
|
||||
.setData(data).setCode(code).setMsg(msg);
|
||||
return message;
|
||||
}
|
||||
|
||||
// ========== 核心方法:在 of 基础方法之上,添加对应 method ==========
|
||||
|
||||
public static IotDeviceMessage buildStateUpdateOnline() {
|
||||
return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(),
|
||||
MapUtil.of("state", IotDeviceStateEnum.ONLINE.getState()));
|
||||
}
|
||||
|
||||
public static IotDeviceMessage buildStateOffline() {
|
||||
return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(),
|
||||
MapUtil.of("state", IotDeviceStateEnum.OFFLINE.getState()));
|
||||
}
|
||||
|
||||
public static IotDeviceMessage buildOtaUpgrade(String version, String fileUrl, Long fileSize,
|
||||
String fileDigestAlgorithm, String fileDigestValue) {
|
||||
return requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), MapUtil.builder()
|
||||
.put("version", version).put("fileUrl", fileUrl).put("fileSize", fileSize)
|
||||
.put("fileDigestAlgorithm", fileDigestAlgorithm).put("fileDigestValue", fileDigestValue)
|
||||
.build());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.iocoder.yudao.module.iot.core.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.crypto.digest.HmacAlgorithm;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
|
||||
/**
|
||||
* IoT 设备【认证】的工具类,参考阿里云
|
||||
*
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/how-do-i-obtain-mqtt-parameters-for-authentication">如何计算 MQTT 签名参数</a>
|
||||
*/
|
||||
public class IotDeviceAuthUtils {
|
||||
|
||||
public static IotDeviceAuthReqDTO getAuthInfo(String productKey, String deviceName, String deviceSecret) {
|
||||
String clientId = buildClientId(productKey, deviceName);
|
||||
String username = buildUsername(productKey, deviceName);
|
||||
String password = buildPassword(deviceSecret,
|
||||
buildContent(clientId, productKey, deviceName, deviceSecret));
|
||||
return new IotDeviceAuthReqDTO(clientId, username, password);
|
||||
}
|
||||
|
||||
public static String buildClientId(String productKey, String deviceName) {
|
||||
return String.format("%s.%s", productKey, deviceName);
|
||||
}
|
||||
|
||||
public static String buildUsername(String productKey, String deviceName) {
|
||||
return String.format("%s&%s", deviceName, productKey);
|
||||
}
|
||||
|
||||
public static String buildPassword(String deviceSecret, String content) {
|
||||
return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, StrUtil.utf8Bytes(deviceSecret))
|
||||
.digestHex(content);
|
||||
}
|
||||
|
||||
private static String buildContent(String clientId, String productKey, String deviceName, String deviceSecret) {
|
||||
return "clientId" + clientId +
|
||||
"deviceName" + deviceName +
|
||||
"deviceSecret" + deviceSecret +
|
||||
"productKey" + productKey;
|
||||
}
|
||||
|
||||
public static IotDeviceIdentity parseUsername(String username) {
|
||||
String[] usernameParts = username.split("&");
|
||||
if (usernameParts.length != 2) {
|
||||
return null;
|
||||
}
|
||||
return new IotDeviceIdentity(usernameParts[1], usernameParts[0]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,17 +2,14 @@ package cn.iocoder.yudao.module.iot.dal.dataobject.device;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.type.LongSetTypeHandler;
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -126,18 +123,7 @@ public class IotDeviceDO extends TenantBaseDO {
|
||||
* 设备密钥,用于设备认证
|
||||
*/
|
||||
private String deviceSecret;
|
||||
/**
|
||||
* 认证类型(如一机一密、动态注册)
|
||||
*/
|
||||
// TODO @haohao:是不是要枚举哈
|
||||
private String authType;
|
||||
|
||||
/**
|
||||
* 定位方式
|
||||
* <p>
|
||||
* 枚举 {@link cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum}
|
||||
*/
|
||||
private Integer locationType;
|
||||
/**
|
||||
* 设备位置的纬度
|
||||
*/
|
||||
@@ -146,16 +132,6 @@ public class IotDeviceDO extends TenantBaseDO {
|
||||
* 设备位置的经度
|
||||
*/
|
||||
private BigDecimal longitude;
|
||||
/**
|
||||
* 地区编码
|
||||
* <p>
|
||||
* 关联 Area 的 id
|
||||
*/
|
||||
private Integer areaId;
|
||||
/**
|
||||
* 设备详细地址
|
||||
*/
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 设备配置
|
||||
|
||||
@@ -4,10 +4,7 @@ import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* IoT 产品 DO
|
||||
@@ -35,6 +32,14 @@ public class IotProductDO extends TenantBaseDO {
|
||||
* 产品标识
|
||||
*/
|
||||
private String productKey;
|
||||
/**
|
||||
* 产品密钥,用于一型一密动态注册
|
||||
*/
|
||||
private String productSecret;
|
||||
/**
|
||||
* 是否开启动态注册
|
||||
*/
|
||||
private Boolean registerEnabled;
|
||||
/**
|
||||
* 产品分类编号
|
||||
* <p>
|
||||
@@ -72,12 +77,6 @@ public class IotProductDO extends TenantBaseDO {
|
||||
* 枚举 {@link cn.iocoder.yudao.module.iot.enums.product.IotNetTypeEnum}
|
||||
*/
|
||||
private Integer netType;
|
||||
/**
|
||||
* 定位方式
|
||||
* <p>
|
||||
* 枚举 {@link cn.iocoder.yudao.module.iot.enums.product.IotLocationTypeEnum}
|
||||
*/
|
||||
private Integer locationType;
|
||||
/**
|
||||
* 数据格式(编解码器类型)
|
||||
* <p>
|
||||
|
||||
@@ -6,7 +6,9 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import jakarta.annotation.Nullable;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@@ -118,4 +120,56 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询有位置信息的设备列表
|
||||
*
|
||||
* @return 设备列表
|
||||
*/
|
||||
default List<IotDeviceDO> selectListByHasLocation() {
|
||||
return selectList(new LambdaQueryWrapperX<IotDeviceDO>()
|
||||
.isNotNull(IotDeviceDO::getLatitude)
|
||||
.isNotNull(IotDeviceDO::getLongitude));
|
||||
}
|
||||
|
||||
// ========== 网关-子设备绑定相关 ==========
|
||||
|
||||
/**
|
||||
* 根据网关编号查询子设备列表
|
||||
*
|
||||
* @param gatewayId 网关设备编号
|
||||
* @return 子设备列表
|
||||
*/
|
||||
default List<IotDeviceDO> selectListByGatewayId(Long gatewayId) {
|
||||
return selectList(IotDeviceDO::getGatewayId, gatewayId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询未绑定网关的子设备
|
||||
*
|
||||
* @param reqVO 分页查询参数
|
||||
* @return 子设备分页
|
||||
*/
|
||||
default PageResult<IotDeviceDO> selectUnboundSubDevicePage(IotDevicePageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<IotDeviceDO>()
|
||||
.likeIfPresent(IotDeviceDO::getDeviceName, reqVO.getDeviceName())
|
||||
.likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname())
|
||||
.eqIfPresent(IotDeviceDO::getProductId, reqVO.getProductId())
|
||||
// 仅查询子设备 + 未绑定网关
|
||||
.eq(IotDeviceDO::getDeviceType, IotProductDeviceTypeEnum.GATEWAY_SUB.getType())
|
||||
.isNull(IotDeviceDO::getGatewayId)
|
||||
.orderByDesc(IotDeviceDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新设备的网关编号
|
||||
*
|
||||
* @param ids 设备编号列表
|
||||
* @param gatewayId 网关设备编号(可以为 null,表示解绑)
|
||||
*/
|
||||
default void updateGatewayIdBatch(Collection<Long> ids, Long gatewayId) {
|
||||
update(null, new LambdaUpdateWrapper<IotDeviceDO>()
|
||||
.set(IotDeviceDO::getGatewayId, gatewayId)
|
||||
.in(IotDeviceDO::getId, ids));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 产品 Mapper
|
||||
@@ -26,6 +27,12 @@ public interface IotProductMapper extends BaseMapperX<IotProductDO> {
|
||||
.orderByDesc(IotProductDO::getId));
|
||||
}
|
||||
|
||||
default List<IotProductDO> selectList(Integer deviceType) {
|
||||
return selectList(new LambdaQueryWrapperX<IotProductDO>()
|
||||
.eqIfPresent(IotProductDO::getDeviceType, deviceType)
|
||||
.orderByDesc(IotProductDO::getId));
|
||||
}
|
||||
|
||||
default IotProductDO selectByProductKey(String productKey) {
|
||||
return selectOne(new LambdaQueryWrapper<IotProductDO>()
|
||||
.apply("LOWER(product_key) = {0}", productKey.toLowerCase()));
|
||||
@@ -36,5 +43,4 @@ public interface IotProductMapper extends BaseMapperX<IotProductDO> {
|
||||
.geIfPresent(IotProductDO::getCreateTime, createTime));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -84,4 +84,12 @@ public interface RedisKeyConstants {
|
||||
*/
|
||||
String SCENE_RULE_LIST = "iot:scene_rule_list";
|
||||
|
||||
/**
|
||||
* WebSocket 连接分布式锁
|
||||
* <p>
|
||||
* KEY 格式:websocket_connect_lock:${serverUrl}
|
||||
* 用于保证 WebSocket 重连操作的线程安全
|
||||
*/
|
||||
String WEBSOCKET_CONNECT_LOCK = "iot:websocket_connect_lock:%s";
|
||||
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
||||
public Long createAlertConfig(IotAlertConfigSaveReqVO createReqVO) {
|
||||
// 校验关联数据是否存在
|
||||
sceneRuleService.validateSceneRuleList(createReqVO.getSceneRuleIds());
|
||||
adminUserApi.validateUserList(createReqVO.getReceiveUserIds());
|
||||
adminUserApi.validateUserList(createReqVO.getReceiveUserIds()).checkError();
|
||||
|
||||
// 插入
|
||||
IotAlertConfigDO alertConfig = BeanUtils.toBean(createReqVO, IotAlertConfigDO.class);
|
||||
@@ -55,7 +55,7 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
||||
validateAlertConfigExists(updateReqVO.getId());
|
||||
// 校验关联数据是否存在
|
||||
sceneRuleService.validateSceneRuleList(updateReqVO.getSceneRuleIds());
|
||||
adminUserApi.validateUserList(updateReqVO.getReceiveUserIds());
|
||||
adminUserApi.validateUserList(updateReqVO.getReceiveUserIds()).checkError();
|
||||
|
||||
// 更新
|
||||
IotAlertConfigDO updateObj = BeanUtils.toBean(updateReqVO, IotAlertConfigDO.class);
|
||||
|
||||
@@ -3,11 +3,19 @@ package cn.iocoder.yudao.module.iot.service.device;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -37,18 +45,6 @@ public interface IotDeviceService {
|
||||
*/
|
||||
void updateDevice(@Valid IotDeviceSaveReqVO updateReqVO);
|
||||
|
||||
// TODO @芋艿:先这么实现。未来看情况,要不要自己实现
|
||||
|
||||
/**
|
||||
* 更新设备的所属网关
|
||||
*
|
||||
* @param id 编号
|
||||
* @param gatewayId 网关设备 ID
|
||||
*/
|
||||
default void updateDeviceGateway(Long id, Long gatewayId) {
|
||||
updateDevice(new IotDeviceSaveReqVO().setId(id).setGatewayId(gatewayId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备状态
|
||||
*
|
||||
@@ -271,4 +267,112 @@ public interface IotDeviceService {
|
||||
*/
|
||||
void updateDeviceFirmware(Long deviceId, Long firmwareId);
|
||||
|
||||
/**
|
||||
* 更新设备定位信息(GeoLocation 上报时调用)
|
||||
*
|
||||
* @param device 设备信息(用于清除缓存)
|
||||
* @param longitude 经度
|
||||
* @param latitude 纬度
|
||||
*/
|
||||
void updateDeviceLocation(IotDeviceDO device, BigDecimal longitude, BigDecimal latitude);
|
||||
|
||||
/**
|
||||
* 获得有位置信息的设备列表
|
||||
*
|
||||
* @return 设备列表
|
||||
*/
|
||||
List<IotDeviceDO> getDeviceListByHasLocation();
|
||||
|
||||
// ========== 网关-拓扑管理(后台操作) ==========
|
||||
|
||||
/**
|
||||
* 绑定子设备到网关
|
||||
*
|
||||
* @param subIds 子设备编号列表
|
||||
* @param gatewayId 网关设备编号
|
||||
*/
|
||||
void bindDeviceGateway(Collection<Long> subIds, Long gatewayId);
|
||||
|
||||
/**
|
||||
* 解绑子设备与网关
|
||||
*
|
||||
* @param subIds 子设备编号列表
|
||||
* @param gatewayId 网关设备编号
|
||||
*/
|
||||
void unbindDeviceGateway(Collection<Long> subIds, Long gatewayId);
|
||||
|
||||
/**
|
||||
* 获取未绑定网关的子设备分页
|
||||
*
|
||||
* @param pageReqVO 分页查询参数(仅使用 productId、deviceName、nickname)
|
||||
* @return 子设备分页
|
||||
*/
|
||||
PageResult<IotDeviceDO> getUnboundSubDevicePage(IotDevicePageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 根据网关编号获取子设备列表
|
||||
*
|
||||
* @param gatewayId 网关设备编号
|
||||
* @return 子设备列表
|
||||
*/
|
||||
List<IotDeviceDO> getDeviceListByGatewayId(Long gatewayId);
|
||||
|
||||
// ========== 网关-拓扑管理(设备上报) ==========
|
||||
|
||||
/**
|
||||
* 处理添加拓扑关系消息(网关设备上报)
|
||||
*
|
||||
* @param message 消息
|
||||
* @param gatewayDevice 网关设备
|
||||
* @return 成功添加的子设备列表
|
||||
*/
|
||||
List<IotDeviceIdentity> handleTopoAddMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice);
|
||||
|
||||
/**
|
||||
* 处理删除拓扑关系消息(网关设备上报)
|
||||
*
|
||||
* @param message 消息
|
||||
* @param gatewayDevice 网关设备
|
||||
* @return 成功删除的子设备列表
|
||||
*/
|
||||
List<IotDeviceIdentity> handleTopoDeleteMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice);
|
||||
|
||||
/**
|
||||
* 处理获取拓扑关系消息(网关设备上报)
|
||||
*
|
||||
* @param gatewayDevice 网关设备
|
||||
* @return 拓扑关系响应
|
||||
*/
|
||||
IotDeviceTopoGetRespDTO handleTopoGetMessage(IotDeviceDO gatewayDevice);
|
||||
|
||||
// ========== 设备动态注册 ==========
|
||||
|
||||
/**
|
||||
* 直连/网关设备动态注册
|
||||
*
|
||||
* @param reqDTO 动态注册请求
|
||||
* @return 注册结果(包含 DeviceSecret)
|
||||
*/
|
||||
IotDeviceRegisterRespDTO registerDevice(@Valid IotDeviceRegisterReqDTO reqDTO);
|
||||
|
||||
/**
|
||||
* 网关子设备动态注册
|
||||
* <p>
|
||||
* 与 {@link #handleSubDeviceRegisterMessage} 方法的区别:
|
||||
* 该方法网关设备信息通过 reqDTO 参数传入,而 {@link #handleSubDeviceRegisterMessage} 方法通过 gatewayDevice 参数传入
|
||||
*
|
||||
* @param reqDTO 子设备注册请求(包含网关设备信息)
|
||||
* @return 注册结果列表
|
||||
*/
|
||||
List<IotSubDeviceRegisterRespDTO> registerSubDevices(@Valid IotSubDeviceRegisterFullReqDTO reqDTO);
|
||||
|
||||
/**
|
||||
* 处理子设备动态注册消息(网关设备上报)
|
||||
*
|
||||
* @param message 消息
|
||||
* @param gatewayDevice 网关设备
|
||||
* @return 注册结果列表
|
||||
*/
|
||||
List<IotSubDeviceRegisterRespDTO> handleSubDeviceRegisterMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
package cn.iocoder.yudao.module.iot.service.device;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoAddReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoChangeReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoDeleteReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.topo.IotDeviceTopoGetRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceGroupDO;
|
||||
@@ -21,6 +35,7 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceMapper;
|
||||
import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
@@ -34,12 +49,14 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
|
||||
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*;
|
||||
import static java.util.Collections.singletonList;
|
||||
|
||||
/**
|
||||
* IoT 设备 Service 实现类
|
||||
@@ -60,9 +77,20 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
@Resource
|
||||
@Lazy // 延迟加载,解决循环依赖
|
||||
private IotDeviceGroupService deviceGroupService;
|
||||
@Resource
|
||||
@Lazy // 延迟加载,解决循环依赖
|
||||
private IotDeviceMessageService deviceMessageService;
|
||||
|
||||
private IotDeviceServiceImpl getSelf() {
|
||||
return SpringUtil.getBean(getClass());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long createDevice(IotDeviceSaveReqVO createReqVO) {
|
||||
return createDevice0(createReqVO).getId();
|
||||
}
|
||||
|
||||
private IotDeviceDO createDevice0(IotDeviceSaveReqVO createReqVO) {
|
||||
// 1.1 校验产品是否存在
|
||||
IotProductDO product = productService.getProduct(createReqVO.getProductId());
|
||||
if (product == null) {
|
||||
@@ -80,7 +108,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
IotDeviceDO device = BeanUtils.toBean(createReqVO, IotDeviceDO.class);
|
||||
initDevice(device, product);
|
||||
deviceMapper.insert(device);
|
||||
return device.getId();
|
||||
return device;
|
||||
}
|
||||
|
||||
private void validateCreateDeviceParam(String productKey, String deviceName,
|
||||
@@ -116,11 +144,13 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
|
||||
private void initDevice(IotDeviceDO device, IotProductDO product) {
|
||||
device.setProductId(product.getId()).setProductKey(product.getProductKey())
|
||||
.setDeviceType(product.getDeviceType());
|
||||
// 生成密钥
|
||||
device.setDeviceSecret(generateDeviceSecret());
|
||||
// 设置设备状态为未激活
|
||||
device.setState(IotDeviceStateEnum.INACTIVE.getState());
|
||||
.setDeviceType(product.getDeviceType())
|
||||
.setDeviceSecret(generateDeviceSecret()) // 生成密钥
|
||||
.setState(IotDeviceStateEnum.INACTIVE.getState()); // 默认未激活
|
||||
}
|
||||
|
||||
private String generateDeviceSecret() {
|
||||
return IdUtil.fastSimpleUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -169,9 +199,10 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
public void deleteDevice(Long id) {
|
||||
// 1.1 校验存在
|
||||
IotDeviceDO device = validateDeviceExists(id);
|
||||
// 1.2 如果是网关设备,检查是否有子设备
|
||||
if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(id) > 0) {
|
||||
throw exception(DEVICE_HAS_CHILDREN);
|
||||
// 1.2 如果是网关设备,检查是否有子设备绑定
|
||||
if (IotProductDeviceTypeEnum.isGateway(device.getDeviceType())
|
||||
&& deviceMapper.selectCountByGatewayId(id) > 0) {
|
||||
throw exception(DEVICE_GATEWAY_HAS_SUB);
|
||||
}
|
||||
|
||||
// 2. 删除设备
|
||||
@@ -192,10 +223,11 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
if (CollUtil.isEmpty(devices)) {
|
||||
return;
|
||||
}
|
||||
// 1.2 校验网关设备是否存在
|
||||
// 1.2 如果是网关设备,检查是否有子设备绑定
|
||||
for (IotDeviceDO device : devices) {
|
||||
if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(device.getId()) > 0) {
|
||||
throw exception(DEVICE_HAS_CHILDREN);
|
||||
if (IotProductDeviceTypeEnum.isGateway(device.getDeviceType())
|
||||
&& deviceMapper.selectCountByGatewayId(device.getId()) > 0) {
|
||||
throw exception(DEVICE_GATEWAY_HAS_SUB);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +327,37 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
|
||||
// 2. 清空对应缓存
|
||||
deleteDeviceCache(device);
|
||||
|
||||
// 3. 网关设备下线时,联动所有子设备下线
|
||||
if (Objects.equals(state, IotDeviceStateEnum.OFFLINE.getState())
|
||||
&& IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) {
|
||||
handleGatewayOffline(device);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理网关下线,联动所有子设备下线
|
||||
*
|
||||
* @param gatewayDevice 网关设备
|
||||
*/
|
||||
private void handleGatewayOffline(IotDeviceDO gatewayDevice) {
|
||||
List<IotDeviceDO> subDevices = deviceMapper.selectListByGatewayId(gatewayDevice.getId());
|
||||
if (CollUtil.isEmpty(subDevices)) {
|
||||
return;
|
||||
}
|
||||
for (IotDeviceDO subDevice : subDevices) {
|
||||
if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) {
|
||||
try {
|
||||
updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState());
|
||||
log.info("[handleGatewayOffline][网关({}/{}) 下线,子设备({}/{}) 联动下线]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
|
||||
subDevice.getProductKey(), subDevice.getDeviceName());
|
||||
} catch (Exception ex) {
|
||||
log.error("[handleGatewayOffline][子设备({}/{}) 下线失败]",
|
||||
subDevice.getProductKey(), subDevice.getDeviceName(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -315,15 +378,6 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
return deviceMapper.selectCountByGroupId(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 deviceSecret
|
||||
*
|
||||
* @return 生成的 deviceSecret
|
||||
*/
|
||||
private String generateDeviceSecret() {
|
||||
return IdUtil.fastSimpleUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入
|
||||
public IotDeviceImportRespVO importDevice(List<IotDeviceImportExcelVO> importDevices, boolean updateSupport) {
|
||||
@@ -376,8 +430,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
if (existDevice == null) {
|
||||
createDevice(new IotDeviceSaveReqVO()
|
||||
.setDeviceName(importDevice.getDeviceName())
|
||||
.setProductId(product.getId()).setGatewayId(gatewayId).setGroupIds(groupIds)
|
||||
.setLocationType(importDevice.getLocationType()));
|
||||
.setProductId(product.getId()).setGatewayId(gatewayId).setGroupIds(groupIds));
|
||||
respVO.getCreateDeviceNames().add(importDevice.getDeviceName());
|
||||
return;
|
||||
}
|
||||
@@ -386,7 +439,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
throw exception(DEVICE_KEY_EXISTS);
|
||||
}
|
||||
updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId())
|
||||
.setGatewayId(gatewayId).setGroupIds(groupIds).setLocationType(importDevice.getLocationType()));
|
||||
.setGatewayId(gatewayId).setGroupIds(groupIds));
|
||||
respVO.getUpdateDeviceNames().add(importDevice.getDeviceName());
|
||||
} catch (ServiceException ex) {
|
||||
respVO.getFailureDeviceNames().put(importDevice.getDeviceName(), ex.getMessage());
|
||||
@@ -399,7 +452,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
public IotDeviceAuthInfoRespVO getDeviceAuthInfo(Long id) {
|
||||
IotDeviceDO device = validateDeviceExists(id);
|
||||
// 使用 IotDeviceAuthUtils 生成认证信息
|
||||
IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(
|
||||
device.getProductKey(), device.getDeviceName(), device.getDeviceSecret());
|
||||
return BeanUtils.toBean(authInfo, IotDeviceAuthInfoRespVO.class);
|
||||
}
|
||||
@@ -447,7 +500,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
@Override
|
||||
public boolean authDevice(IotDeviceAuthReqDTO authReqDTO) {
|
||||
// 1. 校验设备是否存在
|
||||
IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authReqDTO.getUsername());
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(authReqDTO.getUsername());
|
||||
if (deviceInfo == null) {
|
||||
log.error("[authDevice][认证失败,username({}) 格式不正确]", authReqDTO.getUsername());
|
||||
return false;
|
||||
@@ -461,7 +514,7 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
}
|
||||
|
||||
// 2. 校验密码
|
||||
IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, device.getDeviceSecret());
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, device.getDeviceSecret());
|
||||
if (ObjUtil.notEqual(authInfo.getPassword(), authReqDTO.getPassword())) {
|
||||
log.error("[authDevice][设备({}/{}) 密码不正确]", productKey, deviceName);
|
||||
return false;
|
||||
@@ -490,17 +543,388 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
public void updateDeviceFirmware(Long deviceId, Long firmwareId) {
|
||||
// 1. 校验设备是否存在
|
||||
IotDeviceDO device = validateDeviceExists(deviceId);
|
||||
|
||||
|
||||
// 2. 更新设备固件版本
|
||||
IotDeviceDO updateObj = new IotDeviceDO().setId(deviceId).setFirmwareId(firmwareId);
|
||||
deviceMapper.updateById(updateObj);
|
||||
|
||||
|
||||
// 3. 清空对应缓存
|
||||
deleteDeviceCache(device);
|
||||
}
|
||||
|
||||
private IotDeviceServiceImpl getSelf() {
|
||||
return SpringUtil.getBean(getClass());
|
||||
@Override
|
||||
public void updateDeviceLocation(IotDeviceDO device, BigDecimal longitude, BigDecimal latitude) {
|
||||
// 1. 更新定位信息
|
||||
deviceMapper.updateById(new IotDeviceDO().setId(device.getId())
|
||||
.setLongitude(longitude).setLatitude(latitude));
|
||||
|
||||
// 2. 清空对应缓存
|
||||
deleteDeviceCache(device);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotDeviceDO> getDeviceListByHasLocation() {
|
||||
return deviceMapper.selectListByHasLocation();
|
||||
}
|
||||
|
||||
// ========== 网关-拓扑管理(后台操作) ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void bindDeviceGateway(Collection<Long> subIds, Long gatewayId) {
|
||||
if (CollUtil.isEmpty(subIds)) {
|
||||
return;
|
||||
}
|
||||
// 1.1 校验网关设备存在且类型正确
|
||||
validateGatewayDeviceExists(gatewayId);
|
||||
// 1.2 校验每个设备是否可绑定
|
||||
List<IotDeviceDO> devices = deviceMapper.selectByIds(subIds);
|
||||
for (IotDeviceDO device : devices) {
|
||||
checkSubDeviceCanBind(device, gatewayId);
|
||||
}
|
||||
|
||||
// 2. 批量更新数据库
|
||||
List<IotDeviceDO> updateList = convertList(devices, device ->
|
||||
new IotDeviceDO().setId(device.getId()).setGatewayId(gatewayId));
|
||||
deviceMapper.updateBatch(updateList);
|
||||
|
||||
// 3. 清空对应缓存
|
||||
deleteDeviceCache(devices);
|
||||
|
||||
// 4. 下发网关设备拓扑变更通知(增加)
|
||||
sendTopoChangeNotify(gatewayId, IotDeviceTopoChangeReqDTO.STATUS_CREATE, devices);
|
||||
}
|
||||
|
||||
private void checkSubDeviceCanBind(IotDeviceDO device, Long gatewayId) {
|
||||
if (!IotProductDeviceTypeEnum.isGatewaySub(device.getDeviceType())) {
|
||||
throw exception(DEVICE_NOT_GATEWAY_SUB, device.getProductKey(), device.getDeviceName());
|
||||
}
|
||||
// 已绑定到其他网关,拒绝绑定(需先解绑)
|
||||
if (device.getGatewayId() != null && ObjUtil.notEqual(device.getGatewayId(), gatewayId)) {
|
||||
throw exception(DEVICE_GATEWAY_BINDTO_EXISTS, device.getProductKey(), device.getDeviceName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void unbindDeviceGateway(Collection<Long> subIds, Long gatewayId) {
|
||||
// 1. 校验设备存在
|
||||
if (CollUtil.isEmpty(subIds)) {
|
||||
return;
|
||||
}
|
||||
List<IotDeviceDO> devices = deviceMapper.selectByIds(subIds);
|
||||
devices.removeIf(device -> ObjUtil.notEqual(device.getGatewayId(), gatewayId));
|
||||
if (CollUtil.isEmpty(devices)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 批量更新数据库(将 gatewayId 设置为 null)
|
||||
deviceMapper.updateGatewayIdBatch(convertList(devices, IotDeviceDO::getId), null);
|
||||
|
||||
// 3. 清空对应缓存
|
||||
deleteDeviceCache(devices);
|
||||
|
||||
// 4. 下发网关设备拓扑变更通知(删除)
|
||||
sendTopoChangeNotify(gatewayId, IotDeviceTopoChangeReqDTO.STATUS_DELETE, devices);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<IotDeviceDO> getUnboundSubDevicePage(IotDevicePageReqVO pageReqVO) {
|
||||
return deviceMapper.selectUnboundSubDevicePage(pageReqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotDeviceDO> getDeviceListByGatewayId(Long gatewayId) {
|
||||
return deviceMapper.selectListByGatewayId(gatewayId);
|
||||
}
|
||||
|
||||
// ========== 网关-拓扑管理(设备上报) ==========
|
||||
|
||||
@Override
|
||||
public List<IotDeviceIdentity> handleTopoAddMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice) {
|
||||
// 1.1 校验网关设备类型
|
||||
if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) {
|
||||
throw exception(DEVICE_NOT_GATEWAY);
|
||||
}
|
||||
// 1.2 解析参数
|
||||
IotDeviceTopoAddReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceTopoAddReqDTO.class);
|
||||
if (params == null || CollUtil.isEmpty(params.getSubDevices())) {
|
||||
throw exception(DEVICE_TOPO_PARAMS_INVALID);
|
||||
}
|
||||
|
||||
// 2. 遍历处理每个子设备
|
||||
List<IotDeviceIdentity> addedSubDevices = new ArrayList<>();
|
||||
for (IotDeviceAuthReqDTO subDeviceAuth : params.getSubDevices()) {
|
||||
try {
|
||||
IotDeviceDO subDevice = addDeviceTopo(gatewayDevice, subDeviceAuth);
|
||||
addedSubDevices.add(new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName()));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[handleTopoAddMessage][网关({}/{}) 添加子设备失败,message={}]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 返回响应数据(包含成功添加的子设备列表)
|
||||
return addedSubDevices;
|
||||
}
|
||||
|
||||
private IotDeviceDO addDeviceTopo(IotDeviceDO gatewayDevice, IotDeviceAuthReqDTO subDeviceAuth) {
|
||||
// 1.1 解析子设备信息
|
||||
IotDeviceIdentity subDeviceInfo = IotDeviceAuthUtils.parseUsername(subDeviceAuth.getUsername());
|
||||
if (subDeviceInfo == null) {
|
||||
throw exception(DEVICE_TOPO_SUB_DEVICE_USERNAME_INVALID);
|
||||
}
|
||||
// 1.2 校验子设备认证信息
|
||||
if (!authDevice(subDeviceAuth)) {
|
||||
throw exception(DEVICE_TOPO_SUB_DEVICE_AUTH_FAILED);
|
||||
}
|
||||
// 1.3 获取子设备
|
||||
IotDeviceDO subDevice = getSelf().getDeviceFromCache(subDeviceInfo.getProductKey(), subDeviceInfo.getDeviceName());
|
||||
if (subDevice == null) {
|
||||
throw exception(DEVICE_NOT_EXISTS);
|
||||
}
|
||||
// 1.4 校验子设备类型
|
||||
checkSubDeviceCanBind(subDevice, gatewayDevice.getId());
|
||||
|
||||
// 2. 更新数据库
|
||||
deviceMapper.updateById(new IotDeviceDO().setId(subDevice.getId()).setGatewayId(gatewayDevice.getId()));
|
||||
log.info("[addDeviceTopo][网关({}/{}) 绑定子设备({}/{})]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
|
||||
subDevice.getProductKey(), subDevice.getDeviceName());
|
||||
|
||||
// 3. 清空对应缓存
|
||||
deleteDeviceCache(subDevice);
|
||||
return subDevice;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotDeviceIdentity> handleTopoDeleteMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice) {
|
||||
// 1.1 校验网关设备类型
|
||||
if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) {
|
||||
throw exception(DEVICE_NOT_GATEWAY);
|
||||
}
|
||||
// 1.2 解析参数
|
||||
IotDeviceTopoDeleteReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceTopoDeleteReqDTO.class);
|
||||
if (params == null || CollUtil.isEmpty(params.getSubDevices())) {
|
||||
throw exception(DEVICE_TOPO_PARAMS_INVALID);
|
||||
}
|
||||
|
||||
// 2. 遍历处理每个子设备
|
||||
List<IotDeviceIdentity> deletedSubDevices = new ArrayList<>();
|
||||
for (IotDeviceIdentity subDeviceIdentity : params.getSubDevices()) {
|
||||
try {
|
||||
deleteDeviceTopo(gatewayDevice, subDeviceIdentity);
|
||||
deletedSubDevices.add(subDeviceIdentity);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[handleTopoDeleteMessage][网关({}/{}) 删除子设备失败,productKey={}, deviceName={}]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
|
||||
subDeviceIdentity.getProductKey(), subDeviceIdentity.getDeviceName(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 返回响应数据(包含成功删除的子设备列表)
|
||||
return deletedSubDevices;
|
||||
}
|
||||
|
||||
private void deleteDeviceTopo(IotDeviceDO gatewayDevice, IotDeviceIdentity subDeviceIdentity) {
|
||||
// 1.1 获取子设备
|
||||
IotDeviceDO subDevice = getSelf().getDeviceFromCache(subDeviceIdentity.getProductKey(), subDeviceIdentity.getDeviceName());
|
||||
if (subDevice == null) {
|
||||
throw exception(DEVICE_NOT_EXISTS);
|
||||
}
|
||||
// 1.2 校验子设备是否绑定到该网关
|
||||
if (ObjUtil.notEqual(subDevice.getGatewayId(), gatewayDevice.getId())) {
|
||||
throw exception(DEVICE_TOPO_SUB_NOT_BINDTO_GATEWAY,
|
||||
subDeviceIdentity.getProductKey(), subDeviceIdentity.getDeviceName());
|
||||
}
|
||||
|
||||
// 2. 更新数据库(将 gatewayId 设置为 null)
|
||||
deviceMapper.updateGatewayIdBatch(singletonList(subDevice.getId()), null);
|
||||
log.info("[deleteDeviceTopo][网关({}/{}) 解绑子设备({}/{})]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
|
||||
subDevice.getProductKey(), subDevice.getDeviceName());
|
||||
|
||||
// 3. 清空对应缓存
|
||||
deleteDeviceCache(subDevice);
|
||||
|
||||
// 4. 子设备下线
|
||||
if (Objects.equals(subDevice.getState(), IotDeviceStateEnum.ONLINE.getState())) {
|
||||
updateDeviceState(subDevice, IotDeviceStateEnum.OFFLINE.getState());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotDeviceTopoGetRespDTO handleTopoGetMessage(IotDeviceDO gatewayDevice) {
|
||||
// 1. 校验网关设备类型
|
||||
if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) {
|
||||
throw exception(DEVICE_NOT_GATEWAY);
|
||||
}
|
||||
|
||||
// 2. 获取子设备列表并转换
|
||||
List<IotDeviceDO> subDevices = deviceMapper.selectListByGatewayId(gatewayDevice.getId());
|
||||
List<IotDeviceIdentity> subDeviceIdentities = convertList(subDevices, subDevice ->
|
||||
new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName()));
|
||||
return new IotDeviceTopoGetRespDTO().setSubDevices(subDeviceIdentities);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送拓扑变更通知给网关设备
|
||||
*
|
||||
* @param gatewayId 网关设备编号
|
||||
* @param status 变更状态(0-创建, 1-删除)
|
||||
* @param subDevices 子设备列表
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/notify-gateway-topology-changes">阿里云 - 通知网关拓扑关系变化</a>
|
||||
*/
|
||||
private void sendTopoChangeNotify(Long gatewayId, Integer status, List<IotDeviceDO> subDevices) {
|
||||
if (CollUtil.isEmpty(subDevices)) {
|
||||
return;
|
||||
}
|
||||
// 1. 获取网关设备
|
||||
IotDeviceDO gatewayDevice = deviceMapper.selectById(gatewayId);
|
||||
if (gatewayDevice == null) {
|
||||
log.warn("[sendTopoChangeNotify][网关设备({}) 不存在,无法发送拓扑变更通知]", gatewayId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2.1 构建拓扑变更通知消息
|
||||
List<IotDeviceIdentity> subList = convertList(subDevices, subDevice ->
|
||||
new IotDeviceIdentity(subDevice.getProductKey(), subDevice.getDeviceName()));
|
||||
IotDeviceTopoChangeReqDTO params = new IotDeviceTopoChangeReqDTO(status, subList);
|
||||
IotDeviceMessage notifyMessage = IotDeviceMessage.requestOf(
|
||||
IotDeviceMessageMethodEnum.TOPO_CHANGE.getMethod(), params);
|
||||
|
||||
// 2.2 发送消息
|
||||
deviceMessageService.sendDeviceMessage(notifyMessage, gatewayDevice);
|
||||
log.info("[sendTopoChangeNotify][网关({}/{}) 发送拓扑变更通知成功,status={}, subDevices={}]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
|
||||
status, subList);
|
||||
} catch (Exception ex) {
|
||||
log.error("[sendTopoChangeNotify][网关({}/{}) 发送拓扑变更通知失败,status={}]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(), status, ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 设备动态注册 ==========
|
||||
|
||||
@Override
|
||||
public IotDeviceRegisterRespDTO registerDevice(IotDeviceRegisterReqDTO reqDTO) {
|
||||
// 1.1 校验产品
|
||||
IotProductDO product = TenantUtils.executeIgnore(() ->
|
||||
productService.getProductByProductKey(reqDTO.getProductKey()));
|
||||
if (product == null) {
|
||||
throw exception(PRODUCT_NOT_EXISTS);
|
||||
}
|
||||
// 1.2 校验产品是否开启动态注册
|
||||
if (BooleanUtil.isFalse(product.getRegisterEnabled())) {
|
||||
throw exception(DEVICE_REGISTER_DISABLED);
|
||||
}
|
||||
// 1.3 验证 productSecret
|
||||
if (ObjUtil.notEqual(product.getProductSecret(), reqDTO.getProductSecret())) {
|
||||
throw exception(DEVICE_REGISTER_SECRET_INVALID);
|
||||
}
|
||||
return TenantUtils.execute(product.getTenantId(), () -> {
|
||||
// 1.4 校验设备是否已存在(已存在则不允许重复注册)
|
||||
IotDeviceDO device = getSelf().getDeviceFromCache(reqDTO.getProductKey(), reqDTO.getDeviceName());
|
||||
if (device != null) {
|
||||
throw exception(DEVICE_REGISTER_ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
// 2.1 自动创建设备
|
||||
IotDeviceSaveReqVO createReqVO = new IotDeviceSaveReqVO()
|
||||
.setDeviceName(reqDTO.getDeviceName())
|
||||
.setProductId(product.getId());
|
||||
device = createDevice0(createReqVO);
|
||||
log.info("[registerDevice][产品({}) 自动创建设备({})]",
|
||||
reqDTO.getProductKey(), reqDTO.getDeviceName());
|
||||
// 2.2 返回设备密钥
|
||||
return new IotDeviceRegisterRespDTO(device.getProductKey(), device.getDeviceName(), device.getDeviceSecret());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotSubDeviceRegisterRespDTO> registerSubDevices(IotSubDeviceRegisterFullReqDTO reqDTO) {
|
||||
// 1. 校验网关设备
|
||||
IotDeviceDO gatewayDevice = getSelf().getDeviceFromCache(reqDTO.getGatewayProductKey(), reqDTO.getGatewayDeviceName());
|
||||
|
||||
// 2. 遍历注册每个子设备
|
||||
return TenantUtils.execute(gatewayDevice.getTenantId(), () ->
|
||||
registerSubDevices0(gatewayDevice, reqDTO.getSubDevices()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotSubDeviceRegisterRespDTO> handleSubDeviceRegisterMessage(IotDeviceMessage message, IotDeviceDO gatewayDevice) {
|
||||
// 1. 解析参数
|
||||
if (!(message.getParams() instanceof List)) {
|
||||
throw exception(DEVICE_SUB_REGISTER_PARAMS_INVALID);
|
||||
}
|
||||
List<IotSubDeviceRegisterReqDTO> subDevices = JsonUtils.convertList(message.getParams(), IotSubDeviceRegisterReqDTO.class);
|
||||
|
||||
// 2. 遍历注册每个子设备
|
||||
return registerSubDevices0(gatewayDevice, subDevices);
|
||||
}
|
||||
|
||||
private List<IotSubDeviceRegisterRespDTO> registerSubDevices0(IotDeviceDO gatewayDevice,
|
||||
List<IotSubDeviceRegisterReqDTO> subDevices) {
|
||||
// 1.1 校验网关设备
|
||||
if (gatewayDevice == null) {
|
||||
throw exception(DEVICE_NOT_EXISTS);
|
||||
}
|
||||
if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) {
|
||||
throw exception(DEVICE_NOT_GATEWAY);
|
||||
}
|
||||
// 1.2 注册设备不能为空
|
||||
if (CollUtil.isEmpty(subDevices)) {
|
||||
throw exception(DEVICE_SUB_REGISTER_PARAMS_INVALID);
|
||||
}
|
||||
|
||||
// 2. 遍历注册每个子设备
|
||||
List<IotSubDeviceRegisterRespDTO> results = new ArrayList<>(subDevices.size());
|
||||
for (IotSubDeviceRegisterReqDTO subDevice : subDevices) {
|
||||
try {
|
||||
IotDeviceDO device = registerSubDevice0(gatewayDevice, subDevice);
|
||||
results.add(new IotSubDeviceRegisterRespDTO(
|
||||
subDevice.getProductKey(), subDevice.getDeviceName(), device.getDeviceSecret()));
|
||||
} catch (Exception ex) {
|
||||
log.error("[registerSubDevices0][子设备({}/{}) 注册失败]",
|
||||
subDevice.getProductKey(), subDevice.getDeviceName(), ex);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private IotDeviceDO registerSubDevice0(IotDeviceDO gatewayDevice, IotSubDeviceRegisterReqDTO params) {
|
||||
// 1.1 校验产品
|
||||
IotProductDO product = productService.getProductByProductKey(params.getProductKey());
|
||||
if (product == null) {
|
||||
throw exception(PRODUCT_NOT_EXISTS);
|
||||
}
|
||||
// 1.2 校验产品是否为网关子设备类型
|
||||
if (!IotProductDeviceTypeEnum.isGatewaySub(product.getDeviceType())) {
|
||||
throw exception(DEVICE_SUB_REGISTER_PRODUCT_NOT_GATEWAY_SUB, params.getProductKey());
|
||||
}
|
||||
// 1.3 校验设备是否已存在(子设备动态注册:设备必须已预注册)
|
||||
IotDeviceDO existDevice = getSelf().getDeviceFromCache(params.getProductKey(), params.getDeviceName());
|
||||
if (existDevice == null) {
|
||||
throw exception(DEVICE_NOT_EXISTS);
|
||||
}
|
||||
// 1.4 校验是否绑定到其他网关
|
||||
if (existDevice.getGatewayId() != null && ObjUtil.notEqual(existDevice.getGatewayId(), gatewayDevice.getId())) {
|
||||
throw exception(DEVICE_GATEWAY_BINDTO_EXISTS,
|
||||
existDevice.getProductKey(), existDevice.getDeviceName());
|
||||
}
|
||||
|
||||
// 2. 绑定到网关(如果尚未绑定)
|
||||
if (existDevice.getGatewayId() == null) {
|
||||
// 2.1 更新数据库
|
||||
deviceMapper.updateById(new IotDeviceDO().setId(existDevice.getId()).setGatewayId(gatewayDevice.getId()));
|
||||
// 2.2 清空对应缓存
|
||||
deleteDeviceCache(existDevice);
|
||||
log.info("[registerSubDevice][网关({}/{}) 绑定子设备({}/{})]",
|
||||
gatewayDevice.getProductKey(), gatewayDevice.getDeviceName(),
|
||||
existDevice.getProductKey(), existDevice.getDeviceName());
|
||||
}
|
||||
return existDevice;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsD
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
@@ -75,7 +74,7 @@ public interface IotDeviceMessageService {
|
||||
*/
|
||||
List<IotDeviceMessageDO> getDeviceMessageListByRequestIdsAndReply(
|
||||
@NotNull(message = "设备编号不能为空") Long deviceId,
|
||||
@NotEmpty(message = "请求编号不能为空") List<String> requestIds,
|
||||
List<String> requestIds,
|
||||
Boolean reply);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package cn.iocoder.yudao.module.iot.service.device.message;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
@@ -16,6 +18,10 @@ import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsD
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPackPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO;
|
||||
@@ -98,7 +104,6 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
|
||||
return sendDeviceMessage(message, device);
|
||||
}
|
||||
|
||||
// TODO @芋艿:针对连接网关的设备,是不是 productKey、deviceName 需要调整下;
|
||||
@Override
|
||||
public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device) {
|
||||
return sendDeviceMessage(message, device, null);
|
||||
@@ -168,7 +173,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
|
||||
// 2. 记录消息
|
||||
getSelf().createDeviceLogAsync(message);
|
||||
|
||||
// 3. 回复消息。前提:非 _reply 消息,并且非禁用回复的消息
|
||||
// 3. 回复消息。前提:非 _reply 消息、非禁用回复的消息
|
||||
if (IotDeviceMessageUtils.isReplyMessage(message)
|
||||
|| IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod())
|
||||
|| StrUtil.isEmpty(message.getServerId())) {
|
||||
@@ -185,15 +190,14 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
|
||||
}
|
||||
|
||||
// TODO @芋艿:可优化:未来逻辑复杂后,可以独立拆除 Processor 处理器
|
||||
@SuppressWarnings("SameReturnValue")
|
||||
private Object handleUpstreamDeviceMessage0(IotDeviceMessage message, IotDeviceDO device) {
|
||||
// 设备上下线
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod())) {
|
||||
String stateStr = IotDeviceMessageUtils.getIdentifier(message);
|
||||
assert stateStr != null;
|
||||
Assert.notEmpty(stateStr, "设备状态不能为空");
|
||||
deviceService.updateDeviceState(device, Integer.valueOf(stateStr));
|
||||
// TODO 芋艿:子设备的关联
|
||||
Integer state = Integer.valueOf(stateStr);
|
||||
deviceService.updateDeviceState(device, state);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -202,6 +206,11 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
|
||||
devicePropertyService.saveDeviceProperty(device, message);
|
||||
return null;
|
||||
}
|
||||
// 批量上报(属性+事件+子设备)
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_PACK_POST.getMethod())) {
|
||||
handlePackMessage(message, device);
|
||||
return null;
|
||||
}
|
||||
|
||||
// OTA 上报升级进度
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.OTA_PROGRESS.getMethod())) {
|
||||
@@ -209,10 +218,109 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO @芋艿:这里可以按需,添加别的逻辑;
|
||||
// 添加拓扑关系
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_ADD.getMethod())) {
|
||||
return deviceService.handleTopoAddMessage(message, device);
|
||||
}
|
||||
// 删除拓扑关系
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_DELETE.getMethod())) {
|
||||
return deviceService.handleTopoDeleteMessage(message, device);
|
||||
}
|
||||
// 获取拓扑关系
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.TOPO_GET.getMethod())) {
|
||||
return deviceService.handleTopoGetMessage(device);
|
||||
}
|
||||
|
||||
// 子设备动态注册
|
||||
if (Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.SUB_DEVICE_REGISTER.getMethod())) {
|
||||
return deviceService.handleSubDeviceRegisterMessage(message, device);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== 批量上报处理方法 ==========
|
||||
|
||||
/**
|
||||
* 处理批量上报消息
|
||||
* <p>
|
||||
* 将 pack 消息拆分成多条标准消息,发送到 MQ 让规则引擎处理
|
||||
*
|
||||
* @param packMessage 批量消息
|
||||
* @param gatewayDevice 网关设备
|
||||
*/
|
||||
private void handlePackMessage(IotDeviceMessage packMessage, IotDeviceDO gatewayDevice) {
|
||||
// 1. 解析参数
|
||||
IotDevicePropertyPackPostReqDTO params = JsonUtils.convertObject(
|
||||
packMessage.getParams(), IotDevicePropertyPackPostReqDTO.class);
|
||||
if (params == null) {
|
||||
log.warn("[handlePackMessage][消息({}) 参数解析失败]", packMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理网关设备(自身)的数据
|
||||
sendDevicePackData(gatewayDevice, packMessage.getServerId(), params.getProperties(), params.getEvents());
|
||||
|
||||
// 3. 处理子设备的数据
|
||||
if (CollUtil.isEmpty(params.getSubDevices())) {
|
||||
return;
|
||||
}
|
||||
for (IotDevicePropertyPackPostReqDTO.SubDeviceData subDeviceData : params.getSubDevices()) {
|
||||
try {
|
||||
IotDeviceIdentity identity = subDeviceData.getIdentity();
|
||||
IotDeviceDO subDevice = deviceService.getDeviceFromCache(identity.getProductKey(), identity.getDeviceName());
|
||||
if (subDevice == null) {
|
||||
log.warn("[handlePackMessage][子设备({}/{}) 不存在]", identity.getProductKey(), identity.getDeviceName());
|
||||
continue;
|
||||
}
|
||||
// 特殊:子设备不需要指定 serverId,因为子设备实际可能连接在不同的 gateway-server 上,导致 serverId 不同
|
||||
sendDevicePackData(subDevice, null, subDeviceData.getProperties(), subDeviceData.getEvents());
|
||||
} catch (Exception ex) {
|
||||
log.error("[handlePackMessage][子设备({}/{}) 数据处理失败]", subDeviceData.getIdentity().getProductKey(),
|
||||
subDeviceData.getIdentity().getDeviceName(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备 pack 数据到 MQ(属性 + 事件)
|
||||
*
|
||||
* @param device 设备
|
||||
* @param serverId 服务标识
|
||||
* @param properties 属性数据
|
||||
* @param events 事件数据
|
||||
*/
|
||||
private void sendDevicePackData(IotDeviceDO device, String serverId,
|
||||
Map<String, Object> properties,
|
||||
Map<String, IotDevicePropertyPackPostReqDTO.EventValue> events) {
|
||||
// 1. 发送属性消息
|
||||
if (MapUtil.isNotEmpty(properties)) {
|
||||
IotDeviceMessage propertyMsg = IotDeviceMessage.requestOf(
|
||||
device.getId(), device.getTenantId(), serverId,
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(),
|
||||
IotDevicePropertyPostReqDTO.of(properties));
|
||||
deviceMessageProducer.sendDeviceMessage(propertyMsg);
|
||||
}
|
||||
|
||||
// 2. 发送事件消息
|
||||
if (MapUtil.isNotEmpty(events)) {
|
||||
for (Map.Entry<String, IotDevicePropertyPackPostReqDTO.EventValue> eventEntry : events.entrySet()) {
|
||||
String eventId = eventEntry.getKey();
|
||||
IotDevicePropertyPackPostReqDTO.EventValue eventValue = eventEntry.getValue();
|
||||
if (eventValue == null) {
|
||||
continue;
|
||||
}
|
||||
IotDeviceMessage eventMsg = IotDeviceMessage.requestOf(
|
||||
device.getId(), device.getTenantId(), serverId,
|
||||
IotDeviceMessageMethodEnum.EVENT_POST.getMethod(),
|
||||
IotDeviceEventPostReqDTO.of(eventId, eventValue.getValue(), eventValue.getTime()));
|
||||
deviceMessageProducer.sendDeviceMessage(eventMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========= 设备消息查询 ==========
|
||||
|
||||
@Override
|
||||
public PageResult<IotDeviceMessageDO> getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) {
|
||||
try {
|
||||
@@ -228,9 +336,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotDeviceMessageDO> getDeviceMessageListByRequestIdsAndReply(Long deviceId,
|
||||
List<String> requestIds,
|
||||
Boolean reply) {
|
||||
public List<IotDeviceMessageDO> getDeviceMessageListByRequestIdsAndReply(Long deviceId, List<String> requestIds, Boolean reply) {
|
||||
if (CollUtil.isEmpty(requestIds)) {
|
||||
return ListUtil.of();
|
||||
}
|
||||
return deviceMessageMapper.selectListByRequestIdsAndReply(deviceId, requestIds, reply);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper;
|
||||
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -30,10 +31,12 @@ import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.getBigDecimal;
|
||||
|
||||
/**
|
||||
* IoT 设备【属性】数据 Service 实现类
|
||||
@@ -66,6 +69,9 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
||||
@Resource
|
||||
@Lazy // 延迟加载,解决循环依赖
|
||||
private IotProductService productService;
|
||||
@Resource
|
||||
@Lazy // 延迟加载,解决循环依赖
|
||||
private IotDeviceService deviceService;
|
||||
|
||||
@Resource
|
||||
private DevicePropertyRedisDAO deviceDataRedisDAO;
|
||||
@@ -126,48 +132,60 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("PatternVariableCanBeUsed")
|
||||
public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) {
|
||||
if (!(message.getParams() instanceof Map)) {
|
||||
log.error("[saveDeviceProperty][消息内容({}) 的 data 类型不正确]", message);
|
||||
return;
|
||||
}
|
||||
Map<?, ?> params = (Map<?, ?>) message.getParams();
|
||||
if (CollUtil.isEmpty(params)) {
|
||||
log.error("[saveDeviceProperty][消息内容({}) 的 data 为空]", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 根据物模型,拼接合法的属性
|
||||
// TODO @芋艿:【待定 004】赋能后,属性到底以 thingModel 为准(ik),还是 db 的表结构为准(tl)?
|
||||
List<IotThingModelDO> thingModels = thingModelService.getThingModelListByProductIdFromCache(device.getProductId());
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
((Map<?, ?>) message.getParams()).forEach((key, value) -> {
|
||||
params.forEach((key, value) -> {
|
||||
IotThingModelDO thingModel = CollUtil.findOne(thingModels, o -> o.getIdentifier().equals(key));
|
||||
if (thingModel == null || thingModel.getProperty() == null) {
|
||||
log.error("[saveDeviceProperty][消息({}) 的属性({}) 不存在]", message, key);
|
||||
return;
|
||||
}
|
||||
if (ObjectUtils.equalsAny(thingModel.getProperty().getDataType(),
|
||||
String dataType = thingModel.getProperty().getDataType();
|
||||
if (ObjectUtils.equalsAny(dataType,
|
||||
IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) {
|
||||
// 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储
|
||||
properties.put((String) key, JsonUtils.toJsonString(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(thingModel.getProperty().getDataType())) {
|
||||
properties.put((String) key, Convert.toDouble(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(thingModel.getProperty().getDataType())) {
|
||||
} else if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) {
|
||||
properties.put((String) key, Convert.toInt(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(dataType)) {
|
||||
properties.put((String) key, Convert.toFloat(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(thingModel.getProperty().getDataType())) {
|
||||
} else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(dataType)) {
|
||||
properties.put((String) key, Convert.toDouble(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) {
|
||||
properties.put((String) key, Convert.toByte(value));
|
||||
} else {
|
||||
} else {
|
||||
properties.put((String) key, value);
|
||||
}
|
||||
});
|
||||
if (CollUtil.isEmpty(properties)) {
|
||||
log.error("[saveDeviceProperty][消息({}) 没有合法的属性]", message);
|
||||
return;
|
||||
} else {
|
||||
// 2.1 保存设备属性【数据】
|
||||
devicePropertyMapper.insert(device, properties, LocalDateTimeUtil.toEpochMilli(message.getReportTime()));
|
||||
|
||||
// 2.2 保存设备属性【日志】
|
||||
Map<String, IotDevicePropertyDO> properties2 = convertMap(properties.entrySet(), Map.Entry::getKey, entry ->
|
||||
IotDevicePropertyDO.builder().value(entry.getValue()).updateTime(message.getReportTime()).build());
|
||||
deviceDataRedisDAO.putAll(device.getId(), properties2);
|
||||
}
|
||||
|
||||
// 2.1 保存设备属性【数据】
|
||||
devicePropertyMapper.insert(device, properties, LocalDateTimeUtil.toEpochMilli(message.getReportTime()));
|
||||
|
||||
// 2.2 保存设备属性【日志】
|
||||
Map<String, IotDevicePropertyDO> properties2 = convertMap(properties.entrySet(), Map.Entry::getKey, entry ->
|
||||
IotDevicePropertyDO.builder().value(entry.getValue()).updateTime(message.getReportTime()).build());
|
||||
deviceDataRedisDAO.putAll(device.getId(), properties2);
|
||||
// 2.3 提取 GeoLocation 并更新设备定位
|
||||
// 为什么 properties 为空,也要执行定位更新?因为可能上报的属性里,没有合法属性,但是包含 GeoLocation 定位属性
|
||||
extractAndUpdateDeviceLocation(device, (Map<?, ?>) message.getParams());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -213,4 +231,77 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
||||
return deviceServerIdRedisDAO.get(id);
|
||||
}
|
||||
|
||||
// ========== 设备定位相关操作 ==========
|
||||
|
||||
/**
|
||||
* 从属性中提取 GeoLocation 并更新设备定位
|
||||
*
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/device-geolocation">阿里云规范</a>
|
||||
* GeoLocation 结构体包含:Longitude, Latitude, Altitude, CoordinateSystem
|
||||
*/
|
||||
private void extractAndUpdateDeviceLocation(IotDeviceDO device, Map<?, ?> params) {
|
||||
// 1. 解析 GeoLocation 经纬度坐标
|
||||
BigDecimal[] location = parseGeoLocation(params);
|
||||
if (location == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 更新设备定位
|
||||
deviceService.updateDeviceLocation(device, location[0], location[1]);
|
||||
log.info("[extractAndUpdateGeoLocation][设备({}) 定位更新: lng={}, lat={}]",
|
||||
device.getId(), location[0], location[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从属性参数中解析 GeoLocation,返回经纬度坐标数组 [longitude, latitude]
|
||||
*
|
||||
* @param params 属性参数
|
||||
* @return [经度, 纬度],解析失败返回 null
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private BigDecimal[] parseGeoLocation(Map<?, ?> params) {
|
||||
if (params == null) {
|
||||
return null;
|
||||
}
|
||||
// 1. 查找 GeoLocation 属性(标识符为 GeoLocation 或 geoLocation)
|
||||
Object geoValue = params.get("GeoLocation");
|
||||
if (geoValue == null) {
|
||||
geoValue = params.get("geoLocation");
|
||||
}
|
||||
if (geoValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 转换为 Map
|
||||
Map<String, Object> geoLocation = null;
|
||||
if (geoValue instanceof Map) {
|
||||
geoLocation = (Map<String, Object>) geoValue;
|
||||
} else if (geoValue instanceof String) {
|
||||
geoLocation = JsonUtils.parseObject((String) geoValue, Map.class);
|
||||
}
|
||||
if (geoLocation == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 提取经纬度(支持阿里云命名规范:首字母大写)
|
||||
BigDecimal longitude = getBigDecimal(geoLocation, "Longitude");
|
||||
if (longitude == null) {
|
||||
longitude = getBigDecimal(geoLocation, "longitude");
|
||||
}
|
||||
BigDecimal latitude = getBigDecimal(geoLocation, "Latitude");
|
||||
if (latitude == null) {
|
||||
latitude = getBigDecimal(geoLocation, "latitude");
|
||||
}
|
||||
if (longitude == null || latitude == null) {
|
||||
return null;
|
||||
}
|
||||
// 校验经纬度范围:经度 -180 到 180,纬度 -90 到 90
|
||||
if (longitude.compareTo(BigDecimal.valueOf(-180)) < 0 || longitude.compareTo(BigDecimal.valueOf(180)) > 0
|
||||
|| latitude.compareTo(BigDecimal.valueOf(-90)) < 0 || latitude.compareTo(BigDecimal.valueOf(90)) > 0) {
|
||||
log.warn("[parseGeoLocation][经纬度超出有效范围: lng={}, lat={}]", longitude, latitude);
|
||||
return null;
|
||||
}
|
||||
return new BigDecimal[]{longitude, latitude};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -105,6 +105,14 @@ public interface IotProductService {
|
||||
*/
|
||||
List<IotProductDO> getProductList();
|
||||
|
||||
/**
|
||||
* 根据设备类型获得产品列表
|
||||
*
|
||||
* @param deviceType 设备类型(可选)
|
||||
* @return 产品列表
|
||||
*/
|
||||
List<IotProductDO> getProductList(@Nullable Integer deviceType);
|
||||
|
||||
/**
|
||||
* 获得产品数量
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.service.product;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO;
|
||||
@@ -53,19 +54,22 @@ public class IotProductServiceImpl implements IotProductService {
|
||||
|
||||
// 2. 插入
|
||||
IotProductDO product = BeanUtils.toBean(createReqVO, IotProductDO.class)
|
||||
.setStatus(IotProductStatusEnum.UNPUBLISHED.getStatus());
|
||||
.setStatus(IotProductStatusEnum.UNPUBLISHED.getStatus())
|
||||
.setProductSecret(generateProductSecret());
|
||||
productMapper.insert(product);
|
||||
return product.getId();
|
||||
}
|
||||
|
||||
private String generateProductSecret() {
|
||||
return IdUtil.fastSimpleUUID();
|
||||
}
|
||||
|
||||
@Override
|
||||
@CacheEvict(value = RedisKeyConstants.PRODUCT, key = "#updateReqVO.id")
|
||||
public void updateProduct(IotProductSaveReqVO updateReqVO) {
|
||||
updateReqVO.setProductKey(null); // 不更新产品标识
|
||||
// 1.1 校验存在
|
||||
IotProductDO iotProductDO = validateProductExists(updateReqVO.getId());
|
||||
// 1.2 发布状态不可更新
|
||||
validateProductStatus(iotProductDO);
|
||||
// 1. 校验存在
|
||||
validateProductExists(updateReqVO.getId());
|
||||
|
||||
// 2. 更新
|
||||
IotProductDO updateObj = BeanUtils.toBean(updateReqVO, IotProductDO.class);
|
||||
@@ -157,6 +161,11 @@ public class IotProductServiceImpl implements IotProductService {
|
||||
return productMapper.selectList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotProductDO> getProductList(Integer deviceType) {
|
||||
return productMapper.selectList(deviceType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getProductCount(LocalDateTime createTime) {
|
||||
return productMapper.selectCountByCreateTime(createTime);
|
||||
|
||||
@@ -271,6 +271,10 @@ public class IotDataRuleServiceImpl implements IotDataRuleService {
|
||||
if (ObjUtil.notEqual(action.getType(), dataSink.getType())) {
|
||||
return;
|
||||
}
|
||||
if (CommonStatusEnum.isDisable(dataSink.getStatus())) {
|
||||
log.warn("[executeDataRuleAction][消息({}) 数据目的({}) 状态为禁用]", message.getId(), dataSink.getId());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
action.execute(message, dataSink);
|
||||
log.info("[executeDataRuleAction][消息({}) 数据目的({}) 执行成功]", message.getId(), dataSink.getId());
|
||||
|
||||
@@ -43,7 +43,6 @@ public class IotTcpDataRuleAction extends
|
||||
config.getConnectTimeoutMs(),
|
||||
config.getReadTimeoutMs(),
|
||||
config.getSsl(),
|
||||
config.getSslCertPath(),
|
||||
config.getDataFormat()
|
||||
);
|
||||
// 2.2 连接服务器
|
||||
|
||||
@@ -8,6 +8,10 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSock
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* WebSocket 的 {@link IotDataRuleAction} 实现类
|
||||
* <p>
|
||||
@@ -22,6 +26,18 @@ import org.springframework.stereotype.Component;
|
||||
public class IotWebSocketDataRuleAction extends
|
||||
IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> {
|
||||
|
||||
/**
|
||||
* 锁等待超时时间(毫秒)
|
||||
*/
|
||||
private static final long LOCK_WAIT_TIME_MS = 5000;
|
||||
|
||||
/**
|
||||
* 重连锁,key 为 WebSocket 服务器地址
|
||||
* <p>
|
||||
* WebSocket 连接是与特定服务器实例绑定的,使用单机锁即可保证重连的线程安全
|
||||
*/
|
||||
private final ConcurrentHashMap<String, ReentrantLock> reconnectLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Integer getType() {
|
||||
return IotDataSinkTypeEnum.WEBSOCKET.getType();
|
||||
@@ -62,12 +78,11 @@ public class IotWebSocketDataRuleAction extends
|
||||
protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception {
|
||||
try {
|
||||
// 1.1 获取或创建 WebSocket 客户端
|
||||
// TODO @puhui999:需要加锁,保证必须连接上;
|
||||
IotWebSocketClient webSocketClient = getProducer(config);
|
||||
// 1.2 检查连接状态,如果断开则重新连接
|
||||
|
||||
// 1.2 检查连接状态,如果断开则使用分布式锁保证重连的线程安全
|
||||
if (!webSocketClient.isConnected()) {
|
||||
log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
|
||||
webSocketClient.connect();
|
||||
reconnectWithLock(webSocketClient, config);
|
||||
}
|
||||
|
||||
// 2.1 发送消息
|
||||
@@ -82,4 +97,34 @@ public class IotWebSocketDataRuleAction extends
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @puhui999:为什么这里要加锁呀?
|
||||
/**
|
||||
* 使用锁进行重连,保证同一服务器地址的重连操作线程安全
|
||||
*
|
||||
* @param webSocketClient WebSocket 客户端
|
||||
* @param config 配置信息
|
||||
*/
|
||||
private void reconnectWithLock(IotWebSocketClient webSocketClient, IotDataSinkWebSocketConfig config) throws Exception {
|
||||
ReentrantLock lock = reconnectLocks.computeIfAbsent(config.getServerUrl(), k -> new ReentrantLock());
|
||||
boolean acquired = false;
|
||||
try {
|
||||
acquired = lock.tryLock(LOCK_WAIT_TIME_MS, TimeUnit.MILLISECONDS);
|
||||
if (!acquired) {
|
||||
throw new RuntimeException("获取 WebSocket 重连锁超时,服务器: " + config.getServerUrl());
|
||||
}
|
||||
// 双重检查:获取锁后再次检查连接状态,避免重复连接
|
||||
if (!webSocketClient.isConnected()) {
|
||||
log.warn("[reconnectWithLock][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl());
|
||||
webSocketClient.connect();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("获取 WebSocket 重连锁被中断,服务器: " + config.getServerUrl(), e);
|
||||
} finally {
|
||||
if (acquired && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp;
|
||||
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig;
|
||||
@@ -31,8 +32,6 @@ public class IotTcpClient {
|
||||
private final Integer connectTimeoutMs;
|
||||
private final Integer readTimeoutMs;
|
||||
private final Boolean ssl;
|
||||
// TODO @puhui999:sslCertPath 是不是没在用?
|
||||
private final String sslCertPath;
|
||||
private final String dataFormat;
|
||||
|
||||
private Socket socket;
|
||||
@@ -41,15 +40,13 @@ public class IotTcpClient {
|
||||
private final AtomicBoolean connected = new AtomicBoolean(false);
|
||||
|
||||
public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs,
|
||||
Boolean ssl, String sslCertPath, String dataFormat) {
|
||||
Boolean ssl, String dataFormat) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS;
|
||||
this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS;
|
||||
this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL;
|
||||
this.sslCertPath = sslCertPath;
|
||||
// TODO @puhui999:可以使用 StrUtil.defaultIfBlank 方法简化
|
||||
this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT;
|
||||
this.dataFormat = ObjUtil.defaultIfBlank(dataFormat, IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,9 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -19,21 +15,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
* <p>
|
||||
* 负责与外部 WebSocket 服务器建立连接并发送设备消息
|
||||
* 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式
|
||||
* 基于 Java 11+ 内置的 java.net.http.WebSocket 实现
|
||||
* 基于 OkHttp WebSocket 实现,兼容 JDK 8+
|
||||
* <p>
|
||||
* 注意:该类的线程安全由调用方(IotWebSocketDataRuleAction)通过分布式锁保证
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotWebSocketClient implements WebSocket.Listener {
|
||||
public class IotWebSocketClient {
|
||||
|
||||
private final String serverUrl;
|
||||
private final Integer connectTimeoutMs;
|
||||
private final Integer sendTimeoutMs;
|
||||
private final String dataFormat;
|
||||
|
||||
private WebSocket webSocket;
|
||||
private OkHttpClient okHttpClient;
|
||||
private volatile WebSocket webSocket;
|
||||
private final AtomicBoolean connected = new AtomicBoolean(false);
|
||||
private final StringBuilder messageBuffer = new StringBuilder();
|
||||
|
||||
public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) {
|
||||
this.serverUrl = serverUrl;
|
||||
@@ -44,8 +42,9 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
|
||||
/**
|
||||
* 连接到 WebSocket 服务器
|
||||
* <p>
|
||||
* 注意:调用方需要通过分布式锁保证并发安全
|
||||
*/
|
||||
@SuppressWarnings("resource")
|
||||
public void connect() throws Exception {
|
||||
if (connected.get()) {
|
||||
log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]");
|
||||
@@ -53,17 +52,30 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
}
|
||||
|
||||
try {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(connectTimeoutMs))
|
||||
// 创建 OkHttpClient
|
||||
okHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(sendTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
CompletableFuture<WebSocket> future = httpClient.newWebSocketBuilder()
|
||||
.connectTimeout(Duration.ofMillis(connectTimeoutMs))
|
||||
.buildAsync(URI.create(serverUrl), this);
|
||||
// 创建 WebSocket 请求
|
||||
Request request = new Request.Builder()
|
||||
.url(serverUrl)
|
||||
.build();
|
||||
|
||||
// 使用 CountDownLatch 等待连接完成
|
||||
CountDownLatch connectLatch = new CountDownLatch(1);
|
||||
AtomicBoolean connectSuccess = new AtomicBoolean(false);
|
||||
// 创建 WebSocket 连接
|
||||
webSocket = okHttpClient.newWebSocket(request, new IotWebSocketListener(connectLatch, connectSuccess));
|
||||
|
||||
// 等待连接完成
|
||||
webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS);
|
||||
connected.set(true);
|
||||
boolean await = connectLatch.await(connectTimeoutMs, TimeUnit.MILLISECONDS);
|
||||
if (!await || !connectSuccess.get()) {
|
||||
close();
|
||||
throw new Exception("WebSocket 连接超时或失败,服务器地址: " + serverUrl);
|
||||
}
|
||||
log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl);
|
||||
} catch (Exception e) {
|
||||
close();
|
||||
@@ -72,36 +84,6 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket) {
|
||||
log.debug("[onOpen][WebSocket 连接已打开]");
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||
messageBuffer.append(data);
|
||||
if (last) {
|
||||
log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer);
|
||||
messageBuffer.setLength(0);
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||
connected.set(false);
|
||||
log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Throwable error) {
|
||||
connected.set(false);
|
||||
log.error("[onError][WebSocket 发生错误]", error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备消息
|
||||
*
|
||||
@@ -109,7 +91,8 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
* @throws Exception 发送异常
|
||||
*/
|
||||
public void sendMessage(IotDeviceMessage message) throws Exception {
|
||||
if (!connected.get() || webSocket == null) {
|
||||
WebSocket ws = this.webSocket;
|
||||
if (!connected.get() || ws == null) {
|
||||
throw new IllegalStateException("WebSocket 客户端未连接");
|
||||
}
|
||||
|
||||
@@ -121,9 +104,11 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
messageData = message.toString();
|
||||
}
|
||||
|
||||
// 发送消息并等待完成
|
||||
CompletableFuture<WebSocket> future = webSocket.sendText(messageData, true);
|
||||
future.get(sendTimeoutMs, TimeUnit.MILLISECONDS);
|
||||
// 发送消息
|
||||
boolean success = ws.send(messageData);
|
||||
if (!success) {
|
||||
throw new Exception("WebSocket 发送消息失败,消息队列已满或连接已关闭");
|
||||
}
|
||||
log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]",
|
||||
message.getDeviceId(), messageData.length());
|
||||
} catch (Exception e) {
|
||||
@@ -136,18 +121,18 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
* 关闭连接
|
||||
*/
|
||||
public void close() {
|
||||
if (!connected.get() && webSocket == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (webSocket != null) {
|
||||
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭")
|
||||
.orTimeout(5, TimeUnit.SECONDS)
|
||||
.exceptionally(e -> {
|
||||
log.warn("[close][发送关闭帧失败]", e);
|
||||
return null;
|
||||
});
|
||||
// 发送正常关闭帧,状态码 1000 表示正常关闭
|
||||
// TODO @puhui999:有没 1000 的枚举哈?在 okhttp 里
|
||||
webSocket.close(1000, "客户端主动关闭");
|
||||
webSocket = null;
|
||||
}
|
||||
if (okHttpClient != null) {
|
||||
// 关闭连接池和调度器
|
||||
okHttpClient.dispatcher().executorService().shutdown();
|
||||
okHttpClient.connectionPool().evictAll();
|
||||
okHttpClient = null;
|
||||
}
|
||||
connected.set(false);
|
||||
log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl);
|
||||
@@ -174,4 +159,51 @@ public class IotWebSocketClient implements WebSocket.Listener {
|
||||
'}';
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttp WebSocket 监听器
|
||||
*/
|
||||
@SuppressWarnings("NullableProblems")
|
||||
private class IotWebSocketListener extends WebSocketListener {
|
||||
|
||||
private final CountDownLatch connectLatch;
|
||||
private final AtomicBoolean connectSuccess;
|
||||
|
||||
public IotWebSocketListener(CountDownLatch connectLatch, AtomicBoolean connectSuccess) {
|
||||
this.connectLatch = connectLatch;
|
||||
this.connectSuccess = connectSuccess;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket, Response response) {
|
||||
connected.set(true);
|
||||
connectSuccess.set(true);
|
||||
connectLatch.countDown();
|
||||
log.info("[onOpen][WebSocket 连接已打开,服务器: {}]", serverUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(WebSocket webSocket, String text) {
|
||||
log.debug("[onMessage][收到消息: {}]", text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing(WebSocket webSocket, int code, String reason) {
|
||||
connected.set(false);
|
||||
log.info("[onClosing][WebSocket 正在关闭,code: {}, reason: {}]", code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosed(WebSocket webSocket, int code, String reason) {
|
||||
connected.set(false);
|
||||
log.info("[onClosed][WebSocket 已关闭,code: {}, reason: {}]", code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(WebSocket webSocket, Throwable t, Response response) {
|
||||
connected.set(false);
|
||||
connectLatch.countDown(); // 确保连接失败时也释放等待
|
||||
log.error("[onFailure][WebSocket 连接失败]", t);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherManager;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -62,6 +63,8 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
|
||||
private List<IotSceneRuleAction> sceneRuleActions;
|
||||
@Resource
|
||||
private IotSceneRuleTimerHandler timerHandler;
|
||||
@Resource
|
||||
private IotTimerConditionEvaluator timerConditionEvaluator;
|
||||
|
||||
@Override
|
||||
@CacheEvict(value = RedisKeyConstants.SCENE_RULE_LIST, allEntries = true)
|
||||
@@ -222,18 +225,98 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService {
|
||||
return;
|
||||
}
|
||||
// 1.2 判断是否有定时触发器,避免脏数据
|
||||
IotSceneRuleDO.Trigger config = CollUtil.findOne(scene.getTriggers(),
|
||||
IotSceneRuleDO.Trigger timerTrigger = CollUtil.findOne(scene.getTriggers(),
|
||||
trigger -> ObjUtil.equals(trigger.getType(), IotSceneRuleTriggerTypeEnum.TIMER.getType()));
|
||||
if (config == null) {
|
||||
if (timerTrigger == null) {
|
||||
log.error("[executeSceneRuleByTimer][规则场景({}) 不存在定时触发器]", scene);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行规则场景
|
||||
// 2. 评估条件组(新增逻辑)
|
||||
log.info("[executeSceneRuleByTimer][规则场景({}) 开始评估条件组]", id);
|
||||
if (!evaluateTimerConditionGroups(scene, timerTrigger)) {
|
||||
log.info("[executeSceneRuleByTimer][规则场景({}) 条件组不满足,跳过执行]", id);
|
||||
return;
|
||||
}
|
||||
log.info("[executeSceneRuleByTimer][规则场景({}) 条件组评估通过,准备执行动作]", id);
|
||||
|
||||
// 3. 执行规则场景
|
||||
TenantUtils.execute(scene.getTenantId(),
|
||||
() -> executeSceneRuleAction(null, ListUtil.toList(scene)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估定时触发器的条件组
|
||||
*
|
||||
* @param scene 场景规则
|
||||
* @param trigger 定时触发器
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateTimerConditionGroups(IotSceneRuleDO scene, IotSceneRuleDO.Trigger trigger) {
|
||||
// 1. 如果没有条件组,直接返回 true(直接执行动作)
|
||||
if (CollUtil.isEmpty(trigger.getConditionGroups())) {
|
||||
log.debug("[evaluateTimerConditionGroups][规则场景({}) 无条件组配置,直接执行]", scene.getId());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 条件组之间是 OR 关系,任一条件组满足即可
|
||||
for (List<IotSceneRuleDO.TriggerCondition> conditionGroup : trigger.getConditionGroups()) {
|
||||
if (evaluateSingleConditionGroup(scene, conditionGroup)) {
|
||||
log.debug("[evaluateTimerConditionGroups][规则场景({}) 条件组匹配成功]", scene.getId());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 所有条件组都不满足
|
||||
log.debug("[evaluateTimerConditionGroups][规则场景({}) 所有条件组都不满足]", scene.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估单个条件组
|
||||
*
|
||||
* @param scene 场景规则
|
||||
* @param conditionGroup 条件组
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateSingleConditionGroup(IotSceneRuleDO scene,
|
||||
List<IotSceneRuleDO.TriggerCondition> conditionGroup) {
|
||||
// 1. 空条件组视为满足
|
||||
if (CollUtil.isEmpty(conditionGroup)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 条件之间是 AND 关系,所有条件都必须满足
|
||||
for (IotSceneRuleDO.TriggerCondition condition : conditionGroup) {
|
||||
if (!evaluateTimerCondition(scene, condition)) {
|
||||
log.debug("[evaluateSingleConditionGroup][规则场景({}) 条件({}) 不满足]",
|
||||
scene.getId(), condition);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估单个条件(定时触发器专用)
|
||||
*
|
||||
* @param scene 场景规则
|
||||
* @param condition 条件
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateTimerCondition(IotSceneRuleDO scene, IotSceneRuleDO.TriggerCondition condition) {
|
||||
try {
|
||||
boolean result = timerConditionEvaluator.evaluate(condition);
|
||||
log.debug("[evaluateTimerCondition][规则场景({}) 条件类型({}) 评估结果: {}]",
|
||||
scene.getId(), condition.getType(), result);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("[evaluateTimerCondition][规则场景({}) 条件评估异常]", scene.getId(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于消息,获得匹配的规则场景列表
|
||||
*
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.text.CharPool;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition.IotCurrentTimeConditionMatcher;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 场景规则时间匹配工具类
|
||||
* <p>
|
||||
* 提供时间条件匹配的通用方法,供 {@link IotCurrentTimeConditionMatcher} 和 {@link IotTimerConditionEvaluator} 共同使用。
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotSceneRuleTimeHelper {
|
||||
|
||||
/**
|
||||
* 时间格式化器 - HH:mm:ss
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/**
|
||||
* 时间格式化器 - HH:mm
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
// TODO @puhui999:可以使用 lombok 简化
|
||||
private IotSceneRuleTimeHelper() {
|
||||
// 工具类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为日期时间操作符
|
||||
*
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @return 是否为日期时间操作符
|
||||
*/
|
||||
public static boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) {
|
||||
return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN
|
||||
|| operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN
|
||||
|| operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为时间操作符(包括日期时间操作符和当日时间操作符)
|
||||
*
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @return 是否为时间操作符
|
||||
*/
|
||||
public static boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) {
|
||||
return operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN
|
||||
&& operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN
|
||||
&& operatorEnum != IotSceneRuleConditionOperatorEnum.TIME_BETWEEN
|
||||
&& !isDateTimeOperator(operatorEnum);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行时间匹配逻辑
|
||||
*
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @param param 参数值
|
||||
* @return 是否匹配
|
||||
*/
|
||||
public static boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (isDateTimeOperator(operatorEnum)) {
|
||||
// 日期时间匹配(时间戳,秒级)
|
||||
long currentTimestamp = now.atZone(ZoneId.systemDefault()).toEpochSecond();
|
||||
return matchDateTime(currentTimestamp, operatorEnum, param);
|
||||
} else {
|
||||
// 当日时间匹配(HH:mm:ss)
|
||||
return matchTime(now.toLocalTime(), operatorEnum, param);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[executeTimeMatching][operatorEnum({}) param({}) 时间匹配异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配日期时间(时间戳,秒级)
|
||||
*
|
||||
* @param currentTimestamp 当前时间戳
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @param param 参数值
|
||||
* @return 是否匹配
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum,
|
||||
String param) {
|
||||
try {
|
||||
// DATE_TIME_BETWEEN 需要解析两个时间戳,单独处理
|
||||
if (operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN) {
|
||||
return matchDateTimeBetween(currentTimestamp, param);
|
||||
}
|
||||
// 其他操作符只需要解析一个时间戳
|
||||
long targetTimestamp = Long.parseLong(param);
|
||||
switch (operatorEnum) {
|
||||
case DATE_TIME_GREATER_THAN:
|
||||
return currentTimestamp > targetTimestamp;
|
||||
case DATE_TIME_LESS_THAN:
|
||||
return currentTimestamp < targetTimestamp;
|
||||
default:
|
||||
log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配日期时间区间
|
||||
*
|
||||
* @param currentTimestamp 当前时间戳
|
||||
* @param param 参数值(格式:startTimestamp,endTimestamp)
|
||||
* @return 是否匹配
|
||||
*/
|
||||
public static boolean matchDateTimeBetween(long currentTimestamp, String param) {
|
||||
List<String> timestampRange = StrUtil.splitTrim(param, CharPool.COMMA);
|
||||
if (timestampRange.size() != 2) {
|
||||
log.warn("[matchDateTimeBetween][param({}) 时间戳区间参数格式错误]", param);
|
||||
return false;
|
||||
}
|
||||
long startTimestamp = Long.parseLong(timestampRange.get(0).trim());
|
||||
long endTimestamp = Long.parseLong(timestampRange.get(1).trim());
|
||||
// TODO @puhui999:hutool 里,看看有没 between 方法
|
||||
return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配当日时间(HH:mm:ss 或 HH:mm)
|
||||
*
|
||||
* @param currentTime 当前时间
|
||||
* @param operatorEnum 操作符枚举
|
||||
* @param param 参数值
|
||||
* @return 是否匹配
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum,
|
||||
String param) {
|
||||
try {
|
||||
// TIME_BETWEEN 需要解析两个时间,单独处理
|
||||
if (operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN) {
|
||||
return matchTimeBetween(currentTime, param);
|
||||
}
|
||||
// 其他操作符只需要解析一个时间
|
||||
LocalTime targetTime = parseTime(param);
|
||||
switch (operatorEnum) {
|
||||
case TIME_GREATER_THAN:
|
||||
return currentTime.isAfter(targetTime);
|
||||
case TIME_LESS_THAN:
|
||||
return currentTime.isBefore(targetTime);
|
||||
default:
|
||||
log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[matchTime][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配时间区间
|
||||
*
|
||||
* @param currentTime 当前时间
|
||||
* @param param 参数值(格式:startTime,endTime)
|
||||
* @return 是否匹配
|
||||
*/
|
||||
public static boolean matchTimeBetween(LocalTime currentTime, String param) {
|
||||
List<String> timeRange = StrUtil.splitTrim(param, CharPool.COMMA);
|
||||
if (timeRange.size() != 2) {
|
||||
log.warn("[matchTimeBetween][param({}) 时间区间参数格式错误]", param);
|
||||
return false;
|
||||
}
|
||||
LocalTime startTime = parseTime(timeRange.get(0).trim());
|
||||
LocalTime endTime = parseTime(timeRange.get(1).trim());
|
||||
// TODO @puhui999:hutool 里,看看有没 between 方法
|
||||
return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间字符串
|
||||
* 支持 HH:mm 和 HH:mm:ss 两种格式
|
||||
*
|
||||
* @param timeStr 时间字符串
|
||||
* @return 解析后的 LocalTime
|
||||
*/
|
||||
public static LocalTime parseTime(String timeStr) {
|
||||
Assert.isFalse(StrUtil.isBlank(timeStr), "时间字符串不能为空");
|
||||
try {
|
||||
// 尝试不同的时间格式
|
||||
if (timeStr.length() == 5) { // HH:mm
|
||||
return LocalTime.parse(timeStr, TIME_FORMATTER_SHORT);
|
||||
} else if (timeStr.length() == 8) { // HH:mm:ss
|
||||
return LocalTime.parse(timeStr, TIME_FORMATTER);
|
||||
} else {
|
||||
throw new IllegalArgumentException("时间格式长度不正确,期望 HH:mm 或 HH:mm:ss 格式");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[parseTime][timeStr({}) 时间格式解析失败]", timeStr, e);
|
||||
throw new IllegalArgumentException("时间格式无效: " + timeStr, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.text.CharPool;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleTimeHelper;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 当前时间条件匹配器:处理时间相关的子条件匹配逻辑
|
||||
*
|
||||
@@ -25,16 +18,6 @@ import java.util.List;
|
||||
@Slf4j
|
||||
public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatcher {
|
||||
|
||||
/**
|
||||
* 时间格式化器 - HH:mm:ss
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/**
|
||||
* 时间格式化器 - HH:mm
|
||||
*/
|
||||
private static final DateTimeFormatter TIME_FORMATTER_SHORT = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
@Override
|
||||
public IotSceneRuleConditionTypeEnum getSupportedConditionType() {
|
||||
return IotSceneRuleConditionTypeEnum.CURRENT_TIME;
|
||||
@@ -62,13 +45,13 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isTimeOperator(operatorEnum)) {
|
||||
if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) {
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "不支持的时间操作符: " + operator);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2.1 执行时间匹配
|
||||
boolean matched = executeTimeMatching(operatorEnum, condition.getParam());
|
||||
boolean matched = IotSceneRuleTimeHelper.executeTimeMatching(operatorEnum, condition.getParam());
|
||||
|
||||
// 2.2 记录匹配结果
|
||||
if (matched) {
|
||||
@@ -80,145 +63,6 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行时间匹配逻辑
|
||||
* 直接实现时间条件匹配,不使用 Spring EL 表达式
|
||||
*/
|
||||
private boolean executeTimeMatching(IotSceneRuleConditionOperatorEnum operatorEnum, String param) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (isDateTimeOperator(operatorEnum)) {
|
||||
// 日期时间匹配(时间戳)
|
||||
long currentTimestamp = now.toEpochSecond(java.time.ZoneOffset.of("+8"));
|
||||
return matchDateTime(currentTimestamp, operatorEnum, param);
|
||||
} else {
|
||||
// 当日时间匹配(HH:mm:ss)
|
||||
return matchTime(now.toLocalTime(), operatorEnum, param);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[executeTimeMatching][operatorEnum({}) param({}) 时间匹配异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为日期时间操作符
|
||||
*/
|
||||
private boolean isDateTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) {
|
||||
return operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_GREATER_THAN ||
|
||||
operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_LESS_THAN ||
|
||||
operatorEnum == IotSceneRuleConditionOperatorEnum.DATE_TIME_BETWEEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为时间操作符
|
||||
*/
|
||||
private boolean isTimeOperator(IotSceneRuleConditionOperatorEnum operatorEnum) {
|
||||
return operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_GREATER_THAN ||
|
||||
operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_LESS_THAN ||
|
||||
operatorEnum == IotSceneRuleConditionOperatorEnum.TIME_BETWEEN ||
|
||||
isDateTimeOperator(operatorEnum);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配日期时间(时间戳)
|
||||
* 直接实现时间戳比较逻辑
|
||||
*/
|
||||
private boolean matchDateTime(long currentTimestamp, IotSceneRuleConditionOperatorEnum operatorEnum, String param) {
|
||||
try {
|
||||
long targetTimestamp = Long.parseLong(param);
|
||||
switch (operatorEnum) {
|
||||
case DATE_TIME_GREATER_THAN:
|
||||
return currentTimestamp > targetTimestamp;
|
||||
case DATE_TIME_LESS_THAN:
|
||||
return currentTimestamp < targetTimestamp;
|
||||
case DATE_TIME_BETWEEN:
|
||||
return matchDateTimeBetween(currentTimestamp, param);
|
||||
default:
|
||||
log.warn("[matchDateTime][operatorEnum({}) 不支持的日期时间操作符]", operatorEnum);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[matchDateTime][operatorEnum({}) param({}) 日期时间匹配异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配日期时间区间
|
||||
*/
|
||||
private boolean matchDateTimeBetween(long currentTimestamp, String param) {
|
||||
List<String> timestampRange = StrUtil.splitTrim(param, CharPool.COMMA);
|
||||
if (timestampRange.size() != 2) {
|
||||
log.warn("[matchDateTimeBetween][param({}) 时间戳区间参数格式错误]", param);
|
||||
return false;
|
||||
}
|
||||
long startTimestamp = Long.parseLong(timestampRange.get(0).trim());
|
||||
long endTimestamp = Long.parseLong(timestampRange.get(1).trim());
|
||||
return currentTimestamp >= startTimestamp && currentTimestamp <= endTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配当日时间(HH:mm:ss)
|
||||
* 直接实现时间比较逻辑
|
||||
*/
|
||||
private boolean matchTime(LocalTime currentTime, IotSceneRuleConditionOperatorEnum operatorEnum, String param) {
|
||||
try {
|
||||
LocalTime targetTime = parseTime(param);
|
||||
switch (operatorEnum) {
|
||||
case TIME_GREATER_THAN:
|
||||
return currentTime.isAfter(targetTime);
|
||||
case TIME_LESS_THAN:
|
||||
return currentTime.isBefore(targetTime);
|
||||
case TIME_BETWEEN:
|
||||
return matchTimeBetween(currentTime, param);
|
||||
default:
|
||||
log.warn("[matchTime][operatorEnum({}) 不支持的时间操作符]", operatorEnum);
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[matchTime][][operatorEnum({}) param({}) 时间解析异常]", operatorEnum, param, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配时间区间
|
||||
*/
|
||||
private boolean matchTimeBetween(LocalTime currentTime, String param) {
|
||||
List<String> timeRange = StrUtil.splitTrim(param, CharPool.COMMA);
|
||||
if (timeRange.size() != 2) {
|
||||
log.warn("[matchTimeBetween][param({}) 时间区间参数格式错误]", param);
|
||||
return false;
|
||||
}
|
||||
LocalTime startTime = parseTime(timeRange.get(0).trim());
|
||||
LocalTime endTime = parseTime(timeRange.get(1).trim());
|
||||
return !currentTime.isBefore(startTime) && !currentTime.isAfter(endTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析时间字符串
|
||||
* 支持 HH:mm 和 HH:mm:ss 两种格式
|
||||
*/
|
||||
private LocalTime parseTime(String timeStr) {
|
||||
Assert.isFalse(StrUtil.isBlank(timeStr), "时间字符串不能为空");
|
||||
|
||||
try {
|
||||
// 尝试不同的时间格式
|
||||
if (timeStr.length() == 5) { // HH:mm
|
||||
return LocalTime.parse(timeStr, TIME_FORMATTER_SHORT);
|
||||
} else if (timeStr.length() == 8) { // HH:mm:ss
|
||||
return LocalTime.parse(timeStr, TIME_FORMATTER);
|
||||
} else {
|
||||
throw new IllegalArgumentException("时间格式长度不正确,期望 HH:mm 或 HH:mm:ss 格式");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[parseTime][timeStr({}) 时间格式解析失败]", timeStr, e);
|
||||
throw new IllegalArgumentException("时间格式无效: " + timeStr, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPriority() {
|
||||
return 40; // 较低优先级
|
||||
|
||||
@@ -38,8 +38,7 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM
|
||||
|
||||
// 1.3 检查消息中是否包含触发器指定的属性标识符
|
||||
// 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中
|
||||
// TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反)
|
||||
if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) {
|
||||
if (IotDeviceMessageUtils.notContainsIdentifier(message, trigger.getIdentifier())) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " +
|
||||
trigger.getIdentifier());
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
@@ -8,6 +9,8 @@ import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备服务调用触发器匹配器:处理设备服务调用的触发器匹配逻辑
|
||||
*
|
||||
@@ -28,13 +31,11 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "触发器基础参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.2 检查消息方法是否匹配
|
||||
if (!IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod().equals(message.getMethod())) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod() + ", 实际: " + message.getMethod());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查标识符是否匹配
|
||||
String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message);
|
||||
if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) {
|
||||
@@ -42,13 +43,58 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 对于服务调用触发器,通常只需要匹配服务标识符即可
|
||||
// 不需要检查操作符和值,因为服务调用本身就是触发条件
|
||||
// TODO @puhui999: 服务调用时校验输入参数是否匹配条件?
|
||||
// 2. 检查是否配置了参数条件
|
||||
if (hasParameterCondition(trigger)) {
|
||||
return matchParameterCondition(message, trigger);
|
||||
}
|
||||
|
||||
// 3. 无参数条件时,标识符匹配即成功
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断触发器是否配置了参数条件
|
||||
*
|
||||
* @param trigger 触发器配置
|
||||
* @return 是否配置了参数条件
|
||||
*/
|
||||
private boolean hasParameterCondition(IotSceneRuleDO.Trigger trigger) {
|
||||
return StrUtil.isNotBlank(trigger.getOperator()) && StrUtil.isNotBlank(trigger.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配参数条件
|
||||
*
|
||||
* @param message 设备消息
|
||||
* @param trigger 触发器配置
|
||||
* @return 是否匹配
|
||||
*/
|
||||
private boolean matchParameterCondition(IotDeviceMessage message, IotSceneRuleDO.Trigger trigger) {
|
||||
// 1.1 从消息中提取服务调用的输入参数
|
||||
Map<String, Object> inputParams = IotDeviceMessageUtils.extractServiceInputParams(message);
|
||||
// TODO @puhui999:要考虑 empty 的情况么?
|
||||
if (inputParams == null) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中缺少服务输入参数");
|
||||
return false;
|
||||
}
|
||||
// 1.2 获取要匹配的参数值(使用 identifier 作为参数名)
|
||||
Object paramValue = inputParams.get(trigger.getIdentifier());
|
||||
if (paramValue == null) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "服务输入参数中缺少指定参数: " + trigger.getIdentifier());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 使用条件评估器进行匹配
|
||||
boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(paramValue, trigger.getOperator(), trigger.getValue());
|
||||
if (matched) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchSuccess(message, trigger);
|
||||
} else {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "服务输入参数条件不匹配");
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPriority() {
|
||||
return 40; // 较低优先级
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene.timer;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleTimeHelper;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 定时触发器条件评估器
|
||||
* <p>
|
||||
* 与设备触发器不同,定时触发器没有设备消息上下文,
|
||||
* 需要主动查询设备属性和状态来评估条件。
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class IotTimerConditionEvaluator {
|
||||
|
||||
@Resource
|
||||
private IotDevicePropertyService devicePropertyService;
|
||||
|
||||
@Resource
|
||||
private IotDeviceService deviceService;
|
||||
|
||||
/**
|
||||
* 评估条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public boolean evaluate(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1.1 基础参数校验
|
||||
if (condition == null || condition.getType() == null) {
|
||||
log.warn("[evaluate][条件为空或类型为空]");
|
||||
return false;
|
||||
}
|
||||
// 1.2 根据条件类型分发到具体的评估方法
|
||||
IotSceneRuleConditionTypeEnum conditionType =
|
||||
IotSceneRuleConditionTypeEnum.typeOf(condition.getType());
|
||||
if (conditionType == null) {
|
||||
log.warn("[evaluate][未知的条件类型: {}]", condition.getType());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 分发评估
|
||||
switch (conditionType) {
|
||||
case DEVICE_PROPERTY:
|
||||
return evaluateDevicePropertyCondition(condition);
|
||||
case DEVICE_STATE:
|
||||
return evaluateDeviceStateCondition(condition);
|
||||
case CURRENT_TIME:
|
||||
return evaluateCurrentTimeCondition(condition);
|
||||
default:
|
||||
log.warn("[evaluate][未知的条件类型: {}]", conditionType);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估设备属性条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateDevicePropertyCondition(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1. 校验必要参数
|
||||
if (condition.getDeviceId() == null) {
|
||||
log.debug("[evaluateDevicePropertyCondition][设备ID为空]");
|
||||
return false;
|
||||
}
|
||||
if (StrUtil.isBlank(condition.getIdentifier())) {
|
||||
log.debug("[evaluateDevicePropertyCondition][属性标识符为空]");
|
||||
return false;
|
||||
}
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
log.debug("[evaluateDevicePropertyCondition][操作符或参数无效]");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2.1 获取设备最新属性值
|
||||
Map<String, IotDevicePropertyDO> properties =
|
||||
devicePropertyService.getLatestDeviceProperties(condition.getDeviceId());
|
||||
if (CollUtil.isEmpty(properties)) {
|
||||
log.debug("[evaluateDevicePropertyCondition][设备({}) 无属性数据]", condition.getDeviceId());
|
||||
return false;
|
||||
}
|
||||
// 2.2 获取指定属性
|
||||
IotDevicePropertyDO property = properties.get(condition.getIdentifier());
|
||||
if (property == null || property.getValue() == null) {
|
||||
log.debug("[evaluateDevicePropertyCondition][设备({}) 属性({}) 不存在或值为空]",
|
||||
condition.getDeviceId(), condition.getIdentifier());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 使用现有的条件评估逻辑进行比较
|
||||
boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(
|
||||
property.getValue(), condition.getOperator(), condition.getParam());
|
||||
log.debug("[evaluateDevicePropertyCondition][设备({}) 属性({}) 值({}) 操作符({}) 参数({}) 匹配结果: {}]",
|
||||
condition.getDeviceId(), condition.getIdentifier(), property.getValue(),
|
||||
condition.getOperator(), condition.getParam(), matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估设备状态条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateDeviceStateCondition(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1. 校验必要参数
|
||||
if (condition.getDeviceId() == null) {
|
||||
log.debug("[evaluateDeviceStateCondition][设备ID为空]");
|
||||
return false;
|
||||
}
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
log.debug("[evaluateDeviceStateCondition][操作符或参数无效]");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2.1 获取设备信息
|
||||
IotDeviceDO device = deviceService.getDevice(condition.getDeviceId());
|
||||
if (device == null) {
|
||||
log.debug("[evaluateDeviceStateCondition][设备({}) 不存在]", condition.getDeviceId());
|
||||
return false;
|
||||
}
|
||||
// 2.2 获取设备状态
|
||||
Integer state = device.getState();
|
||||
if (state == null) {
|
||||
log.debug("[evaluateDeviceStateCondition][设备({}) 状态为空]", condition.getDeviceId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 比较状态
|
||||
boolean matched = IotSceneRuleMatcherHelper.evaluateCondition(
|
||||
state.toString(), condition.getOperator(), condition.getParam());
|
||||
log.debug("[evaluateDeviceStateCondition][设备({}) 状态({}) 操作符({}) 参数({}) 匹配结果: {}]",
|
||||
condition.getDeviceId(), state, condition.getOperator(), condition.getParam(), matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估当前时间条件
|
||||
*
|
||||
* @param condition 条件配置
|
||||
* @return 是否满足条件
|
||||
*/
|
||||
private boolean evaluateCurrentTimeCondition(IotSceneRuleDO.TriggerCondition condition) {
|
||||
// 1.1 校验必要参数
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
log.debug("[evaluateCurrentTimeCondition][操作符或参数无效]");
|
||||
return false;
|
||||
}
|
||||
// 1.2 验证操作符是否为支持的时间操作符
|
||||
IotSceneRuleConditionOperatorEnum operatorEnum =
|
||||
IotSceneRuleConditionOperatorEnum.operatorOf(condition.getOperator());
|
||||
if (operatorEnum == null) {
|
||||
log.debug("[evaluateCurrentTimeCondition][无效的操作符: {}]", condition.getOperator());
|
||||
return false;
|
||||
}
|
||||
if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) {
|
||||
log.debug("[evaluateCurrentTimeCondition][不支持的时间操作符: {}]", condition.getOperator());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 执行时间匹配
|
||||
boolean matched = IotSceneRuleTimeHelper.executeTimeMatching(operatorEnum, condition.getParam());
|
||||
log.debug("[evaluateCurrentTimeCondition][操作符({}) 参数({}) 匹配结果: {}]",
|
||||
condition.getOperator(), condition.getParam(), matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp;
|
||||
|
||||
import cn.hutool.core.util.ReflectUtil;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link IotTcpClient} 的单元测试
|
||||
* <p>
|
||||
* 测试 dataFormat 默认值行为
|
||||
* Property 1: TCP 客户端 dataFormat 默认值行为
|
||||
* Validates: Requirements 1.1, 1.2
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
class IotTcpClientTest {
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatNull() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
|
||||
// 断言:dataFormat 为 null 时应使用默认值
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatEmpty() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, "");
|
||||
|
||||
// 断言:dataFormat 为空字符串时应使用默认值
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatBlank() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, " ");
|
||||
|
||||
// 断言:dataFormat 为纯空白字符串时应使用默认值
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_dataFormatValid() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
String dataFormat = "BINARY";
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, dataFormat);
|
||||
|
||||
// 断言:dataFormat 为有效值时应保持原值
|
||||
assertEquals(dataFormat, ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_defaultValues() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
|
||||
// 断言:验证所有默认值
|
||||
assertEquals(host, ReflectUtil.getFieldValue(client, "host"));
|
||||
assertEquals(port, ReflectUtil.getFieldValue(client, "port"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
ReflectUtil.getFieldValue(client, "connectTimeoutMs"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS,
|
||||
ReflectUtil.getFieldValue(client, "readTimeoutMs"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_SSL,
|
||||
ReflectUtil.getFieldValue(client, "ssl"));
|
||||
assertEquals(IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT,
|
||||
ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_customValues() {
|
||||
// 准备参数
|
||||
String host = "192.168.1.100";
|
||||
Integer port = 9090;
|
||||
Integer connectTimeoutMs = 3000;
|
||||
Integer readTimeoutMs = 8000;
|
||||
Boolean ssl = true;
|
||||
String dataFormat = "BINARY";
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, connectTimeoutMs, readTimeoutMs, ssl, dataFormat);
|
||||
|
||||
// 断言:验证自定义值
|
||||
assertEquals(host, ReflectUtil.getFieldValue(client, "host"));
|
||||
assertEquals(port, ReflectUtil.getFieldValue(client, "port"));
|
||||
assertEquals(connectTimeoutMs, ReflectUtil.getFieldValue(client, "connectTimeoutMs"));
|
||||
assertEquals(readTimeoutMs, ReflectUtil.getFieldValue(client, "readTimeoutMs"));
|
||||
assertEquals(ssl, ReflectUtil.getFieldValue(client, "ssl"));
|
||||
assertEquals(dataFormat, ReflectUtil.getFieldValue(client, "dataFormat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIsConnected_initialState() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
|
||||
// 断言:初始状态应为未连接
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToString() {
|
||||
// 准备参数
|
||||
String host = "localhost";
|
||||
Integer port = 8080;
|
||||
|
||||
// 调用
|
||||
IotTcpClient client = new IotTcpClient(host, port, null, null, null, null);
|
||||
String result = client.toString();
|
||||
|
||||
// 断言
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("host='localhost'"));
|
||||
assertTrue(result.contains("port=8080"));
|
||||
assertTrue(result.contains("dataFormat='JSON'"));
|
||||
assertTrue(result.contains("connected=false"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.WebSocket;
|
||||
import okhttp3.WebSocketListener;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link IotWebSocketClient} 的单元测试
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
class IotWebSocketClientTest {
|
||||
|
||||
private MockWebServer mockWebServer;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() throws Exception {
|
||||
mockWebServer = new MockWebServer();
|
||||
mockWebServer.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() throws Exception {
|
||||
if (mockWebServer != null) {
|
||||
mockWebServer.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 WebSocket 监听器,用于测试
|
||||
*/
|
||||
private static class TestWebSocketListener extends WebSocketListener {
|
||||
@Override
|
||||
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
|
||||
// 连接打开
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
|
||||
// 收到消息
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
|
||||
webSocket.close(code, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
|
||||
// 连接失败
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_defaultValues() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
|
||||
// 调用
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, null, null, null);
|
||||
|
||||
// 断言:验证默认值被正确设置
|
||||
assertNotNull(client);
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConstructor_customValues() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
Integer connectTimeoutMs = 3000;
|
||||
Integer sendTimeoutMs = 5000;
|
||||
String dataFormat = "TEXT";
|
||||
|
||||
// 调用
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, connectTimeoutMs, sendTimeoutMs, dataFormat);
|
||||
|
||||
// 断言
|
||||
assertNotNull(client);
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConnect_success() throws Exception {
|
||||
// 准备参数:使用 MockWebServer 的 WebSocket 端点
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// mock:设置 MockWebServer 响应 WebSocket 升级请求
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
|
||||
// 断言
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConnect_alreadyConnected() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用:第一次连接
|
||||
client.connect();
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 调用:第二次连接(应该不会重复连接)
|
||||
client.connect();
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessage_success() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
IotDeviceMessage message = IotDeviceMessage.builder()
|
||||
.deviceId(123L)
|
||||
.method("thing.property.report")
|
||||
.params("{\"temperature\": 25.5}")
|
||||
.build();
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
client.sendMessage(message);
|
||||
|
||||
// 断言:消息发送成功不抛异常
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessage_notConnected() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
IotDeviceMessage message = IotDeviceMessage.builder()
|
||||
.deviceId(123L)
|
||||
.method("thing.property.report")
|
||||
.params("{\"temperature\": 25.5}")
|
||||
.build();
|
||||
|
||||
// 调用 & 断言:未连接时发送消息应抛出异常
|
||||
assertThrows(IllegalStateException.class, () -> client.sendMessage(message));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClose_success() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
client.close();
|
||||
|
||||
// 断言
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClose_notConnected() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// 调用:关闭未连接的客户端不应抛异常
|
||||
assertDoesNotThrow(client::close);
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIsConnected_initialState() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// 断言:初始状态应为未连接
|
||||
assertFalse(client.isConnected());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testToString() {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://localhost:8080";
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "JSON");
|
||||
|
||||
// 调用
|
||||
String result = client.toString();
|
||||
|
||||
// 断言
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("serverUrl='ws://localhost:8080'"));
|
||||
assertTrue(result.contains("dataFormat='JSON'"));
|
||||
assertTrue(result.contains("connected=false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessage_textFormat() throws Exception {
|
||||
// 准备参数
|
||||
String serverUrl = "ws://" + mockWebServer.getHostName() + ":" + mockWebServer.getPort();
|
||||
IotWebSocketClient client = new IotWebSocketClient(serverUrl, 5000, 5000, "TEXT");
|
||||
|
||||
IotDeviceMessage message = IotDeviceMessage.builder()
|
||||
.deviceId(123L)
|
||||
.method("thing.property.report")
|
||||
.params("{\"temperature\": 25.5}")
|
||||
.build();
|
||||
|
||||
// mock
|
||||
mockWebServer.enqueue(new MockResponse().withWebSocketUpgrade(new TestWebSocketListener()));
|
||||
|
||||
// 调用
|
||||
client.connect();
|
||||
client.sendMessage(message);
|
||||
|
||||
// 断言:消息发送成功不抛异常
|
||||
assertTrue(client.isConnected());
|
||||
|
||||
// 清理
|
||||
client.close();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package cn.iocoder.yudao.module.iot.service.rule.scene;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.mysql.rule.IotSceneRuleMapper;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.action.IotSceneRuleAction;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotSceneRuleTimerHandler;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.timer.IotTimerConditionEvaluator;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link IotSceneRuleServiceImpl} 定时触发器条件组集成测试
|
||||
* <p>
|
||||
* 测试定时触发器的条件组评估功能:
|
||||
* - 空条件组直接执行动作
|
||||
* - 条件组评估后决定是否执行动作
|
||||
* - 条件组之间的 OR 逻辑
|
||||
* - 条件组内的 AND 逻辑
|
||||
* - 所有条件组不满足时跳过执行
|
||||
* <p>
|
||||
* Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5
|
||||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@Disabled // TODO @puhui999:单测有报错,先屏蔽
|
||||
public class IotSceneRuleTimerConditionIntegrationTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private IotSceneRuleServiceImpl sceneRuleService;
|
||||
|
||||
@Mock
|
||||
private IotSceneRuleMapper sceneRuleMapper;
|
||||
|
||||
@Mock
|
||||
private IotDeviceService deviceService;
|
||||
|
||||
@Mock
|
||||
private IotDevicePropertyService devicePropertyService;
|
||||
|
||||
@Mock
|
||||
private List<IotSceneRuleAction> sceneRuleActions;
|
||||
|
||||
@Mock
|
||||
private IotSceneRuleTimerHandler timerHandler;
|
||||
|
||||
private IotTimerConditionEvaluator timerConditionEvaluator;
|
||||
|
||||
// 测试常量
|
||||
private static final Long SCENE_RULE_ID = 1L;
|
||||
private static final Long TENANT_ID = 1L;
|
||||
private static final Long DEVICE_ID = 100L;
|
||||
private static final String PROPERTY_IDENTIFIER = "temperature";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 创建并注入 timerConditionEvaluator 的依赖
|
||||
timerConditionEvaluator = new IotTimerConditionEvaluator();
|
||||
try {
|
||||
var devicePropertyServiceField = IotTimerConditionEvaluator.class.getDeclaredField("devicePropertyService");
|
||||
devicePropertyServiceField.setAccessible(true);
|
||||
devicePropertyServiceField.set(timerConditionEvaluator, devicePropertyService);
|
||||
|
||||
var deviceServiceField = IotTimerConditionEvaluator.class.getDeclaredField("deviceService");
|
||||
deviceServiceField.setAccessible(true);
|
||||
deviceServiceField.set(timerConditionEvaluator, deviceService);
|
||||
|
||||
var evaluatorField = IotSceneRuleServiceImpl.class.getDeclaredField("timerConditionEvaluator");
|
||||
evaluatorField.setAccessible(true);
|
||||
evaluatorField.set(sceneRuleService, timerConditionEvaluator);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to inject dependencies", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
private IotSceneRuleDO createBaseSceneRule() {
|
||||
IotSceneRuleDO sceneRule = new IotSceneRuleDO();
|
||||
sceneRule.setId(SCENE_RULE_ID);
|
||||
sceneRule.setTenantId(TENANT_ID);
|
||||
sceneRule.setName("测试定时触发器");
|
||||
sceneRule.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
sceneRule.setActions(Collections.emptyList());
|
||||
return sceneRule;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.Trigger createTimerTrigger(String cronExpression,
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups) {
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.TIMER.getType());
|
||||
trigger.setCronExpression(cronExpression);
|
||||
trigger.setConditionGroups(conditionGroups);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.TriggerCondition createDevicePropertyCondition(Long deviceId, String identifier,
|
||||
String operator, String param) {
|
||||
IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition();
|
||||
condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_PROPERTY.getType());
|
||||
condition.setDeviceId(deviceId);
|
||||
condition.setIdentifier(identifier);
|
||||
condition.setOperator(operator);
|
||||
condition.setParam(param);
|
||||
return condition;
|
||||
}
|
||||
|
||||
private IotSceneRuleDO.TriggerCondition createDeviceStateCondition(Long deviceId, String operator, String param) {
|
||||
IotSceneRuleDO.TriggerCondition condition = new IotSceneRuleDO.TriggerCondition();
|
||||
condition.setType(IotSceneRuleConditionTypeEnum.DEVICE_STATE.getType());
|
||||
condition.setDeviceId(deviceId);
|
||||
condition.setOperator(operator);
|
||||
condition.setParam(param);
|
||||
return condition;
|
||||
}
|
||||
|
||||
private void mockDeviceProperty(Long deviceId, String identifier, Object value) {
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO property = new IotDevicePropertyDO();
|
||||
property.setValue(value);
|
||||
properties.put(identifier, property);
|
||||
when(devicePropertyService.getLatestDeviceProperties(deviceId)).thenReturn(properties);
|
||||
}
|
||||
|
||||
private void mockDeviceState(Long deviceId, Integer state) {
|
||||
IotDeviceDO device = new IotDeviceDO();
|
||||
device.setId(deviceId);
|
||||
device.setState(state);
|
||||
when(deviceService.getDevice(deviceId)).thenReturn(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单条件的条件组列表
|
||||
*/
|
||||
private List<List<IotSceneRuleDO.TriggerCondition>> createSingleConditionGroups(
|
||||
IotSceneRuleDO.TriggerCondition condition) {
|
||||
List<IotSceneRuleDO.TriggerCondition> group = new ArrayList<>();
|
||||
group.add(condition);
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>();
|
||||
groups.add(group);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建两个单条件组的条件组列表
|
||||
*/
|
||||
private List<List<IotSceneRuleDO.TriggerCondition>> createTwoSingleConditionGroups(
|
||||
IotSceneRuleDO.TriggerCondition cond1, IotSceneRuleDO.TriggerCondition cond2) {
|
||||
List<IotSceneRuleDO.TriggerCondition> group1 = new ArrayList<>();
|
||||
group1.add(cond1);
|
||||
List<IotSceneRuleDO.TriggerCondition> group2 = new ArrayList<>();
|
||||
group2.add(cond2);
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>();
|
||||
groups.add(group1);
|
||||
groups.add(group2);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个多条件组的条件组列表
|
||||
*/
|
||||
private List<List<IotSceneRuleDO.TriggerCondition>> createSingleGroupWithMultipleConditions(
|
||||
IotSceneRuleDO.TriggerCondition... conditions) {
|
||||
List<IotSceneRuleDO.TriggerCondition> group = new ArrayList<>(Arrays.asList(conditions));
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> groups = new ArrayList<>();
|
||||
groups.add(group);
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ========== 测试用例 ==========
|
||||
|
||||
@Nested
|
||||
@DisplayName("空条件组测试 - Validates: Requirement 2.1")
|
||||
class EmptyConditionGroupsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("定时触发器无条件组时,应直接执行动作")
|
||||
void testTimerTrigger_withNullConditionGroups_shouldExecuteActions() {
|
||||
// 准备数据
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", null);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(sceneRuleMapper, times(1)).selectById(SCENE_RULE_ID);
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
verify(deviceService, never()).getDevice(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("定时触发器条件组为空列表时,应直接执行动作")
|
||||
void testTimerTrigger_withEmptyConditionGroups_shouldExecuteActions() {
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", Collections.emptyList());
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(sceneRuleMapper, times(1)).selectById(SCENE_RULE_ID);
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("条件组 OR 逻辑测试 - Validates: Requirements 2.2, 2.3")
|
||||
class ConditionGroupOrLogicTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("多个条件组中第一个满足时,应执行动作")
|
||||
void testMultipleConditionGroups_firstGroupMatches_shouldExecuteActions() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createTwoSingleConditionGroups(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多个条件组中第二个满足时,应执行动作")
|
||||
void testMultipleConditionGroups_secondGroupMatches_shouldExecuteActions() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createTwoSingleConditionGroups(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("条件组内 AND 逻辑测试 - Validates: Requirement 2.4")
|
||||
class ConditionGroupAndLogicTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("条件组内所有条件都满足时,该组应匹配成功")
|
||||
void testSingleConditionGroup_allConditionsMatch_shouldPass() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "80");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createSingleGroupWithMultipleConditions(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO tempProperty = new IotDevicePropertyDO();
|
||||
tempProperty.setValue(30);
|
||||
properties.put("temperature", tempProperty);
|
||||
IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO();
|
||||
humidityProperty.setValue(60);
|
||||
properties.put("humidity", humidityProperty);
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("条件组内有一个条件不满足时,该组应匹配失败")
|
||||
void testSingleConditionGroup_oneConditionFails_shouldFail() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "50");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createSingleGroupWithMultipleConditions(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO tempProperty = new IotDevicePropertyDO();
|
||||
tempProperty.setValue(30);
|
||||
properties.put("temperature", tempProperty);
|
||||
IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO();
|
||||
humidityProperty.setValue(60); // 不满足 < 50
|
||||
properties.put("humidity", humidityProperty);
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("所有条件组不满足测试 - Validates: Requirement 2.5")
|
||||
class AllConditionGroupsFailTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("所有条件组都不满足时,应跳过动作执行")
|
||||
void testAllConditionGroups_allFail_shouldSkipExecution() {
|
||||
IotSceneRuleDO.TriggerCondition condition1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
IotSceneRuleDO.TriggerCondition condition2 = createDevicePropertyCondition(
|
||||
DEVICE_ID + 1, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "50");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createTwoSingleConditionGroups(condition1, condition2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceProperty(DEVICE_ID + 1, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("设备状态条件测试 - Validates: Requirements 4.1, 4.2")
|
||||
class DeviceStateConditionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("设备在线状态条件满足时,应匹配成功")
|
||||
void testDeviceStateCondition_online_shouldMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备不存在时,条件应不匹配")
|
||||
void testDeviceStateCondition_deviceNotExists_shouldNotMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(deviceService.getDevice(DEVICE_ID)).thenReturn(null);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("设备属性条件测试 - Validates: Requirements 3.1, 3.2, 3.3")
|
||||
class DevicePropertyConditionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属性条件满足时,应匹配成功")
|
||||
void testDevicePropertyCondition_match_shouldPass() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "25");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属性不存在时,条件应不匹配")
|
||||
void testDevicePropertyCondition_propertyNotExists_shouldNotMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition(
|
||||
DEVICE_ID, "nonexistent", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "25");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(Collections.emptyMap());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("设备属性等于条件测试")
|
||||
void testDevicePropertyCondition_equals_shouldMatch() {
|
||||
IotSceneRuleDO.TriggerCondition condition = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(), "30");
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = createSingleConditionGroups(condition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("场景规则状态测试")
|
||||
class SceneRuleStatusTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("场景规则不存在时,应直接返回")
|
||||
void testSceneRule_notExists_shouldReturn() {
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(null);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("场景规则已禁用时,应直接返回")
|
||||
void testSceneRule_disabled_shouldReturn() {
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
sceneRule.setStatus(CommonStatusEnum.DISABLE.getStatus());
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("场景规则无定时触发器时,应直接返回")
|
||||
void testSceneRule_noTimerTrigger_shouldReturn() {
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger deviceTrigger = new IotSceneRuleDO.Trigger();
|
||||
deviceTrigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_PROPERTY_POST.getType());
|
||||
sceneRule.setTriggers(ListUtil.toList(deviceTrigger));
|
||||
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, never()).getLatestDeviceProperties(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("复杂条件组合测试")
|
||||
class ComplexConditionCombinationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("混合条件类型测试:设备属性 + 设备状态")
|
||||
void testMixedConditionTypes_propertyAndState() {
|
||||
IotSceneRuleDO.TriggerCondition propertyCondition = createDevicePropertyCondition(
|
||||
DEVICE_ID, PROPERTY_IDENTIFIER, IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition stateCondition = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups =
|
||||
createSingleGroupWithMultipleConditions(propertyCondition, stateCondition);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
mockDeviceProperty(DEVICE_ID, PROPERTY_IDENTIFIER, 30);
|
||||
mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
verify(deviceService, atLeastOnce()).getDevice(DEVICE_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多条件组 OR 逻辑 + 组内 AND 逻辑综合测试")
|
||||
void testComplexOrAndLogic() {
|
||||
// 条件组1:温度 > 30 AND 湿度 < 50(不满足)
|
||||
// 条件组2:温度 > 20 AND 设备在线(满足)
|
||||
IotSceneRuleDO.TriggerCondition group1Cond1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "30");
|
||||
IotSceneRuleDO.TriggerCondition group1Cond2 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "humidity", IotSceneRuleConditionOperatorEnum.LESS_THAN.getOperator(), "50");
|
||||
|
||||
IotSceneRuleDO.TriggerCondition group2Cond1 = createDevicePropertyCondition(
|
||||
DEVICE_ID, "temperature", IotSceneRuleConditionOperatorEnum.GREATER_THAN.getOperator(), "20");
|
||||
IotSceneRuleDO.TriggerCondition group2Cond2 = createDeviceStateCondition(
|
||||
DEVICE_ID, IotSceneRuleConditionOperatorEnum.EQUALS.getOperator(),
|
||||
String.valueOf(IotDeviceStateEnum.ONLINE.getState()));
|
||||
|
||||
// 创建两个条件组
|
||||
List<IotSceneRuleDO.TriggerCondition> group1 = new ArrayList<>();
|
||||
group1.add(group1Cond1);
|
||||
group1.add(group1Cond2);
|
||||
List<IotSceneRuleDO.TriggerCondition> group2 = new ArrayList<>();
|
||||
group2.add(group2Cond1);
|
||||
group2.add(group2Cond2);
|
||||
List<List<IotSceneRuleDO.TriggerCondition>> conditionGroups = new ArrayList<>();
|
||||
conditionGroups.add(group1);
|
||||
conditionGroups.add(group2);
|
||||
|
||||
IotSceneRuleDO sceneRule = createBaseSceneRule();
|
||||
IotSceneRuleDO.Trigger trigger = createTimerTrigger("0 0 12 * * ?", conditionGroups);
|
||||
sceneRule.setTriggers(ListUtil.toList(trigger));
|
||||
|
||||
// Mock:温度 25,湿度 60,设备在线
|
||||
Map<String, IotDevicePropertyDO> properties = new HashMap<>();
|
||||
IotDevicePropertyDO tempProperty = new IotDevicePropertyDO();
|
||||
tempProperty.setValue(25);
|
||||
properties.put("temperature", tempProperty);
|
||||
IotDevicePropertyDO humidityProperty = new IotDevicePropertyDO();
|
||||
humidityProperty.setValue(60);
|
||||
properties.put("humidity", humidityProperty);
|
||||
when(devicePropertyService.getLatestDeviceProperties(DEVICE_ID)).thenReturn(properties);
|
||||
mockDeviceState(DEVICE_ID, IotDeviceStateEnum.ONLINE.getState());
|
||||
when(sceneRuleMapper.selectById(SCENE_RULE_ID)).thenReturn(sceneRule);
|
||||
|
||||
assertDoesNotThrow(() -> sceneRuleService.executeSceneRuleByTimer(SCENE_RULE_ID));
|
||||
|
||||
verify(devicePropertyService, atLeastOnce()).getLatestDeviceProperties(DEVICE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -378,6 +378,268 @@ public class IotDeviceServiceInvokeTriggerMatcherTest extends IotBaseConditionMa
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
|
||||
// ========== 参数条件匹配测试 ==========
|
||||
|
||||
/**
|
||||
* 测试无参数条件时的匹配逻辑 - 只要标识符匹配就返回 true
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.2**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_noParameterCondition_success() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "testService";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("level", 5)
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(null); // 无参数条件
|
||||
trigger.setValue(null);
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试有参数条件时的匹配逻辑 - 参数条件匹配成功
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.1**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_withParameterCondition_greaterThan_success() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "level";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("level", 5)
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(">"); // 大于操作符
|
||||
trigger.setValue("3");
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试有参数条件时的匹配逻辑 - 参数条件匹配失败
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.1**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_withParameterCondition_greaterThan_failure() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "level";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("level", 2)
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(">"); // 大于操作符
|
||||
trigger.setValue("3");
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试有参数条件时的匹配逻辑 - 等于操作符
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.1**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_withParameterCondition_equals_success() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "mode";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("mode", "auto")
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator("=="); // 等于操作符
|
||||
trigger.setValue("auto");
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试参数缺失时的处理 - 消息中缺少 inputData
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.3**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_withParameterCondition_missingInputData() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "testService";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
// 缺少 inputData 字段
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(">"); // 配置了参数条件
|
||||
trigger.setValue("3");
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试参数缺失时的处理 - inputData 中缺少指定参数
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.3**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_withParameterCondition_missingParam() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "level";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("otherParam", 5) // 不是 level 参数
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(">"); // 配置了参数条件
|
||||
trigger.setValue("3");
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试只有 operator 没有 value 时不触发参数条件匹配
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.2**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_onlyOperator_noValue() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "testService";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("level", 5)
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(">"); // 只有 operator
|
||||
trigger.setValue(null); // 没有 value
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言:只有 operator 没有 value 时,不触发参数条件匹配,标识符匹配即成功
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试只有 value 没有 operator 时不触发参数条件匹配
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.2**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_onlyValue_noOperator() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "testService";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputData", MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("level", 5)
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(null); // 没有 operator
|
||||
trigger.setValue("3"); // 只有 value
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言:只有 value 没有 operator 时,不触发参数条件匹配,标识符匹配即成功
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试使用 inputParams 字段(替代 inputData)
|
||||
* **Property 4: 服务调用触发器参数匹配逻辑**
|
||||
* **Validates: Requirements 5.1**
|
||||
*/
|
||||
@Test
|
||||
public void testMatches_withInputParams_success() {
|
||||
// 准备参数
|
||||
String serviceIdentifier = "level";
|
||||
Map<String, Object> serviceParams = MapUtil.builder(new HashMap<String, Object>())
|
||||
.put("identifier", serviceIdentifier)
|
||||
.put("inputParams", MapUtil.builder(new HashMap<String, Object>()) // 使用 inputParams 而不是 inputData
|
||||
.put("level", 5)
|
||||
.build())
|
||||
.build();
|
||||
IotDeviceMessage message = createServiceInvokeMessage(serviceParams);
|
||||
IotSceneRuleDO.Trigger trigger = new IotSceneRuleDO.Trigger();
|
||||
trigger.setType(IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE.getType());
|
||||
trigger.setIdentifier(serviceIdentifier);
|
||||
trigger.setOperator(">"); // 大于操作符
|
||||
trigger.setValue("3");
|
||||
|
||||
// 调用
|
||||
boolean result = matcher.matches(message, trigger);
|
||||
|
||||
// 断言
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,888 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>MQTT WebSocket 测试客户端</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
margin-left: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #d63384;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-size: 20px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #ffc107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.disconnected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status.connected {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.connecting {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.log-area {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.log-entry.info {
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.log-entry.success {
|
||||
color: #6a9955;
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
.log-entry.warning {
|
||||
color: #dcdcaa;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🚀 MQTT WebSocket 测试客户端</h1>
|
||||
<p>RuoYi-Vue-Pro IoT 模块 - MQTT over WebSocket 在线测试工具</p>
|
||||
</div>
|
||||
|
||||
<!-- 协议格式说明 -->
|
||||
<div class="info-box">
|
||||
<h3>📌 标准协议格式说明</h3>
|
||||
<ul>
|
||||
<li><strong>Topic 格式:</strong><code>/sys/{productKey}/{deviceName}/thing/property/post</code></li>
|
||||
<li><strong>Client ID 格式:</strong><code>{productKey}.{deviceName}</code> 例如:<code>zOXKLvHjUqTo7ipD.ceshi001</code>
|
||||
</li>
|
||||
<li><strong>Username 格式:</strong><code>{deviceName}&{productKey}</code> 例如:<code>ceshi001&zOXKLvHjUqTo7ipD</code>
|
||||
</li>
|
||||
<li><strong>消息格式(Alink 协议):</strong>
|
||||
<pre style="background: #e9ecef; padding: 10px; border-radius: 5px; margin-top: 5px; overflow-x: auto;">
|
||||
{
|
||||
"id": "消息 ID(唯一标识)",
|
||||
"version": "1.0",
|
||||
"method": "thing.property.post",
|
||||
"params": {
|
||||
"temperature": 25.5,
|
||||
"humidity": 60
|
||||
}
|
||||
}</pre>
|
||||
</li>
|
||||
<li><strong>常用 Topic(下行 - 服务端推送):</strong>
|
||||
<ul style="margin-top: 5px;">
|
||||
<li>属性设置:<code>/sys/{pk}/{dn}/thing/property/set</code></li>
|
||||
<li>服务调用:<code>/sys/{pk}/{dn}/thing/service/invoke</code></li>
|
||||
<li>配置推送:<code>/sys/{pk}/{dn}/thing/config/push</code></li>
|
||||
<li>OTA 升级:<code>/sys/{pk}/{dn}/thing/ota/upgrade</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>常用 Topic(上行 - 设备上报):</strong>
|
||||
<ul style="margin-top: 5px;">
|
||||
<li>状态更新:<code>/sys/{pk}/{dn}/thing/state/update</code></li>
|
||||
<li>属性上报:<code>/sys/{pk}/{dn}/thing/property/post</code></li>
|
||||
<li>事件上报:<code>/sys/{pk}/{dn}/thing/event/post</code></li>
|
||||
<li>OTA 进度:<code>/sys/{pk}/{dn}/thing/ota/progress</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 连接配置面板 -->
|
||||
<div class="panel">
|
||||
<h2>📡 连接配置</h2>
|
||||
|
||||
<div class="status disconnected" id="statusBar">
|
||||
⚫ 未连接
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>服务器地址</label>
|
||||
<input id="serverUrl" placeholder="ws://host:port/path" type="text" value="ws://localhost:8083/mqtt">
|
||||
<small style="color: #666; font-size: 12px;">WebSocket 地址,支持 ws:// 和 wss://</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Client ID</label>
|
||||
<input id="clientId" placeholder="设备客户端 ID" type="text" value="fqTn4Afs982Nak4N.jiali001">
|
||||
<small style="color: #666; font-size: 12px;">格式:{productKey}.{deviceName}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input id="username" placeholder="用户名" type="text" value="jiali001&fqTn4Afs982Nak4N">
|
||||
<small style="color: #666; font-size: 12px;">格式:{deviceName}&{productKey}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input id="password" placeholder="设备密钥"
|
||||
type="password" value="ae10188f93febbb6b37bd57f463b2a795ae2800fab8933aef75d3c6422873f28">
|
||||
<small style="color: #666; font-size: 12px;">设备的认证密钥(Device Secret)</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-success" id="connectBtn" onclick="connect()">🔌 连接</button>
|
||||
<button class="btn btn-danger" disabled id="disconnectBtn" onclick="disconnect()">🔌 断开</button>
|
||||
<button class="btn btn-warning" onclick="clearLogs()">🗑️ 清空日志</button>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="value" id="sentCount">0</div>
|
||||
<div class="label">发送消息数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value" id="receivedCount">0</div>
|
||||
<div class="label">接收消息数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="value" id="errorCount">0</div>
|
||||
<div class="label">错误次数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息发布面板 -->
|
||||
<div class="panel">
|
||||
<h2>📤 消息发布</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>快捷主题选择(上行消息 - 设备 → 服务端)</label>
|
||||
<select id="quickPublishTopicSelect" onchange="selectQuickPublishTopic()"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
|
||||
<option value="">-- 选择上行消息类型 --</option>
|
||||
<option value="thing.state.update">设备状态更新 (thing.state.update)</option>
|
||||
<option value="thing.property.post">属性上报 (thing.property.post)</option>
|
||||
<option value="thing.event.post">事件上报 (thing.event.post)</option>
|
||||
<option value="thing.ota.progress">OTA 升级进度 (thing.ota.progress)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>主题 (Topic)</label>
|
||||
<input id="pubTopic" placeholder="消息主题,格式:/sys/{productKey}/{deviceName}/thing/property/post" type="text"
|
||||
value="/sys/fqTn4Afs982Nak4N/jiali001/thing/property/post">
|
||||
<small style="color: #666; font-size: 12px;">标准格式:/sys/{productKey}/{deviceName}/thing/property/post</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>QoS 级别</label>
|
||||
<select id="pubQos">
|
||||
<option value="0">0 - 最多一次</option>
|
||||
<option selected value="1">1 - 至少一次</option>
|
||||
<option value="2">2 - 刚好一次</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>消息内容 (JSON - Alink 协议格式)</label>
|
||||
<textarea id="pubMessage" placeholder='Alink 协议格式消息'>{
|
||||
"id": "123456789",
|
||||
"version": "1.0",
|
||||
"method": "thing.property.post",
|
||||
"params": {
|
||||
"temperature": 25.5,
|
||||
"humidity": 60
|
||||
}
|
||||
}</textarea>
|
||||
<small style="color: #666; font-size: 12px;">
|
||||
Alink 协议格式:id(消息 ID)、version(协议版本)、method(方法)、params(参数)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="publish()">📤 发布消息</button>
|
||||
<button class="btn btn-success" onclick="publishSampleData()">📊 发送样例数据</button>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 30px;">📥 主题订阅</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>快捷主题选择(下行消息 - 服务端 → 设备)</label>
|
||||
<select id="quickTopicSelect" onchange="selectQuickTopic()"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
|
||||
<option value="">-- 选择下行消息类型 --</option>
|
||||
<optgroup label="📥 下行消息">
|
||||
<option value="thing.property.set">属性设置 (thing.property.set)</option>
|
||||
<option value="thing.service.invoke">服务调用 (thing.service.invoke)</option>
|
||||
<option value="thing.config.push">配置推送 (thing.config.push)</option>
|
||||
<option value="thing.ota.upgrade">OTA 固件推送 (thing.ota.upgrade)</option>
|
||||
</optgroup>
|
||||
<optgroup label="🔄 回复主题(上行消息的回复)">
|
||||
<option value="thing.property.post_reply">属性上报回复 (thing.property.post_reply)</option>
|
||||
<option value="thing.event.post_reply">事件上报回复 (thing.event.post_reply)</option>
|
||||
</optgroup>
|
||||
<optgroup label="🔧 通配符订阅">
|
||||
<option value="wildcard_all">订阅所有主题 (/sys/+/+/#)</option>
|
||||
<option value="wildcard_thing">订阅所有 thing 主题 (/sys/+/+/thing/#)</option>
|
||||
<option value="wildcard_reply">订阅所有回复主题 (/sys/+/+/#_reply)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>订阅主题</label>
|
||||
<input id="subTopic" placeholder="订阅主题,格式:/sys/{productKey}/{deviceName}/thing/property/set" type="text"
|
||||
value="/sys/fqTn4Afs982Nak4N/jiali001/thing/property/set">
|
||||
<small style="color: #666; font-size: 12px;">标准格式:/sys/{productKey}/{deviceName}/thing/method 或使用通配符
|
||||
/sys/+/+/#</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>QoS 级别</label>
|
||||
<select id="subQos">
|
||||
<option value="0">0 - 最多一次</option>
|
||||
<option selected value="1">1 - 至少一次</option>
|
||||
<option value="2">2 - 刚好一次</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="subscribe()">📥 订阅</button>
|
||||
<button class="btn btn-danger" onclick="unsubscribe()">❌ 取消订阅</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志面板 -->
|
||||
<div class="panel" style="grid-column: 1 / -1;">
|
||||
<h2>📝 日志输出</h2>
|
||||
<div class="log-area" id="logArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用 MQTT.js 库 -->
|
||||
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
|
||||
|
||||
<script>
|
||||
let client = null;
|
||||
let sentCount = 0;
|
||||
let receivedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// 添加日志
|
||||
function addLog(message, type = 'info') {
|
||||
const logArea = document.getElementById('logArea');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry ${type}`;
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
logArea.appendChild(logEntry);
|
||||
logArea.scrollTop = logArea.scrollHeight;
|
||||
}
|
||||
|
||||
// 更新状态栏
|
||||
function updateStatus(status, text) {
|
||||
const statusBar = document.getElementById('statusBar');
|
||||
statusBar.className = `status ${status}`;
|
||||
|
||||
const icons = {
|
||||
'disconnected': '⚫',
|
||||
'connecting': '🟡',
|
||||
'connected': '🟢'
|
||||
};
|
||||
|
||||
statusBar.textContent = `${icons[status]} ${text}`;
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
function updateStats() {
|
||||
document.getElementById('sentCount').textContent = sentCount;
|
||||
document.getElementById('receivedCount').textContent = receivedCount;
|
||||
document.getElementById('errorCount').textContent = errorCount;
|
||||
}
|
||||
|
||||
// 连接到服务器
|
||||
function connect() {
|
||||
const serverUrl = document.getElementById('serverUrl').value;
|
||||
const clientId = document.getElementById('clientId').value;
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!serverUrl || !clientId) {
|
||||
addLog('❌ 请填写服务器地址和 Client ID', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus('connecting', '正在连接...');
|
||||
addLog(`🔄 正在连接到 ${serverUrl}...`, 'info');
|
||||
|
||||
const options = {
|
||||
clientId: clientId,
|
||||
username: username,
|
||||
password: password,
|
||||
clean: true,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 30000,
|
||||
};
|
||||
|
||||
client = mqtt.connect(serverUrl, options);
|
||||
|
||||
// 连接成功
|
||||
client.on('connect', () => {
|
||||
updateStatus('connected', '已连接');
|
||||
addLog('✅ 连接成功!', 'success');
|
||||
document.getElementById('connectBtn').disabled = true;
|
||||
document.getElementById('disconnectBtn').disabled = false;
|
||||
});
|
||||
|
||||
// 接收消息
|
||||
client.on('message', (topic, message) => {
|
||||
receivedCount++;
|
||||
updateStats();
|
||||
addLog(`📥 收到消息 [${topic}]: ${message.toString()}`, 'success');
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
client.on('error', (error) => {
|
||||
errorCount++;
|
||||
updateStats();
|
||||
addLog(`❌ 连接错误: ${error.message}`, 'error');
|
||||
});
|
||||
|
||||
// 断开连接
|
||||
client.on('close', () => {
|
||||
updateStatus('disconnected', '未连接');
|
||||
addLog('🔌 连接已断开', 'warning');
|
||||
document.getElementById('connectBtn').disabled = false;
|
||||
document.getElementById('disconnectBtn').disabled = true;
|
||||
});
|
||||
|
||||
// 离线
|
||||
client.on('offline', () => {
|
||||
updateStatus('disconnected', '离线');
|
||||
addLog('⚠️ 客户端离线', 'warning');
|
||||
});
|
||||
|
||||
// 重连
|
||||
client.on('reconnect', () => {
|
||||
updateStatus('connecting', '正在重连...');
|
||||
addLog('🔄 正在重连...', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
function disconnect() {
|
||||
if (client) {
|
||||
client.end();
|
||||
addLog('👋 主动断开连接', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 发布消息
|
||||
function publish() {
|
||||
if (!client || !client.connected) {
|
||||
addLog('❌ 请先连接到服务器', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = document.getElementById('pubTopic').value;
|
||||
const qos = parseInt(document.getElementById('pubQos').value);
|
||||
const message = document.getElementById('pubMessage').value;
|
||||
|
||||
if (!topic || !message) {
|
||||
addLog('❌ 请填写主题和消息内容', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
try {
|
||||
JSON.parse(message);
|
||||
} catch (e) {
|
||||
addLog('⚠️ 消息不是有效的 JSON 格式,将作为纯文本发送', 'warning');
|
||||
}
|
||||
|
||||
client.publish(topic, message, {qos: qos}, (error) => {
|
||||
if (error) {
|
||||
errorCount++;
|
||||
updateStats();
|
||||
addLog(`❌ 发布失败: ${error.message}`, 'error');
|
||||
} else {
|
||||
sentCount++;
|
||||
updateStats();
|
||||
addLog(`📤 消息已发布 [${topic}] (QoS ${qos})`, 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 发送样例数据
|
||||
function publishSampleData() {
|
||||
// 使用 Alink 协议格式的样例数据
|
||||
const sampleData = {
|
||||
id: Date.now().toString(),
|
||||
version: "1.0",
|
||||
method: "thing.property.post",
|
||||
params: {
|
||||
temperature: parseFloat((20 + Math.random() * 10).toFixed(2)),
|
||||
humidity: parseFloat((50 + Math.random() * 20).toFixed(2)),
|
||||
pressure: parseFloat((1000 + Math.random() * 50).toFixed(2))
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('pubMessage').value = JSON.stringify(sampleData, null, 2);
|
||||
addLog('样例数据已生成(Alink 协议格式)', 'info');
|
||||
publish();
|
||||
}
|
||||
|
||||
// 获取 productKey 和 deviceName
|
||||
function getDeviceInfo() {
|
||||
const clientId = document.getElementById('clientId').value;
|
||||
const parts = clientId.split('.');
|
||||
|
||||
if (parts.length !== 2) {
|
||||
addLog('❌ Client ID 格式不正确(应为 {productKey}.{deviceName}),无法生成主题', 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
productKey: parts[0],
|
||||
deviceName: parts[1]
|
||||
};
|
||||
}
|
||||
|
||||
// 快捷主题选择(消息发布 - 上行消息)
|
||||
function selectQuickPublishTopic() {
|
||||
const select = document.getElementById('quickPublishTopicSelect');
|
||||
const selectedValue = select.value;
|
||||
|
||||
console.log('[selectQuickPublishTopic] 选择的值:', selectedValue);
|
||||
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceInfo = getDeviceInfo();
|
||||
if (!deviceInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[selectQuickPublishTopic] 设备信息:', deviceInfo);
|
||||
|
||||
// 构建标准主题,将枚举中的点号替换为斜杠
|
||||
// 例如:thing.property.post -> /sys/{pk}/{dn}/thing/property/post
|
||||
const topic = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/${selectedValue.replace(/\./g, '/')}`;
|
||||
|
||||
console.log('[selectQuickPublishTopic] 生成的主题:', topic);
|
||||
|
||||
const pubTopicInput = document.getElementById('pubTopic');
|
||||
pubTopicInput.value = topic;
|
||||
|
||||
console.log('[selectQuickPublishTopic] 输入框的值已设置为:', pubTopicInput.value);
|
||||
|
||||
addLog(`📋 已选择发布主题: ${topic}`, 'info');
|
||||
|
||||
// 需要 reply 的消息类型(不在 REPLY_DISABLED 列表中)
|
||||
const needsReply = [
|
||||
'thing.property.post',
|
||||
'thing.event.post'
|
||||
];
|
||||
|
||||
// 如果需要 reply,自动订阅 reply 主题
|
||||
if (needsReply.includes(selectedValue)) {
|
||||
const replyTopic = `${topic}_reply`;
|
||||
|
||||
if (client && client.connected) {
|
||||
// 自动订阅 reply 主题
|
||||
client.subscribe(replyTopic, {qos: 1}, (err) => {
|
||||
if (!err) {
|
||||
addLog(`✅ 已自动订阅回复主题: ${replyTopic}`, 'success');
|
||||
} else {
|
||||
addLog(`❌ 自动订阅回复主题失败: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
addLog(`💡 提示: 该消息需要订阅回复主题 ${replyTopic}`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// 重置下拉框到默认选项
|
||||
select.selectedIndex = 0;
|
||||
console.log('[selectQuickPublishTopic] 下拉框已重置');
|
||||
}
|
||||
|
||||
// 快捷主题选择(主题订阅 - 下行消息)
|
||||
function selectQuickTopic() {
|
||||
const select = document.getElementById('quickTopicSelect');
|
||||
const selectedValue = select.value;
|
||||
|
||||
console.log('[selectQuickTopic] 选择的值:', selectedValue);
|
||||
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subTopicInput = document.getElementById('subTopic');
|
||||
|
||||
// 处理通配符订阅
|
||||
if (selectedValue === 'wildcard_all') {
|
||||
subTopicInput.value = '/sys/+/+/#';
|
||||
addLog('📋 已选择订阅主题: /sys/+/+/#(订阅所有主题)', 'info');
|
||||
console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
|
||||
select.selectedIndex = 0;
|
||||
console.log('[selectQuickTopic] 下拉框已重置');
|
||||
return;
|
||||
} else if (selectedValue === 'wildcard_thing') {
|
||||
subTopicInput.value = '/sys/+/+/thing/#';
|
||||
addLog('📋 已选择订阅主题: /sys/+/+/thing/#(订阅所有 thing 主题)', 'info');
|
||||
console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
|
||||
select.selectedIndex = 0;
|
||||
console.log('[selectQuickTopic] 下拉框已重置');
|
||||
return;
|
||||
} else if (selectedValue === 'wildcard_reply') {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
if (!deviceInfo) {
|
||||
select.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
subTopicInput.value = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/#_reply`;
|
||||
addLog(`📋 已选择订阅主题: /sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/#_reply(订阅所有回复主题)`, 'info');
|
||||
console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
|
||||
select.selectedIndex = 0;
|
||||
console.log('[selectQuickTopic] 下拉框已重置');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceInfo = getDeviceInfo();
|
||||
if (!deviceInfo) {
|
||||
select.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[selectQuickTopic] 设备信息:', deviceInfo);
|
||||
|
||||
// 构建标准主题,将枚举中的点号替换为斜杠
|
||||
// 例如:thing.property.set -> /sys/{pk}/{dn}/thing/property/set
|
||||
const topic = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/${selectedValue.replace(/\./g, '/')}`;
|
||||
|
||||
console.log('[selectQuickTopic] 生成的主题:', topic);
|
||||
|
||||
subTopicInput.value = topic;
|
||||
addLog(`📋 已选择订阅主题: ${topic}`, 'info');
|
||||
|
||||
console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value);
|
||||
|
||||
// 重置下拉框到默认选项
|
||||
select.selectedIndex = 0;
|
||||
console.log('[selectQuickTopic] 下拉框已重置');
|
||||
}
|
||||
|
||||
// 订阅主题
|
||||
function subscribe() {
|
||||
if (!client || !client.connected) {
|
||||
addLog('❌ 请先连接到服务器', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = document.getElementById('subTopic').value;
|
||||
const qos = parseInt(document.getElementById('subQos').value);
|
||||
|
||||
if (!topic) {
|
||||
addLog('❌ 请填写订阅主题', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
client.subscribe(topic, {qos: qos}, (error) => {
|
||||
if (error) {
|
||||
errorCount++;
|
||||
updateStats();
|
||||
addLog(`❌ 订阅失败: ${error.message}`, 'error');
|
||||
} else {
|
||||
addLog(`📥 已订阅主题 [${topic}] (QoS ${qos})`, 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 取消订阅
|
||||
function unsubscribe() {
|
||||
if (!client || !client.connected) {
|
||||
addLog('❌ 请先连接到服务器', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
const topic = document.getElementById('subTopic').value;
|
||||
|
||||
if (!topic) {
|
||||
addLog('❌ 请填写要取消的订阅主题', 'error');
|
||||
errorCount++;
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
|
||||
client.unsubscribe(topic, (error) => {
|
||||
if (error) {
|
||||
errorCount++;
|
||||
updateStats();
|
||||
addLog(`❌ 取消订阅失败: ${error.message}`, 'error');
|
||||
} else {
|
||||
addLog(`❌ 已取消订阅 [${topic}]`, 'info');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
function clearLogs() {
|
||||
document.getElementById('logArea').innerHTML = '';
|
||||
sentCount = 0;
|
||||
receivedCount = 0;
|
||||
errorCount = 0;
|
||||
updateStats();
|
||||
addLog('🗑️ 日志已清空', 'info');
|
||||
}
|
||||
|
||||
// 页面加载完成
|
||||
window.onload = function () {
|
||||
addLog('👋 欢迎使用 MQTT WebSocket 测试客户端!', 'success');
|
||||
addLog('📚 请配置连接参数后点击"连接"按钮', 'info');
|
||||
};
|
||||
|
||||
// 页面关闭前断开连接
|
||||
window.onbeforeunload = function () {
|
||||
if (client && client.connected) {
|
||||
client.end();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user