diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index e0bea18..a0d3696 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -76,6 +76,7 @@ 4.12.0 3.14.0 3.4.0 + 1.7.15.1 2.57.3 1.16.7 @@ -660,6 +661,13 @@ ${j2mod.version} + + + org.mozilla + rhino + ${rhino.version} + + software.amazon.awssdk diff --git a/yudao-module-iot/pom.xml b/yudao-module-iot/pom.xml index 97df8e5..83ac01a 100644 --- a/yudao-module-iot/pom.xml +++ b/yudao-module-iot/pom.xml @@ -18,8 +18,8 @@ ${project.artifactId} - 物联网模块 - + 物联网大模块,由 biz 业务、gateway 设备网关、core 核心等组成 + 例如说:产品、物模型、设备、规则引擎、OTA 升级、设备接入等等 \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/pom.xml b/yudao-module-iot/yudao-module-iot-biz/pom.xml index b671a62..8b26a5e 100644 --- a/yudao-module-iot/yudao-module-iot-biz/pom.xml +++ b/yudao-module-iot/yudao-module-iot-biz/pom.xml @@ -13,8 +13,11 @@ ${project.artifactId} - 物联网 模块,主要实现 产品管理、设备管理、协议管理等功能。 - + iot 模块下,物联网的业务模块,提供管理后台与平台侧的处理。例如说: + 1. 产品管理:产品分类、产品、物模型、数据解析脚本 + 2. 设备管理:设备、设备分组、设备消息、设备属性、设备影子、Modbus 点位,以及 OTA 升级 + 3. 规则引擎:数据流转(数据规则、数据目的)、场景联动、告警 + 4. 数据统计,以及提供给 iot-gateway 的设备 API;设备消息与属性的时序数据可存储到 TDengine、IoTDB @@ -96,11 +99,10 @@ - org.apache.rocketmq rocketmq-spring-boot-starter - + true org.springframework.kafka diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java index 08b8cb3..a5d4c13 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; @@ -74,7 +75,10 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { return success(BeanUtils.toBean(device, IotDeviceRespDTO.class, deviceDTO -> { IotProductDO product = productService.getProductFromCache(deviceDTO.getProductId()); if (product != null) { - deviceDTO.setProtocolType(product.getProtocolType()).setSerializeType(product.getSerializeType()); + // 数据解析脚本随设备信息返回(网关按设备缓存),只在启用时返回内容 + deviceDTO.setProtocolType(product.getProtocolType()).setSerializeType(product.getSerializeType()) + .setScriptEnabled(product.getScriptEnabled()) + .setScriptContent(Boolean.TRUE.equals(product.getScriptEnabled()) ? product.getScriptContent() : null); } })); } @@ -114,10 +118,8 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { } } // 3.3 获取启用的点位列表 - List pointList = pointMap.get(config.getDeviceId()); - if (CollUtil.isEmpty(pointList)) { - continue; - } + // 点位为空也返回:连接配置有效即可认证、收发透传(与脚本开关无关);没有点位时,轮询调度器本身会跳过 + List pointList = pointMap.getOrDefault(config.getDeviceId(), Collections.emptyList()); // 3.4 构建 IotModbusDeviceConfigRespDTO 对象 IotModbusDeviceConfigRespDTO configDTO = BeanUtils.toBean(config, IotModbusDeviceConfigRespDTO.class, o -> diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java index 61d936a..8e86fd2 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java @@ -4,10 +4,12 @@ import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespPairVO; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespVO; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageSendReqVO; +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.dal.dataobject.device.IotDeviceMessageDO; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; @@ -24,9 +26,12 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 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; +import static cn.iocoder.yudao.module.iot.enums.IotPermissionConstants.DEVICE_MESSAGE_RAW_SEND; @Tag(name = "管理后台 - IoT 设备消息") @RestController @@ -40,6 +45,8 @@ public class IotDeviceMessageController { private IotDeviceService deviceService; @Resource private IotThingModelService thingModelService; + @Resource + private SecurityFrameworkService securityFrameworkService; @GetMapping("/page") @Operation(summary = "获得设备消息分页") @@ -82,6 +89,12 @@ public class IotDeviceMessageController { @Operation(summary = "发送消息", description = "可用于设备模拟") @PreAuthorize("@ss.hasPermission('iot:device:message-send')") public CommonResult sendDeviceMessage(@Valid @RequestBody IotDeviceMessageSendReqVO sendReqVO) { + // 1. 校验透传下发的权限:透传下发是新增的危险能力(可以直接驱动 Modbus 写),需要独立的按钮权限;raw.post 不提供模拟(会改写在线状态和路由) + if (IotDeviceMessageMethodEnum.RAW_PUSH.getMethod().equals(sendReqVO.getMethod()) + && !securityFrameworkService.hasPermission(DEVICE_MESSAGE_RAW_SEND)) { + throw exception(FORBIDDEN); + } + // 2. 发送消息 deviceMessageService.sendDeviceMessage(BeanUtils.toBean(sendReqVO, IotDeviceMessage.class)); return success(true); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java index 1894dc9..1675678 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessagePageReqVO.java @@ -34,6 +34,9 @@ public class IotDeviceMessagePageReqVO extends PageParam { @Schema(description = "标识符", example = "temperature") private String identifier; + @Schema(description = "是否有原始帧:true 只看带原始帧(raw 非空)的消息,false 只看没有原始帧的消息", example = "true") + private Boolean raw; + @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @Size(min = 2, max = 2, message = "请选择时间范围") diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java index f9e4b75..d022c24 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java @@ -53,4 +53,7 @@ public class IotDeviceMessageRespVO { @Schema(description = "响应提示", example = "操作成功") private String msg; + @Schema(description = "与设备实际收发的原始字节(Base64);为空表示没有原始帧", example = "AQMAAAAK") + private String raw; + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java index 679f779..b933a80 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java @@ -10,6 +10,9 @@ import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductRespVO; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductCategoryDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.service.product.IotProductCategoryService; @@ -69,6 +72,22 @@ public class IotProductController { return success(true); } + @PutMapping("/update-script") + @Operation(summary = "更新产品数据解析脚本", description = "部分更新:只修改本次提交的字段;保存即生效") + @PreAuthorize("@ss.hasPermission('iot:product:update')") + public CommonResult updateProductScript(@Valid @RequestBody IotProductScriptUpdateReqVO updateReqVO) { + productService.updateProductScript(updateReqVO); + return success(true); + } + + @PostMapping("/debug-script") + @Operation(summary = "调试产品数据解析脚本", description = "执行当前编辑内容;不保存、不发消息、不改属性") + @PreAuthorize("@ss.hasPermission('iot:product:update')") + public CommonResult debugProductScript( + @Valid @RequestBody IotProductScriptDebugReqVO debugReqVO) { + return success(productService.debugProductScript(debugReqVO)); + } + @DeleteMapping("/delete") @Operation(summary = "删除产品") @Parameter(name = "id", description = "编号", required = true) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java index aec1bb5..16136af 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductRespVO.java @@ -77,6 +77,12 @@ public class IotProductRespVO { @DictFormat(DictTypeConstants.SERIALIZE_TYPE) private String serializeType; + @Schema(description = "数据解析脚本(JS ES5)") + private String scriptContent; + + @Schema(description = "是否启用数据解析脚本", example = "false") + private Boolean scriptEnabled; + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) @ExcelProperty("创建时间") private LocalDateTime createTime; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugReqVO.java new file mode 100644 index 0000000..e83060f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugReqVO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; + +import java.util.Map; + +/** + * 管理后台 - IoT 产品数据解析脚本调试 Request VO + *

+ * 执行当前编辑内容:不保存、不发消息、不改属性 + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IoT 产品数据解析脚本调试 Request VO") +@Data +public class IotProductScriptDebugReqVO { + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "产品编号不能为空") + private Long id; + + @Schema(description = "数据解析脚本(JS ES5)", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "脚本内容不能为空") + private String scriptContent; + + @Schema(description = "方向:decode 上行解码,encode 下行编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "decode") + @NotEmpty(message = "调试方向不能为空") + @Pattern(regexp = "decode|encode", message = "调试方向只能是 decode 或 encode") + private String direction; + + @Schema(description = "上行原始字节的 HEX(decode 时必填,允许空格)", example = "01 03 00 00") + private String inputHex; + + @Schema(description = "下行指令(encode 时必填):{method, requestId?, params?}") + private Map message; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugRespVO.java new file mode 100644 index 0000000..93e888d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugRespVO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Map; + +/** + * 管理后台 - IoT 产品数据解析脚本调试 Response VO + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IoT 产品数据解析脚本调试 Response VO") +@Data +public class IotProductScriptDebugRespVO { + + @Schema(description = "是否成功", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean success; + + @Schema(description = "执行耗时(毫秒)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Long durationMs; + + @Schema(description = "decode:解析出的消息;为空表示没有业务消息(只记录原始消息)") + private Map message; + + @Schema(description = "encode:是否走标准 JSON 下发", example = "false") + private Boolean standard; + + @Schema(description = "encode:下行字节的 HEX", example = "0103000A0001") + private String outputHex; + + @Schema(description = "失败信息") + private ErrorInfo error; + + @Schema(description = "调试失败信息") + @Data + public static class ErrorInfo { + + @Schema(description = "类型:ERROR、TIMEOUT、REJECTED、OUTPUT_INVALID、ENCODE_EMPTY", example = "ERROR") + private String type; + + @Schema(description = "错误信息") + private String message; + + @Schema(description = "出错行号", example = "3") + private Integer line; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptUpdateReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptUpdateReqVO.java new file mode 100644 index 0000000..ca2bca9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptUpdateReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 管理后台 - IoT 产品数据解析脚本更新 Request VO + *

+ * 部分更新:scriptContent、scriptEnabled 为 null 时不修改 + * + * @author 芋道源码 + */ +@Schema(description = "管理后台 - IoT 产品数据解析脚本更新 Request VO") +@Data +public class IotProductScriptUpdateReqVO { + + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "产品编号不能为空") + private Long id; + + @Schema(description = "数据解析脚本(JS ES5);空字符串表示清空,仅在停用时允许") + private String scriptContent; + + @Schema(description = "是否启用数据解析脚本", example = "true") + private Boolean scriptEnabled; + + @AssertTrue(message = "脚本内容和启用状态至少提交一个") + @JsonIgnore + public boolean isAnyFieldPresent() { + return scriptContent != null || scriptEnabled != null; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java index 3b24aa9..5f81ffb 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java @@ -106,4 +106,13 @@ public class IotDeviceMessageDO { */ private String msg; + // ========== 透传(raw)字段 ========== + + /** + * 与设备之间实际收发的原始字节(Base64) + * + * 只由服务端可信赋值,见 {@link IotDeviceMessage#getRaw()};为空表示没有原始帧 + */ + private String raw; + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java index a1a77fc..6536c63 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/product/IotProductDO.java @@ -90,4 +90,13 @@ public class IotProductDO extends TenantBaseDO { */ private String serializeType; + /** + * 数据解析脚本(JS ES5),UTF-8 不超过 32KB + */ + private String scriptContent; + /** + * 是否启用数据解析脚本 + */ + private Boolean scriptEnabled; + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java index 95afc90..eb13802 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java @@ -41,7 +41,8 @@ public class IotDeviceMessageIoTDBCodec { .setParams(encodeString(toJsonString(message.getParams()))) .setData(encodeString(toJsonString(message.getData()))) .setMsg(encodeString(message.getMsg())).setReportTime(encodeString(message.getReportTime())) - .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())); + .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())) + .setRaw(encodeString(message.getRaw())); } public static IotDeviceMessageDO decode(IotDeviceMessageIoTDBRow row) { @@ -57,7 +58,8 @@ public class IotDeviceMessageIoTDBCodec { .setMsg(decodeString(row.getMsg())) .setReportTime(reportTime != null ? Long.valueOf(reportTime) : null) .setTenantId(tenantId != null ? Long.valueOf(tenantId) : null) - .setCode(code != null ? Integer.valueOf(code) : null); + .setCode(code != null ? Integer.valueOf(code) : null) + .setRaw(decodeString(row.getRaw())); } /** diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java index 1af22d9..4ab796b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java @@ -50,6 +50,7 @@ public class IotDeviceMessageIoTDBDAO implements IotDeviceMessageDAO { .put("method", "STRING").put("identifier", "STRING").put("request_id", "STRING").put("server_id", "STRING") .put("params", "STRING").put("data", "STRING").put("msg", "STRING") .put("report_time", "STRING").put("tenant_id", "STRING").put("code", "STRING") + .put("raw", "STRING") .build(); @Resource @@ -152,15 +153,20 @@ public class IotDeviceMessageIoTDBDAO implements IotDeviceMessageDAO { Integer upstream = reqVO.getUpstream() != null ? encodeBoolean(reqVO.getUpstream()) : null; Integer reply = reqVO.getReply() != null ? encodeBoolean(reqVO.getReply()) : null; String identifier = StrUtil.isNotEmpty(reqVO.getIdentifier()) ? encodeString(reqVO.getIdentifier()) : null; + // 是否有原始帧:true 为 v: 开头;false 为 n: + Boolean raw = reqVO.getRaw(); + String rawValuePattern = PREFIX_VALUE + "%"; try { // 2.1 查询总数 - Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier); + Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier, + raw, PREFIX_NULL, rawValuePattern); if (total == null || total == 0) { return PageResult.empty(); } // 2.2 查询列表:pageSize 为 -1 时,不分页 boolean pageable = ObjUtil.notEqual(reqVO.getPageSize(), PageParam.PAGE_SIZE_NONE); List rows = deviceMessageMapper.selectList(deviceId, method, upstream, reply, identifier, + raw, PREFIX_NULL, rawValuePattern, pageable ? PageUtils.getStart(reqVO) : null, pageable ? reqVO.getPageSize() : null); return new PageResult<>(convertList(rows, IotDeviceMessageIoTDBCodec::decode), total); } catch (Exception exception) { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java index f64156c..f5a3043 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java @@ -32,11 +32,21 @@ public interface IotDeviceMessageIoTDBMapper { void insert(IotDeviceMessageIoTDBRow row); + /** + * 分页查询 + * + * @param raw 是否有原始帧:true 为 raw 匹配 rawValuePattern;false 为 raw 等于 rawNull + * @param rawNull raw 为 null 的存储值 + * @param rawValuePattern raw 非 null 的存储值的 LIKE 模式 + */ List selectList(@Param("deviceId") String deviceId, @Param("method") String method, @Param("upstream") Integer upstream, @Param("reply") Integer reply, @Param("identifier") String identifier, + @Param("raw") Boolean raw, + @Param("rawNull") String rawNull, + @Param("rawValuePattern") String rawValuePattern, @Param("offset") Integer offset, @Param("limit") Integer limit); @@ -44,7 +54,10 @@ public interface IotDeviceMessageIoTDBMapper { @Param("method") String method, @Param("upstream") Integer upstream, @Param("reply") Integer reply, - @Param("identifier") String identifier); + @Param("identifier") String identifier, + @Param("raw") Boolean raw, + @Param("rawNull") String rawNull, + @Param("rawValuePattern") String rawValuePattern); List selectListByRequestIdsAndReply(@Param("deviceId") String deviceId, @Param("requestIds") Collection requestIds, diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java index 1cc9eb6..1ff760b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java @@ -38,15 +38,49 @@ public class IotDeviceMessageIoTDBRow { // ========== 以下字段,统一使用 n:/v: 编码 ========== + /** + * 请求方法 + */ private String method; + /** + * 标识符 + */ private String identifier; + /** + * 请求编号 + */ private String requestId; + /** + * 服务编号,该消息由哪个 server 发送 + */ private String serverId; + /** + * 请求参数(JSON) + */ private String params; + /** + * 响应结果(JSON) + */ private String data; + /** + * 响应提示 + */ private String msg; + /** + * 上报时间戳(毫秒) + */ private String reportTime; + /** + * 租户编号 + */ private String tenantId; + /** + * 响应错误码 + */ private String code; + /** + * 原始帧(Base64) + */ + private String raw; } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java index e228325..4a88404 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java @@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; import java.util.List; import java.util.Map; -// TODO DONE @AI:storage 包下,是不是加个 package-info.java,说明这个包的作用?已新增 package-info.java /** * IoT 设备【属性】历史数据的存储 DAO 接口 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java index cb8b9b6..2df60db 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java @@ -51,6 +51,10 @@ public interface ErrorCodeConstants { ErrorCode DEVICE_PROPERTY_FIELD_NAME_INVALID = new ErrorCode(1_050_003_301, "属性标识符({}) 不合法,无法作为存储字段"); ErrorCode DEVICE_PROPERTY_FIELD_NAME_RESERVED = new ErrorCode(1_050_003_302, "属性标识符({}) 与存储保留字段({}) 冲突"); ErrorCode DEVICE_PROPERTY_FIELD_NAME_DUPLICATE = new ErrorCode(1_050_003_303, "属性标识符({}) 与({}) 的存储字段({}) 重复"); + // 设备消息相关错误码 1-050-003-400 + ErrorCode DEVICE_MESSAGE_RAW_PAYLOAD_INVALID = new ErrorCode(1_050_003_400, "透传载荷不合法:{}"); + ErrorCode DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED = new ErrorCode(1_050_003_401, "协议({}) 不支持透传下发"); + ErrorCode DEVICE_MESSAGE_RAW_METHOD_RESERVED = new ErrorCode(1_050_003_402, "透传上报只能由网关创建"); // ========== 产品分类 1-050-004-000 ========== ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); @@ -96,6 +100,12 @@ public interface ErrorCodeConstants { ErrorCode OTA_TASK_RECORD_CANCEL_FAIL_STATUS_ERROR = new ErrorCode(1_050_008_201, "取消 OTA 升级记录失败,原因:记录状态不是进行中"); ErrorCode OTA_TASK_RECORD_UPDATE_PROGRESS_FAIL_NO_EXISTS = new ErrorCode(1_050_008_202, "更新 OTA 升级记录进度失败,原因:该设备没有进行中的升级记录"); + // ========== IoT 产品数据解析脚本 1-050-009-000 ========== + ErrorCode PRODUCT_SCRIPT_TOO_LONG = new ErrorCode(1_050_009_000, "数据解析脚本不能超过 {} 字节"); + ErrorCode PRODUCT_SCRIPT_COMPILE_FAILED = new ErrorCode(1_050_009_001, "数据解析脚本语法错误:{}"); + ErrorCode PRODUCT_SCRIPT_EMPTY = new ErrorCode(1_050_009_002, "启用数据解析脚本时,脚本内容不能为空"); + ErrorCode PRODUCT_SCRIPT_DEBUG_BUSY = new ErrorCode(1_050_009_003, "脚本调试繁忙或已熔断,请稍后再试"); + // ========== IoT 数据流转规则 1-050-010-000 ========== ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/IotPermissionConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/IotPermissionConstants.java new file mode 100644 index 0000000..2ce0949 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/IotPermissionConstants.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.iot.enums; + +/** + * IoT 权限标识常量 + * + * 只收录业务代码中动态校验的权限标识,与菜单配置的权限标识保持一致;接口上的 {@code @PreAuthorize} 仍直接书写权限标识 + * + * @author 芋道源码 + */ +public interface IotPermissionConstants { + + /** + * 设备消息的透传下发(raw.push):可以直接驱动 Modbus 写,需要独立的按钮权限 + */ + String DEVICE_MESSAGE_RAW_SEND = "iot:device:message-raw-send"; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java index 0798dc9..de42194 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.iot.framework.storage.config; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; -// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除重复的装配单测 IotStorageConfigurationTest,实际装配由实库验收覆盖 /** * IoT 时序存储的配置类 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java index 40093a5..d1f1836 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java @@ -11,7 +11,6 @@ import org.springframework.stereotype.Component; import javax.sql.DataSource; -// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除独立单测 IotStorageInitRunnerTest,关键的缺数据源防回落 master 断言并入实库验收 /** * IoT 时序存储的初始化 Runner:校验数据源、前提条件,并创建消息表 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java index 09c8488..fb217d5 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java @@ -16,6 +16,7 @@ import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceM import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 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; @@ -25,11 +26,13 @@ import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqD 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; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; 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.device.shadow.IotDeviceShadowService; import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; import com.google.common.base.Objects; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -46,6 +49,10 @@ import java.util.Map; 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.DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_METHOD_RESERVED; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_PAYLOAD_INVALID; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.PRODUCT_NOT_EXISTS; /** * IoT 设备消息 Service 实现类 @@ -67,6 +74,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Resource @Lazy // 延迟加载,避免循环依赖 private IotDeviceShadowService deviceShadowService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private IotProductService productService; @Resource private IotDeviceMessageDAO deviceMessageDAO; @@ -116,6 +126,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Override public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { + // 0. 透传:raw.post 只能由网关创建;raw.push 校验后改写为 raw,网关从 raw 取字节;其它消息不能携带 raw + byte[] rawPayload = validateRawMessage(message, device); + message.setRaw(rawPayload != null ? IotDeviceMessageUtils.encodeRaw(rawPayload) : null); + if (rawPayload != null) { + message.setParams(null); + } // 1. 补充信息 appendDeviceMessage(message, device); @@ -147,6 +163,44 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { } /** + * 校验透传消息:raw.post 只能由网关创建;raw.push 校验 params.payload 与产品协议 + * + * @param message 消息 + * @param device 设备信息 + * @return raw.push 请求的载荷;其它消息返回 null + */ + private byte[] validateRawMessage(IotDeviceMessage message, IotDeviceDO device) { + // 1.1 raw.post 只能由网关创建 + if (IotDeviceMessageMethodEnum.RAW_POST.getMethod().equals(message.getMethod())) { + throw exception(DEVICE_MESSAGE_RAW_METHOD_RESERVED); + } + // 1.2 只校验 raw.push 请求,其它消息直接跳过 + if (!Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()) + || IotDeviceMessageUtils.isReplyMessage(message)) { + return null; + } + + // 2. 校验产品协议:HTTP、CoAP 没有长连接,不支持透传下发 + IotProductDO product = productService.getProductFromCache(device.getProductId()); + if (product == null) { + throw exception(PRODUCT_NOT_EXISTS); + } + IotProtocolTypeEnum protocol = IotProtocolTypeEnum.of(product.getProtocolType()); + if (protocol == IotProtocolTypeEnum.HTTP || protocol == IotProtocolTypeEnum.COAP) { + throw exception(DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED, product.getProtocolType()); + } + + // 3. 校验载荷:按产品协议的字节规则,与网关 encode 输出、脚本调试是同一份 + try { + byte[] payload = IotDeviceMessageUtils.decodeRawPayload(message.getParams(), IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); + IotDeviceMessageUtils.validateRawPayload(protocol, payload); + return payload; + } catch (IllegalArgumentException e) { + throw exception(DEVICE_MESSAGE_RAW_PAYLOAD_INVALID, e.getMessage()); + } + } + + /** * 补充消息的后端字段 * * @param message 消息 @@ -180,8 +234,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { getSelf().createDeviceLogAsync(message); // 3. 回复消息。前提:非 _reply 消息、非禁用回复的消息 + // 另外:带原始帧(raw)的是网关透传解析出的消息,不自动回复;设备经标准路径上报的 raw 由网关清空,不能伪造 if (IotDeviceMessageUtils.isReplyMessage(message) || IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod()) + || message.getRaw() != null || StrUtil.isEmpty(message.getServerId())) { return; } @@ -335,6 +391,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Override public PageResult getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) { + // 先校验设备属于当前租户(MySQL 租户插件生效);时序库查询本身只按 deviceId 限定 + deviceService.validateDeviceExists(pageReqVO.getDeviceId()); return deviceMessageDAO.selectPage(pageReqVO); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java index 87aa61b..16286fe 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java @@ -3,6 +3,9 @@ package cn.iocoder.yudao.module.iot.service.product; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import jakarta.validation.Valid; @@ -37,6 +40,21 @@ public interface IotProductService { void updateProduct(@Valid IotProductSaveReqVO updateReqVO); /** + * 更新产品数据解析脚本:只修改非 null 的字段(脚本内容、是否启用),最后保存者生效 + * + * @param updateReqVO 更新信息 + */ + void updateProductScript(@Valid IotProductScriptUpdateReqVO updateReqVO); + + /** + * 调试产品数据解析脚本:执行当前编辑内容,不保存、不发消息、不改属性 + * + * @param debugReqVO 调试信息 + * @return 调试结果 + */ + IotProductScriptDebugRespVO debugProductScript(@Valid IotProductScriptDebugReqVO debugReqVO); + + /** * 删除产品 * * @param id 编号 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java index 8db045e..7def331 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java @@ -1,12 +1,30 @@ package cn.iocoder.yudao.module.iot.service.product; +import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.ReUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.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; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; @@ -14,6 +32,8 @@ import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import com.baomidou.dynamic.datasource.annotation.DSTransactional; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; @@ -25,9 +45,12 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; /** @@ -40,6 +63,23 @@ import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; @Validated public class IotProductServiceImpl implements IotProductService { + /** + * 数据解析脚本的最大字节数(UTF-8) + */ + private static final int SCRIPT_MAX_BYTES = 32 * 1024; + /** + * 调试时 ctx.deviceName 的固定值:ctx 中不带任何真实设备的信息或秘密 + */ + private static final String DEBUG_DEVICE_NAME = "debug-device"; + /** + * 调试的截止时间:覆盖排队与执行,也是请求线程的最长等待时间 + */ + private static final long DEBUG_DEADLINE_MILLIS = 2000; + /** + * 保存时语法检查的最长等待时间 + */ + private static final long COMPILE_TIMEOUT_MILLIS = 2000; + @Resource private IotProductMapper productMapper; @@ -49,6 +89,24 @@ public class IotProductServiceImpl implements IotProductService { @Lazy // 延迟加载,避免循环依赖 private IotDevicePropertyService devicePropertyDataService; + /** + * 调试池:2 个线程、队列 4;请求线程阻塞等待结果,最长到截止时间 + *

+ * Rhino 下死循环会被预算中断、线程可复用;不经过检查点的原生长操作仍可能卡住线程 → 熔断调试功能,需要重启 biz + */ + private IotScriptExecutor debugScriptExecutor; + + @PostConstruct + public void initDebugScriptExecutor() { + debugScriptExecutor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options().setName("iot-script-debug") + .setPoolSize(2).setQueueCapacity(4).setMaxStuckThreads(2)); + } + + @PreDestroy + public void destroyDebugScriptExecutor() { + debugScriptExecutor.shutdown(); + } + @Override public Long createProduct(IotProductSaveReqVO createReqVO) { // 1. 校验 ProductKey @@ -204,4 +262,157 @@ public class IotProductServiceImpl implements IotProductService { } } + // ========== 数据解析脚本 ========== + + @Override + @CacheEvict(value = RedisKeyConstants.PRODUCT, key = "#updateReqVO.id") + public void updateProductScript(IotProductScriptUpdateReqVO updateReqVO) { + // 1.1 校验存在 + IotProductDO product = validateProductExists(updateReqVO.getId()); + // 1.2 校验脚本:UTF-8 不超过 32KB;语法只编译、不执行 + if (updateReqVO.getScriptContent() != null) { + validateProductScript(updateReqVO.getScriptContent()); + } + // 1.3 启用时内容不能为空:没提交的字段取库里的值 + String content = ObjUtil.defaultIfNull(updateReqVO.getScriptContent(), product.getScriptContent()); + Boolean enabled = ObjUtil.defaultIfNull(updateReqVO.getScriptEnabled(), product.getScriptEnabled()); + if (Boolean.TRUE.equals(enabled) && StrUtil.isBlank(content)) { + throw exception(PRODUCT_SCRIPT_EMPTY); + } + + // 2. 部分更新:只写本次提交的字段(null 跳过),保存内容不会顺带改启用状态 + IotProductDO updateObj = BeanUtils.toBean(updateReqVO, IotProductDO.class); + productMapper.updateById(updateObj); + } + + private void validateProductScriptLength(String content) { + if (StrUtil.utf8Bytes(content).length > SCRIPT_MAX_BYTES) { + throw exception(PRODUCT_SCRIPT_TOO_LONG, SCRIPT_MAX_BYTES); + } + } + + private void validateProductScript(String content) { + validateProductScriptLength(content); + // 空字符串表示清空 + if (StrUtil.isEmpty(content)) { + return; + } + // 在调试池线程上编译:只做语法检查,不执行任何用户代码 + IotScriptResult result = debugScriptExecutor.compileOnly(content, COMPILE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + if (result.getStatus() == IotScriptResult.Status.REJECTED || result.isTimeout()) { + throw exception(PRODUCT_SCRIPT_DEBUG_BUSY); + } + if (!result.isSuccess()) { + throw exception(PRODUCT_SCRIPT_COMPILE_FAILED, result.getLineNumber() > 0 + ? StrUtil.format("第 {} 行:{}", result.getLineNumber(), result.getMessage()) : result.getMessage()); + } + } + + @Override + public IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO debugReqVO) { + // 1.1 校验当前租户内的产品 + IotProductDO product = validateProductExists(debugReqVO.getId()); + // 1.2 校验脚本长度 + validateProductScriptLength(debugReqVO.getScriptContent()); + // 1.3 校验调试输入,构建 ctx + String ctx = buildDebugContext(debugReqVO, product); + + // 2.1 执行:与网关相同的运行时和预算;请求线程阻塞等待,最长到截止时间 + IotScriptResult result = debugScriptExecutor.execute(IotScriptSource.ofDebug(debugReqVO.getScriptContent()), + debugReqVO.getDirection(), ctx, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DEBUG_DEADLINE_MILLIS)); + // 2.2 构建结果:与网关相同的输出校验 + return buildDebugResult(debugReqVO, product, result); + } + + /** + * 构建调试的 ctx:与网关的结构一致,但设备名固定为 {@link #DEBUG_DEVICE_NAME},不带任何真实设备的信息或秘密 + * + * @param reqVO 调试请求 + * @param product 产品 + * @return ctx(JSON) + */ + private static String buildDebugContext(IotProductScriptDebugReqVO reqVO, IotProductDO product) { + // 1. decode:上行原始字节 + if (IotScriptUtils.FUNCTION_DECODE.equals(reqVO.getDirection())) { + // 1.1 校验 HEX(允许空格) + String hex = StrUtil.cleanBlank(reqVO.getInputHex()); + if (StrUtil.isEmpty(hex) || hex.length() % 2 != 0 || !ReUtil.isMatch("[0-9a-fA-F]+", hex)) { + throw invalidParamException("上行数据必须是偶数位的 HEX"); + } + // 1.2 校验长度:与透传上报的上限一致 + byte[] bytes = HexUtil.decodeHex(hex); + if (bytes.length > IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES) { + throw invalidParamException("上行数据不能超过 {} 字节", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); + } + // 1.3 构建 decode 的 ctx + return IotScriptUtils.buildDecodeContext(product.getProtocolType(), product.getProductKey(), + DEBUG_DEVICE_NAME, System.currentTimeMillis(), bytes, null); + } + + // 2.1 encode:与网关一致,只编码 property.set / service.invoke 请求 + Map message = reqVO.getMessage(); + if (message == null) { + throw invalidParamException("下行指令不能为空"); + } + IotDeviceMessage request = IotDeviceMessage.requestOf( + StrUtil.blankToDefault(MapUtil.getStr(message, "requestId"), "debug"), + MapUtil.getStr(message, "method"), message.get("params")); + if (!IotDeviceMessageUtils.isCommandRequest(request)) { + throw invalidParamException("下行编码只支持 thing.property.set、thing.service.invoke"); + } + // 2.2 构建 encode 的 ctx + return IotScriptUtils.buildEncodeContext(product.getProtocolType(), product.getProductKey(), + DEBUG_DEVICE_NAME, request); + } + + /** + * 构建调试结果:输出按与网关相同的规则校验 + * + * @param reqVO 调试请求 + * @param product 产品:encode 输出的透传字节,按产品协议的规则校验 + * @param result 脚本的执行结果 + * @return 调试结果 + */ + private static IotProductScriptDebugRespVO buildDebugResult(IotProductScriptDebugReqVO reqVO, IotProductDO product, + IotScriptResult result) { + // 1. 执行失败:异常、超时、拒绝 + IotProductScriptDebugRespVO respVO = new IotProductScriptDebugRespVO().setDurationMs(result.getDurationMillis()); + if (!result.isSuccess()) { + return failDebugResult(respVO, result.getStatus().name(), result.getMessage(), result.getLineNumber()); + } + + // 2. 校验输出:不合法时返回 OUTPUT_INVALID 等错误 + try { + if (IotScriptUtils.FUNCTION_DECODE.equals(reqVO.getDirection())) { + // 2.1 decode + // 与网关一致:decode 未定义或返回 null,表示没有业务消息(只记录原始消息),message 为空 + IotDeviceMessage message = IotScriptUtils.validateDecode(result.getJson(), null); + respVO.setMessage(message != null ? buildDebugMessage(message) : null); + } else { + // 2.2 encode + // 与网关一致:字节按产品协议的规则校验(Modbus 必须是合法的请求 PDU) + IotScriptUtils.EncodeOutput output = IotScriptUtils.validateEncode(result.getJson(), + IotProtocolTypeEnum.of(product.getProtocolType())); + if (output == null) { // 与网关一致:未生成下行数据 + return failDebugResult(respVO, "ENCODE_EMPTY", "数据解析脚本未生成下行数据", -1); + } + respVO.setStandard(output.isStandard()); + respVO.setOutputHex(output.getBytes() != null ? HexUtil.encodeHexStr(output.getBytes(), false) : null); + } + } catch (IotScriptException e) { + return failDebugResult(respVO, e.getStatus().name(), e.getMessage(), e.getLineNumber()); + } + // 3. 成功 + return respVO.setSuccess(true); + } + + private static IotProductScriptDebugRespVO failDebugResult(IotProductScriptDebugRespVO respVO, String type, String message, int lineNumber) { + return respVO.setSuccess(false).setError(new IotProductScriptDebugRespVO.ErrorInfo() + .setType(type).setMessage(message).setLine(lineNumber > 0 ? lineNumber : null)); + } + + private static Map buildDebugMessage(IotDeviceMessage message) { + return BeanUtil.beanToMap(message, "method", "requestId", "params", "data", "code", "msg"); + } + } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml index 15185d1..b5deba1 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml @@ -19,7 +19,8 @@ params VARCHAR(8192), data VARCHAR(8192), code INT, - msg NCHAR(256) + msg NCHAR(256), + raw VARCHAR(8192) ) TAGS ( device_id BIGINT ) @@ -33,21 +34,21 @@ INSERT INTO device_message_${deviceId} ( ts, id, report_time, tenant_id, server_id, upstream, reply, identifier, request_id, method, - params, data, code, msg + params, data, code, msg, raw ) USING device_message TAGS (#{deviceId}) VALUES ( #{ts}, #{id}, #{reportTime}, #{tenantId}, #{serverId}, - #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, - #{params}, #{data}, #{code}, #{msg} + #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, + #{params}, #{data}, #{code}, #{msg}, #{raw} ) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java new file mode 100644 index 0000000..f1cfb02 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java @@ -0,0 +1,116 @@ +package cn.iocoder.yudao.module.iot.api.device; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +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.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import jakarta.validation.Validation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.aop.support.AopUtils; +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IoTDeviceApiImpl} 的单元测试:经过真实的 Spring 方法校验代理(与 @Validated 的 Bean 在容器中一致) + * + * @author 芋道源码 + */ +public class IoTDeviceApiImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private IoTDeviceApiImpl deviceApiImpl; + + @Mock + private IotDeviceService deviceService; + @Mock + private IotProductService productService; + @Mock + private IotDeviceModbusConfigService modbusConfigService; + @Mock + private IotDeviceModbusPointService modbusPointService; + + /** + * 方法校验代理:参数约束继承不合法时,代理上的任何方法调用都会抛 ConstraintDeclarationException(HV000151) + */ + private IotDeviceCommonApi deviceApi; + + @BeforeEach + public void setUp() { + MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); + processor.setValidator(Validation.buildDefaultValidatorFactory().getValidator()); + processor.afterPropertiesSet(); + deviceApi = (IotDeviceCommonApi) processor.postProcessAfterInitialization(deviceApiImpl, "ioTDeviceApiImpl"); + assertTrue(AopUtils.isAopProxy(deviceApi)); + } + + @Test + public void testAuthDevice() { + // 准备参数 + IotDeviceAuthReqDTO reqDTO = new IotDeviceAuthReqDTO("client", "dn&pk", "password"); + // mock 方法 + when(deviceService.authDevice(same(reqDTO))).thenReturn(true); + + // 调用 + CommonResult result = deviceApi.authDevice(reqDTO); + // 断言:方法校验代理正常放行 + assertTrue(result.isSuccess()); + assertTrue(result.getData()); + } + + @Test + public void testGetDevice_scriptEnabled() { + // 准备参数 + IotDeviceGetReqDTO reqDTO = new IotDeviceGetReqDTO().setId(10L); + // mock 方法:产品启用数据解析脚本 + when(deviceService.getDeviceFromCache(eq(10L))).thenReturn(IotDeviceDO.builder().id(10L).productId(1L) + .productKey("pk").deviceName("dn").build()); + when(productService.getProductFromCache(eq(1L))).thenReturn(IotProductDO.builder().id(1L) + .protocolType("tcp").serializeType("json") + .scriptEnabled(true).scriptContent("function decode(ctx) {}").build()); + + // 调用 + CommonResult result = deviceApi.getDevice(reqDTO); + // 断言:产品字段随设备信息返回,启用时带上脚本内容 + assertTrue(result.isSuccess()); + IotDeviceRespDTO device = result.getData(); + assertEquals(10L, device.getId()); + assertEquals(1L, device.getProductId()); + assertEquals("tcp", device.getProtocolType()); + assertEquals("json", device.getSerializeType()); + assertTrue(device.getScriptEnabled()); + assertEquals("function decode(ctx) {}", device.getScriptContent()); + } + + @Test + public void testGetDevice_scriptDisabled() { + // 准备参数 + IotDeviceGetReqDTO reqDTO = new IotDeviceGetReqDTO().setProductKey("pk").setDeviceName("dn"); + // mock 方法:产品停用数据解析脚本,但保留了脚本内容 + when(deviceService.getDeviceFromCache(eq("pk"), eq("dn"))).thenReturn(IotDeviceDO.builder().id(10L) + .productId(1L).productKey("pk").deviceName("dn").build()); + when(productService.getProductFromCache(eq(1L))).thenReturn(IotProductDO.builder().id(1L) + .scriptEnabled(false).scriptContent("function decode(ctx) {}").build()); + + // 调用 + CommonResult result = deviceApi.getDevice(reqDTO); + // 断言:停用时不返回脚本内容 + assertTrue(result.isSuccess()); + assertFalse(result.getData().getScriptEnabled()); + assertNull(result.getData().getScriptContent()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodecTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodecTest.java new file mode 100644 index 0000000..fbc85c7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodecTest.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotDeviceMessageIoTDBCodec} 的单元测试 + * + * @author 芋道源码 + */ +public class IotDeviceMessageIoTDBCodecTest { + + @Test + public void testEncode_raw() { + // 调用 + 断言:有原始帧 + IotDeviceMessageIoTDBRow row = IotDeviceMessageIoTDBCodec.encode(buildMessage().setRaw("AQID")); + assertEquals("v:AQID", row.getRaw()); + // 调用 + 断言:没有原始帧,也写入非 NULL 值,保证同一 (device_id, time) 整行替换 + IotDeviceMessageIoTDBRow nullRow = IotDeviceMessageIoTDBCodec.encode(buildMessage()); + assertEquals("n:", nullRow.getRaw()); + } + + @Test + public void testDecode_raw() { + // 调用 + 断言:v: 为值,n: 为 null + assertEquals("AQID", IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("v:AQID")).getRaw()); + assertNull(IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("n:")).getRaw()); + } + + @Test + public void testDecode_rawCorrupted() { + // 调用 + 断言:不是本系统的编码,按损坏处理,不静默 + assertThrows(IllegalArgumentException.class, + () -> IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("AQID"))); + } + + @Test + public void testEncodeDecode_roundTrip() { + // 准备参数 + IotDeviceMessageDO message = buildMessage().setRaw("AQID"); + + // 调用 + IotDeviceMessageDO result = IotDeviceMessageIoTDBCodec.decode(IotDeviceMessageIoTDBCodec.encode(message)); + // 断言 + assertEquals(message.getRaw(), result.getRaw()); + assertEquals(message.getId(), result.getId()); + assertEquals(message.getDeviceId(), result.getDeviceId()); + assertEquals(message.getMethod(), result.getMethod()); + assertEquals(message.getUpstream(), result.getUpstream()); + assertNull(result.getParams()); + } + + private static IotDeviceMessageDO buildMessage() { + return new IotDeviceMessageDO().setTs(1000L).setDeviceId(2L).setId("msg-1") + .setMethod("thing.property.post").setUpstream(true).setReply(false); + } + + private static IotDeviceMessageIoTDBRow buildRow() { + return new IotDeviceMessageIoTDBRow().setTs(1000L).setDeviceId("2").setId("msg-1") + .setUpstream(1).setReply(0).setMethod("v:thing.property.post").setIdentifier("n:") + .setRequestId("n:").setServerId("n:").setParams("n:").setData("n:").setMsg("n:") + .setReportTime("n:").setTenantId("n:").setCode("n:"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java index 96df722..92f74e0 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriberTest.java @@ -85,6 +85,27 @@ public class IotDeviceMessageSubscriberTest extends BaseMockitoUnitTest { verifyNoInteractions(devicePropertyService, deviceMessageService); } + @Test + public void testOnMessage_replyForceOnline() { + // 准备参数:设备实际返回的回复(例如 Modbus 异常响应),按原逻辑作为设备活动 + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) + .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) + .setCode(1_051_003_111); + // mock 方法:设备离线 + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) + .setState(IotDeviceStateEnum.OFFLINE.getState()); + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device); + + // 调用 + deviceMessageSubscriber.onMessage(message); + // 断言:更新上报时间、连接 server,并强制上线 + verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class)); + verify(devicePropertyService).updateDeviceServerIdAsync(eq(device.getId()), eq(message.getServerId())); + verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) -> + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod()))); + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); + } + private static IotDeviceMessage buildPropertyPostMessage() { return new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java index 8bb9b64..449e923 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.device.message; import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.enums.DateIntervalEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; @@ -13,24 +14,31 @@ 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.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; 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.ota.IotOtaTaskRecordService; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import java.sql.Timestamp; import java.time.LocalDateTime; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.*; @@ -40,7 +48,7 @@ import static org.mockito.Mockito.*; * * 注:时序库没有 embedded 替代,存储 DAO 与依赖 service 走 mock; * handleUpstreamDeviceMessage 与 sendDeviceMessage 下行成功路径依赖 SpringUtil.getBean 的自调用 - * createDeviceLogAsync,更适合放到集成测试,本类不展开。 + * createDeviceLogAsync,通过 mockStatic 返回本实例(同步执行)。 * * @author 芋道源码 */ @@ -59,6 +67,8 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { private IotDeviceMessageDAO deviceMessageDAO; @Mock private IotDeviceMessageProducer deviceMessageProducer; + @Mock + private IotProductService productService; // ========== createDeviceLogAsync ========== @@ -245,6 +255,208 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(1, list.get(0).getDownstreamCount()); } + // ========== 透传校验 ========== + + @Test + public void testSendDeviceMessage_rawPostReserved() { + // 准备参数:raw.post 只能由网关 raw 入口创建 + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_POST.getMethod()); + + // 调用,并断言异常 + assertServiceException(() -> service.sendDeviceMessage(message, buildDevice(), null), + DEVICE_MESSAGE_RAW_METHOD_RESERVED); + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendDeviceMessage_rawPushProtocolNotSupported() { + // 准备参数 + IotDeviceMessage message = buildRawPush("AQID"); + // mock 方法:HTTP 产品 + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("http").build()); + + // 调用,并断言异常 + assertServiceException(() -> service.sendDeviceMessage(message, buildDevice(), null), + DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED, "http"); + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendDeviceMessage_rawPushPayloadInvalid() { + // mock 方法:TCP 产品 + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("tcp").build()); + + // 调用,并断言异常:非法 Base64、空载荷 + assertRawPayloadInvalid(buildRawPush("not base64!")); + assertRawPayloadInvalid(buildRawPush("")); + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendDeviceMessage_rawPushModbusPduInvalid() { + // mock 方法:Modbus 产品 + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("modbus_tcp_client").build()); + + // 调用,并断言异常:功能码 08 不在白名单;03 数量 126 超限 + assertRawPayloadInvalid(buildRawPush(Base64.getEncoder().encodeToString(new byte[]{8, 0, 0, 0, 1}))); + assertRawPayloadInvalid(buildRawPush(Base64.getEncoder().encodeToString(new byte[]{3, 0, 0, 0, 126}))); + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testGetDeviceMessagePage_validateDevice() { + // 准备参数 + IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); + reqVO.setDeviceId(2L); + // mock 方法:设备不属于当前租户 + when(deviceService.validateDeviceExists(2L)).thenThrow(new ServiceException(DEVICE_NOT_EXISTS)); + + // 调用,并断言异常:不查时序库 + assertServiceException(() -> service.getDeviceMessagePage(reqVO), DEVICE_NOT_EXISTS); + verifyNoInteractions(deviceMessageDAO); + } + + @Test + public void testSendDeviceMessage_rawPushRewriteToRaw() { + // 准备参数:未带 padding 的 Base64,规范化为标准 Base64 + IotDeviceMessage message = buildRawPush("AQI"); + // mock 方法:TCP 产品,设备已连接网关 + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("tcp").build()); + when(devicePropertyService.getDeviceServerId(2L)).thenReturn("server-1"); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.sendDeviceMessage(message, buildDevice(), null); + } + // 断言:发给网关的消息,载荷在 raw,params 置空 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), messageCaptor.capture()); + assertEquals(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), messageCaptor.getValue().getMethod()); + assertEquals("AQI=", messageCaptor.getValue().getRaw()); + assertNull(messageCaptor.getValue().getParams()); + // 断言:消息日志带上 raw + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); + verify(deviceMessageDAO).insert(logCaptor.capture()); + assertEquals("AQI=", logCaptor.getValue().getRaw()); + assertNull(logCaptor.getValue().getParams()); + assertFalse(logCaptor.getValue().getUpstream()); + } + + @Test + public void testSendDeviceMessage_clearForgedRaw() { + // 准备参数:非 raw.push 的下行消息,携带伪造的 raw + HashMap params = new HashMap<>(); + params.put("power", 1); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) + .setParams(params).setRaw("AQID"); + // mock 方法:设备已连接网关 + when(devicePropertyService.getDeviceServerId(2L)).thenReturn("server-1"); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.sendDeviceMessage(message, buildDevice(), null); + } + // 断言:raw 被清空,params 保持不变 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), messageCaptor.capture()); + assertNull(messageCaptor.getValue().getRaw()); + assertSame(params, messageCaptor.getValue().getParams()); + verifyNoInteractions(productService); + } + + @Test + public void testSendDeviceMessage_upstreamClearForgedRaw() { + // 准备参数:模拟上报,携带伪造的 raw(否则可绕过标准自动回复) + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()).setRaw("AQID"); + + // 调用 + service.sendDeviceMessage(message, buildDevice(), null); + // 断言:raw 被清空后再发到消息总线 + verify(deviceMessageProducer).sendDeviceMessage(same(message)); + assertNull(message.getRaw()); + } + + // ========== handleUpstreamDeviceMessage ========== + + @Test + public void testHandleUpstreamDeviceMessage_reply() { + // 准备参数:设备经标准路径上报的属性(没有 raw) + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .setRequestId("req-1").setServerId("server-1"); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.handleUpstreamDeviceMessage(message, device); + } + // 断言:保存属性,并自动回复 + verify(devicePropertyService).saveDeviceProperty(same(device), same(message)); + ArgumentCaptor replyCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), replyCaptor.capture()); + assertEquals("req-1", replyCaptor.getValue().getRequestId()); + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), replyCaptor.getValue().getMethod()); + assertEquals(0, replyCaptor.getValue().getCode()); + assertNull(replyCaptor.getValue().getRaw()); + } + + @Test + public void testHandleUpstreamDeviceMessage_rawNoReply() { + // 准备参数:网关透传解析出的属性(带原始帧) + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) + .setRequestId("req-1").setServerId("server-1").setRaw("AQID"); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.handleUpstreamDeviceMessage(message, device); + } + // 断言:照常保存属性,日志带上 raw;不自动回复 + verify(devicePropertyService).saveDeviceProperty(same(device), same(message)); + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); + verify(deviceMessageDAO).insert(logCaptor.capture()); + assertEquals("AQID", logCaptor.getValue().getRaw()); + assertTrue(logCaptor.getValue().getUpstream()); + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); + } + + @Test + public void testHandleUpstreamDeviceMessage_rawPostNoReply() { + // 准备参数:网关未解析的透传上报 + IotDeviceDO device = buildDevice(); + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_POST.getMethod()) + .setParams(null).setServerId("server-1").setRaw("AQID"); + + try (MockedStatic springUtilMock = mockStatic(SpringUtil.class)) { + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); + // 调用 + service.handleUpstreamDeviceMessage(message, device); + } + // 断言:只记录日志(带 raw),不自动回复 + ArgumentCaptor logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); + verify(deviceMessageDAO).insert(logCaptor.capture()); + assertEquals("AQID", logCaptor.getValue().getRaw()); + assertNull(logCaptor.getValue().getParams()); + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); + } + + private void assertRawPayloadInvalid(IotDeviceMessage message) { + ServiceException ex = assertThrows(ServiceException.class, + () -> service.sendDeviceMessage(message, buildDevice(), null)); + assertEquals(DEVICE_MESSAGE_RAW_PAYLOAD_INVALID.getCode(), ex.getCode()); + } + + private IotDeviceMessage buildRawPush(String payload) { + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); + HashMap params = new HashMap<>(); + params.put("payload", payload); + message.setParams(params); + return message; + } + // ========== 辅助方法 ========== /** 构造一条最简消息(指定 method 决定上下行分支) */ @@ -259,7 +471,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { /** 构造最简设备 */ private IotDeviceDO buildDevice() { - return IotDeviceDO.builder().id(2L).build(); + return IotDeviceDO.builder().id(2L).productId(3L).build(); } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java index 0f03aa1..7c6e97b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java @@ -1,22 +1,37 @@ package cn.iocoder.yudao.module.iot.service.product; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import java.io.Serializable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -127,6 +142,231 @@ public class IotProductServiceImplTest extends BaseMockitoUnitTest { verify(productMapper, times(2)).deleteById(id); } + @Test + public void testUpdateProduct_notOverwriteScript() { + // 准备参数:普通产品表单不含脚本字段 + IotProductSaveReqVO reqVO = new IotProductSaveReqVO(); + reqVO.setId(1L); + reqVO.setName("温湿度"); + // mock 方法 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, IotProductStatusEnum.UNPUBLISHED.getStatus())); + + // 调用 + productService.updateProduct(reqVO); + // 断言:脚本两列为 null,按默认 NOT_NULL 策略不会写回 + IotProductDO updateObj = captureUpdate(); + assertEquals("温湿度", updateObj.getName()); + assertNull(updateObj.getScriptContent()); + assertNull(updateObj.getScriptEnabled()); + } + + @Test + public void testUpdateProductScript_contentOnly() { + // 准备参数 + productService.initDebugScriptExecutor(); + try { + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); + reqVO.setId(1L); + reqVO.setScriptContent("function decode(ctx) { return {method: 'thing.property.post', params: {}}; }"); + // mock 方法:已启用旧脚本 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptEnabled(true).setScriptContent("old")); + + // 调用 + productService.updateProductScript(reqVO); + // 断言:只写内容,不顺带改启用状态 + IotProductDO updateObj = captureUpdate(); + assertEquals(reqVO.getScriptContent(), updateObj.getScriptContent()); + assertNull(updateObj.getScriptEnabled()); + assertNull(updateObj.getName()); + } finally { + productService.destroyDebugScriptExecutor(); + } + } + + @Test + public void testUpdateProductScript_enableOnly() { + // 准备参数 + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); + reqVO.setId(1L); + reqVO.setScriptEnabled(true); + // mock 方法:库里已有内容 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptContent("function decode(ctx) {}")); + + // 调用 + productService.updateProductScript(reqVO); + // 断言:只写启用状态 + IotProductDO updateObj = captureUpdate(); + assertEquals(true, updateObj.getScriptEnabled()); + assertNull(updateObj.getScriptContent()); + } + + @Test + public void testUpdateProductScript_enableWithEmptyContent() { + // 准备参数 + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); + reqVO.setId(1L); + reqVO.setScriptEnabled(true); + // mock 方法:库里没有内容 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); + + // 调用,并断言异常 + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_EMPTY); + verify(productMapper, never()).updateById(any(IotProductDO.class)); + } + + @Test + public void testUpdateProductScript_clearWhileEnabled() { + // 准备参数:启用状态下清空内容 + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); + reqVO.setId(1L); + reqVO.setScriptContent(""); + // mock 方法 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptEnabled(true).setScriptContent("x")); + + // 调用,并断言异常 + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_EMPTY); + verify(productMapper, never()).updateById(any(IotProductDO.class)); + } + + @Test + public void testUpdateProductScript_tooLong() { + // 准备参数:超过 32KB(UTF-8) + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); + reqVO.setId(1L); + reqVO.setScriptContent(StrUtil.repeat('a', 32 * 1024 + 1)); + // mock 方法 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); + + // 调用,并断言异常 + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_TOO_LONG, 32 * 1024); + verify(productMapper, never()).updateById(any(IotProductDO.class)); + } + + @Test + public void testUpdateProductScript_compileFailed() { + // 准备参数 + productService.initDebugScriptExecutor(); + try { + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); + reqVO.setId(1L); + reqVO.setScriptContent("function decode(ctx) {\n return {;\n}"); + // mock 方法 + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); + + // 调用,并断言异常:只编译、不执行,语法错误带行号 + ServiceException ex = assertThrows(ServiceException.class, () -> productService.updateProductScript(reqVO)); + assertEquals(PRODUCT_SCRIPT_COMPILE_FAILED.getCode(), ex.getCode()); + verify(productMapper, never()).updateById(any(IotProductDO.class)); + } finally { + productService.destroyDebugScriptExecutor(); + } + } + + @Test + public void testDebugProductScript_decodeMessage() { + // 准备参数:decode 返回一个标准消息对象 + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO( + "function decode(ctx) { return {method: 'thing.property.post', params: {temperature: ctx.bytes[0]}}; }"); + + // 调用 + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); + // 断言:单条消息 + assertTrue(respVO.getSuccess()); + assertNull(respVO.getError()); + assertNotNull(respVO.getMessage()); + assertEquals("thing.property.post", respVO.getMessage().get("method")); + assertEquals(25, ((Map) respVO.getMessage().get("params")).get("temperature")); + } + + @Test + public void testDebugProductScript_decodeNull() { + // 准备参数:decode 返回 null,表示没有业务消息 + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO("function decode(ctx) { return null; }"); + + // 调用 + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); + // 断言:成功,没有消息 + assertTrue(respVO.getSuccess()); + assertNull(respVO.getError()); + assertNull(respVO.getMessage()); + } + + @Test + public void testDebugProductScript_decodeOutputInvalid() { + // 准备参数:旧的 messages 包装,不再支持 + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO( + "function decode(ctx) { return {messages: [{method: 'thing.property.post', params: {}}]}; }"); + + // 调用 + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); + // 断言:输出不合法 + assertFalse(respVO.getSuccess()); + assertNull(respVO.getMessage()); + assertEquals("OUTPUT_INVALID", respVO.getError().getType()); + } + + @Test + public void testDebugProductScript_encodeModbusPduInvalid() { + // 准备参数:Modbus 产品,encode 输出的字节不是合法的请求 PDU(缺数量);合法的 06 写单个寄存器 + IotProductScriptDebugReqVO invalidReqVO = buildEncodeDebugReqVO("function encode(ctx) { return {bytes: [6, 0, 16]}; }"); + IotProductScriptDebugReqVO validReqVO = buildEncodeDebugReqVO( + "function encode(ctx) { return {bytes: [6, 0, 16, 0, ctx.message.params.power]}; }"); + + // 调用 + IotProductScriptDebugRespVO invalidRespVO = debugProductScript(invalidReqVO, "modbus_tcp_client"); + IotProductScriptDebugRespVO validRespVO = debugProductScript(validReqVO, "modbus_tcp_client"); + // 断言:与网关一致,按产品协议的字节规则判定输出不合法(带原规则的错误信息);合法的 PDU 照常输出 + assertFalse(invalidRespVO.getSuccess()); + assertEquals("OUTPUT_INVALID", invalidRespVO.getError().getType()); + assertEquals("PDU 长度必须在 5~253 字节之间", invalidRespVO.getError().getMessage()); + assertTrue(validRespVO.getSuccess()); + assertEquals("0600100001", validRespVO.getOutputHex()); + } + + private IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO reqVO) { + return debugProductScript(reqVO, "tcp"); + } + + /** + * 执行调试:真实的调试池;mock 产品 + */ + private IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO reqVO, String protocolType) { + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setProtocolType(protocolType).setProductKey("pk")); + productService.initDebugScriptExecutor(); + try { + return productService.debugProductScript(reqVO); + } finally { + productService.destroyDebugScriptExecutor(); + } + } + + private static IotProductScriptDebugReqVO buildDecodeDebugReqVO(String scriptContent) { + IotProductScriptDebugReqVO reqVO = new IotProductScriptDebugReqVO(); + reqVO.setId(1L); + reqVO.setScriptContent(scriptContent); + reqVO.setDirection("decode"); + reqVO.setInputHex("19 01"); + return reqVO; + } + + private static IotProductScriptDebugReqVO buildEncodeDebugReqVO(String scriptContent) { + IotProductScriptDebugReqVO reqVO = new IotProductScriptDebugReqVO(); + reqVO.setId(1L); + reqVO.setScriptContent(scriptContent); + reqVO.setDirection("encode"); + Map message = new HashMap<>(); + message.put("method", "thing.property.set"); + message.put("params", Collections.singletonMap("power", 1)); + reqVO.setMessage(message); + return reqVO; + } + + private IotProductDO captureUpdate() { + ArgumentCaptor captor = ArgumentCaptor.forClass(IotProductDO.class); + verify(productMapper).updateById(captor.capture()); + return captor.getValue(); + } + private void assertNoDelete() { verifyNoInteractions(devicePropertyDataService); verify(productMapper, never()).deleteById(any(Serializable.class)); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql index 7fe0e94..a667a41 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql @@ -24,6 +24,8 @@ CREATE TABLE IF NOT EXISTS "iot_product" ( "pic_url" varchar(255) DEFAULT NULL, "protocol_type" varchar(100) DEFAULT NULL, "serialize_type" varchar(100) DEFAULT NULL, + "script_content" text DEFAULT NULL, + "script_enabled" bit NOT NULL DEFAULT FALSE, "category_id" bigint DEFAULT NULL, "description" varchar(500) DEFAULT NULL, "data_format" tinyint NOT NULL DEFAULT '0', diff --git a/yudao-module-iot/yudao-module-iot-core/pom.xml b/yudao-module-iot/yudao-module-iot-core/pom.xml index 30ebc2d..ecdcc9c 100644 --- a/yudao-module-iot/yudao-module-iot-core/pom.xml +++ b/yudao-module-iot/yudao-module-iot-core/pom.xml @@ -17,6 +17,7 @@ iot 模块下,提供 iot-biz 和 iot-gateway 模块的核心功能。例如说: 1. 消息总线:跨 iot-biz 和 iot-gateway 的设备消息。可选择使用 spring event、redis stream、rocketmq、kafka、rabbitmq 等。 2. 查询设备信息的通用 API + 3. 数据解析脚本:执行产品配置的 JS 脚本,供 iot-biz 和 iot-gateway 共用 @@ -61,6 +62,12 @@ true + + + org.mozilla + rhino + + org.springframework.boot diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java index 8ad2c5b..89b124a 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotDeviceRespDTO.java @@ -41,5 +41,13 @@ public class IotDeviceRespDTO { * 序列化类型 */ private String serializeType; + /** + * 是否启用数据解析脚本 + */ + private Boolean scriptEnabled; + /** + * 数据解析脚本(JS ES5):只在启用时返回 + */ + private String scriptContent; } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java index 1e536ee..0957c60 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java @@ -71,6 +71,11 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable { SHADOW_UPDATE("thing.shadow.update", "设备影子更新", true), + // ========== 透传 ========== + + RAW_POST("thing.raw.post", "透传上报", true), // 只能由网关的 raw 入口创建 + RAW_PUSH("thing.raw.push", "透传下发", false), + ; public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) @@ -82,7 +87,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable { public static final Set REPLY_DISABLED = SetUtils.asSet( STATE_UPDATE.getMethod(), OTA_PROGRESS.getMethod(), // 参考阿里云,OTA 升级进度上报,不进行回复 - SHADOW_UPDATE.getMethod() // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) + SHADOW_UPDATE.getMethod(), // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) + RAW_POST.getMethod() // 透传原始消息(未解析为业务消息)只记日志 ); private final String method; diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java index 7536054..f9c6936 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java @@ -44,4 +44,11 @@ public enum IotProtocolTypeEnum implements ArrayValuable { return ArrayUtil.firstMatch(e -> e.getType().equals(type), values()); } + /** + * @return 是否是 Modbus 协议(client、server):透传字节按请求 PDU 校验 + */ + public boolean isModbus() { + return this == MODBUS_TCP_CLIENT || this == MODBUS_TCP_SERVER; + } + } diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java index cc9b138..3b62ebe 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java @@ -94,6 +94,16 @@ public class IotDeviceMessage { */ private String msg; + // ========== 透传(raw)相关字段 ========== + + /** + * 与设备之间实际收发的原始字节(Base64) + * + * 只由服务端可信赋值:网关的透传上行(解析成功的业务消息、或未解析的 thing.raw.post)、Modbus 设备的实际响应, + * 以及 biz 下发的 thing.raw.push;设备经标准路径上报的消息,网关会清空该字段 + */ + private String raw; + // ========== 基础方法:只传递"serialize(序列化)相关字段" ========== public static IotDeviceMessage requestOf(String method) { diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptException.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptException.java new file mode 100644 index 0000000..8ce873f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptException.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.iot.core.script; + +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import lombok.Getter; + +/** + * IoT 数据解析脚本异常:脚本执行错误({@link IotScriptResult.Status#ERROR}),或输出不合法({@link IotScriptResult.Status#OUTPUT_INVALID}) + * + * @author 芋道源码 + */ +@Getter +public class IotScriptException extends Exception { + + private final IotScriptResult.Status status; + /** + * 出错行号;未知时为 -1 + */ + private final int lineNumber; + + public IotScriptException(IotScriptResult.Status status, String message, int lineNumber) { + super(message, null, false, false); + this.status = status; + this.lineNumber = lineNumber; + } + + /** + * 创建输出不合法的异常 + * + * @param message 错误信息 + * @return 异常 + */ + public static IotScriptException outputInvalid(String message) { + return new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, message, -1); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptExecutor.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptExecutor.java new file mode 100644 index 0000000..55dec80 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptExecutor.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.core.script; + +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; + +import java.util.concurrent.TimeUnit; + +/** + * IoT 数据解析脚本执行器:网关的 decode / encode,biz 的保存语法检查、调试共用 + *

+ * 目前只有 Rhino 实现 {@link cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor},由使用方直接创建 + * + * @author 芋道源码 + */ +public interface IotScriptExecutor { + + /** + * 执行一次脚本调用:调用方线程阻塞等待,最长到截止时间 + * + * @param source 脚本 + * @param function "decode" 或 "encode" + * @param ctxJson 平台构造的入参 JSON + * @param deadlineNanos 截止时间(System.nanoTime):覆盖排队与执行 + * @return 结果,状态见 {@link IotScriptResult.Status} + */ + IotScriptResult execute(IotScriptSource source, String function, String ctxJson, long deadlineNanos); + + /** + * 只做语法检查(不执行任何用户代码):调用方线程有界等待 + * + * @param content 脚本内容 + * @param timeout 等待时间 + * @param unit 时间单位 + * @return 结果:SUCCESS / ERROR(带行号)/ REJECTED / TIMEOUT + */ + IotScriptResult compileOnly(String content, long timeout, TimeUnit unit); + + /** + * 预热:启动时、接收流量之前调用一次 + */ + void warmUp(); + + /** + * 停止:不再接收新任务 + */ + void shutdown(); + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtils.java new file mode 100644 index 0000000..2952825 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtils.java @@ -0,0 +1,344 @@ +package cn.iocoder.yudao.module.iot.core.script; + +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.IntStream; + +/** + * IoT 数据解析脚本的契约:入参构造与输出校验 + *

+ * 网关所有协议和 biz 调试共用同一份规则,结果一致。任何一项不合法就整体拒绝({@link IotScriptResult.Status#OUTPUT_INVALID}),不截断、不部分写出 + * + * @author 芋道源码 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class IotScriptUtils { + + public static final String FUNCTION_DECODE = "decode"; + public static final String FUNCTION_ENCODE = "encode"; + + /** + * 消息 params / data 序列化后的最大 UTF-8 字节数 + */ + public static final int MAX_FIELD_BYTES = 8192; + public static final int MAX_REQUEST_ID_LENGTH = 50; + public static final int MAX_IDENTIFIER_LENGTH = 100; + public static final int MAX_MSG_LENGTH = 256; + + private static final Set TOP_ENCODE_KEYS = new HashSet<>(Arrays.asList("bytes", "standard")); + /** + * decode 输出的消息允许的键:身份、路由与可信字段(deviceId、tenantId、serverId、productKey、deviceName、id、reportTime、raw) + * 和其它未知键都不允许 + */ + private static final Set MESSAGE_KEYS = new HashSet<>(Arrays.asList( + "method", "params", "requestId", "code", "msg", "data")); + /** + * decode 允许的请求方法 + */ + private static final Set DECODE_REQUEST_METHODS = new HashSet<>(Arrays.asList( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), IotDeviceMessageMethodEnum.EVENT_POST.getMethod())); + + // ========== 入参构造 ========== + + /** + * 构造 decode 的入参 JSON + * + * @param protocol 协议类型 + * @param productKey 产品标识 + * @param deviceName 设备名称 + * @param receivedAt 接收时间(毫秒时间戳) + * @param bytes 原始字节 + * @param request encode 下行的原请求(协议回复设备的实际响应时);普通上行为 null + * @return JSON + */ + public static String buildDecodeContext(String protocol, String productKey, String deviceName, + long receivedAt, byte[] bytes, IotDeviceMessage request) { + Map ctx = new LinkedHashMap<>(); + ctx.put("direction", FUNCTION_DECODE); + ctx.put("protocol", protocol); + ctx.put("productKey", productKey); + ctx.put("deviceName", deviceName); + ctx.put("receivedAt", receivedAt); + ctx.put("bytes", toUnsignedArray(bytes)); + ctx.put("hex", HexUtil.encodeHexStr(bytes, false)); + if (request != null) { + ctx.put("request", buildMessageContext(request)); + } + return JsonUtils.toJsonString(ctx); + } + + /** + * 构造 encode 的入参 JSON + * + * @param protocol 协议类型 + * @param productKey 产品标识 + * @param deviceName 设备名称 + * @param message 下行指令(property.set / service.invoke) + * @return JSON + */ + public static String buildEncodeContext(String protocol, String productKey, String deviceName, + IotDeviceMessage message) { + Map ctx = new LinkedHashMap<>(); + ctx.put("direction", FUNCTION_ENCODE); + ctx.put("protocol", protocol); + ctx.put("productKey", productKey); + ctx.put("deviceName", deviceName); + ctx.put("message", buildMessageContext(message)); + return JsonUtils.toJsonString(ctx); + } + + private static Map buildMessageContext(IotDeviceMessage message) { + Map map = new LinkedHashMap<>(); + map.put("method", message.getMethod()); + map.put("requestId", message.getRequestId()); + map.put("params", message.getParams()); + return map; + } + + private static int[] toUnsignedArray(byte[] bytes) { + return IntStream.range(0, bytes.length).map(i -> bytes[i] & 0xFF).toArray(); + } + + // ========== 输出校验 ========== + + /** + * 校验 decode 输出:一个标准消息对象(method、params、requestId、code、msg、data),与阿里云 rawDataToProtocol 一致 + * + * @param json 脚本输出;null 表示没有定义 decode,或返回了 null / undefined,即没有业务消息 + * @param request encode 下行的原请求(协议回复设备的实际响应时):此时必须返回回执,方法和 requestId 必须与原请求一致;普通上行为 null + * @return 业务消息(编号、时间、身份、raw 由调用方可信设置);null 表示没有业务消息(只有普通上行允许) + * @throws IotScriptException 输出不合法 + */ + public static IotDeviceMessage validateDecode(String json, IotDeviceMessage request) throws IotScriptException { + // 1. 没有业务消息:普通上行允许(只记录原始消息);事务响应必须有回执 + if (json == null) { + if (request != null) { + throw IotScriptException.outputInvalid("事务响应必须返回回执"); + } + return null; + } + // 2. 校验消息 + IotDeviceMessage message = validateMessage(parseObject(json, MESSAGE_KEYS)); + if (request != null) { + validateTransactionReply(message, request); + } + return message; + } + + /** + * 校验 encode 输出:脚本输出的结构,以及透传字节是否符合协议的规则({@link IotDeviceMessageUtils#validateRawPayload},Modbus 必须是合法的请求 PDU) + *

+ * 网关的 encode、biz 的脚本调试共用;raw.push 不经过 encode,由调用方自己校验 + * + * @param json 脚本输出;null 表示没有定义 encode,或返回了 null / undefined + * @param protocol 协议类型:透传字节按它的规则校验 + * @return null 表示没有生成下行数据(调用方只记日志,不写设备) + * @throws IotScriptException 输出不合法 + */ + public static EncodeOutput validateEncode(String json, IotProtocolTypeEnum protocol) throws IotScriptException { + // 1. 没有生成下行数据 + if (json == null) { + return null; + } + // 2. 解析对象:只允许 bytes、standard,且只能返回一个 + JsonNode root = parseObject(json, TOP_ENCODE_KEYS); + JsonNode standard = root.get("standard"); + JsonNode bytesNode = root.get("bytes"); + if (standard != null && bytesNode != null) { + throw IotScriptException.outputInvalid("bytes 和 standard 只能返回一个"); + } + // 3.1 走现有的标准路径 + if (standard != null) { + if (!standard.isBoolean() || !standard.booleanValue()) { + throw IotScriptException.outputInvalid("standard 只能是 true"); + } + return new EncodeOutput(true, null); + } + // 3.2 透传字节:按协议的规则校验 + byte[] bytes = parseBytes(bytesNode, "bytes"); + try { + IotDeviceMessageUtils.validateRawPayload(protocol, bytes); + } catch (IllegalArgumentException e) { + throw IotScriptException.outputInvalid(e.getMessage()); + } + return new EncodeOutput(false, bytes); + } + + private static JsonNode parseObject(String json, Set allowedKeys) throws IotScriptException { + // 1. 解析 JSON + JsonNode root; + try { + root = JsonUtils.parseTree(json); + } catch (RuntimeException e) { + throw IotScriptException.outputInvalid("脚本输出不是合法的 JSON"); + } + // 2. 必须是对象,且只包含允许的键 + if (root == null || !root.isObject()) { + throw IotScriptException.outputInvalid("脚本输出必须是对象"); + } + validateKeys(root, allowedKeys, "脚本输出"); + return root; + } + + private static void validateKeys(JsonNode node, Set allowedKeys, String name) throws IotScriptException { + Iterator it = node.fieldNames(); + while (it.hasNext()) { + String key = it.next(); + if (!allowedKeys.contains(key)) { + throw IotScriptException.outputInvalid(name + " 不允许包含字段 " + StrUtil.maxLength(key, 50)); + } + } + } + + private static IotDeviceMessage validateMessage(JsonNode node) throws IotScriptException { + // 1.1 method 必填 + String name = "message"; + String method = textOrNull(node, "method", name); + if (StrUtil.isEmpty(method)) { + throw IotScriptException.outputInvalid(name + ".method 不能为空"); + } + // 1.2 requestId、msg 的长度 + String requestId = textOrNull(node, "requestId", name); + if (requestId != null && requestId.length() > MAX_REQUEST_ID_LENGTH) { + throw IotScriptException.outputInvalid(name + ".requestId 不能超过 " + MAX_REQUEST_ID_LENGTH + " 个字符"); + } + String msg = textOrNull(node, "msg", name); + if (msg != null && msg.length() > MAX_MSG_LENGTH) { + throw IotScriptException.outputInvalid(name + ".msg 不能超过 " + MAX_MSG_LENGTH + " 个字符"); + } + // 1.3 params、data 序列化后的大小 + JsonNode params = nullIfJsonNull(node.get("params")); + JsonNode data = nullIfJsonNull(node.get("data")); + validateFieldSize(params, name + ".params"); + validateFieldSize(data, name + ".data"); + // 1.4 code:有值表示回执 + JsonNode codeNode = nullIfJsonNull(node.get("code")); + Integer code = null; + if (codeNode != null) { + if (!codeNode.isIntegralNumber() || !codeNode.canConvertToInt()) { + throw IotScriptException.outputInvalid(name + ".code 必须是整数"); + } + code = codeNode.intValue(); + } + // 2. 按请求、回执分别校验方法 + if (code == null) { + // 2.1 请求:属性上报、事件上报;事件必须带 identifier + if (!DECODE_REQUEST_METHODS.contains(method)) { + throw IotScriptException.outputInvalid(name + ".method 不支持 " + StrUtil.maxLength(method, 50)); + } + if (params == null || !params.isObject()) { + throw IotScriptException.outputInvalid(name + ".params 必须是对象"); + } + if (IotDeviceMessageMethodEnum.EVENT_POST.getMethod().equals(method)) { + JsonNode identifier = params.get("identifier"); + if (identifier == null || !identifier.isTextual() || StrUtil.isEmpty(identifier.textValue()) + || identifier.textValue().length() > MAX_IDENTIFIER_LENGTH) { + throw IotScriptException.outputInvalid(name + ".params.identifier 必须是 1~" + MAX_IDENTIFIER_LENGTH + " 个字符"); + } + } + } else { + // 2.2 回执:属性设置、服务调用;requestId 不能为空 + if (!IotDeviceMessageUtils.isCommandMethod(method)) { + throw IotScriptException.outputInvalid(name + ".method 不支持回执 " + StrUtil.maxLength(method, 50)); + } + if (StrUtil.isEmpty(requestId)) { + throw IotScriptException.outputInvalid(name + ".requestId 不能为空"); + } + } + return IotDeviceMessage.of(requestId, method, toValue(params), toValue(data), code, msg); + } + + private static void validateTransactionReply(IotDeviceMessage reply, IotDeviceMessage request) + throws IotScriptException { + if (reply.getCode() == null || !Objects.equals(reply.getMethod(), request.getMethod()) + || !Objects.equals(reply.getRequestId(), request.getRequestId())) { + throw IotScriptException.outputInvalid("事务响应的回执方法和 requestId 必须与原请求一致"); + } + } + + private static String textOrNull(JsonNode node, String field, String name) throws IotScriptException { + JsonNode value = nullIfJsonNull(node.get(field)); + if (value == null) { + return null; + } + if (!value.isTextual()) { + throw IotScriptException.outputInvalid(name + "." + field + " 必须是字符串"); + } + return value.textValue(); + } + + private static void validateFieldSize(JsonNode value, String name) throws IotScriptException { + if (value != null && value.toString().getBytes(StandardCharsets.UTF_8).length > MAX_FIELD_BYTES) { + throw IotScriptException.outputInvalid(name + " 不能超过 " + MAX_FIELD_BYTES + " 字节"); + } + } + + /** + * 解析字节数组:每个元素都是 0~255 的整数;长度之后按协议的规则校验(脚本输出最多 65536 字符,数组不会过大) + */ + private static byte[] parseBytes(JsonNode node, String name) throws IotScriptException { + node = nullIfJsonNull(node); + if (node == null) { + throw IotScriptException.outputInvalid(name + " 不能为空"); + } + if (!node.isArray()) { + throw IotScriptException.outputInvalid(name + " 必须是数组"); + } + byte[] bytes = new byte[node.size()]; + for (int i = 0; i < node.size(); i++) { + JsonNode item = node.get(i); + if (!item.isIntegralNumber() || !item.canConvertToInt() || item.intValue() < 0 || item.intValue() > 255) { + throw IotScriptException.outputInvalid(name + "[" + i + "] 必须是 0~255 的整数"); + } + bytes[i] = (byte) item.intValue(); + } + return bytes; + } + + private static JsonNode nullIfJsonNull(JsonNode node) { + return node == null || node.isNull() ? null : node; + } + + private static Object toValue(JsonNode node) { + return node == null ? null : JsonUtils.getObjectMapper().convertValue(node, Object.class); + } + + /** + * encode 输出:二选一 + */ + @Getter + @AllArgsConstructor + public static final class EncodeOutput { + + /** + * true = 走现有的标准路径 + */ + private final boolean standard; + /** + * 下行字节 + */ + private final byte[] bytes; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptResult.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptResult.java new file mode 100644 index 0000000..41c7768 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptResult.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.iot.core.script.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * IoT 数据解析脚本的一次调用结果 + *

+ * core 不依赖 biz / gateway 的错误码:由调用方按 {@link Status} 翻译为各自的 ErrorCode + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public final class IotScriptResult { + + /** + * 调用结果的状态 + */ + public enum Status { + + /** + * 执行成功(json 可能为 null:没有定义该函数,或返回了 null / undefined) + */ + SUCCESS, + /** + * 脚本异常:语法错误、运行错误、throw、超出栈深等 + */ + ERROR, + /** + * 超时:排队超过截止时间、超过执行预算(时间或指令) + */ + TIMEOUT, + /** + * 拒绝:JS 线程池已满、已熔断 + */ + REJECTED, + /** + * 输出不合法 + */ + OUTPUT_INVALID + + } + + // DONE @AI:字段注释,还是要写下 + /** + * 状态 + */ + private final Status status; + /** + * 结果 JSON;只有 SUCCESS 时可能非空 + */ + private final String json; + /** + * 错误信息 + */ + private final String message; + /** + * 出错行号;未知时为 -1 + */ + private final int lineNumber; + /** + * 执行耗时(毫秒) + */ + private final long durationMillis; + + public static IotScriptResult success(String json, long durationMillis) { + return new IotScriptResult(Status.SUCCESS, json, null, -1, durationMillis); + } + + public static IotScriptResult error(String message, long durationMillis) { + return new IotScriptResult(Status.ERROR, null, message, -1, durationMillis); + } + + public static IotScriptResult timeout(String message) { + return new IotScriptResult(Status.TIMEOUT, null, message, -1, 0); + } + + public static IotScriptResult rejected(String message) { + return new IotScriptResult(Status.REJECTED, null, message, -1, 0); + } + + public static IotScriptResult of(Status status, String message, int lineNumber, long durationMillis) { + return new IotScriptResult(status, null, message, lineNumber, durationMillis); + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public boolean isTimeout() { + return status == Status.TIMEOUT; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptSource.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptSource.java new file mode 100644 index 0000000..8d96879 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptSource.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.iot.core.script.dto; + +import cn.hutool.crypto.digest.DigestUtil; +import lombok.Getter; + +/** + * IoT 数据解析脚本:不可变对象 {productId, hash, content} + *

+ * hash = 源码 UTF-8 的完整 SHA-256(64 位 hex),作为缓存和编译身份;{@link #getShortHash()} 只用于日志和界面。 + * 源码字符串可以被多个线程只读共享,编译结果不共享 + * + * @author 芋道源码 + */ +@Getter +public final class IotScriptSource { + + /** + * 产品编号;调试时为 null + */ + private final Long productId; + /** + * 源码 UTF-8 的完整 SHA-256 + */ + private final String hash; + /** + * 源码 + */ + private final String content; + /** + * 编译缓存的 key:productId:hash;调试时为 debug:hash + */ + private final String key; + + private IotScriptSource(Long productId, String content) { + this.productId = productId; + this.content = content; + this.hash = DigestUtil.sha256Hex(content); + this.key = (productId != null ? productId.toString() : "debug") + ":" + hash; + } + + public static IotScriptSource of(Long productId, String content) { + return new IotScriptSource(productId, content); + } + + public static IotScriptSource ofDebug(String content) { + return new IotScriptSource(null, content); + } + + /** + * @return hash 前 12 位:只用于日志和界面,不作为唯一标识 + */ + public String getShortHash() { + return hash.substring(0, 12); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/package-info.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/package-info.java new file mode 100644 index 0000000..25b94c8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/package-info.java @@ -0,0 +1,13 @@ +/** + * IoT 数据解析脚本:网关所有协议与 biz 调试共用 + *

    + *
  • 本包:定义。{@link cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor} 执行器接口、 + * {@link cn.iocoder.yudao.module.iot.core.script.IotScriptUtils} 脚本契约(入参构造、输出校验)、 + * {@link cn.iocoder.yudao.module.iot.core.script.IotScriptException} 脚本异常;
  • + *
  • dto:调用双方传递的数据。{@link cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource} 脚本、 + * {@link cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult} 结果;
  • + *
  • rhino:Rhino 实现。{@link cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor}, + * 包内其余类(运行时、ContextFactory、执行预算)为包私有的内部实现。
  • + *
+ */ +package cn.iocoder.yudao.module.iot.core.script; diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutor.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutor.java new file mode 100644 index 0000000..903e6eb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutor.java @@ -0,0 +1,505 @@ +package cn.iocoder.yudao.module.iot.core.script.rhino; + +import cn.hutool.core.thread.NamedThreadFactory; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +// DONE @AI:字段注释; +/** + * IoT 数据解析脚本执行器:同一个 JVM 内执行,不是沙箱 + *

+ * Rhino 下,经过指令检查点的超限会真正中断执行、释放线程;不经过检查点的原生长操作只能被标记为超时,线程计入卡住检测。 + * 本节点的熔断(按 productId:hash,或卡住线程过多时全局)只在内存中生效,不写库,重启后复位 + * + * @author 芋道源码 + */ +@Slf4j +public class IotRhinoScriptExecutor implements IotScriptExecutor { + + /** + * 熔断:1 分钟内超时达到该次数 + */ + private static final int BREAKER_TIMEOUT_COUNT = 3; + // DONE @AI:字段注释; + /** + * 熔断:统计超时次数的时间窗口(1 分钟) + */ + private static final long BREAKER_TIMEOUT_WINDOW_NANOS = TimeUnit.MINUTES.toNanos(1); + + // DONE @AI:字段注释; + /** + * IoT 专用的 Rhino ContextFactory:所有 JS 线程共用,不使用全局 ContextFactory + */ + private final IotScriptContextFactory contextFactory = new IotScriptContextFactory(); + // DONE @AI:字段注释; + /** + * 每个 JS 线程独占的 Rhino 运行时:预热或首次调用时创建,不跨线程共享 + */ + private final ThreadLocal runtimes = new ThreadLocal<>(); + + // DONE @AI:字段注释; + /** + * 执行器名称:线程名前缀,也用于日志 + */ + private final String name; + /** + * JS 线程池:核心数 = 最大数,有界队列,AbortPolicy + */ + private final ThreadPoolExecutor jsPool; + /** + * 单线程计时器:执行预算兜底、卡住扫描;不执行业务回调。由本执行器创建,{@link #shutdown()} 时关闭 + */ + private final ScheduledThreadPoolExecutor timer; + /** + * 单次执行的时间预算(纳秒):实际预算取它与截止时间中较早的一个 + */ + private final long execBudgetNanos; + /** + * 单次执行的指令上限 + */ + private final long maxInstructions; + /** + * 运行超过该时长视为卡住(纳秒) + */ + private final long stuckNanos; + /** + * 卡住的线程达到该数量时全局熔断 + */ + private final int maxStuckThreads; + /** + * 卡住扫描的计时任务:每秒一次,{@link #shutdown()} 时取消 + */ + private final ScheduledFuture stuckScanner; + + /** + * 正在执行的任务 → 开始时间 + */ + private final Map running = new ConcurrentHashMap<>(); + /** + * 已熔断的脚本 key(productId:hash) + */ + private final Set openKeys = ConcurrentHashMap.newKeySet(); + /** + * 最近的执行超时时间:key → 时间(nanoTime) + */ + private final Map> recentTimeouts = new ConcurrentHashMap<>(); + /** + * 当前卡住的线程数:每秒扫描一次 + */ + private volatile int stuckThreads; + /** + * 预热失败:全局熔断,需要重启 + */ + private volatile boolean warmUpFailed; + + public IotRhinoScriptExecutor(Options options) { + this.name = options.getName(); + this.jsPool = new ThreadPoolExecutor(options.getPoolSize(), options.getPoolSize(), 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(options.getQueueCapacity()), new NamedThreadFactory(options.getName() + "-", true), + new ThreadPoolExecutor.AbortPolicy()); + this.timer = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory(options.getName() + "-timer-", true)); + this.timer.setRemoveOnCancelPolicy(true); // 被取消的计时任务立即移出队列,不留到原截止时间 + this.execBudgetNanos = TimeUnit.MILLISECONDS.toNanos(options.getExecBudgetMillis()); + this.maxInstructions = options.getMaxInstructions(); + this.stuckNanos = TimeUnit.MILLISECONDS.toNanos(options.getStuckMillis()); + this.maxStuckThreads = Math.max(1, options.getMaxStuckThreads()); + this.stuckScanner = timer.scheduleWithFixedDelay(this::scanStuck, 1, 1, TimeUnit.SECONDS); + } + + /** + * 执行一次脚本调用:调用方线程(网关为 Vert.x worker、biz 调试为请求线程)阻塞等待,最长到截止时间 + *

+ * 脚本在 JS 线程池执行,受执行预算(时间、指令)约束;等待到期时,还在排队的任务被终结,之后不会再启动 + * + * @param source 脚本 + * @param function "decode" 或 "encode" + * @param ctxJson 平台构造的入参 JSON + * @param deadlineNanos 截止时间(System.nanoTime):覆盖排队与执行 + * @return 结果:SUCCESS / ERROR / TIMEOUT / REJECTED + */ + @Override + public IotScriptResult execute(IotScriptSource source, String function, String ctxJson, long deadlineNanos) { + if (isOpen(source)) { + return IotScriptResult.rejected("脚本已熔断"); + } + long remain = deadlineNanos - System.nanoTime(); + if (remain <= 0) { + return IotScriptResult.timeout("进入 JS 池前已超过截止时间"); + } + IotScriptTask task = new IotScriptTask(source); + try { + jsPool.execute(() -> run(task, function, ctxJson, deadlineNanos)); + } catch (RejectedExecutionException e) { + return IotScriptResult.rejected("脚本线程池已满"); + } + try { + return task.getResult().get(remain, TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + // 到期:排队中 PENDING → DONE(结果为超时),之后 markRunning 失败,不会启动;执行中的由预算中断或计入卡住检测 + finish(task, IotScriptResult.timeout("超过截止时间")); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + finish(task, IotScriptResult.error("等待脚本结果被中断", 0)); + } catch (ExecutionException e) { + finish(task, IotScriptResult.error(StrUtil.maxLength(String.valueOf(e.getCause()), 200), 0)); + } + return task.getResult().join(); // finish 只会成功一次:这里拿到的是最终终结的那个结果 + } + + /** + * 只做语法检查(不执行任何用户代码):在 JS 线程上编译,调用方线程有界等待 + * + * @param content 脚本内容 + * @param timeout 等待时间 + * @param unit 时间单位 + * @return 结果:SUCCESS / ERROR(带行号)/ REJECTED / TIMEOUT + */ + @Override + public IotScriptResult compileOnly(String content, long timeout, TimeUnit unit) { + if (isGlobalOpen()) { + return IotScriptResult.rejected("脚本已熔断"); + } + Future future; + try { + future = jsPool.submit(() -> { + try { + runtime().compileOnly(content); + return IotScriptResult.success(null, 0); + } catch (IotScriptException e) { + return IotScriptResult.of(e.getStatus(), e.getMessage(), e.getLineNumber(), 0); + } + }); + } catch (RejectedExecutionException e) { + return IotScriptResult.rejected("脚本线程池已满"); + } + try { + return future.get(timeout, unit); + } catch (TimeoutException e) { + future.cancel(false); + return IotScriptResult.timeout("编译超时"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return IotScriptResult.error("编译被中断", 0); + } catch (ExecutionException e) { + return IotScriptResult.error(StrUtil.maxLength(String.valueOf(e.getCause()), 200), 0); + } + } + + private void run(IotScriptTask task, String function, String ctxJson, long deadlineNanos) { + if (System.nanoTime() >= deadlineNanos) { // 出队时已过期(等待方通常已先终结;finish 只会成功一次) + finish(task, IotScriptResult.timeout("排队期间超过截止时间")); + return; + } + if (!task.markRunning()) { // 已被超时或拒绝终结:不启动 + return; + } + long start = System.nanoTime(); + long budgetDeadline = Math.min(start + execBudgetNanos, deadlineNanos); + // 兜底:观察器到不了的原生长操作 + try { + task.setBudgetTimer(timer.schedule(() -> finishTimeoutRunning(task, "执行超过预算"), + Math.max(0L, budgetDeadline - start), TimeUnit.NANOSECONDS)); + } catch (RejectedExecutionException e) { // 停机时 timer 已关闭:不执行,直接终结 + finish(task, IotScriptResult.rejected("脚本执行器已关闭")); + return; + } + running.put(task, start); + try { + IotScriptRuntime runtime = runtime(); // 正常已在启动时预热;线程被替换后为冷启动,耗时计入本次预算 + if (System.nanoTime() >= budgetDeadline || task.isFinished()) { // 实际调用前再校验一次 + finish(task, IotScriptResult.timeout("调用前已超过截止时间")); + return; + } + // 顶层代码、入参原生化、调用、序列化共用这一个预算 + String json = runtime.invoke(task.getSource(), function, ctxJson, + new IotScriptBudget(budgetDeadline, maxInstructions)); + finish(task, IotScriptResult.success(json, elapsedMillis(start))); + } catch (IotScriptBudget.BudgetExceededError e) { + finishTimeoutRunning(task, e.getMessage()); // 观察器中断:线程已经回到这里,可以继续使用 + } catch (IotScriptException e) { + finish(task, IotScriptResult.of(e.getStatus(), e.getMessage(), e.getLineNumber(), elapsedMillis(start))); + } catch (StackOverflowError e) { + finish(task, IotScriptResult.error("调用层级过深", elapsedMillis(start))); // 原生递归可能绕过解释器栈深限制 + } catch (RuntimeException e) { + finish(task, IotScriptResult.error(StrUtil.maxLength(e.getMessage(), 200), elapsedMillis(start))); + } finally { + running.remove(task); // OutOfMemoryError 等其它 Error 不捕获,交给 JVM 现有的处理 + } + } + + /** + * 执行中的超时:计入熔断统计 + */ + private void finishTimeoutRunning(IotScriptTask task, String message) { + if (finish(task, IotScriptResult.timeout(message))) { + recordTimeout(task.getSource()); + } + } + + /** + * 只终结一次:终结的那个结果交给等待中的调用方 + * + * @return 是否由本次调用终结 + */ + private boolean finish(IotScriptTask task, IotScriptResult result) { + if (!task.finish()) { + return false; // 已终结:迟到的结果直接丢弃 + } + task.getResult().complete(result); + return true; + } + + private IotScriptRuntime runtime() { + IotScriptRuntime runtime = runtimes.get(); + if (runtime == null) { // 只有预热,或线程被替换后的首次调用才会进入:创建封存的标准对象 + runtime = new IotScriptRuntime(contextFactory); + runtimes.set(runtime); + } + return runtime; + } + + /** + * 启动时调用一次,在接收流量之前:N 个任务通过屏障同时占住 N 个不同的 worker,保证每个 worker 都实际创建了 runtime + *

+ * 失败 → 全局熔断(记 ERROR),之后的调用都按「已熔断」拒绝 + */ + @Override + public void warmUp() { + int n = jsPool.getCorePoolSize(); + try { + jsPool.prestartAllCoreThreads(); + CyclicBarrier barrier = new CyclicBarrier(n); + List> futures = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + futures.add(jsPool.submit(() -> { + barrier.await(10, TimeUnit.SECONDS); + return runtime(); + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + warmUpFailed = true; + log.error("[warmUp][{} 预热失败,本节点脚本执行全局熔断]", name, e); + } + } + + /** + * 停止:不再接收新任务,正在执行的脚本由预算中断后退出;同时关闭本执行器的计时器(停机后提交的预算计时被拒绝,任务直接终结) + */ + @Override + public void shutdown() { + stuckScanner.cancel(false); + jsPool.shutdown(); + timer.shutdownNow(); + } + + // ========== 熔断(只在本节点生效,不写库) ========== + + /** + * 是否已熔断:预热失败、卡住的线程过多(全局),或该脚本已被熔断 + */ + public boolean isOpen(IotScriptSource source) { + return isGlobalOpen() || openKeys.contains(source.getKey()); + } + + private boolean isGlobalOpen() { + return warmUpFailed || stuckThreads >= maxStuckThreads; + } + + private void recordTimeout(IotScriptSource source) { + long now = System.nanoTime(); + Deque times = recentTimeouts.computeIfAbsent(source.getKey(), key -> new ArrayDeque<>()); + synchronized (times) { + times.addLast(now); + while (!times.isEmpty() && now - times.peekFirst() > BREAKER_TIMEOUT_WINDOW_NANOS) { + times.pollFirst(); + } + if (times.size() < BREAKER_TIMEOUT_COUNT) { + return; + } + } + if (openKeys.add(source.getKey())) { + log.error("[recordTimeout][{} 脚本 1 分钟内超时 {} 次,本节点熔断 product({}) hash({})]", name, + BREAKER_TIMEOUT_COUNT, source.getProductId(), source.getShortHash()); + } + } + + /** + * 每秒执行一次:运行超过 stuckNanos 的任务记为卡住 → 本节点熔断这个 (productId, hash),记 ERROR;不调用 RPC、不改数据库开关 + */ + private void scanStuck() { + try { + long now = System.nanoTime(); + int stuck = 0; + for (Map.Entry entry : running.entrySet()) { + if (now - entry.getValue() <= stuckNanos) { + continue; + } + stuck++; + IotScriptSource source = entry.getKey().getSource(); + if (openKeys.add(source.getKey())) { + log.error("[scanStuck][{} 脚本卡住一个线程,本节点熔断 product({}) hash({})]", name, + source.getProductId(), source.getShortHash()); + } + } + if (stuck >= maxStuckThreads && stuckThreads < maxStuckThreads) { + log.error("[scanStuck][{} 卡住的线程数({}) 达到上限,本节点全局熔断;线程恢复后自动解除]", name, stuck); + } + stuckThreads = stuck; + } catch (RuntimeException e) { + log.error("[scanStuck][{} 卡住扫描异常]", name, e); + } + } + + private static long elapsedMillis(long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } + + /** + * 执行器参数 + */ + @Data + @Accessors(chain = true) + public static class Options { + + /** + * 线程名前缀,如 iot-script-js + */ + private String name = "iot-script-js"; + /** + * 线程数 + */ + private int poolSize = 4; + /** + * 队列容量 + */ + private int queueCapacity = 200; + /** + * 单次执行的时间预算(毫秒) + */ + private long execBudgetMillis = 500; + /** + * 单次执行的指令上限 + */ + private long maxInstructions = 1_000_000; + /** + * 运行超过该时长视为卡住(毫秒) + */ + private long stuckMillis = 5000; + /** + * 卡住的线程达到该数量时全局熔断 + */ + private int maxStuckThreads = 3; + + } + + /** + * JS 任务:一个 AtomicInteger 状态机;JS 线程的「开始」与超时、拒绝、完成的「终结」竞争同一个状态 + *

+ * 只代表「这一次脚本调用」的终态。终结时取消的是本任务自己的预算计时器,不涉及调用方(协议)的响应计时器 + */ + static final class IotScriptTask { + + /** + * 状态:排队中,还没开始 + */ + static final int PENDING = 0; + /** + * 状态:JS 线程执行中 + */ + static final int RUNNING = 1; + /** + * 状态:已终结(成功、错误、拒绝或超时;具体原因见终结的结果) + */ + static final int DONE = 2; + + /** + * 当前状态:PENDING → RUNNING(markRunning);PENDING 或 RUNNING → DONE(finish),只能终结一次 + */ + private final AtomicInteger state = new AtomicInteger(PENDING); + /** + * 本次调用的脚本 + */ + @Getter + private final IotScriptSource source; + /** + * 终结的结果:调用方在 {@link #execute} 中等待 + */ + @Getter + private final CompletableFuture result = new CompletableFuture<>(); + /** + * 执行中的时间兜底(原生长操作不经过观察器时) + */ + @Setter + private volatile ScheduledFuture budgetTimer; + + IotScriptTask(IotScriptSource source) { + this.source = source; + } + + /** + * JS 线程:只有 PENDING 才能开始;已被超时或拒绝终结的任务返回 false,不再启动 + */ + boolean markRunning() { + return state.compareAndSet(PENDING, RUNNING); + } + + /** + * 任意线程:PENDING 或 RUNNING → DONE;并发调用时只有一个返回 true + */ + boolean finish() { + for (;;) { + int s = state.get(); + if (s == DONE) { + return false; + } + if (state.compareAndSet(s, DONE)) { + ScheduledFuture timer = budgetTimer; + if (timer != null) { + timer.cancel(false); + } + return true; + } + } + } + + boolean isFinished() { + return state.get() == DONE; + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptBudget.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptBudget.java new file mode 100644 index 0000000..ebb69a4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptBudget.java @@ -0,0 +1,53 @@ +package cn.iocoder.yudao.module.iot.core.script.rhino; + +/** + * 一次脚本调用的执行预算:用户源码顶层、ctx 原生化、函数调用、结果序列化共用同一个预算对象 + *

+ * 只在本次调用的 JS 线程上使用,不需要同步 + * + * @author 芋道源码 + */ +final class IotScriptBudget { + + /** + * 截止时间(System.nanoTime)= min(开始执行 + execBudget, 调用方传入的截止时间) + */ + private final long deadlineNanos; + /** + * 指令上限 + */ + private final long maxInstructions; + /** + * 已执行的指令数 + */ + private long usedInstructions; + + IotScriptBudget(long deadlineNanos, long maxInstructions) { + this.deadlineNanos = deadlineNanos; + this.maxInstructions = maxInstructions; + } + + void consume(int count) { + usedInstructions += count; + if (usedInstructions >= maxInstructions) { + throw new BudgetExceededError("超过指令预算"); + } + if (System.nanoTime() >= deadlineNanos) { + throw new BudgetExceededError("超过执行时间预算"); + } + } + + /** + * 观察器抛出的宿主 Error:Rhino 原样向上抛,脚本的 catch/finally 拦不住 + *

+ * 一旦超限,之后每个检查点都会再抛,所以 finally 中的死循环也会被中断 + */ + static final class BudgetExceededError extends Error { + + BudgetExceededError(String message) { + super(message, null, false, false); // 不收集栈:开销小 + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptContextFactory.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptContextFactory.java new file mode 100644 index 0000000..fff0a26 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptContextFactory.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.iot.core.script.rhino; + +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; + +/** + * IoT 脚本专用的 Rhino ContextFactory:不使用 ContextFactory.getGlobal(),不影响进程内其它 Rhino 使用方 + * + * @author 芋道源码 + */ +final class IotScriptContextFactory extends ContextFactory { + + /** + * 每执行这么多条解释器指令,回调一次 observeInstructionCount + */ + static final int INSTRUCTION_THRESHOLD = 1000; + /** + * 解释器最大栈深(递归深度) + */ + static final int MAX_STACK_DEPTH = 64; + + @Override + protected Context makeContext() { + Context cx = super.makeContext(); + cx.setOptimizationLevel(-1); // 解释模式:指令观察器只在解释模式下生效 + cx.setLanguageVersion(Context.VERSION_1_8); + cx.setInstructionObserverThreshold(INSTRUCTION_THRESHOLD); + cx.setMaximumInterpreterStackDepth(MAX_STACK_DEPTH); // 超出 → EvaluatorException「Exceeded maximum stack depth」 + cx.setClassShutter(className -> false); // 拒绝任何 Java 类 + cx.setGeneratingDebug(false); + return cx; + } + + @Override + protected void observeInstructionCount(Context cx, int instructionCount) { + IotScriptBudget budget = (IotScriptBudget) cx.getThreadLocal(IotScriptBudget.class); + if (budget == null) { + throw new IotScriptBudget.BudgetExceededError("缺少执行预算"); // fail-closed:没有预算的 Context 不允许继续执行 + } + budget.consume(instructionCount); // 超限时抛 BudgetExceededError + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptRuntime.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptRuntime.java new file mode 100644 index 0000000..78c5a8a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptRuntime.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.iot.core.script.rhino; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.Function; +import org.mozilla.javascript.RhinoException; +import org.mozilla.javascript.Script; +import org.mozilla.javascript.Scriptable; +import org.mozilla.javascript.ScriptableObject; +import org.mozilla.javascript.Undefined; + +import java.util.LinkedHashMap; +import java.util.Map; + +// DONE @AI:字段注释; +/** + * 单个 JS worker 线程独占的 Rhino 运行时 + *

    + *
  • sealedStandard:initSafeStandardObjects(null, true) 创建的标准对象,不含 Packages / java / JavaAdapter / getClass,已封存;
  • + *
  • userScripts:按 productId:hash 的编译缓存,只在本线程使用;
  • + *
  • 每次调用新建一个可写作用域,用完即丢,调用之间不保留任何状态。
  • + *
+ * 不跨线程共享 Script,也不跨线程共享作用域 + * + * @author 芋道源码 + */ +final class IotScriptRuntime { + + /** + * 脚本输出(序列化后的 JSON)的最大字符数,超过按输出不合法处理 + */ + static final int MAX_RESULT_CHARS = 65536; + /** + * 编译缓存最多保留的脚本数:超过时淘汰最久未使用的 + */ + private static final int MAX_COMPILED_SCRIPTS = 64; + + /** + * IoT 专用的 Rhino ContextFactory:每次编译、调用都由它进入 Context + */ + private final IotScriptContextFactory factory; + /** + * 创建本运行时的 JS 线程:只能在该线程上使用 + */ + private final Thread owner = Thread.currentThread(); + /** + * 封存的标准对象:每次调用的作用域都以它为原型(prototype),作用域自身没有父作用域 + */ + private final ScriptableObject sealedStandard; + /** + * 封存的标准对象里的 JSON 对象:宿主只用它做 parse/stringify,用户脚本里的同名变量影响不到 + */ + private final Scriptable json; + /** + * 封存的 JSON.parse:把平台构造的入参 JSON 转为脚本里的 ctx 对象 + */ + private final Function jsonParse; + /** + * 封存的 JSON.stringify:把脚本的返回值序列化为结果 JSON + */ + private final Function jsonStringify; + /** + * 编译缓存:key = productId:sha256(调试时为 debug:sha256);只在本线程访问 + */ + private final Map userScripts = new LinkedHashMap(16, 0.75f, true) { + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_COMPILED_SCRIPTS; + } + + }; + + IotScriptRuntime(IotScriptContextFactory factory) { + this.factory = factory; + Context cx = factory.enterContext(); + try { + this.sealedStandard = cx.initSafeStandardObjects(null, true); // 第二个参数 true = 封存 + this.json = (Scriptable) ScriptableObject.getProperty(sealedStandard, "JSON"); + this.jsonParse = (Function) ScriptableObject.getProperty(json, "parse"); + this.jsonStringify = (Function) ScriptableObject.getProperty(json, "stringify"); + } finally { + Context.exit(); + } + } + + /** + * 只编译、不执行任何用户代码:保存前的语法检查(biz)和首次调用前的编译(gateway)都用它 + * + * @throws org.mozilla.javascript.EvaluatorException 语法错误,带行号 + */ + static Script compile(Context cx, String name, String source) { + return cx.compileString(source, name, 1, null); + } + + /** + * 只做语法检查:不执行任何用户代码,也不放入编译缓存 + * + * @param content 脚本内容 + * @throws IotScriptException 语法错误 + */ + void compileOnly(String content) throws IotScriptException { + checkOwner(); + Context cx = factory.enterContext(); + try { + compile(cx, "product", content); + } catch (RhinoException e) { + throw new IotScriptException(IotScriptResult.Status.ERROR, + StrUtil.maxLength(e.details(), 200), e.lineNumber()); + } finally { + Context.exit(); + } + } + + /** + * 执行一次 + * + * @param source 脚本 + * @param function "decode" 或 "encode" + * @param ctxJson 平台构造的 JSON 字符串(不含 Java 对象、byte[]、Map、List 或任何秘密;字节是数字数组) + * @param budget 执行器传入的本次预算:下面的第 2~5 步都受它约束 + * @return 结果 JSON 字符串;null 表示脚本返回了 null/undefined,或没有定义该函数 + */ + String invoke(IotScriptSource source, String function, String ctxJson, IotScriptBudget budget) throws IotScriptException { + checkOwner(); + Context cx = factory.enterContext(); + cx.putThreadLocal(IotScriptBudget.class, budget); // 先挂预算,之后的一切解释执行都计数 + try { + // 1. 编译缓存:首次使用时在本线程编译(编译本身不经过指令观察器,只受 JS 任务的时间兜底约束;源码不超过 32KB) + Script user = userScripts.get(source.getKey()); + if (user == null) { + user = compile(cx, "product-" + source.getProductId(), source.getContent()); + userScripts.put(source.getKey(), user); + } + // 2. 新的可写作用域:原型是封存的标准对象、没有父作用域,脚本能定义自己的变量,但改不了 Array.prototype 等标准对象 + Scriptable scope = cx.newObject(sealedStandard); + scope.setPrototype(sealedStandard); + scope.setParentScope(null); + // 3. 用户源码顶层在这个作用域中执行(每次都执行),定义 decode / encode + user.exec(cx, scope); + Object fn = ScriptableObject.getProperty(scope, function); + if (!(fn instanceof Function)) { + return null; // 没有定义这个方向的函数:等同于返回 null + } + // 4. 入参原生化:JSON 字符串 → JS 原生对象(字节是原生数字数组);不传任何 Java 对象 + Object ctx = jsonParse.call(cx, scope, json, new Object[]{ctxJson}); + Object result = ((Function) fn).call(cx, scope, scope, new Object[]{ctx}); + if (result == null || result instanceof Undefined) { + return null; + } + // 5. 结果序列化:result 的 toJSON / getter 也在这里执行,同样受预算约束 + Object out = jsonStringify.call(cx, scope, json, new Object[]{result}); + if (!(out instanceof String)) { // 函数等不能序列化的值得到 undefined:按输出不合法处理 + throw new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, "脚本返回值不能序列化为 JSON", -1); + } + String text = (String) out; + if (text.length() > MAX_RESULT_CHARS) { + throw new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, "脚本输出超过 " + MAX_RESULT_CHARS + " 字符", -1); + } + return text; + } catch (RhinoException e) { // 语法错误、运行错误、throw、封存对象被修改、超出栈深 + throw new IotScriptException(IotScriptResult.Status.ERROR, + StrUtil.maxLength(e.details(), 200), e.lineNumber()); + } finally { + cx.removeThreadLocal(IotScriptBudget.class); // 清理预算 + Context.exit(); // 退出 Context;BudgetExceededError、OOM 等 Error 照常向上抛 + } + } + + private void checkOwner() { + if (Thread.currentThread() != owner) { + throw new IllegalStateException("IotScriptRuntime 只能在创建它的 JS 线程上使用"); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java index 93a9545..10de46a 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java @@ -8,8 +8,10 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.system.SystemUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import java.util.Base64; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; @@ -52,6 +54,150 @@ public class IotDeviceMessageUtils { } /** + * 是否是下行指令的请求:property.set / service.invoke(数据解析脚本 encode 只处理这两种) + * + * @param message 消息 + * @return 是否 + */ + public static boolean isCommandRequest(IotDeviceMessage message) { + return !isReplyMessage(message) && isCommandMethod(message.getMethod()); + } + + /** + * 是否是下行指令的方法:property.set / service.invoke + * + * @param method 消息方法 + * @return 是否 + */ + public static boolean isCommandMethod(String method) { + return StrUtil.equalsAny(method, IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); + } + + // ========== 透传(raw)相关 ========== + + /** + * 通用协议 raw 载荷的最大字节数(Modbus 另按 PDU 不超过 {@link #MODBUS_PDU_MAX_BYTES} 校验) + */ + public static final int RAW_PAYLOAD_MAX_BYTES = 4096; + + /** + * Modbus PDU 的最大字节数(不带 slaveId、CRC、MBAP) + */ + public static final int MODBUS_PDU_MAX_BYTES = 253; + + /** + * 是否是透传方法:thing.raw.post、thing.raw.push + * + * @param method 消息方法 + * @return 是否 + */ + public static boolean isRawMethod(String method) { + return StrUtil.equalsAny(method, IotDeviceMessageMethodEnum.RAW_POST.getMethod(), + IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); + } + + /** + * 编码原始字节为 {@link IotDeviceMessage#getRaw()}(Base64) + * + * @param bytes 原始字节 + * @return Base64 + */ + public static String encodeRaw(byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + /** + * 解码 {@link IotDeviceMessage#getRaw()}(Base64);严格 Base64(必须与 {@link #encodeRaw} 的结果一致,带 padding), + * 非法或超长抛 {@link IllegalArgumentException} + * + * @param raw Base64 + * @param maxBytes 最大字节数 + * @return 原始字节 + */ + public static byte[] decodeRaw(String raw, int maxBytes) { + Assert.notBlank(raw, "raw 不能为空"); + Assert.isTrue(raw.length() <= (maxBytes + 2) / 3 * 4, "raw 长度必须在 1~{} 字节之间", maxBytes); // 先按长度拦截,不解码超长串 + byte[] bytes = Base64.getDecoder().decode(raw); + Assert.isTrue(bytes.length >= 1 && bytes.length <= maxBytes, "raw 长度必须在 1~{} 字节之间", maxBytes); + Assert.isTrue(raw.equals(encodeRaw(bytes)), "raw 必须是标准 Base64(带 padding)"); + return bytes; + } + + /** + * 从 params({"payload": Base64})解出透传载荷:管理后台下发 thing.raw.push 的入参格式; + * 标准 Base64(允许省略 padding,由调用方用 {@link #encodeRaw} 规范化),非法或超长抛 {@link IllegalArgumentException} + * + * @param params 消息参数 + * @param maxBytes 最大字节数 + * @return 载荷 + */ + public static byte[] decodeRawPayload(Object params, int maxBytes) { + String payload = StrUtil.toStringOrNull(readField(params, "payload")); + Assert.notBlank(payload, "payload 不能为空"); + byte[] bytes = Base64.getDecoder().decode(payload); + Assert.isTrue(bytes.length >= 1 && bytes.length <= maxBytes, "payload 长度必须在 1~{} 字节之间", maxBytes); + return bytes; + } + + /** + * 校验透传下行的字节:按协议的规则,Modbus 必须是合法的请求 PDU({@link #validateModbusRequestPdu}),其它协议 1~4096 字节 + *

+ * biz 的 /send、脚本调试的 encode 输出、gateway 的 encode 输出调用同一份规则;不合法抛 {@link IllegalArgumentException} + * + * @param protocol 协议类型 + * @param bytes 下行字节 + */ + public static void validateRawPayload(IotProtocolTypeEnum protocol, byte[] bytes) { + if (protocol != null && protocol.isModbus()) { + validateModbusRequestPdu(bytes); + return; + } + Assert.isTrue(bytes != null && bytes.length >= 1 && bytes.length <= RAW_PAYLOAD_MAX_BYTES, + "raw 长度必须在 1~{} 字节之间", RAW_PAYLOAD_MAX_BYTES); + } + + /** + * 校验 Modbus 原始请求 PDU 的格式:功能码白名单、长度、数量范围、05 的取值、0F/10 的 byteCount + *

+ * 透传下行的字节规则({@link #validateRawPayload})与 Modbus 协议写出前调用同一份规则;不合法抛 {@link IllegalArgumentException} + * + * @param pdu PDU(功能码 + 数据,不带 slaveId、CRC、MBAP) + */ + public static void validateModbusRequestPdu(byte[] pdu) { + Assert.isTrue(pdu != null && pdu.length >= 5 && pdu.length <= MODBUS_PDU_MAX_BYTES, + "PDU 长度必须在 5~{} 字节之间", MODBUS_PDU_MAX_BYTES); + int functionCode = pdu[0] & 0xFF; + int quantity = ((pdu[3] & 0xFF) << 8) | (pdu[4] & 0xFF); + switch (functionCode) { + case 0x01: + case 0x02: + Assert.isTrue(pdu.length == 5 && quantity >= 1 && quantity <= 2000, "PDU 不合法:读线圈数量必须在 1~2000 之间"); + break; + case 0x03: + case 0x04: + Assert.isTrue(pdu.length == 5 && quantity >= 1 && quantity <= 125, "PDU 不合法:读寄存器数量必须在 1~125 之间"); + break; + case 0x05: + Assert.isTrue(pdu.length == 5 && (quantity == 0xFF00 || quantity == 0x0000), "PDU 不合法:写单个线圈的值只能是 FF00 或 0000"); + break; + case 0x06: + Assert.isTrue(pdu.length == 5, "PDU 不合法:写单个寄存器必须是 5 字节"); + break; + case 0x0F: + Assert.isTrue(quantity >= 1 && quantity <= 1968 && pdu.length >= 6 && pdu.length == 6 + (pdu[5] & 0xFF) + && (pdu[5] & 0xFF) == (quantity + 7) / 8, "PDU 不合法:写多个线圈的数量或字节数不一致"); + break; + case 0x10: + Assert.isTrue(quantity >= 1 && quantity <= 123 && pdu.length >= 6 && pdu.length == 6 + (pdu[5] & 0xFF) + && (pdu[5] & 0xFF) == quantity * 2, "PDU 不合法:写多个寄存器的数量或字节数不一致"); + break; + default: + throw new IllegalArgumentException("PDU 不合法:不支持的功能码 " + functionCode); + } + } + + /** * 提取消息中的标识符 * * @param message 消息 diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtilsTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtilsTest.java new file mode 100644 index 0000000..6230d26 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtilsTest.java @@ -0,0 +1,133 @@ +package cn.iocoder.yudao.module.iot.core.script; + +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotScriptUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class IotScriptUtilsTest { + + @Test + public void testValidateDecode_success() throws IotScriptException { + // 调用:一个标准消息对象 + IotDeviceMessage message = IotScriptUtils.validateDecode( + "{\"method\":\"thing.property.post\",\"params\":{\"t\":25}}", null); + // 断言 + assertEquals("thing.property.post", message.getMethod()); + assertEquals(25, ((Map) message.getParams()).get("t")); + assertNull(message.getCode()); + assertNull(message.getRaw()); + // 调用 + 断言:事件 + IotDeviceMessage event = IotScriptUtils.validateDecode( + "{\"method\":\"thing.event.post\",\"params\":{\"identifier\":\"alarm\",\"value\":{}}}", null); + assertEquals("thing.event.post", event.getMethod()); + } + + @Test + public void testValidateDecode_null() throws IotScriptException { + // 调用 + 断言:decode 未定义或返回 null → 没有业务消息 + assertNull(IotScriptUtils.validateDecode(null, null)); + } + + @Test + public void testValidateDecode_invalid() { + // 不是对象、旧的 messages / ack 包装 + assertOutputInvalid("[]"); + assertOutputInvalid("{\"messages\":[{\"method\":\"thing.property.post\",\"params\":{}}]}"); + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"ack\":[1]}"); + // 身份、路由、可信字段和未知键不允许:脚本不能伪造 raw、设备、租户、编号 + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"deviceId\":1}"); + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"tenantId\":1}"); + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"id\":\"x\"}"); + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"raw\":\"AQI=\"}"); + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"unknown\":true}"); + // 不支持的方法;事件缺 identifier;回执缺 requestId + assertOutputInvalid("{\"method\":\"thing.raw.post\",\"params\":{}}"); + assertOutputInvalid("{\"method\":\"thing.event.post\",\"params\":{}}"); + assertOutputInvalid("{\"method\":\"thing.property.set\",\"code\":0}"); + } + + @Test + public void testValidateDecode_transactionReply() throws IotScriptException { + // 准备参数 + IotDeviceMessage request = IotDeviceMessage.requestOf("req-1", "thing.property.set", null); + + // 调用 + 断言:方法、requestId 一致的回执 + IotDeviceMessage reply = IotScriptUtils.validateDecode( + "{\"method\":\"thing.property.set\",\"requestId\":\"req-1\",\"code\":0}", request); + assertEquals(0, reply.getCode()); + // 调用 + 断言:requestId 不一致、不是回执、没有输出 + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode( + "{\"method\":\"thing.property.set\",\"requestId\":\"req-2\",\"code\":0}", request)); + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode( + "{\"method\":\"thing.property.post\",\"params\":{}}", request)); + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode(null, request)); + } + + @Test + public void testValidateEncode() throws IotScriptException { + // 未定义或返回 null → 不写设备 + assertNull(IotScriptUtils.validateEncode(null, IotProtocolTypeEnum.TCP)); + // 走标准路径 + assertTrue(IotScriptUtils.validateEncode("{\"standard\":true}", IotProtocolTypeEnum.TCP).isStandard()); + // 字节 + IotScriptUtils.EncodeOutput output = IotScriptUtils.validateEncode("{\"bytes\":[1,2,255]}", IotProtocolTypeEnum.TCP); + assertFalse(output.isStandard()); + assertArrayEquals(new byte[]{1, 2, (byte) 0xFF}, output.getBytes()); + assertEquals(4096, IotScriptUtils.validateEncode("{\"bytes\":" + buildArray(4096, 0) + "}", + IotProtocolTypeEnum.TCP).getBytes().length); + // 不合法:standard=false、两者都有、没有 bytes、不是数组、超过 255 + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"standard\":false}", IotProtocolTypeEnum.TCP)); + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"standard\":true,\"bytes\":[1]}", IotProtocolTypeEnum.TCP)); + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{}", IotProtocolTypeEnum.TCP)); + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"bytes\":\"AQI=\"}", IotProtocolTypeEnum.TCP)); + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"bytes\":[256]}", IotProtocolTypeEnum.TCP)); + } + + @Test + public void testValidateEncode_rawPayload() throws IotScriptException { + // 调用 + 断言:其它协议 1~4096 字节,不合法为 OUTPUT_INVALID,带原规则的错误信息 + assertEncodeOutputInvalid("{\"bytes\":[]}", IotProtocolTypeEnum.TCP, "raw 长度必须在 1~4096 字节之间"); + assertEncodeOutputInvalid("{\"bytes\":" + buildArray(4097, 0) + "}", IotProtocolTypeEnum.TCP, + "raw 长度必须在 1~4096 字节之间"); + + // 调用 + 断言:Modbus 必须是合法的请求 PDU + assertArrayEquals(new byte[]{3, 0, 0, 0, 10}, IotScriptUtils.validateEncode("{\"bytes\":[3,0,0,0,10]}", + IotProtocolTypeEnum.MODBUS_TCP_CLIENT).getBytes()); + assertEncodeOutputInvalid("{\"bytes\":[3,0,0,0,0]}", IotProtocolTypeEnum.MODBUS_TCP_SERVER, + "PDU 不合法:读寄存器数量必须在 1~125 之间"); + // 调用 + 断言:走标准路径时没有字节,不按协议的规则校验 + assertTrue(IotScriptUtils.validateEncode("{\"standard\":true}", IotProtocolTypeEnum.MODBUS_TCP_CLIENT).isStandard()); + } + + private static void assertOutputInvalid(String json) { + IotScriptException ex = assertThrows(IotScriptException.class, + () -> IotScriptUtils.validateDecode(json, null), json); + assertEquals(IotScriptResult.Status.OUTPUT_INVALID, ex.getStatus()); + } + + private static void assertEncodeOutputInvalid(String json, IotProtocolTypeEnum protocol, String message) { + IotScriptException ex = assertThrows(IotScriptException.class, + () -> IotScriptUtils.validateEncode(json, protocol)); + assertEquals(IotScriptResult.Status.OUTPUT_INVALID, ex.getStatus()); + assertEquals(message, ex.getMessage()); + } + + private static String buildArray(int size, int value) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < size; i++) { + sb.append(i > 0 ? "," : "").append(value); + } + return sb.append("]").toString(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutorTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutorTest.java new file mode 100644 index 0000000..0633578 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutorTest.java @@ -0,0 +1,203 @@ +package cn.iocoder.yudao.module.iot.core.script.rhino; + +import cn.hutool.core.util.ReflectUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotRhinoScriptExecutor} 的单元测试:真实的 Rhino 解释执行 + * + * @author 芋道源码 + */ +public class IotRhinoScriptExecutorTest { + + private static final String CTX = "{\"bytes\":[1,2,3],\"hex\":\"010203\"}"; + + private IotRhinoScriptExecutor executor; + + @BeforeEach + public void setUp() { + executor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options().setName("test-script-js").setPoolSize(1) + .setQueueCapacity(4).setExecBudgetMillis(500).setMaxInstructions(1_000_000)); + } + + @AfterEach + public void tearDown() { + executor.shutdown(); + } + + private IotScriptResult invoke(String content, String function) { + return executor.execute(IotScriptSource.of(1L, content), function, CTX, + System.nanoTime() + TimeUnit.SECONDS.toNanos(3)); + } + + @Test + public void testExecute_decodeSuccess() { + // 准备参数:宿主侧 JSON.parse 入参,函数返回原生对象,宿主侧 JSON.stringify + String content = "function decode(ctx) { return {method: 'thing.property.post'," + + " params: {sum: ctx.bytes[0] + ctx.bytes[1] + ctx.bytes[2], hex: ctx.hex}}; }"; + + // 调用 + IotScriptResult result = invoke(content, IotScriptUtils.FUNCTION_DECODE); + // 断言 + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); + JsonNode root = JsonUtils.parseTree(result.getJson()); + assertEquals(6, root.get("params").get("sum").intValue()); + assertEquals("010203", root.get("params").get("hex").textValue()); + } + + @Test + public void testExecute_functionMissing() { + // 调用:没有定义 encode + IotScriptResult result = invoke("function decode(ctx) { return null; }", IotScriptUtils.FUNCTION_ENCODE); + // 断言:成功但没有输出,由调用方处理 + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); + assertNull(result.getJson()); + } + + @Test + public void testExecute_javaAccessDenied() { + // 调用:java、Packages、getClass 都不可用 + String content = "function decode(ctx) { return {method: 'thing.property.post', params: {" + + "java: typeof java === 'undefined' ? 1 : 0, packages: typeof Packages === 'undefined' ? 1 : 0}}; }"; + IotScriptResult result = invoke(content, IotScriptUtils.FUNCTION_DECODE); + // 断言 + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); + assertEquals("{\"java\":1,\"packages\":1}", JsonUtils.parseTree(result.getJson()).get("params").toString()); + + // 调用:显式访问 Java 类 + IotScriptResult result2 = invoke("function decode(ctx) { return {method: 'thing.property.post'," + + " params: {home: java.lang.System.getProperty('user.home').length}}; }", IotScriptUtils.FUNCTION_DECODE); + // 断言:脚本错误,不是成功 + assertEquals(IotScriptResult.Status.ERROR, result2.getStatus()); + } + + @Test + public void testExecute_infiniteLoopTimeoutAndReuse() { + // 调用:死循环被预算中断 + IotScriptResult result = invoke("function decode(ctx) { while (true) {} }", IotScriptUtils.FUNCTION_DECODE); + // 断言 + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); + + // 调用:同一个线程继续可用(线程池只有 1 个线程) + IotScriptResult result2 = invoke("function decode(ctx) { return null; }", IotScriptUtils.FUNCTION_DECODE); + // 断言 + assertEquals(IotScriptResult.Status.SUCCESS, result2.getStatus()); + } + + @Test + public void testExecute_breakerOpenAfterTimeouts() { + // 准备参数:同一个脚本 1 分钟内超时 3 次 + String content = "function decode(ctx) { for (;;) {} }"; + for (int i = 0; i < 3; i++) { + assertEquals(IotScriptResult.Status.TIMEOUT, invoke(content, IotScriptUtils.FUNCTION_DECODE).getStatus()); + } + + // 调用 + 断言:之后直接拒绝;其它脚本不受影响 + assertTrue(executor.isOpen(IotScriptSource.of(1L, content))); + assertEquals(IotScriptResult.Status.REJECTED, invoke(content, IotScriptUtils.FUNCTION_DECODE).getStatus()); + assertEquals(IotScriptResult.Status.SUCCESS, invoke("function decode(ctx) { return null; }", + IotScriptUtils.FUNCTION_DECODE).getStatus()); + } + + @Test + public void testExecute_deadlinePassed() { + // 调用:截止时间已过 + IotScriptResult result = executor.execute(IotScriptSource.of(1L, "function decode(ctx) { return null; }"), + IotScriptUtils.FUNCTION_DECODE, CTX, System.nanoTime() - 1); + // 断言:不进入 JS 池 + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); + } + + @Test + public void testExecute_pendingTimeoutNeverStarts() throws Exception { + // 准备参数:用一个 Java 任务确定地占住唯一的 JS 线程(不依赖脚本预算与睡眠); + // 该线程还没有执行过脚本(没有预热),所以它的 runtime 为空:任何脚本一旦在它上面启动,都会创建 runtime + ThreadPoolExecutor jsPool = (ThreadPoolExecutor) ReflectUtil.getFieldValue(executor, "jsPool"); + ThreadLocal runtimes = (ThreadLocal) ReflectUtil.getFieldValue(executor, "runtimes"); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + jsPool.execute(() -> { + entered.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + + // 调用:排队中的任务 100ms 后到期 + IotScriptSource pending = IotScriptSource.of(2L, "function decode(ctx) { return {method: 'thing.property.post'}; }"); + IotScriptResult result = executor.execute(pending, IotScriptUtils.FUNCTION_DECODE, CTX, + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(100)); + // 断言:调用方按截止时间返回超时;任务仍在 JS 池的队列里,没有启动 + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); + assertEquals(1, jsPool.getQueue().size()); + + // 调用:放开 JS 线程,再提交一个探测任务;单线程 FIFO,探测任务执行时,排队超时的任务已经出队 + release.countDown(); + Future runtimeCreated = jsPool.submit(() -> runtimes.get() != null); + // 断言:排队超时的任务出队后没有启动脚本(JS 线程上仍没有创建 runtime) + assertFalse(runtimeCreated.get(5, TimeUnit.SECONDS)); + // 断言:排队超时的任务不计入熔断;JS 线程可以继续使用,之后真正执行了脚本 + assertFalse(executor.isOpen(pending)); + assertEquals(IotScriptResult.Status.SUCCESS, invoke("function decode(ctx) { return null; }", + IotScriptUtils.FUNCTION_DECODE).getStatus()); + assertTrue(jsPool.submit(() -> runtimes.get() != null).get(5, TimeUnit.SECONDS)); + } + + @Test + public void testScriptTask_finishOnce() { + // 准备参数:执行中的任务,预算计时器 1 小时后才会触发 + ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); + try { + IotScriptSource source = IotScriptSource.of(1L, "function decode(ctx) { return null; }"); + IotRhinoScriptExecutor.IotScriptTask task = new IotRhinoScriptExecutor.IotScriptTask(source); + assertTrue(task.markRunning()); + ScheduledFuture budgetTimer = timer.schedule(() -> { }, 1, TimeUnit.HOURS); + task.setBudgetTimer(budgetTimer); + + // 调用 + 断言:第一次终结成功,并取消预算计时器 + assertTrue(task.finish()); + assertTrue(task.isFinished()); + assertTrue(budgetTimer.isCancelled()); + // 调用 + 断言:重复终结失败(迟到的结果直接丢弃) + assertFalse(task.finish()); + + // 调用 + 断言:排队中已被终结的任务,之后不能再开始 + IotRhinoScriptExecutor.IotScriptTask pending = new IotRhinoScriptExecutor.IotScriptTask(source); + assertTrue(pending.finish()); + assertFalse(pending.markRunning()); + } finally { + timer.shutdownNow(); + } + } + + @Test + public void testCompileOnly() { + // 调用 + 断言:语法正确,不执行顶层代码(死循环也不会卡住) + assertTrue(executor.compileOnly("while (true) {} function decode(ctx) {}", 2, TimeUnit.SECONDS).isSuccess()); + // 调用 + 断言:语法错误带行号(第 2 行只有一个不能作为语句开头的 ")",解析器在该 token 上立即报错,行号无歧义) + IotScriptResult result = executor.compileOnly("var a = 1;\n)\nfunction decode(ctx) {}", 2, TimeUnit.SECONDS); + assertEquals(IotScriptResult.Status.ERROR, result.getStatus()); + assertEquals(2, result.getLineNumber()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java index a0c4650..3764851 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java +++ b/yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.core.util; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; import org.junit.jupiter.api.Test; @@ -348,4 +349,78 @@ public class IotDeviceMessageUtilsTest { assertTrue(identifiers.contains("humidity")); } + @Test + public void testEncodeRaw_decodeRaw() { + // 标准 Base64(带 padding)往返 + byte[] bytes = new byte[]{0x01, 0x02, (byte) 0xFF, 0x00}; + String raw = IotDeviceMessageUtils.encodeRaw(bytes); + assertEquals("AQL/AA==", raw); + assertArrayEquals(bytes, IotDeviceMessageUtils.decodeRaw(raw, 4)); + // 上限边界:4096 字节 + byte[] max = new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]; + assertArrayEquals(max, IotDeviceMessageUtils.decodeRaw(IotDeviceMessageUtils.encodeRaw(max), + IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES)); + } + + @Test + public void testDecodeRaw_invalid() { + // 为空、不是 Base64 + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw(null, 10)); + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("", 10)); + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("!!not-base64!!", 10)); + // 不规范:省略 padding、末尾多余的位不为 0(与 AQI= 解出相同字节) + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("AQI", 10)); + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("AQJ=", 10)); + // 超过上限:Modbus PDU 253 字节 + String tooLong = IotDeviceMessageUtils.encodeRaw(new byte[IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES + 1]); + assertThrows(IllegalArgumentException.class, + () -> IotDeviceMessageUtils.decodeRaw(tooLong, IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES)); + } + + @Test + public void testDecodeRawPayload_withoutPadding() { + // 管理后台的 payload 允许省略 padding,由调用方规范化 + Map params = new HashMap<>(); + params.put("payload", "AQI"); + assertArrayEquals(new byte[]{0x01, 0x02}, IotDeviceMessageUtils.decodeRawPayload(params, 10)); + } + + @Test + public void testIsCommandRequest() { + // 下行指令的请求:property.set / service.invoke + assertTrue(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.property.set"))); + assertTrue(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.service.invoke"))); + // 其它方法、回执不是 + assertFalse(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.ota.upgrade"))); + assertFalse(IotDeviceMessageUtils.isCommandRequest( + IotDeviceMessage.replyOf("1", "thing.property.set", null, 0, null))); + } + + @Test + public void testValidateRawPayload() { + // 通用协议:1~4096 字节 + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[]{0x01}); + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.MQTT, + new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]); + assertThrows(IllegalArgumentException.class, + () -> IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[0])); + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( + IotProtocolTypeEnum.TCP, new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES + 1])); + } + + @Test + public void testValidateRawPayload_modbus() { + // 合法的 03 读保持寄存器:地址 0、数量 10 + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.MODBUS_TCP_CLIENT, new byte[]{3, 0, 0, 0, 10}); + // 不合法:不支持的功能码、数量超限、超过 253 字节(通用协议允许的长度,Modbus 也拒绝) + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, new byte[]{8, 0, 0, 0, 1})); + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( + IotProtocolTypeEnum.MODBUS_TCP_SERVER, new byte[]{3, 0, 0, 0, 126})); + byte[] tooLong = new byte[IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES + 1]; + tooLong[0] = 0x10; + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, tooLong)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/pom.xml b/yudao-module-iot/yudao-module-iot-gateway/pom.xml index d7eae3c..2c2320b 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/pom.xml +++ b/yudao-module-iot/yudao-module-iot-gateway/pom.xml @@ -12,8 +12,9 @@ ${project.artifactId} iot 模块下,设备网关: - ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息网关,提供给 iot-biz 进行处理 - ② 功能二:接收来自消息网关的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 + ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息总线,提供给 iot-biz 进行处理 + ② 功能二:接收来自消息总线的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 + 目前已支持 HTTP、MQTT、EMQX、TCP、UDP、WebSocket、CoAP、Modbus 等协议 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java index 63894dc..4bd0954 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java @@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketConfig import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -33,6 +34,11 @@ public class IotGatewayProperties { * Token 配置 */ private TokenProperties token; + /** + * 数据解析脚本配置 + */ + @Valid + private ScriptProperties script = new ScriptProperties(); /** * 协议实例列表 @@ -77,6 +83,41 @@ public class IotGatewayProperties { } /** + * 数据解析脚本配置 + */ + @Data + public static class ScriptProperties { + + /** + * 是否开启数据解析脚本功能(运维开关,默认关闭) + *

+ * 关闭时整体绕开脚本:不执行、不预热;raw 上行纯透传,标准下行与未开启时相同,raw.push 照常写出 + */ + private boolean enabled = false; + /** + * 透传处理的截止时间:raw 上行(含 HTTP / CoAP 同一请求内应答)、脚本下行、透传回复,从收到(入队)开始计算, + * 覆盖排队与执行脚本 + */ + @NotNull(message = "透传处理截止时间不能为空") + private Duration deadline = Duration.ofMillis(2000); + /** + * 单次脚本执行的时间预算:与截止时间取较早者 + */ + @NotNull(message = "脚本执行时间预算不能为空") + private Duration execBudget = Duration.ofMillis(500); + /** + * 单次脚本执行的指令上限 + */ + private long maxInstructions = 1_000_000; + /** + * 每个设备最多排队的脚本下行指令数(不含执行中的一条):超过时丢弃并记日志 + */ + @Positive(message = "每个设备最多排队的下行指令数必须大于 0") + private int maxPendingCommandsPerDevice = 256; + + } + + /** * 协议实例配置 */ @Data diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java index 90afda2..1095c4d 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/enums/ErrorCodeConstants.java @@ -16,4 +16,19 @@ public interface ErrorCodeConstants { // ========== 设备信息 1-050-002-000 ============ ErrorCode DEVICE_NOT_EXISTS = new ErrorCode(1_051_002_001, "设备({}/{}) 不存在"); + // ========== 设备透传与数据解析脚本 1-051-003-000 ============ + ErrorCode DEVICE_MESSAGE_RAW_METHOD_RESERVED = new ErrorCode(1_051_003_000, "透传方法只能由网关 raw 入口使用"); + ErrorCode SCRIPT_EXECUTE_FAILED = new ErrorCode(1_051_003_100, "数据解析脚本执行失败:{}"); + ErrorCode SCRIPT_TIMEOUT = new ErrorCode(1_051_003_101, "处理超时:未在截止时间内完成(排队、加载配置或脚本执行)"); + ErrorCode SCRIPT_REJECTED = new ErrorCode(1_051_003_102, "数据解析脚本繁忙或已熔断"); + ErrorCode SCRIPT_OUTPUT_INVALID = new ErrorCode(1_051_003_103, "数据解析脚本输出不合法:{}"); + ErrorCode RAW_NOT_SUPPORTED = new ErrorCode(1_051_003_105, "当前连接不支持透传下发"); + ErrorCode DEVICE_OFFLINE_OR_WRITE_FAILED = new ErrorCode(1_051_003_106, "设备不在线或写入失败"); + ErrorCode MODBUS_TIMEOUT = new ErrorCode(1_051_003_110, "Modbus 响应超时"); + ErrorCode MODBUS_EXCEPTION = new ErrorCode(1_051_003_111, "Modbus 异常响应(异常码 {})"); + ErrorCode MODBUS_PDU_INVALID = new ErrorCode(1_051_003_112, "Modbus PDU 不合法:{}"); + ErrorCode MODBUS_QUEUE_FULL = new ErrorCode(1_051_003_113, "Modbus 事务队列已满"); + ErrorCode MODBUS_RESPONSE_INVALID = new ErrorCode(1_051_003_114, "Modbus 响应不合法"); + ErrorCode MODBUS_DISCONNECTED = new ErrorCode(1_051_003_115, "Modbus 连接已断开"); + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java index efd61e1..3b685e2 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java @@ -1,13 +1,31 @@ package cn.iocoder.yudao.module.iot.gateway.protocol; import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; +import io.vertx.core.Future; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; + /** * IoT 协议下行消息订阅者抽象类 * @@ -23,6 +41,34 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess private final IotMessageBus messageBus; + /** + * 有排队 / 执行中脚本指令的设备:设备编号 → 待执行的指令(第一条已在 worker 上执行时,队列可为空) + *

+ * 总线的消费线程与 worker 都会访问,用它自身加锁;设备的指令全部执行完后移除 + */ + private final Map> deviceCommandQueues = new HashMap<>(); + + /** + * @return 协议类型:encode 的入参与字节规则按协议区分 + */ + public IotProtocolTypeEnum getProtocolType() { + return protocol.getType(); + } + + /** + * @return 是否支持透传下发:不支持时不执行编码脚本,指令直接走标准路径 + */ + public boolean supportsRawDownstream() { + return false; + } + + /** + * @return 本协议实例的 serverId(可信来源):回复设备响应时显式传入 + */ + public String getServerId() { + return protocol.getServerId(); + } + @Override public String getTopic() { return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); @@ -45,6 +91,10 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess @Override public void stop() { messageBus.unregister(this); + // 丢弃排队中的指令:执行中的那一条照常结束,之后不再执行 + synchronized (deviceCommandQueues) { + deviceCommandQueues.clear(); + } log.info("[stop][{} 下行消息订阅已停止,Topic:{}]", protocol.getType().name(), getTopic()); } @@ -61,7 +111,27 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess return; } - // 2. 处理下行消息 + // 2.1 透传下发(raw.push):字节在 raw 字段,不经过脚本,直接交给协议写出(功能开关关闭时也照常); + // 协议自己的字节规则(例如请求 PDU)由协议写出前校验 + if (IotDeviceMessageMethodEnum.RAW_PUSH.getMethod().equals(method) + && !IotDeviceMessageUtils.isReplyMessage(message)) { + writeRawMessage(message, IotDeviceMessageUtils.decodeRaw(message.getRaw(), + IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES), null); + return; + } + // 2.2 指令(property.set / service.invoke):功能开关开启、协议支持透传下发时,按设备排队,切到协议的 worker 上 + // 按产品的数据解析脚本 encode(加载设备信息、执行脚本会阻塞),再透传写出或走标准路径。 + // 同一设备的指令(包括 encode 后走标准路径的)按收到的顺序执行,不同设备之间并行 + IotGatewayProperties.ScriptProperties script = SpringUtil.getBean(IotGatewayProperties.class).getScript(); + if (script.isEnabled() && supportsRawDownstream() && IotDeviceMessageUtils.isCommandRequest(message)) { + // 截止时间从收到(按设备排队之前)开始计算 + IotDeviceMessageService deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + long receivedNanos = System.nanoTime(); + enqueueDeviceCommand(message, () -> handleScriptCommand(deviceMessageService, message, receivedNanos), + script.getMaxPendingCommandsPerDevice()); + return; + } + // 2.3 其它(OTA、影子、配置、回复等,功能开关关闭,或协议不支持透传下发):标准路径 handleMessage(message); } catch (Exception e) { log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", @@ -70,10 +140,182 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess } /** + * 按设备排队脚本指令:同一设备按提交顺序在 worker 上逐条执行,不同设备之间并行; + * 每个设备最多排队 maxPending 条(不含执行中的一条),超过时丢弃并记日志 + * + * @param message 下行指令 + * @param task 阻塞任务 + * @param maxPending 每个设备最多排队的指令数 + */ + private void enqueueDeviceCommand(IotDeviceMessage message, Runnable task, int maxPending) { + // 1. 没有设备编号(不应出现):无法保序,直接执行 + Long deviceId = message.getDeviceId(); + if (deviceId == null) { + executeCommands(null, new ArrayDeque<>(Collections.singletonList(task))); + return; + } + ArrayDeque tasks; + synchronized (deviceCommandQueues) { + // 2. 设备还有排队 / 执行中的指令:排在它们之后,由正在执行的 worker 依次执行;队列已满时丢弃 + Deque pending = deviceCommandQueues.get(deviceId); + if (pending != null) { + if (pending.size() >= maxPending) { + log.error("[enqueueDeviceCommand][设备({}) 待处理的下行指令已达上限 {},丢弃 messageId: {}, method: {}]", + deviceId, maxPending, message.getId(), message.getMethod()); + } else { + pending.add(task); + } + return; + } + // 3. 设备没有在途的指令:新建队列,交给一个 worker 执行 + tasks = new ArrayDeque<>(); + tasks.add(task); + deviceCommandQueues.put(deviceId, tasks); + } + executeCommands(deviceId, tasks); + } + + /** + * 在一个 worker 上依次执行设备的指令队列,直到队列为空(或已被 {@link #stop()} 清空);单条失败只记日志,不影响后续指令 + * + * @param deviceId 设备编号(为 null 时只执行给定的任务) + * @param tasks 设备的指令队列 + */ + private void executeCommands(Long deviceId, Deque tasks) { + try { + protocol.getVertx().executeBlocking(() -> { + while (true) { + // 1. 取下一条:队列空了就移除(之后的指令新建队列);队列已被 stop 清掉或换成新的一代,不再执行 + Runnable task; + synchronized (deviceCommandQueues) { + task = tasks.poll(); + if (task == null) { + if (deviceId != null) { + deviceCommandQueues.remove(deviceId, tasks); + } + return null; + } + // 已停止 + if (deviceId != null && deviceCommandQueues.get(deviceId) != tasks) { + return null; + } + } + // 2. 执行:单条失败只记日志,不影响后续指令 + try { + task.run(); + } catch (Exception e) { + log.error("[executeCommands][设备({}) 处理下行指令失败]", deviceId, e); + } + } + }, false).onFailure(e -> { + // 3. 提交到 worker 失败:丢弃排队的指令,移除队列 + log.error("[executeCommands][设备({}) 下行指令无法提交到 worker,丢弃排队的指令]", deviceId, e); + removeDeviceCommandQueue(deviceId, tasks); + }); + } catch (Exception e) { + // Vertx 已关闭时 worker 池会同步拒绝 + log.error("[executeCommands][设备({}) 下行指令无法提交到 worker,丢弃排队的指令]", deviceId, e); + removeDeviceCommandQueue(deviceId, tasks); + } + } + + /** + * 移除设备的指令队列:只移除给定的这一代(已被 stop 清掉或换成新一代时不影响新队列);排队中的指令随之丢弃 + * + * @param deviceId 设备编号(为 null 时没有队列,直接返回) + * @param tasks 设备的指令队列 + */ + private void removeDeviceCommandQueue(Long deviceId, Deque tasks) { + if (deviceId == null) { + return; + } + synchronized (deviceCommandQueues) { + deviceCommandQueues.remove(deviceId, tasks); + } + } + + /** + * 在 worker 上处理脚本指令:按 encode 的结果走标准路径、透传写出,或者不下发(设备消息 Service 已记日志) + * + * @param deviceMessageService 设备消息 Service(在总线消费线程获取) + * @param message 下行指令 + * @param receivedNanos 收到指令的时刻(System.nanoTime):按设备排队之前取得;设备消息 Service 据此计算截止, + * 返回结果之前已核对,拿到结果立即下发 + */ + private void handleScriptCommand(IotDeviceMessageService deviceMessageService, IotDeviceMessage message, + long receivedNanos) { + // 1. encode:null 表示不下发(设备消息 Service 已记日志) + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(message, getProtocolType(), + receivedNanos); + if (result == null) { + return; + } + // 2.1 走标准路径:与未启用脚本一致,由子类按协议的标准格式下发 + if (result.isStandard()) { + handleMessage(message); + return; + } + // 2.2 透传写出 encode 的字节,带上 encode 时固定的脚本(设备响应用它解析) + writeRawMessage(message, result.getBytes(), result.getSource()); + } + + /** * 处理下行消息 * * @param message 下行消息 */ protected abstract void handleMessage(IotDeviceMessage message); + /** + * 透传写出:交给协议写出({@link #handleRawMessage}),写出失败只记日志 + * + * @param message 下行消息(raw.push,或 encode 的 property.set / service.invoke) + * @param bytes 要写出的字节 + * @param source encode 时使用的脚本(固定 hash);raw.push 为 null + */ + private void writeRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + Future write = handleRawMessage(message, bytes, source); + if (write == null) { + log.warn("[writeRawMessage][下行({}) 设备不在线或写入失败]", message.getRequestId()); + return; + } + write.onFailure(e -> log.warn("[writeRawMessage][下行({}) 写出失败:{}]", message.getRequestId(), e.getMessage())); + } + + /** + * 透传写出。约定:只返回 Future,自己不发消息给 biz;设备的实际响应由协议经 {@link #replyRawResult} 回复 + *

+ * 失败 = ServiceException(ErrorCode),只记日志;协议自己的字节规则(例如请求 PDU)在写出之前校验,不合法返回失败。 + * 成功:一般为本地异步写出已完成(MQTT 为 publish 已提交,晚到的写出失败由协议记日志),不代表设备已确认;有请求-响应事务的协议,提交事务之前的失败返回失败 Future, + * 提交之后 Future 在事务终态(响应 / 超时 / 断线 / 写失败)成功完成,网关判定的失败只记日志 + * + * @param message 下行消息(raw.push,或 encode 的 property.set / service.invoke) + * @param bytes 要写出的字节 + * @param source encode 时使用的脚本(固定 hash);raw.push 为 null + * @return 写出结果 + */ + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + return Future.failedFuture(exception(RAW_NOT_SUPPORTED)); + } + + /** + * 回复透传下行的设备实际响应:切到协议的 worker,交给设备消息 Service 回复给 biz + *

+ * 只用于设备的实际响应(包括协议的异常响应);网关判定的失败(超时、断线、写失败等)由协议只记日志, + * 不调用本方法,也不给 biz 构造回复 + * + * @param request 原请求 + * @param source encode 时固定的脚本;raw.push 为 null + * @param payload 设备的响应字节 + * @param deviceError 协议判定的设备错误(例如异常响应);正常响应为 null + */ + protected final void replyRawResult(IotDeviceMessage request, IotScriptSource source, + byte[] payload, ErrorCode deviceError) { + String serverId = getServerId(); + protocol.getVertx().executeBlocking(() -> { + SpringUtil.getBean(IotDeviceMessageService.class).sendRawReply(request, source, payload, deviceError, serverId); + return null; + }, false).onFailure(e -> log.error("[replyRawResult][下行({}) 回复失败]", request.getRequestId(), e)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java index bdfc28b..6f458ac 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import io.vertx.core.Vertx; /** * IoT 协议接口 @@ -49,4 +50,13 @@ public interface IotProtocol { */ boolean isRunning(); + /** + * 获取协议的 Vert.x 实例:阻塞的处理(数据解析脚本、发送消息)切到它的 worker 上执行 + * + * @return Vert.x 实例;没有使用 Vert.x 的协议(CoAP)返回 null + */ + default Vertx getVertx() { + return null; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java index 4bc8cdb..2425910 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java @@ -62,6 +62,11 @@ public class IotCoapProtocol implements IotProtocol { private CoapServer coapServer; /** + * 上行处理器:停止时关闭它的透传截止计时器 + */ + private IotCoapUpstreamHandler upstreamHandler; + + /** * 下行消息订阅者 */ private IotCoapDownstreamSubscriber downstreamSubscriber; @@ -116,7 +121,7 @@ public class IotCoapProtocol implements IotProtocol { add(registerSubResource); }}); // 2.4 添加 /topic 根资源(用于上行消息) - IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler(serverId); + upstreamHandler = new IotCoapUpstreamHandler(serverId); IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(serverId, upstreamHandler); coapServer.add(topicResource); @@ -168,6 +173,11 @@ public class IotCoapProtocol implements IotProtocol { log.error("[stop][IoT CoAP 协议 {} 服务器停止失败]", getId(), e); } } + // 3. 关闭上行处理器的透传截止计时器 + if (upstreamHandler != null) { + upstreamHandler.stop(); + upstreamHandler = null; + } running = false; log.info("[stop][IoT CoAP 协议 {} 已停止]", getId()); } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java index 994fb14..a024354 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapAbstractHandler.java @@ -36,6 +36,11 @@ public abstract class IotCoapAbstractHandler { */ public static final int OPTION_TOKEN = 2088; + /** + * handle0 返回这个哨兵:表示响应由子类异步写出(透传上报:worker 处理结果与截止计时只响应一次),基类不再写响应 + */ + protected static final CommonResult ASYNC_RESPONSE = new CommonResult<>(); + private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); /** @@ -50,6 +55,9 @@ public abstract class IotCoapAbstractHandler { // 2. 执行业务逻辑 CommonResult result = handle0(exchange); + if (result == ASYNC_RESPONSE) { // 响应由子类异步写出 + return; + } writeResponse(exchange, result); } catch (ServiceException e) { // 业务异常,返回对应的错误码和消息 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java index d9e349b..e4ccf88 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandler.java @@ -4,14 +4,35 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; import cn.hutool.core.text.StrPool; +import cn.hutool.core.thread.NamedThreadFactory; +import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.coap.MediaTypeRegistry; import org.eclipse.californium.core.server.resources.CoapExchange; +import java.time.Duration; import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; /** * IoT 网关 CoAP 协议的【上行】处理器 @@ -19,6 +40,7 @@ import java.util.List; * 处理设备通过 CoAP 协议发送的上行消息,包括: * 1. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post * 2. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post + * 3. 透传上报:POST /topic/sys/{productKey}/{deviceName}/thing/raw/post,content-format 为 42(octet-stream) * * Token 通过自定义 CoAP Option 2088 携带 * @@ -27,13 +49,41 @@ import java.util.List; @Slf4j public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { + /** + * 透传 worker 的线程数:阻塞在 IO 上,取 CPU 核数的 2 倍 + */ + private static final int RAW_WORKER_THREADS = Runtime.getRuntime().availableProcessors() * 2; + /** + * 透传上报最多排队的请求数:超过时立即响应 429 + */ + private static final int RAW_MAX_PENDING = 1024; + private final String serverId; + /** + * 透传上报的应答计时时长:同一请求内应答(截止判断由设备消息 Service 负责) + */ + private final Duration deadline; + + /** + * 透传上报的截止计时器:到期先返回超时,与处理结果谁先到谁响应 + */ + private final ScheduledExecutorService deadlineTimer = Executors.newSingleThreadScheduledExecutor( + new NamedThreadFactory("iot-coap-raw-deadline-", true)); + /** + * 透传上报的 worker(有界):解析与发送会阻塞,不能占着 Californium 处理请求的线程(Exchange 的响应排在它之后) + */ + private final ThreadPoolExecutor rawExecutor = new ThreadPoolExecutor(RAW_WORKER_THREADS, RAW_WORKER_THREADS, + 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(RAW_MAX_PENDING), + new NamedThreadFactory("iot-coap-raw-", true)); + private final IotDeviceMessageService deviceMessageService; public IotCoapUpstreamHandler(String serverId) { this.serverId = serverId; + this.deadline = SpringUtil.getBean(IotGatewayProperties.class).getScript().getDeadline(); this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.rawExecutor.allowCoreThreadTimeOut(true); } @Override @@ -44,7 +94,11 @@ public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { String productKey = getProductKey(uriPath); String deviceName = getDeviceName(uriPath); String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size())); - // 1.2 解析消息 + // 1.2 透传上报 + if (isRawPost(exchange, method)) { + return handleRawPost(exchange, productKey, deviceName); + } + // 1.3 解析消息 IotDeviceMessage message = deserializeRequest(exchange, IotDeviceMessage.class); Assert.notNull(message, "请求参数不能为空"); Assert.equals(method, message.getMethod(), "method 不匹配"); @@ -56,6 +110,95 @@ public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { return CommonResult.success(MapUtil.of("messageId", message.getId())); } + /** + * 是否透传上报:路径为 thing/raw/post,并且 content-format 为 42(application/octet-stream) + *

+ * 其它 content-format 走 JSON 分支,由标准路径拒绝透传方法 + */ + private boolean isRawPost(CoapExchange exchange, String method) { + return StrUtil.equals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), method) + && exchange.getRequestOptions().getContentFormat() == MediaTypeRegistry.APPLICATION_OCTET_STREAM; + } + + /** + * 透传上报:身份来自前置认证(token 与路径一致),与 JSON 分支相同 + *

+ * Californium 在 Exchange 的串行执行器上处理请求,其它线程写的响应要排在本次处理之后才发出; + * 所以这里不阻塞:解析与发送交给透传 worker,立即返回。截止计时归协议:到期先返回超时(101), + * 之后完成的结果丢弃,只响应一次。已开始的投递无法撤销;其它情况由 service 的截止检查决定是否投递,超时响应不等待它 + */ + private CommonResult handleRawPost(CoapExchange exchange, String productKey, String deviceName) { + // 1. CON 请求:先回空 ACK,避免处理期间设备重传;之后的响应作为分离响应发送 + if (exchange.advanced().getRequest().isConfirmable()) { + exchange.accept(); + } + + // 2. 应答计时:在计时器线程上到期响应;截止时间从收到(交给 worker 之前)开始计算 + AtomicBoolean responded = new AtomicBoolean(); + long receivedNanos = System.nanoTime(); + ScheduledFuture timer = deadlineTimer.schedule(() -> writeRawResponse(exchange, responded, + CommonResult.error(SCRIPT_TIMEOUT)), deadline.toNanos(), TimeUnit.NANOSECONDS); + + // 3. 交给透传 worker:按产品的数据解析脚本 decode 后发送;队列已满时立即响应 + byte[] payload = exchange.getRequestPayload(); + try { + rawExecutor.execute(() -> { + // 排队期间已到截止时,由 service 的截止分支处理:不执行脚本、不投递,只记错误日志; + // 超时响应已由计时器写出,这里的结果被门闩丢弃 + CommonResult result = sendRawMessage(payload, productKey, deviceName, receivedNanos); + timer.cancel(false); + writeRawResponse(exchange, responded, result); + }); + } catch (RejectedExecutionException e) { + log.warn("[handleRawPost][productKey({}) deviceName({}) 透传上报积压,拒绝处理]", productKey, deviceName); + timer.cancel(false); + writeRawResponse(exchange, responded, CommonResult.error(TOO_MANY_REQUESTS)); + } + return ASYNC_RESPONSE; + } + + /** + * 发送透传上报(透传 worker 上执行):结果(含失败)转为响应,与基类的异常处理一致 + */ + private CommonResult sendRawMessage(byte[] payload, String productKey, String deviceName, + long receivedNanos) { + try { + String messageId = deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, + IotProtocolTypeEnum.COAP, receivedNanos); + return CommonResult.success(MapUtil.of("messageId", messageId)); + } catch (ServiceException e) { + return CommonResult.error(e.getCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + return CommonResult.error(BAD_REQUEST.getCode(), e.getMessage()); + } catch (Exception e) { + log.error("[sendRawMessage][productKey({}) deviceName({}) 处理异常]", productKey, deviceName, e); + return CommonResult.error(INTERNAL_SERVER_ERROR); + } + } + + /** + * 写透传上报的响应:截止计时器与处理线程竞争,只有第一个响应,迟到的结果丢弃 + */ + private void writeRawResponse(CoapExchange exchange, AtomicBoolean responded, CommonResult result) { + if (!responded.compareAndSet(false, true)) { + log.warn("[writeRawResponse][已响应(超时),丢弃结果({})]", result.getCode()); + return; + } + try { + writeResponse(exchange, result); + } catch (Exception e) { + log.error("[writeRawResponse][写响应异常]", e); + } + } + + /** + * 停止:关闭透传 worker(排队中的不再执行)与截止计时器 + */ + public void stop() { + rawExecutor.shutdownNow(); + deadlineTimer.shutdownNow(); + } + @Override protected boolean requiresAuthentication() { return true; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java index f110f64..14d6f6a 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java @@ -14,6 +14,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmq import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler; import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpServer; @@ -31,7 +32,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; /** * IoT 网关 EMQX 协议实现: @@ -67,6 +70,7 @@ public class IotEmqxProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private Vertx vertx; /** * HTTP Hook 服务器 @@ -519,6 +523,35 @@ public class IotEmqxProtocol implements IotProtocol { } /** + * 异步发布消息到 MQTT Broker:返回 publish 真实的结果(透传下发使用) + *

+ * 成功只表示已交给 Broker,不代表设备已收到;未连接或发布失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) + * + * @param topic 主题 + * @param payload 消息内容 + * @return 发布结果 + */ + public Future publishMessageAsync(String topic, byte[] payload) { + MqttClient client = this.mqttClient; + if (client == null || !client.isConnected()) { + log.warn("[publishMessageAsync][IoT EMQX 协议 {} MQTT Client 未连接, 无法发布消息]", getId()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + try { + MqttQoS qos = MqttQoS.valueOf(emqxConfig.getMqttQos()); + return client.publish(topic, Buffer.buffer(payload), qos, false, false) + .mapEmpty() + .recover(e -> { + log.warn("[publishMessageAsync][IoT EMQX 协议 {} 发布失败, topic: {}, 错误: {}]", getId(), topic, e.getMessage()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + }); + } catch (Exception e) { + log.error("[publishMessageAsync][IoT EMQX 协议 {} 发布异常, topic: {}]", getId(), topic, e); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + } + + /** * 延迟发布消息到 MQTT Broker * * @param topic 主题 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java index db5ea12..1f8f999 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamHandler.java @@ -9,8 +9,12 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; + /** * IoT 网关 EMQX 下行消息处理器 *

@@ -61,6 +65,26 @@ public class IotEmqxDownstreamHandler { } /** + * 处理透传下行(raw.push 或编码脚本的输出):原始字节发布到 raw/push 主题 + * + * @param message 下行消息 + * @param bytes 要写出的原始字节 + * @return 真实的发布结果 + */ + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes) { + // 1. 获取设备信息 + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); + if (deviceInfo == null) { + log.error("[handleRawMessage][设备信息({})不存在]", message.getDeviceId()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + + // 2. 发布到 raw/push 主题 + String topic = IotMqttTopicUtils.buildRawPushTopic(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + return protocol.publishMessageAsync(topic, bytes); + } + + /** * 根据消息方法和回复状态构建主题 * * @param message 设备消息 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java index e7e5de9..95744ca 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; +import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; /** @@ -26,4 +28,14 @@ public class IotEmqxDownstreamSubscriber extends AbstractIotProtocolDownstreamSu downstreamHandler.handle(message); } + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + return downstreamHandler.handleRawMessage(message, bytes); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java index ccc7a2b..1863a5a 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxAuthEventHandler.java @@ -229,7 +229,7 @@ public class IotEmqxAuthEventHandler { return; } - // 2. 执行 ACL 校验 + // 2. 执行 ACL 校验(透传的 raw/post 发布、raw/push 订阅同样只放行设备自己的 /sys/{productKey}/{deviceName}/ 前缀) boolean allowed = subscribe ? IotMqttTopicUtils.isTopicSubscribeAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()) : IotMqttTopicUtils.isTopicPublishAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java index 45c9481..24faf3c 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java @@ -3,9 +3,13 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Context; +import io.vertx.core.Vertx; import io.vertx.mqtt.messages.MqttPublishMessage; import lombok.extern.slf4j.Slf4j; @@ -17,10 +21,24 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public class IotEmqxUpstreamHandler { + /** + * 标准消息、透传上报最多排队的消息数:超过时丢弃 + */ + private static final int MAX_PENDING = 1024; + private final IotDeviceMessageService deviceMessageService; private final String serverId; + /** + * 标准消息的 worker 队列(有序、有界):所有设备共用一个 MQTT Client,与原来在 event loop 上一样按收到的顺序逐条处理 + */ + private IotWorkerTaskQueue queue; + /** + * 透传上报的 worker 队列(无序、有界):执行脚本耗时较长,消息之间并行 + */ + private IotWorkerTaskQueue rawQueue; + public IotEmqxUpstreamHandler(String serverId) { this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); this.serverId = serverId; @@ -33,8 +51,12 @@ public class IotEmqxUpstreamHandler { log.debug("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); String topic = mqttMessage.topicName(); byte[] payload = mqttMessage.payload().getBytes(); + // 0. 网关自己发布的透传下发(raw/push)被自己订阅到:直接忽略(回环) + if (IotMqttTopicUtils.isRawPushTopic(topic)) { + return; + } try { - // 1. 解析主题,一次性获取所有信息 + // 1.1 解析主题,一次性获取所有信息 String[] topicParts = topic.split("/"); String productKey = ArrayUtil.get(topicParts, 2); String deviceName = ArrayUtil.get(topicParts, 3); @@ -42,21 +64,74 @@ public class IotEmqxUpstreamHandler { log.warn("[handle][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic); return; } - - // 2.1 反序列化消息 - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); - if (message == null) { - log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); + // 1.2 透传上报:身份来自 Topic(由 EMQX ACL 保证设备只能发布自己的 Topic),跳过反序列化 + if (IotMqttTopicUtils.isRawPostTopic(topic)) { + handleRawRequest(topic, productKey, deviceName, payload); return; } - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) - IotMqttTopicUtils.normalizeReplyMethod(message); - // 3. 发送消息到队列 - deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + // 2. 标准消息:反序列化(获取设备)、发送总线会阻塞,交给 worker 按收到的顺序执行 + getQueue().submit(() -> { + handleStandardRequest(topic, productKey, deviceName, payload); + return null; + }).onFailure(e -> log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e)); } catch (Exception e) { log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e); } } + /** + * 处理标准消息(worker 线程):反序列化后发送 + * + * @param topic 主题 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param payload 消息内容 + */ + private void handleStandardRequest(String topic, String productKey, String deviceName, byte[] payload) { + // 1.1 反序列化消息 + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); + if (message == null) { + log.warn("[handleStandardRequest][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); + return; + } + // 1.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) + IotMqttTopicUtils.normalizeReplyMethod(message); + + // 2. 发送消息到队列 + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + } + + /** + * 获取标准消息的 worker 队列:按当前(MQTT Client 所在的)context 创建;协议重启后 context 变化时重建 + */ + private IotWorkerTaskQueue getQueue() { + Context context = Vertx.currentContext(); + if (queue == null || queue.getContext() != context) { + queue = new IotWorkerTaskQueue(context, true, MAX_PENDING); + } + return queue; + } + + /** + * 处理透传上报:原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给 worker 执行(不同消息之间并行) + * + * @param topic 主题 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param payload 原始字节 + */ + private void handleRawRequest(String topic, String productKey, String deviceName, byte[] payload) { + Context context = Vertx.currentContext(); + if (rawQueue == null || rawQueue.getContext() != context) { // 协议重启后 MQTT Client 换了 Vertx:按新的 context 重建 + rawQueue = new IotWorkerTaskQueue(context, false, MAX_PENDING); + } + // 截止时间从收到(入队之前)开始计算 + long receivedNanos = System.nanoTime(); + rawQueue.submit(() -> deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, + IotProtocolTypeEnum.EMQX, receivedNanos)).onFailure(e -> + log.warn("[handleRawRequest][topic({}) 透传上报处理失败,数据长度: {} 字节,错误: {}]", + topic, payload.length, e.getMessage())); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java index c403ee9..14d95cc 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpAbstractHandler.java @@ -29,6 +29,11 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU @Slf4j public abstract class IotHttpAbstractHandler implements Handler { + /** + * handle0 返回这个哨兵:表示响应已由子类异步接管(上报在 worker 上处理完成后写一次,透传上报到期先写超时),基类不再写响应 + */ + protected static final CommonResult ASYNC_RESPONSE = new CommonResult<>(); + private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); @Override @@ -39,6 +44,9 @@ public abstract class IotHttpAbstractHandler implements Handler // 2. 执行逻辑 CommonResult result = handle0(context); + if (result == ASYNC_RESPONSE) { // 响应由子类异步写出 + return; + } writeResponse(context, result); } catch (ServiceException e) { // 已知异常,返回对应的错误码和错误信息 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java index aa408dc..29030e9 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandler.java @@ -3,16 +3,34 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; import cn.hutool.core.lang.Assert; import cn.hutool.core.map.MapUtil; import cn.hutool.core.text.StrPool; +import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.AsyncResult; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpHeaders; import io.vertx.ext.web.RoutingContext; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; + +import java.time.Duration; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; /** * IoT 网关 HTTP 协议的【上行】处理器 + *

+ * 透传上报:POST /topic/sys/{productKey}/{deviceName}/thing/raw/post,Content-Type 为 application/octet-stream, + * 请求体是原始字节;同一请求内应答 JSON 结果(与标准上报一致,data 为 messageId) * * @author 芋道源码 */ @@ -23,10 +41,16 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { private final String serverId; + /** + * 透传上报的应答计时时长:同一请求内应答,到期先返回超时(截止判断由设备消息 Service 负责) + */ + private final Duration deadline; + private final IotDeviceMessageService deviceMessageService; public IotHttpUpstreamHandler(IotHttpProtocol protocol) { this.serverId = protocol.getServerId(); + this.deadline = SpringUtil.getBean(IotGatewayProperties.class).getScript().getDeadline(); this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); } @@ -36,17 +60,90 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { String productKey = context.pathParam("productKey"); String deviceName = context.pathParam("deviceName"); String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT); - // 1.2 根据 Content-Type 反序列化消息 + // 1.2 透传上报:在 worker 上处理,响应异步写一次 + if (isRawPost(context, method)) { + return handleRawPost(context, productKey, deviceName); + } + // 1.3 根据 Content-Type 反序列化消息 IotDeviceMessage message = deserializeRequest(context, IotDeviceMessage.class); Assert.notNull(message, "请求参数不能为空"); Assert.equals(method, message.getMethod(), "method 不匹配"); - // 2. 发送消息 - deviceMessageService.sendDeviceMessage(message, - productKey, deviceName, serverId); + // 2. 发送消息:获取设备、发送总线会阻塞,交给 worker 执行,完成后在 event loop 上响应(与原来一样等发送完成) + context.vertx().executeBlocking(() -> { + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + return message.getId(); + }, false).onComplete(ar -> writeAsyncResponse(context, buildResult(context, ar))); + return ASYNC_RESPONSE; + } + + /** + * 是否透传上报:路径为 thing/raw/post,并且 Content-Type 为 application/octet-stream + *

+ * 其它 Content-Type 走 JSON 分支,由标准路径拒绝透传方法 + */ + private boolean isRawPost(RoutingContext context, String method) { + if (!StrUtil.equals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), method)) { + return false; + } + String contentType = context.request().getHeader(HttpHeaders.CONTENT_TYPE); + return StrUtil.equalsIgnoreCase(StrUtil.trim(StrUtil.subBefore(contentType, ';', false)), + MediaType.APPLICATION_OCTET_STREAM_VALUE); + } + + /** + * 透传上报:身份来自前置认证(token 与路径一致),与 JSON 分支相同 + *

+ * 解析与发送会阻塞,交给 worker 执行;截止时间到期先返回超时(101),之后完成的结果不再响应。 + * 计时器与 worker 的结果都回到请求所在的 event loop,以「响应是否已结束」保证只响应一次 + */ + private CommonResult handleRawPost(RoutingContext context, String productKey, String deviceName) { + byte[] body = context.body().buffer() != null ? context.body().buffer().getBytes() : null; + // 截止时间从收到(交给 worker 之前)开始计算 + long receivedNanos = System.nanoTime(); + Vertx vertx = context.vertx(); + long timerId = vertx.setTimer(Math.max(1L, deadline.toMillis()), + id -> writeAsyncResponse(context, CommonResult.error(SCRIPT_TIMEOUT))); + vertx.executeBlocking(() -> deviceMessageService.sendRawMessage(body, productKey, deviceName, serverId, + IotProtocolTypeEnum.HTTP, receivedNanos), false).onComplete(ar -> { + vertx.cancelTimer(timerId); + writeAsyncResponse(context, buildResult(context, ar)); + }); + return ASYNC_RESPONSE; + } + + /** + * worker 的处理结果转为响应:与基类的异常处理一致 + */ + private static CommonResult buildResult(RoutingContext context, AsyncResult ar) { + if (ar.succeeded()) { + return CommonResult.success(MapUtil.of("messageId", ar.result())); + } + if (ar.cause() instanceof ServiceException) { + ServiceException e = (ServiceException) ar.cause(); + return CommonResult.error(e.getCode(), e.getMessage()); + } + if (ar.cause() instanceof IllegalArgumentException) { + return CommonResult.error(BAD_REQUEST.getCode(), ar.cause().getMessage()); + } + log.error("[buildResult][path({}) 处理异常]", context.request().path(), ar.cause()); + return CommonResult.error(INTERNAL_SERVER_ERROR); + } - // 3. 返回结果 - return CommonResult.success(MapUtil.of("messageId", message.getId())); + /** + * 写 worker 处理结果的响应(event loop 上执行):响应已结束(透传已超时返回)或连接已关闭时丢弃 + */ + private static void writeAsyncResponse(RoutingContext context, CommonResult result) { + if (context.response().ended() || context.response().closed()) { + log.warn("[writeAsyncResponse][path({}) 响应已结束或连接已关闭,丢弃结果({})]", + context.request().path(), result.getCode()); + return; + } + try { + writeResponse(context, result); + } catch (Exception e) { + log.error("[writeAsyncResponse][path({}) 写响应异常]", context.request().path(), e); + } } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/IotModbusTx.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/IotModbusTx.java new file mode 100644 index 0000000..9b8aa70 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/IotModbusTx.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +/** + * IoT Modbus 透传事务(thing.raw.push,或编码脚本输出的 PDU),server、client 共用 + *

+ * 标准轮询、标准写不创建事务,保持原有的发送与匹配方式。 + * 只有一个终态:{@link #complete} / {@link #fail} 通过 CAS 只生效一次,之后的终态信号(迟到的响应、重复的超时、断线)直接忽略。 + * 终态回调在触发终态的线程上执行(server:连接的 owner;client:executeBlocking 的完成回调),只能做非阻塞动作 + * + * @author 芋道源码 + */ +@Getter +@Slf4j +public class IotModbusTx { + + /** + * 原下行请求 + */ + private final IotDeviceMessage request; + /** + * 请求 PDU(功能码 + 数据,不带 slaveId、CRC、MBAP) + */ + private final byte[] pdu; + /** + * 截止时间(System.nanoTime):排队超过它就不再发送 + */ + private final long deadlineNanos; + + /** + * 终态回调:responsePdu = 设备响应 PDU(含异常响应 fc|0x80 + 异常码),failure = 网关判定的失败,二者只有一个非空 + */ + @Getter(AccessLevel.NONE) + private final BiConsumer callback; + @Getter(AccessLevel.NONE) + private final AtomicBoolean done = new AtomicBoolean(); + + /** + * 只通过 {@link #ofServer} / {@link #ofClient} 创建 + */ + private IotModbusTx(IotDeviceMessage request, byte[] pdu, long deadlineNanos, + BiConsumer callback) { + this.request = request; + this.pdu = pdu; + this.deadlineNanos = deadlineNanos; + this.callback = callback; + } + + /** + * 创建 server 端的透传事务:截止时间 = 现在 + 通道的 requestTimeout + * + * @param channel 提交它的连接通道:只用于读取 requestTimeout + * @param pdu 请求 PDU + * @param request 原下行请求 + * @param callback 终态回调(可为 null) + * @return 事务 + */ + public static IotModbusTx ofServer(IotModbusTcpServerTransactionChannel channel, byte[] pdu, + IotDeviceMessage request, BiConsumer callback) { + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(channel.getRequestTimeout()); + return new IotModbusTx(request, pdu, deadlineNanos, callback); + } + + /** + * 创建 client 端的透传事务 + * + * @param request 原下行请求 + * @param pdu 请求 PDU + * @param deadlineNanos 截止时间(System.nanoTime) + * @param callback 终态回调 + * @return 事务 + */ + public static IotModbusTx ofClient(IotDeviceMessage request, byte[] pdu, long deadlineNanos, + BiConsumer callback) { + return new IotModbusTx(request, pdu, deadlineNanos, callback); + } + + /** + * 收到设备响应(包括异常响应):进入成功终态 + * + * @param responsePdu 响应 PDU + * @return 是否由本次调用进入终态 + */ + public boolean complete(byte[] responsePdu) { + return terminate(responsePdu, null); + } + + /** + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法):进入失败终态 + * + * @param failure 错误码 + * @return 是否由本次调用进入终态 + */ + public boolean fail(ErrorCode failure) { + return terminate(null, failure); + } + + /** + * @return 是否已经超过截止时间 + */ + public boolean isExpired() { + return System.nanoTime() - deadlineNanos >= 0; + } + + /** + * 进入终态:只有第一次调用生效,并执行终态回调 + * + * @param responsePdu 响应 PDU;失败时为 null + * @param failure 网关判定的失败;成功时为 null + * @return 是否由本次调用进入终态 + */ + private boolean terminate(byte[] responsePdu, ErrorCode failure) { + // 1. CAS:已进入终态时直接忽略(迟到的响应、重复的超时、断线) + if (!done.compareAndSet(false, true)) { + return false; + } + if (callback == null) { + return true; + } + // 2. 执行终态回调:异常只记日志,不影响调用方(owner 上的清理、executeBlocking 的完成回调) + try { + callback.accept(responsePdu, failure); + } catch (Exception e) { + log.error("[terminate][Modbus 透传事务终态回调异常, deviceId={}]", + request != null ? request.getDeviceId() : null, e); + } + return true; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java index 312e796..9366c84 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java @@ -4,11 +4,13 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum; import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; @@ -17,6 +19,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.Arrays; /** * IoT Modbus 协议工具类 @@ -524,6 +527,113 @@ public class IotModbusCommonUtils { return -1; } + // ==================== 事务响应:PDU 还原与格式校验 ==================== + + /** + * 构建异常响应 PDU:[fc | 0x80, 异常码] + * + * @param functionCode 原功能码 + * @param exceptionCode 异常码 + * @return 异常响应 PDU + */ + public static byte[] buildExceptionPdu(int functionCode, int exceptionCode) { + return new byte[]{(byte) (functionCode | FC_EXCEPTION_MASK), (byte) exceptionCode}; + } + + /** + * 从解码后的响应帧还原响应 PDU(不带 slaveId、CRC、MBAP) + *

+ * 解码器的 {@link IotModbusFrame#getPdu()} 是功能码之后的全部数据(读响应含 byteCount); + * 异常响应时解码器已把功能码还原为原功能码、异常码单独存放,这里重新加上 0x80 异常位 + * + * @param frame 响应帧 + * @return 异常 → [fc | 0x80, 异常码];正常 → [fc] + pdu + */ + public static byte[] buildResponsePdu(IotModbusFrame frame) { + if (frame.isException()) { + return buildExceptionPdu(frame.getFunctionCode(), frame.getExceptionCode()); + } + byte[] data = frame.getPdu() != null ? frame.getPdu() : new byte[0]; + byte[] responsePdu = new byte[1 + data.length]; + responsePdu[0] = (byte) frame.getFunctionCode(); + System.arraycopy(data, 0, responsePdu, 1, data.length); + return responsePdu; + } + + /** + * 获取透传响应对应的设备错误:用于回复 biz 时的结果码 + * + * @param responsePdu 设备响应 PDU(非空) + * @return 异常响应 [fc | 0x80, 异常码] → {@link ErrorCodeConstants#MODBUS_EXCEPTION}(带异常码);正常响应 → null + */ + public static ErrorCode getRawResponseError(byte[] responsePdu) { + if (!isExceptionResponse(responsePdu[0] & 0xFF) || responsePdu.length < 2) { + return null; + } + ErrorCode exception = ErrorCodeConstants.MODBUS_EXCEPTION; + return new ErrorCode(exception.getCode(), StrUtil.format(exception.getMsg(), responsePdu[1] & 0xFF)); + } + + /** + * 获取请求 PDU 中的数量:FC01-04 / FC15 / FC16 取第 3-4 字节;FC05 / FC06 固定为 1 + * + * @param requestPdu 请求 PDU(已通过格式校验,至少 5 字节) + * @return 数量 + */ + public static int getRequestQuantity(byte[] requestPdu) { + int functionCode = requestPdu[0] & 0xFF; + if (functionCode == FC_WRITE_SINGLE_COIL || functionCode == FC_WRITE_SINGLE_REGISTER) { + return 1; + } + return ((requestPdu[3] & 0xFF) << 8) | (requestPdu[4] & 0xFF); + } + + /** + * 校验正常响应 PDU 的格式是否与请求一致(异常响应不经过这里) + *

    + *
  • FC01 / FC02:byteCount = ceil(数量 / 8),总长 = 2 + byteCount;
  • + *
  • FC03 / FC04:byteCount = 2 × 数量,总长 = 2 + byteCount;
  • + *
  • FC05 / FC06:原样回显请求;
  • + *
  • FC15 / FC16:回显地址和数量。
  • + *
+ * + * @param requestPdu 请求 PDU + * @param responsePdu 响应 PDU + * @return 是否合法 + */ + @SuppressWarnings("EnhancedSwitchMigration") + public static boolean isResponseShapeValid(byte[] requestPdu, byte[] responsePdu) { + if (requestPdu == null || requestPdu.length < 5 || responsePdu == null || responsePdu.length < 1) { + return false; + } + int functionCode = requestPdu[0] & 0xFF; + if ((responsePdu[0] & 0xFF) != functionCode) { + return false; + } + int quantity = getRequestQuantity(requestPdu); + switch (functionCode) { + case FC_READ_COILS: + case FC_READ_DISCRETE_INPUTS: + return isReadResponseShapeValid(responsePdu, (quantity + 7) / 8); + case FC_READ_HOLDING_REGISTERS: + case FC_READ_INPUT_REGISTERS: + return isReadResponseShapeValid(responsePdu, quantity * 2); + case FC_WRITE_SINGLE_COIL: + case FC_WRITE_SINGLE_REGISTER: + return responsePdu.length == 5 && Arrays.equals(Arrays.copyOf(requestPdu, 5), responsePdu); + case FC_WRITE_MULTIPLE_COILS: + case FC_WRITE_MULTIPLE_REGISTERS: + return responsePdu.length == 5 + && Arrays.equals(Arrays.copyOfRange(requestPdu, 1, 5), Arrays.copyOfRange(responsePdu, 1, 5)); + default: + return false; + } + } + + private static boolean isReadResponseShapeValid(byte[] responsePdu, int expectedByteCount) { + return responsePdu.length == 2 + expectedByteCount && (responsePdu[1] & 0xFF) == expectedByteCount; + } + // ==================== 点位查找 ==================== /** diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java index 1d52f8d..7d4bcb0 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java @@ -1,7 +1,15 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusRawRequest; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; +import com.ghgande.j2mod.modbus.ModbusException; +import com.ghgande.j2mod.modbus.ModbusIOException; +import com.ghgande.j2mod.modbus.ModbusSlaveException; import com.ghgande.j2mod.modbus.io.ModbusTCPTransaction; import com.ghgande.j2mod.modbus.msg.*; import com.ghgande.j2mod.modbus.procimg.InputRegister; @@ -12,6 +20,13 @@ import io.vertx.core.Future; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; +import java.io.IOException; +import java.net.SocketTimeoutException; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_TIMEOUT; import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.*; /** @@ -124,6 +139,118 @@ public class IotModbusTcpClientUtils { } /** + * 执行透传请求(raw.push / encode 输出的 PDU) + *

+ * 设备的正常、异常响应都从 transaction.getResponse() 取真实响应,先核对事务号、unit、功能码,再还原 PDU: + * 异常响应还原为 [fc | 0x80, 异常码] 作为成功结果;在有序队列里排队超过截止 → 110; + * 身份不一致 → 在本有序任务内关闭连接再 114(下一个任务开始时重连,不会读到错位的响应);格式与请求不一致 → 114; + * 传输失败由 executeRawBlocking 的守卫先断开连接再抛出(只作用于透传;标准读写超时不断开共享连接) + * + * @param connection Modbus 连接 + * @param slaveId 从站地址 + * @param tx 透传事务 + * @return 响应 PDU + */ + public static Future executeRaw(IotModbusTcpClientConnectionManager.ModbusConnection connection, + Integer slaveId, IotModbusTx tx) { + return connection.executeRawBlocking(tcpConnection -> { + // 1. 在有序队列里排得太久(超过本连接的 timeout):不再执行 + if (tx.isExpired()) { + throw exception(MODBUS_TIMEOUT); + } + // 2.1 构造事务:retries = 1 表示只尝试 1 次(0 会被 j2mod 当成默认的 5 次;写功能码重试会重复写出) + IotModbusRawRequest request = new IotModbusRawRequest(tx.getPdu()); + request.setUnitID(slaveId); + ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection); + transaction.setRetries(1); + transaction.setRequest(request); + // 2.2 保存发出的身份:setRequest 分配事务号,execute 结束后 j2mod 会把请求的事务号自增 + int transactionId = request.getTransactionID(); + int unitId = request.getUnitID(); + int functionCode = request.getFunctionCode(); + // 2.3 执行事务 + try { + transaction.execute(); + } catch (ModbusSlaveException e) { + // 设备异常响应:不在这里拼异常 PDU,下面用 transaction.getResponse() 的真实异常响应核对身份 + log.debug("[executeRaw][设备异常响应, slaveId={}, functionCode={}]", slaveId, functionCode); + } catch (ModbusException e) { + throw new RuntimeException(String.format("Modbus 透传失败 [slaveId=%d, functionCode=%d]", + slaveId, functionCode), e); + } + // 3.1 核对真实响应的身份:不一致说明读到的不是这次请求的响应,在本有序任务内关闭连接 + ModbusResponse response = transaction.getResponse(); + if (!IotModbusRawRequest.isResponseOf(response, transactionId, unitId, functionCode)) { + log.warn("[executeRaw][响应与请求不一致, 关闭连接, 请求(tid={}, unit={}, fc={}), 响应(tid={}, unit={}, fc={})]", + transactionId, unitId, functionCode, + response != null ? response.getTransactionID() : null, + response != null ? response.getUnitID() : null, + response != null ? response.getFunctionCode() : null); + tcpConnection.close(); + throw exception(MODBUS_RESPONSE_INVALID); + } + // 3.2 还原响应 PDU,并核对响应格式 + return request.buildResponsePdu(response); + }); + } + + /** + * 是否是传输失败(读超时、IO 异常):沿 cause 链查找 ModbusIOException / IOException; + * 遇到 ModbusSlaveException(设备异常响应,说明链路正常)返回 false + * + * @param e 异常 + * @return 是否传输失败 + */ + public static boolean isTransportFailure(Throwable e) { + int depth = 0; + for (Throwable cause = e; cause != null && depth < 16; cause = cause.getCause(), depth++) { + if (cause instanceof ModbusSlaveException) { + return false; + } + if (cause instanceof ModbusIOException || cause instanceof IOException) { + return true; + } + } + return false; + } + + /** + * 是否是读超时:j2mod 的 ModbusTCPTransport.readResponse 把 SocketTimeoutException 转为 + * ModbusIOException("Socket timeout reading response - ..."),不保留 cause,所以同时按消息判断 + * + * @param e 异常 + * @return 是否读超时 + */ + public static boolean isTimeoutFailure(Throwable e) { + int depth = 0; + for (Throwable cause = e; cause != null && depth < 16; cause = cause.getCause(), depth++) { + if (cause instanceof SocketTimeoutException) { + return true; + } + if (cause instanceof ModbusIOException && StrUtil.containsIgnoreCase(cause.getMessage(), "timeout")) { + return true; + } + } + return false; + } + + /** + * 把透传任务的失败转换为网关判定的错误码:ServiceException 保留其错误码(110 / 114 / 115),读超时 → 110,其它 → 106 + * + * @param e 异常 + * @return 错误码 + */ + public static ErrorCode toRawFailure(Throwable e) { + if (e instanceof ServiceException) { + return new ErrorCode(((ServiceException) e).getCode(), e.getMessage()); + } + if (isTimeoutFailure(e)) { + return MODBUS_TIMEOUT; + } + return DEVICE_OFFLINE_OR_WRITE_FAILED; + } + + /** * 创建读取请求 */ @SuppressWarnings("EnhancedSwitchMigration") diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java index f632b62..7fe67bb 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java @@ -52,6 +52,7 @@ public class IotModbusTcpClientProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private final Vertx vertx; /** * 配置刷新定时器 ID @@ -127,7 +128,8 @@ public class IotModbusTcpClientProtocol implements IotProtocol { IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); IotModbusTcpClientDownstreamHandler downstreamHandler = new IotModbusTcpClientDownstreamHandler(connectionManager, configCacheService); - this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, messageBus); + this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, + connectionManager, messageBus); this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT Modbus TCP Client 协议 {} 启动失败]", getId(), e); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequest.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequest.java new file mode 100644 index 0000000..0c1b14b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequest.java @@ -0,0 +1,134 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; + +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import com.ghgande.j2mod.modbus.msg.ExceptionResponse; +import com.ghgande.j2mod.modbus.msg.ModbusRequest; +import com.ghgande.j2mod.modbus.msg.ModbusResponse; +import com.ghgande.j2mod.modbus.net.AbstractModbusListener; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.Arrays; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; + +/** + * Modbus 原始 PDU 请求(只用于透传,主站发送) + *

+ * j2mod 3.4.0 的 ModbusTCPTransport.writeMessage 按 [MBAP][unitId][getFunctionCode()][getMessage()] 写出, + * 所以 {@link #getMessage()} 返回 PDU 去掉功能码之后的数据部分(不带 unit、MBAP)。 + * 响应由 ModbusTCPTransport.readResponse 按功能码用 ModbusResponse.createModbusResponse 创建(不经过 {@link #getResponse()}), + * 白名单内的 8 个功能码(01-06、0F、10)都有对应的标准响应类,{@link #buildResponsePdu} 用它的 getMessage 还原数据部分; + * 异常响应由 ModbusTCPTransaction.execute 转为 ModbusSlaveException,调用方再用 transaction.getResponse() 拿到真实的 ExceptionResponse 交给这里。 + * 正常、异常响应都先经过 {@link #isResponseOf} 核对身份 + * + * @author 芋道源码 + */ +public class IotModbusRawRequest extends ModbusRequest { + + /** + * 完整的请求 PDU(功能码 + 数据) + */ + private final byte[] pdu; + /** + * PDU 去掉功能码之后的部分 + */ + private final byte[] data; + + public IotModbusRawRequest(byte[] pdu) { + this.pdu = pdu.clone(); + this.data = Arrays.copyOfRange(pdu, 1, pdu.length); + setFunctionCode(pdu[0] & 0xFF); + setDataLength(data.length); + } + + /** + * TCP 主站不经过这里;按 j2mod 的约定返回带请求头的空响应 + */ + @Override + public ModbusResponse getResponse() { + ModbusResponse response = ModbusResponse.createModbusResponse(getFunctionCode()); + response.setHeadless(isHeadless()); + response.setTransactionID(getTransactionID()); + response.setProtocolID(getProtocolID()); + response.setUnitID(getUnitID()); + return response; + } + + @Override + public ModbusResponse createResponse(AbstractModbusListener listener) { + throw new UnsupportedOperationException("透传请求只用于主站发送"); + } + + @Override + public void writeData(DataOutput dout) throws IOException { + dout.write(data); + } + + @Override + public void readData(DataInput din) { + throw new UnsupportedOperationException("透传请求只用于主站发送"); + } + + @Override + public byte[] getMessage() { + return data; + } + + /** + * 响应是否属于这次发出的请求:事务号、unit、功能码(异常响应为 fc | 0x80)都一致 + *

+ * j2mod 只在正常响应时核对事务号(异常响应在核对之前就抛出 ModbusSlaveException),unit 都不核对,所以这里逐项核对。 + * 发出的身份要在 setRequest 之后、execute 之前保存:execute 结束后 j2mod 会把请求的事务号自增,不能再用请求本身核对 + * + * @param response j2mod 响应(transaction.getResponse(),正常或异常响应) + * @param transactionId 发出的事务号 + * @param unitId 发出的 unit + * @param functionCode 发出的功能码 + * @return 是否属于这次请求 + */ + public static boolean isResponseOf(ModbusResponse response, int transactionId, int unitId, int functionCode) { + if (response == null) { + return false; + } + int expectedFunctionCode = response instanceof ExceptionResponse + ? functionCode | IotModbusCommonUtils.FC_EXCEPTION_MASK : functionCode; + return response.getTransactionID() == transactionId + && response.getUnitID() == unitId + && response.getFunctionCode() == expectedFunctionCode; + } + + /** + * 从 j2mod 解析出的响应还原响应 PDU,并核对功能码和响应格式 + * + * @param response j2mod 响应(正常或异常响应) + * @return 响应 PDU:异常 → [fc | 0x80, 异常码];正常 → 功能码 + 数据 + */ + public byte[] buildResponsePdu(ModbusResponse response) { + // 1.1 异常响应:功能码必须是 fc | 0x80(ExceptionResponse.getMessage 是 1 字节异常码) + if (response instanceof ExceptionResponse) { + if (response.getFunctionCode() != (getFunctionCode() | IotModbusCommonUtils.FC_EXCEPTION_MASK)) { + throw exception(MODBUS_RESPONSE_INVALID); + } + return IotModbusCommonUtils.buildExceptionPdu(getFunctionCode(), + ((ExceptionResponse) response).getExceptionCode()); + } + // 1.2 正常响应:功能码必须与请求一致 + if (response == null || response.getFunctionCode() != getFunctionCode()) { + throw exception(MODBUS_RESPONSE_INVALID); + } + // 2. 还原 PDU:功能码 + 标准响应类重新序列化的数据部分 + byte[] message = response.getMessage(); + byte[] responsePdu = new byte[1 + message.length]; + responsePdu[0] = (byte) getFunctionCode(); + System.arraycopy(message, 0, responsePdu, 1, message.length); + // 3. 核对响应格式(数量、回显) + if (!IotModbusCommonUtils.isResponseShapeValid(pdu, responsePdu)) { + throw exception(MODBUS_RESPONSE_INVALID); + } + return responsePdu; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java index 6c8a4be..074da3d 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java @@ -1,11 +1,27 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; +import io.vertx.core.Future; +import io.vertx.core.Promise; import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; + /** * IoT Modbus TCP 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 * @@ -14,13 +30,21 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { + /** + * 每个连接透传请求的在途 + 排队上限 + */ + public static final int MAX_RAW_OUTSTANDING = 32; + private final IotModbusTcpClientDownstreamHandler downstreamHandler; + private final IotModbusTcpClientConnectionManager connectionManager; public IotModbusTcpClientDownstreamSubscriber(IotModbusTcpClientProtocol protocol, IotModbusTcpClientDownstreamHandler downstreamHandler, + IotModbusTcpClientConnectionManager connectionManager, IotMessageBus messageBus) { super(protocol, messageBus); this.downstreamHandler = downstreamHandler; + this.connectionManager = connectionManager; } @Override @@ -28,4 +52,81 @@ public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolD downstreamHandler.handle(message); } + /** + * 透传写出:占用 raw 名额,提交到连接的有序 executeBlocking + *

+ * 提交之前的失败(PDU 不合法、未连接、raw 名额已满、提交或注册完成回调被同步拒绝)返回失败 Future,只记日志; + * 提交之后,executeBlocking 结束(成功、异常、j2mod 超时)就是事务终态:返回的 Future 随即成功完成, + * 结果由事务经 {@link #handleRawResult} 处理。raw 名额在事务终态回调里释放,事务只进入一次终态,名额也只释放一次 + */ + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + // 1.1 校验 PDU:与 biz 的 /send、encode 输出校验是同一份规则 + try { + IotDeviceMessageUtils.validateModbusRequestPdu(bytes); + } catch (IllegalArgumentException e) { + return Future.failedFuture(exception(MODBUS_PDU_INVALID, e.getMessage())); + } + // 1.2 获取连接和 slave ID(一个连接可挂多个从站) + ModbusConnection connection = connectionManager.getConnection(message.getDeviceId()); + Integer slaveId = connection != null ? connection.getSlaveId(message.getDeviceId()) : null; + if (slaveId == null) { + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + // 1.3 占用 raw 名额:尚未进入事务,失败 Future 只记日志 + if (!connection.tryAcquireRawSlot(MAX_RAW_OUTSTANDING)) { + return Future.failedFuture(exception(MODBUS_QUEUE_FULL)); + } + + // 2.1 构造事务:截止时间取本连接现有的 timeout(也是 j2mod 的读超时),提交前算一次; + // 终态回调先释放名额:事务的终态只生效一次(CAS),名额也只释放一次 + Promise terminal = Promise.promise(); + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(connection.getTimeout()); + IotModbusTx tx = IotModbusTx.ofClient(message, bytes, deadlineNanos, (responsePdu, failure) -> { + connection.releaseRawSlot(); + handleRawResult(terminal, message, source, responsePdu, failure); + }); + // 2.2 提交到有序 executeBlocking:结束即终态(这里不重连,重连由下一个任务开始时的守卫完成) + try { + IotModbusTcpClientUtils.executeRaw(connection, slaveId, tx).onComplete(ar -> { + if (ar.succeeded()) { + tx.complete(ar.result()); + } else { + log.warn("[handleRawMessage][设备 {} 透传失败]", message.getDeviceId(), ar.cause()); + tx.fail(IotModbusTcpClientUtils.toRawFailure(ar.cause())); + } + }); + } catch (RuntimeException e) { + // 2.3 提交或注册完成回调被同步拒绝(例如 Context 已关闭):先让返回的 Future 失败,再结束事务释放名额; + // 完成回调已经执行过(事务已是终态)时,两步都不生效,不会重复释放 + log.warn("[handleRawMessage][设备 {} 透传无法提交到连接的 Context]", message.getDeviceId(), e); + terminal.tryFail(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + tx.fail(DEVICE_OFFLINE_OR_WRITE_FAILED); + } + return terminal.future(); + } + + /** + * 透传事务终态:先完成 Future,再处理结果 + *

+ * 设备的实际响应(包括异常响应)经 {@link #replyRawResult} 回复给 biz; + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法)只记日志,不给 biz 构造回复 + */ + private void handleRawResult(Promise terminal, IotDeviceMessage message, IotScriptSource source, + byte[] responsePdu, ErrorCode failure) { + terminal.tryComplete(); + if (failure != null || ArrayUtil.isEmpty(responsePdu)) { + ErrorCode code = failure != null ? failure : MODBUS_RESPONSE_INVALID; + log.warn("[handleRawResult][下行({}) 事务失败 code({}) msg({})]", message.getRequestId(), + code.getCode(), code.getMsg()); + return; + } + replyRawResult(message, source, responsePdu, IotModbusCommonUtils.getRawResponseError(responsePdu)); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java index 2b7e9c2..a5a2e99 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java @@ -30,7 +30,7 @@ public class IotModbusTcpClientUpstreamHandler { } /** - * 处理 Modbus 读取结果 + * 处理 Modbus 读取结果:发送到消息总线会阻塞,由轮询调度器提交到连接的 worker 队列上执行 * * @param config 设备配置 * @param point 点位配置 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java index bfdacd0..c16bf46 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java @@ -3,12 +3,16 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; import cn.hutool.core.util.ObjUtil; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; import com.ghgande.j2mod.modbus.net.TCPMasterConnection; import io.vertx.core.Context; import io.vertx.core.Future; import io.vertx.core.Vertx; +import lombok.AccessLevel; import lombok.Data; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; @@ -18,6 +22,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_DISCONNECTED; /** * IoT Modbus TCP 连接管理器 @@ -232,6 +241,8 @@ public class IotModbusTcpClientConnectionManager { if (connection == null) { return; } + // 先标记失效:已经排进有序队列、尚未执行的任务直接 115,不会在被关闭的连接上重新 connect() + connection.setRetired(true); try { if (connection.getTcpConnection() != null) { @@ -288,6 +299,35 @@ public class IotModbusTcpClientConnectionManager { * Vert.x Context,用于 executeBlocking 执行 Modbus 操作,保证同一连接的操作串行执行 */ private Context context; + /** + * 本连接读取结果的上报队列({@link #context} 上的有序有界 worker 队列,随 {@link #setContext} 创建): + * 点位翻译、发送到消息总线会阻塞,交给 worker 按读取完成的顺序执行,不占用 context 的 event loop; + * 只在 context 线程上提交(executeBlocking 的结果回调就在它上面) + */ + @Setter(AccessLevel.NONE) + private IotWorkerTaskQueue upstreamQueue; + + /** + * 是否已失效:closeConnection 移出连接池时置为 true;之后开始执行的任务直接 115 + */ + private volatile boolean retired; + + /** + * 透传请求的在途 + 排队计数(只计 raw;轮询和标准读写不计入) + */ + private final AtomicInteger rawOutstanding = new AtomicInteger(); + + /** + * 设置执行 Modbus 操作的 Context,同时创建它上面的上报队列 + * + * @param context Vert.x Context + * @return this + */ + public ModbusConnection setContext(Context context) { + this.context = context; + this.upstreamQueue = new IotWorkerTaskQueue(context); + return this; + } public Integer addDevice(Long deviceId, Integer slaveId) { return deviceSlaveMap.putIfAbsent(deviceId, slaveId); @@ -306,12 +346,75 @@ public class IotModbusTcpClientConnectionManager { } /** - * 执行 Modbus 读取操作(阻塞方式,在 Vert.x worker 线程执行) + * 执行标准 Modbus 操作(轮询读取、标准写入;阻塞方式,在 Vert.x worker 线程执行) + *

+ * 与原逻辑一致:操作失败(包括读超时)只抛出,不关闭这个可能被多个从站共用的物理连接, + * 未连接时由 j2mod 的 ModbusTCPTransaction.execute 自己 connect。 + * 只多一个检查:已被 closeConnection 移出连接池(retired)时,排队中的任务不再执行,直接 115,不会重新连上被关闭的连接 */ - public Future executeBlocking(java.util.function.Function operation) { + public Future executeBlocking(Function operation) { // ordered=true 保证同一 Context 的操作串行执行,不同连接之间可并行 - return context.executeBlocking(() -> operation.apply(tcpConnection), true); + return context.executeBlocking(() -> { + if (retired) { + throw exception(MODBUS_DISCONNECTED); + } + return operation.apply(tcpConnection); + }, true); } + + /** + * 执行透传操作(阻塞方式,在 Vert.x worker 线程执行;与标准操作排在同一个有序队列里) + *

+ * 只有透传使用的传输失效隔离,在有序边界内完成(不在结果投递或 onComplete 之后重连): + * 1. 已被 closeConnection 移出连接池(retired):排队中的任务不再执行,直接 115; + * 2. 之前的透传因传输失败关闭了连接:本任务开始时用现有的 connect() 恢复; + * 3. 传输失败(读超时、IO 异常):在同一个有序任务里先关闭连接,再抛出异常,迟到的响应不会被下一个事务读到; + * 设备异常响应(ModbusSlaveException)说明链路正常,不关闭、不重连 + */ + public Future executeRawBlocking(Function operation) { + return context.executeBlocking(() -> { + // 1. 连接已被 closeConnection 移出连接池:排队中的任务不再执行 + if (retired) { + throw exception(MODBUS_DISCONNECTED); + } + // 2. 之前的透传因传输失败关闭了连接:在本任务里用现有的 connect() 恢复 + // (j2mod 的 ModbusTCPTransaction.execute 也会在未连接时 connect;这里先连上,它看到已连接就不会再连一次) + if (!tcpConnection.isConnected()) { + tcpConnection.connect(); + } + // 3. 执行操作:传输失败时先让连接失效,之后的任务开始时才会重连 + try { + return operation.apply(tcpConnection); + } catch (RuntimeException e) { + if (IotModbusTcpClientUtils.isTransportFailure(e)) { + tcpConnection.close(); + } + throw e; + } + }, true); + } + + /** + * 占用一个透传名额 + * + * @param max 每个连接的上限 + * @return 是否成功;false 时调用方直接返回 113 失败,不进入 executeBlocking + */ + public boolean tryAcquireRawSlot(int max) { + if (rawOutstanding.incrementAndGet() <= max) { + return true; + } + rawOutstanding.decrementAndGet(); + return false; + } + + /** + * 释放一个透传名额(透传任务到达终态时调用) + */ + public void releaseRawSlot() { + rawOutstanding.decrementAndGet(); + } + } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java index fba2f75..00477de 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java @@ -14,6 +14,7 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import java.util.*; +import java.util.concurrent.RejectedExecutionException; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; @@ -76,10 +77,11 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu Integer slaveId = connectionManager.getSlaveId(deviceId); Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); - // 3. 执行 Modbus 批量读取 + // 3. 执行 Modbus 批量读取:读取结果的上报交给连接的 worker 队列 IotModbusTcpClientUtils.read(connection, slaveId, segment.getFunctionCode(), segment.getStartAddress(), segment.getRegisterCount()) - .onSuccess(rawValues -> handleSegmentReadResult(config, segment, rawValues)) + .onSuccess(rawValues -> submitReadResult(connection, deviceId, + () -> handleSegmentReadResult(config, segment, rawValues))) .onFailure(e -> log.error("[pollTask][读取点位段失败, deviceId={}, segment={}]", deviceId, segment.getKey(), e)); } @@ -112,13 +114,38 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu Integer slaveId = connectionManager.getSlaveId(deviceId); Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); - // 3. 执行 Modbus 读取 + // 3. 执行 Modbus 读取:读取结果的上报交给连接的 worker 队列 IotModbusTcpClientUtils.read(connection, slaveId, point) - .onSuccess(rawValue -> upstreamHandler.handleReadResult(config, point, rawValue)) + .onSuccess(rawValue -> submitReadResult(connection, deviceId, + () -> upstreamHandler.handleReadResult(config, point, rawValue))) .onFailure(e -> log.error("[pollPoint][读取点位失败, deviceId={}, identifier={}]", deviceId, point.getIdentifier(), e)); } + /** + * 提交读取结果的上报(在连接的 context 上调用:读取结果的回调就在它上面) + *

+ * 点位翻译、发送到消息总线会阻塞,交给连接的有序 worker 队列,按读取完成的顺序执行,不占用 context 的 event loop; + * 读取本身、透传仍在连接原有的有序 executeBlocking 里,不受上报阻塞影响。队列已满时丢弃这次上报,下一轮轮询会再次读取 + * + * @param connection Modbus 连接 + * @param deviceId 设备 ID + * @param task 上报任务 + */ + private void submitReadResult(IotModbusTcpClientConnectionManager.ModbusConnection connection, Long deviceId, + Runnable task) { + connection.getUpstreamQueue().submit(() -> { + task.run(); + return null; + }).onFailure(e -> { + if (e instanceof RejectedExecutionException) { + log.warn("[submitReadResult][设备 {} 读取结果上报积压, 丢弃本次上报: {}]", deviceId, e.getMessage()); + return; + } + log.error("[submitReadResult][设备 {} 读取结果上报失败]", deviceId, e); + }); + } + private void handleSegmentReadResult(IotModbusDeviceConfigRespDTO config, ReadSegment segment, int[] rawValues) { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java index ea6e908..246ce6e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java @@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; -import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; @@ -21,6 +20,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.Iot import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import io.vertx.core.Vertx; @@ -67,6 +67,7 @@ public class IotModbusTcpServerProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private final Vertx vertx; /** * TCP Server @@ -98,7 +99,6 @@ public class IotModbusTcpServerProtocol implements IotProtocol { private final IotModbusTcpServerPendingRequestManager pendingRequestManager; private final IotModbusTcpServerUpstreamHandler upstreamHandler; private final IotModbusTcpServerPollScheduler pollScheduler; - private final IotDeviceMessageService messageService; public IotModbusTcpServerProtocol(ProtocolProperties properties) { IotModbusTcpServerConfig slaveConfig = properties.getModbusTcpServer(); @@ -119,20 +119,21 @@ public class IotModbusTcpServerProtocol implements IotProtocol { this.frameDecoder = new IotModbusFrameDecoder(slaveConfig.getCustomFunctionCode()); this.frameEncoder = new IotModbusFrameEncoder(slaveConfig.getCustomFunctionCode()); - // 初始化共享事务 ID 自增器(PollScheduler 和 DownstreamHandler 共用,避免 transactionId 冲突) + // 初始化共享事务 ID 自增器(所有连接的事务通道共用:轮询、标准写、透传都从通道分配,避免 transactionId 冲突) AtomicInteger transactionIdCounter = new AtomicInteger(0); // 初始化轮询调度器 this.pollScheduler = new IotModbusTcpServerPollScheduler( vertx, connectionManager, frameEncoder, pendingRequestManager, - slaveConfig.getRequestTimeout(), transactionIdCounter, configCacheService); + slaveConfig.getRequestTimeout(), configCacheService); // 初始化 Handler - this.messageService = SpringUtil.getBean(IotDeviceMessageService.class); + IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class); IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class); this.upstreamHandler = new IotModbusTcpServerUpstreamHandler( - deviceApi, this.messageService, frameEncoder, + deviceApi, messageService, frameEncoder, connectionManager, configCacheService, pendingRequestManager, - pollScheduler, deviceService, serverId); + pollScheduler, deviceService, serverId, + transactionIdCounter, slaveConfig.getRequestTimeout()); } @Override @@ -165,7 +166,7 @@ public class IotModbusTcpServerProtocol implements IotProtocol { // 2.2 启动 PendingRequest 清理定时器 requestCleanupTimerId = vertx.setPeriodic( slaveConfig.getRequestCleanupInterval(), - id -> pendingRequestManager.cleanupExpired()); + id -> cleanupExpiredRequests()); running = true; log.info("[start][IoT Modbus TCP Server 协议 {} 启动成功, serverId={}, port={}]", getId(), serverId, properties.getPort()); @@ -173,9 +174,9 @@ public class IotModbusTcpServerProtocol implements IotProtocol { // 3. 启动下行消息订阅 IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler( - connectionManager, configCacheService, frameEncoder, this.pollScheduler.getTransactionIdCounter()); + connectionManager, configCacheService, frameEncoder); this.downstreamSubscriber = new IotModbusTcpServerDownstreamSubscriber( - this, downstreamHandler, messageBus); + this, downstreamHandler, connectionManager, messageBus); downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT Modbus TCP Server 协议 {} 启动失败]", getId(), e); @@ -281,15 +282,11 @@ public class IotModbusTcpServerProtocol implements IotProtocol { return; } pollScheduler.stopPolling(info.getDeviceId()); - pendingRequestManager.removeDevice(info.getDeviceId()); + // 设备自己断开:排队中、在途的透传全部以 115 收尾,并只清理这条连接登记的待响应请求(不影响同一设备的新连接) + info.getChannel().onClose(); configCacheService.removeConfig(info.getDeviceId()); - // 发送设备下线消息 - try { - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); - messageService.sendDeviceMessage(offlineMessage, info.getProductKey(), info.getDeviceName(), serverId); - } catch (Exception ex) { - log.error("[handleConnection][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), ex); - } + // 发送设备下线消息:交给本连接的 worker 队列,排在已收到的轮询上报之后 + upstreamHandler.sendOfflineMessage(info); log.info("[handleConnection][连接关闭, deviceId={}, remoteAddress={}]", info.getDeviceId(), socket.remoteAddress()); }); @@ -301,6 +298,16 @@ public class IotModbusTcpServerProtocol implements IotProtocol { } /** + * 清理过期的待响应请求(定时调用) + */ + private void cleanupExpiredRequests() { + // 1. 待响应请求:保持原逻辑,到期移除、只记日志 + pendingRequestManager.cleanupExpired(); + // 2. 同一个清理周期里,各连接检查透传在途是否超时、排队是否超过截止时间(切回各自的 owner 收尾,不新增定时器) + connectionManager.getChannels().forEach(IotModbusTcpServerTransactionChannel::onCleanup); + } + + /** * 刷新已连接设备的配置(定时调用) */ private synchronized void refreshConfig() { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java index b5c48b2..3e07064 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java @@ -155,7 +155,7 @@ public class IotModbusFrameEncoder { // ==================== 帧封装 ==================== /** - * 将 PDU 封装为完整帧 + * 将 PDU 封装为完整帧(透传请求直接封装已校验的 PDU) * * @param slaveId 从站地址 * @param pdu PDU 数据(含 functionCode) @@ -163,7 +163,7 @@ public class IotModbusFrameEncoder { * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式可为 null) * @return 完整帧字节数组 */ - private byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { + public byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { if (format == IotModbusFrameFormatEnum.MODBUS_TCP) { return wrapTcpFrame(slaveId, pdu, transactionId != null ? transactionId : 0); } else { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java index eb4683f..c479064 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java @@ -12,10 +12,10 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotMo import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; import lombok.extern.slf4j.Slf4j; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; /** * IoT Modbus TCP Server 下行消息处理器 @@ -33,19 +33,12 @@ public class IotModbusTcpServerDownstreamHandler { private final IotModbusTcpServerConfigCacheService configCacheService; private final IotModbusFrameEncoder frameEncoder; - /** - * TCP 事务 ID 自增器(与 PollScheduler 共享) - */ - private final AtomicInteger transactionIdCounter; - public IotModbusTcpServerDownstreamHandler(IotModbusTcpServerConnectionManager connectionManager, IotModbusTcpServerConfigCacheService configCacheService, - IotModbusFrameEncoder frameEncoder, - AtomicInteger transactionIdCounter) { + IotModbusFrameEncoder frameEncoder) { this.connectionManager = connectionManager; this.configCacheService = configCacheService; this.frameEncoder = frameEncoder; - this.transactionIdCounter = transactionIdCounter; } /** @@ -109,12 +102,11 @@ public class IotModbusTcpServerDownstreamHandler { // 1.1 转换属性值为原始值 int[] rawValues = IotModbusCommonUtils.convertToRawValues(value, point); - // 1.2 确定帧格式和事务 ID + // 1.2 确定帧格式和事务 ID(事务号由连接的事务通道分配:跳过本连接仍占用的编号) IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat(); Assert.notNull(frameFormat, "连接帧格式不能为空"); - Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP - ? (transactionIdCounter.incrementAndGet() & 0xFFFF) - : null; + IotModbusTcpServerTransactionChannel channel = connInfo.getChannel(); + Integer transactionId = channel.allocateTransactionId(); int slaveId = connInfo.getSlaveId() != null ? connInfo.getSlaveId() : 1; // 1.3 编码写请求 byte[] data; @@ -139,8 +131,8 @@ public class IotModbusTcpServerDownstreamHandler { return; } - // 2. 发送消息 - connectionManager.sendToDevice(deviceId, data).onSuccess(v -> + // 2. 发送消息(用 connInfo 对应的连接写出;socket 写出成功即记成功,不等待设备回显) + channel.send(data).onSuccess(v -> log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]", deviceId, point.getIdentifier(), value) ).onFailure(e -> diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java index 1d36b69..25a7b82 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java @@ -1,11 +1,26 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; +import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import io.vertx.core.Future; +import io.vertx.core.Promise; import lombok.extern.slf4j.Slf4j; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_PDU_INVALID; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; + /** * IoT Modbus TCP Server 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 * @@ -15,12 +30,15 @@ import lombok.extern.slf4j.Slf4j; public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { private final IotModbusTcpServerDownstreamHandler downstreamHandler; + private final IotModbusTcpServerConnectionManager connectionManager; public IotModbusTcpServerDownstreamSubscriber(IotModbusTcpServerProtocol protocol, IotModbusTcpServerDownstreamHandler downstreamHandler, + IotModbusTcpServerConnectionManager connectionManager, IotMessageBus messageBus) { super(protocol, messageBus); this.downstreamHandler = downstreamHandler; + this.connectionManager = connectionManager; } @Override @@ -28,4 +46,56 @@ public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolD downstreamHandler.handle(message); } + /** + * 透传写出:提交透传事务到连接的事务通道 + *

+ * 提交之前的失败(PDU 不合法、未连接或未认证)返回失败 Future,只记日志; + * 提交之后,返回的 Future 在事务终态(响应 / 超时 / 断线 / 写失败 / 队列满)成功完成, + * 结果由事务经 {@link #handleRawResult} 处理 + */ + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + // 1.1 校验 PDU:与 biz 的 /send、encode 输出校验是同一份规则 + try { + IotDeviceMessageUtils.validateModbusRequestPdu(bytes); + } catch (IllegalArgumentException e) { + return Future.failedFuture(exception(MODBUS_PDU_INVALID, e.getMessage())); + } + // 1.2 获取连接(未连接、未认证的连接没有事务通道) + ConnectionInfo info = connectionManager.getConnectionInfoByDeviceId(message.getDeviceId()); + if (info == null || info.getChannel() == null) { + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + + // 2. 提交事务:终态时先完成 Future,再处理结果(设备响应含异常响应 → responsePdu;网关判定 → failure) + Promise terminal = Promise.promise(); + IotModbusTx tx = IotModbusTx.ofServer(info.getChannel(), bytes, message, + (responsePdu, failure) -> handleRawResult(terminal, message, source, responsePdu, failure)); + info.getChannel().submitRaw(tx); + return terminal.future(); + } + + /** + * 透传事务终态:先完成 Future,再处理结果 + *

+ * 设备的实际响应(包括异常响应)经 {@link #replyRawResult} 回复给 biz; + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法)只记日志,不给 biz 构造回复 + */ + private void handleRawResult(Promise terminal, IotDeviceMessage message, IotScriptSource source, + byte[] responsePdu, ErrorCode failure) { + terminal.tryComplete(); + if (failure != null || ArrayUtil.isEmpty(responsePdu)) { + ErrorCode code = failure != null ? failure : MODBUS_RESPONSE_INVALID; + log.warn("[handleRawResult][下行({}) 事务失败 code({}) msg({})]", message.getRequestId(), + code.getCode(), code.getMsg()); + return; + } + replyRawResult(message, source, responsePdu, IotModbusCommonUtils.getRawResponseError(responsePdu)); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java index db8a9fd..2dd0649 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java @@ -28,12 +28,18 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.Iot import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Vertx; import io.vertx.core.net.NetSocket; import lombok.extern.slf4j.Slf4j; import java.util.Map; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; @@ -64,6 +70,15 @@ public class IotModbusTcpServerUpstreamHandler { private final String serverId; + /** + * TCP 事务 ID 自增器(所有连接的事务通道共享) + */ + private final AtomicInteger transactionIdCounter; + /** + * 请求超时时间(毫秒) + */ + private final int requestTimeout; + public IotModbusTcpServerUpstreamHandler(IotDeviceCommonApi deviceApi, IotDeviceMessageService messageService, IotModbusFrameEncoder frameEncoder, @@ -72,7 +87,9 @@ public class IotModbusTcpServerUpstreamHandler { IotModbusTcpServerPendingRequestManager pendingRequestManager, IotModbusTcpServerPollScheduler pollScheduler, IotDeviceService deviceService, - String serverId) { + String serverId, + AtomicInteger transactionIdCounter, + int requestTimeout) { this.deviceApi = deviceApi; this.messageService = messageService; this.frameEncoder = frameEncoder; @@ -82,21 +99,25 @@ public class IotModbusTcpServerUpstreamHandler { this.pollScheduler = pollScheduler; this.deviceService = deviceService; this.serverId = serverId; + this.transactionIdCounter = transactionIdCounter; + this.requestTimeout = requestTimeout; } // ========== 帧处理入口 ========== /** - * 处理帧 + * 处理帧(连接的 owner 上调用:RecordParser 回调) */ public void handleFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) { if (frame == null) { return; } - // 1. 异常响应 + // 1. 异常响应:只尝试匹配透传请求,让它以设备的异常响应收尾(例如 83 02); + // 标准轮询的异常响应照旧丢弃,它的待响应请求等到期清理 if (frame.isException()) { log.warn("[handleFrame][设备异常响应, slaveId={}, FC={}, exceptionCode={}]", frame.getSlaveId(), frame.getFunctionCode(), frame.getExceptionCode()); + handlePollingResponse(socket, frame, frameFormat); return; } @@ -192,13 +213,19 @@ public class IotModbusTcpServerUpstreamHandler { return; } - // 3.1 注册连接 + // 3.1 注册连接:事务通道在本连接的 owner(当前 event loop Context)上创建 + Context owner = Vertx.currentContext(); + Assert.notNull(owner, "事务通道必须在连接的 event loop 上创建"); + IotModbusTcpServerTransactionChannel channel = new IotModbusTcpServerTransactionChannel(owner, socket, + device.getId(), frame.getSlaveId(), frameFormat, frameEncoder, pendingRequestManager, + transactionIdCounter, requestTimeout); ConnectionInfo connectionInfo = new ConnectionInfo() .setDeviceId(device.getId()) .setProductKey(deviceInfo.getProductKey()) .setDeviceName(deviceInfo.getDeviceName()) .setSlaveId(frame.getSlaveId()) - .setFrameFormat(frameFormat); + .setFrameFormat(frameFormat) + .setChannel(channel); connectionManager.registerConnection(socket, connectionInfo); // 3.2 发送上线消息 IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); @@ -232,6 +259,9 @@ public class IotModbusTcpServerUpstreamHandler { /** * 处理轮询响应(云端轮询模式) + *

+ * 匹配待响应请求、透传收尾留在连接的 owner 上(与发送、清理同一个线程); + * 标准轮询的点位翻译、发送到消息总线会阻塞,交给本连接(事务通道)的 worker 队列按收到的顺序执行 */ private void handlePollingResponse(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) { @@ -242,39 +272,94 @@ public class IotModbusTcpServerUpstreamHandler { return; } - // 2.1 匹配 PendingRequest + // 2.1 匹配 PendingRequest:只匹配这条连接自己发出的请求 PendingRequest request = pendingRequestManager.matchResponse( - info.getDeviceId(), frame, frameFormat); + info.getDeviceId(), frame, frameFormat, info.getChannel()); if (request == null) { log.debug("[handlePollingResponse][未匹配到 PendingRequest, deviceId={}, FC={}]", info.getDeviceId(), frame.getFunctionCode()); return; } - // 2.2 提取寄存器值 + // 2.2 透传请求:交给事务通道进入唯一终态,不做点位处理 + if (request.getTx() != null) { + info.getChannel().onRawResponse(frame, request); + return; + } + + // 3. 标准轮询:交给本连接的 worker 队列;队列已满时丢弃这次上报(下一轮轮询会再次读取),不断开连接 + info.getChannel().getUpstreamQueue().submit(() -> { + handlePollingResult(info, frame, request); + return null; + }).onFailure(e -> { + if (e instanceof RejectedExecutionException) { + log.warn("[handlePollingResponse][轮询上报积压, 丢弃本次上报, deviceId={}, identifier={}, 错误: {}]", + info.getDeviceId(), request.getIdentifier(), e.getMessage()); + return; + } + log.error("[handlePollingResponse][轮询上报失败, deviceId={}, identifier={}]", + info.getDeviceId(), request.getIdentifier(), e); + }); + } + + /** + * 处理标准轮询的响应(worker 上执行):点位翻译 → thing.property.post 发送到消息总线 + * + * @param info 连接信息 + * @param frame 响应帧 + * @param request 匹配到的待响应请求 + */ + private void handlePollingResult(ConnectionInfo info, IotModbusFrame frame, PendingRequest request) { + // 1.1 提取寄存器值 int[] rawValues = IotModbusCommonUtils.extractValues(frame); if (rawValues == null) { - log.warn("[handlePollingResponse][提取寄存器值失败, deviceId={}, identifier={}]", + log.warn("[handlePollingResult][提取寄存器值失败, deviceId={}, identifier={}]", info.getDeviceId(), request.getIdentifier()); return; } - // 2.3 查找点位配置 + // 1.2 查找点位配置 IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(info.getDeviceId()); IotModbusPointRespDTO point = IotModbusCommonUtils.findPointById(config, request.getPointId()); if (point == null) { return; } - // 3.1 转换原始值为物模型属性值(点位翻译) + // 2.1 转换原始值为物模型属性值(点位翻译) Object convertedValue = IotModbusCommonUtils.convertToPropertyValue(rawValues, point); - // 3.2 构造属性上报消息 + // 2.2 构造属性上报消息 Map params = MapUtil.of(request.getIdentifier(), convertedValue); IotDeviceMessage message = IotDeviceMessage.requestOf( IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params); - // 4. 发送到消息总线 + // 3. 发送到消息总线 messageService.sendDeviceMessage(message, info.getProductKey(), info.getDeviceName(), serverId); - log.debug("[handlePollingResponse][设备={}, 属性={}, 原始值={}, 转换值={}]", + log.debug("[handlePollingResult][设备={}, 属性={}, 原始值={}, 转换值={}]", info.getDeviceId(), request.getIdentifier(), rawValues, convertedValue); } + // ========== 连接关闭 ========== + + /** + * 发送设备下线消息(连接的 owner 上调用:closeHandler) + *

+ * 发送会阻塞,作为本连接的最后一个任务交给 worker 队列,排在已收到的轮询上报之后; + * 执行时设备已在新连接上线就不再发送,发送期间设备在新连接上线则补发上线消息(与 TCP 协议一致) + * + * @param info 已移除的连接信息 + * @return 发送结果(在 owner 上完成);失败只记日志 + */ + public Future sendOfflineMessage(ConnectionInfo info) { + return info.getChannel().getUpstreamQueue().submitLast(() -> { + if (connectionManager.getConnectionInfoByDeviceId(info.getDeviceId()) != null) { + return null; + } + messageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), + info.getProductKey(), info.getDeviceName(), serverId); + if (connectionManager.getConnectionInfoByDeviceId(info.getDeviceId()) != null) { + messageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), + info.getProductKey(), info.getDeviceName(), serverId); + } + return null; + }).onFailure(e -> log.error("[sendOfflineMessage][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), e)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java index 4c01f6a..97f20b2 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java @@ -64,6 +64,11 @@ public class IotModbusTcpServerConnectionManager { */ private IotModbusFrameFormatEnum frameFormat; + /** + * 本连接的事务通道:认证注册时在连接的 owner 上创建;未认证连接没有 + */ + private IotModbusTcpServerTransactionChannel channel; + } /** @@ -75,7 +80,8 @@ public class IotModbusTcpServerConnectionManager { if (oldSocket != null && oldSocket != socket) { log.info("[registerConnection][设备 {} 存在旧连接, 关闭旧 socket, oldRemote={}, newRemote={}]", info.getDeviceId(), oldSocket.remoteAddress(), socket.remoteAddress()); - connectionMap.remove(oldSocket); + // 旧 socket 的 closeHandler 之后拿不到 info(提前返回),所以在这里收尾旧通道 + notifyChannelClosed(connectionMap.remove(oldSocket)); try { oldSocket.close(); } catch (Exception e) { @@ -84,8 +90,11 @@ public class IotModbusTcpServerConnectionManager { } } - // 注册新连接 - connectionMap.put(socket, info); + // 注册新连接(同一个 socket 重复认证时,被替换的旧 info 的通道也要收尾) + ConnectionInfo previous = connectionMap.put(socket, info); + if (previous != null && previous.getChannel() != info.getChannel()) { + notifyChannelClosed(previous); + } deviceSocketMap.put(info.getDeviceId(), socket); log.info("[registerConnection][设备 {} 连接已注册, remoteAddress={}]", info.getDeviceId(), socket.remoteAddress()); @@ -114,6 +123,19 @@ public class IotModbusTcpServerConnectionManager { } /** + * 获取所有已认证连接的事务通道 + */ + public List getChannels() { + List channels = new ArrayList<>(); + for (ConnectionInfo info : connectionMap.values()) { + if (info.getChannel() != null) { + channels.add(info.getChannel()); + } + } + return channels; + } + + /** * 移除连接 */ public ConnectionInfo removeConnection(NetSocket socket) { @@ -139,7 +161,9 @@ public class IotModbusTcpServerConnectionManager { if (socket == null) { return; } - connectionMap.remove(socket); + // 在 ConnectionInfo 还能取到时通知通道(切回 owner 收尾,幂等),然后照旧 close: + // 映射已移除,closeHandler 照旧提前返回,不发普通下线消息 + notifyChannelClosed(connectionMap.remove(socket)); try { socket.close(); log.info("[closeConnection][设备 {} 连接已关闭]", deviceId); @@ -178,6 +202,8 @@ public class IotModbusTcpServerConnectionManager { public void closeAll() { // 1. 先复制再清空,避免 closeHandler 回调时并发修改 List sockets = new ArrayList<>(connectionMap.keySet()); + // 清空前逐个通知通道(与 closeConnection 同理:closeHandler 之后拿不到 info) + connectionMap.values().forEach(this::notifyChannelClosed); connectionMap.clear(); deviceSocketMap.clear(); // 2. 关闭所有 socket(closeHandler 中 removeConnection 发现 map 为空会安全跳过) @@ -190,4 +216,13 @@ public class IotModbusTcpServerConnectionManager { } } + /** + * 通知连接的事务通道已关闭(任意线程:通道内部切回自己的 owner;onClose 可以重复调用) + */ + private void notifyChannelClosed(ConnectionInfo info) { + if (info != null && info.getChannel() != null) { + info.getChannel().onClose(); + } + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java index 9bac7f8..0c722a4 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; import lombok.AllArgsConstructor; @@ -17,9 +18,12 @@ import java.util.concurrent.ConcurrentLinkedDeque; /** * IoT Modbus TCP Server 待响应请求管理器 *

- * 管理轮询下发的请求,用于匹配设备响应: + * 管理轮询、透传下发的请求,用于匹配设备响应: * - TCP 模式:按 transactionId 精确匹配 * - RTU 模式:按 slaveId + functionCode FIFO 匹配 + *

+ * 只匹配收到响应的这条连接自己发出的请求({@link PendingRequest#getChannel()} 的引用身份): + * 同一设备的新连接替换旧连接后,旧连接的条目不能抢走新连接的响应 * * @author 芋道源码 */ @@ -47,6 +51,14 @@ public class IotModbusTcpServerPendingRequestManager { private int registerCount; private Integer transactionId; private long expireAt; + /** + * 发出它的连接通道:登记、发送、匹配、清理都用这条连接的身份 + */ + private IotModbusTcpServerTransactionChannel channel; + /** + * 透传事务;标准轮询为 null + */ + private IotModbusTx tx; } @@ -60,14 +72,19 @@ public class IotModbusTcpServerPendingRequestManager { /** * 匹配响应(TCP 模式按 transactionId,RTU 模式按 FIFO) + *

+ * 异常响应只用来收尾透传请求:标准轮询的异常响应照旧丢弃,它的条目等到期清理。 + * RTU 模式的异常响应保持 FIFO,不会跳过排在前面的标准轮询去配给后面的透传 * * @param deviceId 设备 ID * @param frame 收到的响应帧 * @param frameFormat 帧格式 + * @param channel 收到响应的这条连接的事务通道 * @return 匹配到的 PendingRequest,没有匹配返回 null */ public PendingRequest matchResponse(Long deviceId, IotModbusFrame frame, - IotModbusFrameFormatEnum frameFormat) { + IotModbusFrameFormatEnum frameFormat, + IotModbusTcpServerTransactionChannel channel) { Deque queue = pendingRequests.get(deviceId); if (CollUtil.isEmpty(queue)) { return null; @@ -75,37 +92,61 @@ public class IotModbusTcpServerPendingRequestManager { // TCP 模式:按 transactionId 精确匹配 if (frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP && frame.getTransactionId() != null) { - return matchByTransactionId(queue, frame.getTransactionId()); + return matchByTransactionId(queue, channel, frame); } // RTU 模式:FIFO,匹配 slaveId + functionCode + registerCount + // 异常响应(解码器已还原原功能码)的 pdu[0] 是异常码,不是 byteCount,所以跳过数量校验,只按 unit + 原功能码找排在最前面的请求 + if (frame.isException()) { + return matchExceptionByFifo(queue, channel, frame.getSlaveId(), frame.getFunctionCode()); + } int responseRegisterCount = IotModbusCommonUtils.extractRegisterCountFromResponse(frame); - return matchByFifo(queue, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); + return matchByFifo(queue, channel, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); } /** * 按 transactionId 匹配 + *

+ * 透传请求另外核对 unit 与功能码(异常响应的 functionCode 已由解码器还原),不一致就不出队,等它到期;标准轮询保持只按 transactionId */ - private PendingRequest matchByTransactionId(Deque queue, int transactionId) { + private PendingRequest matchByTransactionId(Deque queue, + IotModbusTcpServerTransactionChannel channel, + IotModbusFrame frame) { + int transactionId = frame.getTransactionId(); Iterator it = queue.iterator(); while (it.hasNext()) { PendingRequest req = it.next(); - if (req.getTransactionId() != null && req.getTransactionId() == transactionId) { - it.remove(); - return req; + if (req.getChannel() != channel + || req.getTransactionId() == null || req.getTransactionId() != transactionId) { + continue; + } + if (req.getTx() == null) { + // 标准轮询:异常响应照旧丢弃,不出队 + if (frame.isException()) { + return null; + } + } else if (req.getSlaveId() != frame.getSlaveId() || req.getFunctionCode() != frame.getFunctionCode()) { + return null; } + it.remove(); + return req; } return null; } /** * 按 FIFO 匹配(slaveId + functionCode + registerCount) + *

+ * 这是原有的 RTU 匹配逻辑,透传请求只是加入同一个 FIFO,有原有的限制: + * RTU 帧没有事务号,同 unit、同功能码、同数量的迟到响应(例如到期清理后才到的轮询响应), + * 或者同功能码的标准写(不登记待响应请求)的回显 / 异常响应,都可能被配给排在前面的另一个请求 */ - private PendingRequest matchByFifo(Deque queue, int slaveId, int functionCode, - int responseRegisterCount) { + private PendingRequest matchByFifo(Deque queue, IotModbusTcpServerTransactionChannel channel, + int slaveId, int functionCode, int responseRegisterCount) { Iterator it = queue.iterator(); while (it.hasNext()) { PendingRequest req = it.next(); - if (req.getSlaveId() == slaveId + if (req.getChannel() == channel + && req.getSlaveId() == slaveId && req.getFunctionCode() == functionCode && (responseRegisterCount <= 0 || req.getRegisterCount() == responseRegisterCount)) { it.remove(); @@ -116,6 +157,53 @@ public class IotModbusTcpServerPendingRequestManager { } /** + * 按 FIFO 匹配 RTU 异常响应(slaveId + 原功能码,异常响应没有数量可核对) + *

+ * 只看本连接排在最前面的同 unit、同功能码的请求,不跳过它: + * 1. 它是透传请求:出队,由设备的异常响应收尾; + * 2. 它是标准轮询:照旧丢弃这个异常响应,轮询请求不出队、等到期清理;排在它后面的透传也不出队(按原有的 FIFO,异常响应属于更早的请求) + */ + private PendingRequest matchExceptionByFifo(Deque queue, IotModbusTcpServerTransactionChannel channel, + int slaveId, int functionCode) { + Iterator it = queue.iterator(); + while (it.hasNext()) { + PendingRequest req = it.next(); + if (req.getChannel() != channel || req.getSlaveId() != slaveId || req.getFunctionCode() != functionCode) { + continue; + } + if (req.getTx() == null) { + return null; + } + it.remove(); + return req; + } + return null; + } + + /** + * 本连接是否有占用该 transactionId 的待响应请求(分配事务号时跳过) + * + * @param deviceId 设备 ID + * @param channel 连接的事务通道 + * @param transactionId 事务号 + * @return 是否占用 + */ + public boolean containsTransactionId(Long deviceId, IotModbusTcpServerTransactionChannel channel, + int transactionId) { + Deque queue = pendingRequests.get(deviceId); + if (CollUtil.isEmpty(queue)) { + return false; + } + for (PendingRequest req : queue) { + if (req.getChannel() == channel + && req.getTransactionId() != null && req.getTransactionId() == transactionId) { + return true; + } + } + return false; + } + + /** * 清理过期请求 */ public void cleanupExpired() { @@ -138,6 +226,26 @@ public class IotModbusTcpServerPendingRequestManager { } /** + * 移除指定的待响应请求:按引用(PendingRequest 是 @Data,equals 按字段比较),已被匹配或清理时无副作用 + */ + public void removeRequest(PendingRequest request) { + Deque queue = pendingRequests.get(request.getDeviceId()); + if (queue != null) { + queue.removeIf(req -> req == request); + } + } + + /** + * 清理指定连接的所有待响应请求:只删这条连接自己的条目,不影响同一设备新连接的条目 + */ + public void removeChannel(Long deviceId, IotModbusTcpServerTransactionChannel channel) { + Deque queue = pendingRequests.get(deviceId); + if (queue != null) { + queue.removeIf(req -> req.getChannel() == channel); + } + } + + /** * 清理指定设备的所有待响应请求 */ public void removeDevice(Long deviceId) { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java index 4660f8e..64804b5 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java @@ -11,11 +11,8 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotMo import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; import io.vertx.core.Vertx; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import java.util.concurrent.atomic.AtomicInteger; - /** * IoT Modbus TCP Server 轮询调度器:编码读请求帧,通过 TCP 连接发送到设备,注册 PendingRequest 等待响应 * @@ -29,25 +26,18 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu private final IotModbusTcpServerPendingRequestManager pendingRequestManager; private final IotModbusTcpServerConfigCacheService configCacheService; private final int requestTimeout; - /** - * TCP 事务 ID 自增器(与 DownstreamHandler 共享) - */ - @Getter - private final AtomicInteger transactionIdCounter; public IotModbusTcpServerPollScheduler(Vertx vertx, IotModbusTcpServerConnectionManager connectionManager, IotModbusFrameEncoder frameEncoder, IotModbusTcpServerPendingRequestManager pendingRequestManager, int requestTimeout, - AtomicInteger transactionIdCounter, IotModbusTcpServerConfigCacheService configCacheService) { super(vertx); this.connectionManager = connectionManager; this.frameEncoder = frameEncoder; this.pendingRequestManager = pendingRequestManager; this.requestTimeout = requestTimeout; - this.transactionIdCounter = transactionIdCounter; this.configCacheService = configCacheService; } @@ -84,10 +74,9 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu Integer slaveId = connection.getSlaveId(); Assert.notNull(connection.getSlaveId(), "设备 {} 的 slaveId 不能为空", deviceId); - // 3.1 编码读请求 - Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP - ? (transactionIdCounter.incrementAndGet() & 0xFFFF) - : null; + // 3.1 编码读请求(事务号由连接的事务通道分配:跳过本连接仍占用的编号) + IotModbusTcpServerTransactionChannel channel = connection.getChannel(); + Integer transactionId = channel.allocateTransactionId(); byte[] data = frameEncoder.encodeReadRequest(slaveId, point.getFunctionCode(), point.getRegisterAddress(), point.getRegisterCount(), frameFormat, transactionId); // 3.2 注册 PendingRequest @@ -96,10 +85,11 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu slaveId, point.getFunctionCode(), point.getRegisterAddress(), point.getRegisterCount(), transactionId, - System.currentTimeMillis() + requestTimeout); + System.currentTimeMillis() + requestTimeout, + channel, null); pendingRequestManager.addRequest(pendingRequest); - // 3.3 发送读请求 - connectionManager.sendToDevice(deviceId, data).onSuccess(v -> + // 3.3 发送读请求(与登记用同一条连接) + channel.send(data).onSuccess(v -> log.debug("[pollPoint][设备={}, 点位={}, FC={}, 地址={}, 数量={}]", deviceId, point.getIdentifier(), point.getFunctionCode(), point.getRegisterAddress(), point.getRegisterCount()) diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannel.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannel.java new file mode 100644 index 0000000..9ba0684 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannel.java @@ -0,0 +1,356 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; + +/** + * IoT Modbus TCP Server 单个连接的事务通道 + *

+ * 认证注册时捕获这条连接的 socket 和 owner(event loop Context):标准轮询、标准写、透传的登记、发送、匹配、清理都用这一个连接身份, + * 不会在 A 连接上登记、从 B 连接发出。 + *

    + *
  • 标准轮询、标准写:只通过 {@link #allocateTransactionId()} 取事务号、{@link #send} 写出,其余保持原逻辑: + * 不排队、不受透传名额限制,超时只由清理定时器移除、不断开连接,标准写不等待回显;
  • + *
  • 透传:排队 + 在途不超过 {@link #MAX_RAW_OUTSTANDING};TCP 帧格式可以多个同时在途, + * RTU 帧格式同一时刻只有 1 个透传在途(轮询、标准写不受此限制);实际发送时才登记待响应请求, + * 排队过期、在途超时、写失败、连接关闭都只进入一次终态,同时释放名额和待响应请求。
  • + *
+ * 透传状态(queue / sent / closed)只在 owner 上读写,不加锁:submitRaw / onCleanup / onClose 可能来自总线消费线程、 + * 清理定时器、配置刷新定时器、stop 线程,一律先 runOnContext 切回 owner;onRawResponse(RecordParser 回调)、写出回调本来就在 owner 上 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerTransactionChannel { + + /** + * 每个连接透传请求的排队 + 在途上限 + */ + public static final int MAX_RAW_OUTSTANDING = 32; + + /** + * 本连接的 event loop Context:透传状态只在它上面读写 + */ + private final Context owner; + /** + * 认证注册时捕获的连接:标准轮询、标准写、透传都从它写出 + */ + private final NetSocket socket; + /** + * 设备编号 + */ + private final Long deviceId; + /** + * 从站地址 + */ + private final int slaveId; + /** + * 帧格式:TCP 可以多个透传同时在途(按事务号匹配),RTU 同一时刻只有 1 个透传在途(按 FIFO 匹配) + */ + private final IotModbusFrameFormatEnum frameFormat; + /** + * 帧编码器:把 PDU 封装成 TCP(MBAP)/ RTU(CRC)帧 + */ + private final IotModbusFrameEncoder frameEncoder; + /** + * 协议共享的待响应请求管理器:透传实际发送时登记,终态时按引用移除 + */ + private final IotModbusTcpServerPendingRequestManager pendingRequestManager; + /** + * 协议共享的事务号计数器(16 位) + */ + private final AtomicInteger transactionIdCounter; + /** + * 请求超时时间(毫秒):透传排队截止、待响应到期都按它计算 + */ + @Getter + private final int requestTimeout; + /** + * 本连接的 worker 队列(owner 上的有序有界队列,只在 owner 上提交): + * 标准轮询响应的点位翻译、发送到消息总线,以及断开后的下线消息会阻塞,按收到的顺序交给 worker 执行,不占用 owner + */ + @Getter + private final IotWorkerTaskQueue upstreamQueue; + + /** + * 排队中的透传 + */ + private final Deque queue = new ArrayDeque<>(); + /** + * 在途的透传 → 它登记的待响应请求 + */ + private final Map sent = new HashMap<>(); + /** + * 连接已关闭:不再发送透传 + */ + private boolean closed; + + public IotModbusTcpServerTransactionChannel(Context owner, NetSocket socket, + Long deviceId, int slaveId, IotModbusFrameFormatEnum frameFormat, + IotModbusFrameEncoder frameEncoder, + IotModbusTcpServerPendingRequestManager pendingRequestManager, + AtomicInteger transactionIdCounter, int requestTimeout) { + this.owner = owner; + this.socket = socket; + this.deviceId = deviceId; + this.slaveId = slaveId; + this.frameFormat = frameFormat; + this.frameEncoder = frameEncoder; + this.pendingRequestManager = pendingRequestManager; + this.transactionIdCounter = transactionIdCounter; + this.requestTimeout = requestTimeout; + this.upstreamQueue = new IotWorkerTaskQueue(owner); + } + + // ========== 标准轮询、标准写的入口(任意线程) ========== + + /** + * 分配事务号:RTU 帧格式返回 null + *

+ * 复用协议共享的计数器(16 位),跳过本连接仍有待响应请求(轮询、在途透传)占用的编号。 + * 标准写不登记待响应请求,它的编号不受保护:计数器回绕时,仍在路上的写回显可能与新编号相同 + * + * @return 事务号 + */ + public Integer allocateTransactionId() { + if (!isTcpFormat()) { + return null; + } + int id; + int attempts = 0; + do { + id = transactionIdCounter.incrementAndGet() & 0xFFFF; + } while (pendingRequestManager.containsTransactionId(deviceId, this, id) && ++attempts <= 0xFFFF); + return id; + } + + /** + * 写出到本连接捕获的 socket + * + * @param data 完整帧 + * @return 写出结果:只代表 socket 写出成功或失败,不代表设备已经响应 + */ + public Future send(byte[] data) { + return socket.write(Buffer.buffer(data)); + } + + // ========== 透传的入口(任意线程,先切回 owner) ========== + + /** + * 提交透传事务 + * + * @param tx 事务 + */ + public void submitRaw(IotModbusTx tx) { + owner.runOnContext(v -> submitRaw0(tx)); + } + + /** + * 清理周期(复用待响应请求的清理定时器):在途透传是否超时、排队透传是否超过截止时间 + */ + public void onCleanup() { + owner.runOnContext(v -> onCleanup0()); + } + + /** + * 连接关闭(closeHandler,或 ConnectionManager 的 closeConnection / closeAll / 旧连接替换);可以重复调用 + */ + public void onClose() { + owner.runOnContext(v -> onClose0()); + } + + /** + * 收到匹配到透传请求的响应(owner:handlePollingResponse 先 matchResponse 出队,再调用这里) + * + * @param frame 响应帧 + * @param request 匹配到的待响应请求 + */ + public void onRawResponse(IotModbusFrame frame, PendingRequest request) { + // 1. 已超时、写失败或已关闭:迟到的响应丢弃 + IotModbusTx tx = request.getTx(); + if (sent.remove(tx) == null) { + log.debug("[onRawResponse][设备 {} 迟到的透传响应, 丢弃, FC={}]", deviceId, frame.getFunctionCode()); + return; + } + // 2. 进入终态:异常响应原样回给透传请求;正常响应核对格式 + byte[] responsePdu = IotModbusCommonUtils.buildResponsePdu(frame); + if (!frame.isException() && !IotModbusCommonUtils.isResponseShapeValid(tx.getPdu(), responsePdu)) { + log.warn("[onRawResponse][设备 {} 透传响应格式与请求不一致, FC={}]", deviceId, frame.getFunctionCode()); + tx.fail(MODBUS_RESPONSE_INVALID); + } else { + tx.complete(responsePdu); + } + // 3. 释放了在途位置:继续发送排队中的透传 + pump(); + } + + // ========== owner 上的处理 ========== + + /** + * owner:透传入队;连接已关闭、排队 + 在途已满时直接失败 + */ + private void submitRaw0(IotModbusTx tx) { + // 1. 校验:连接已关闭、名额已满 + if (closed) { + tx.fail(MODBUS_DISCONNECTED); + return; + } + if (queue.size() + sent.size() >= MAX_RAW_OUTSTANDING) { + tx.fail(MODBUS_QUEUE_FULL); + return; + } + // 2. 入队,尝试发送 + queue.add(tx); + pump(); + } + + /** + * owner:按帧格式允许的在途数量,依次发送排队中的透传(TCP 不限,RTU 只在没有在途时发送 1 个) + */ + private void pump() { + while (!closed && !queue.isEmpty() && (isTcpFormat() || sent.isEmpty())) { + IotModbusTx tx = queue.poll(); + // 1. 排队超过截止时间:不再发送 + if (tx.isExpired()) { + tx.fail(MODBUS_TIMEOUT); + continue; + } + // 2.1 分配事务号、封装帧 + Integer transactionId = allocateTransactionId(); + byte[] data = frameEncoder.wrapFrame(slaveId, tx.getPdu(), frameFormat, transactionId); + // 2.2 只在实际发送时登记待响应请求,并占用在途位置 + PendingRequest request = buildPendingRequest(tx, transactionId); + pendingRequestManager.addRequest(request); + sent.put(tx, request); + // 3. 写出:回调在 owner 上 + send(data).onFailure(e -> onRawWriteFailed(tx, e)); + } + } + + /** + * owner:透传写出失败,结束这一笔并继续发送排队中的透传 + */ + private void onRawWriteFailed(IotModbusTx tx, Throwable e) { + log.warn("[onRawWriteFailed][设备 {} 透传写出失败]", deviceId, e); + // 只结束这一笔透传,不主动关闭连接:连接真的异常时由 closeHandler 收尾 + if (release(tx)) { + tx.fail(DEVICE_OFFLINE_OR_WRITE_FAILED); + pump(); + } + } + + /** + * owner:清理周期,结束超时的在途透传、过期的排队透传 + */ + private void onCleanup0() { + if (closed) { + return; + } + // 1. 在途透传:登记的待响应请求已到期(与 cleanupExpired 的判断一致),不断开连接 + long now = System.currentTimeMillis(); + List timeoutTxs = new ArrayList<>(); + for (Map.Entry entry : sent.entrySet()) { + if (entry.getValue().getExpireAt() < now) { + timeoutTxs.add(entry.getKey()); + } + } + for (IotModbusTx tx : timeoutTxs) { + if (release(tx)) { + tx.fail(MODBUS_TIMEOUT); + } + } + // 2. 排队中的透传:超过截止时间的不再发送 + List expiredTxs = new ArrayList<>(); + for (Iterator it = queue.iterator(); it.hasNext(); ) { + IotModbusTx tx = it.next(); + if (tx.isExpired()) { + it.remove(); + expiredTxs.add(tx); + } + } + for (IotModbusTx tx : expiredTxs) { + tx.fail(MODBUS_TIMEOUT); + } + // 3. 释放了在途位置:继续发送排队中的透传 + pump(); + } + + /** + * owner:连接关闭,排队中、在途的透传全部以断线结束;可能重复到达,照常清理;清理后各集合为空,重复执行无副作用 + */ + private void onClose0() { + // 1. 标记关闭,取出排队中、在途的透传 + closed = true; + List txs = new ArrayList<>(queue); + txs.addAll(sent.keySet()); + queue.clear(); + sent.clear(); + // 2. 只删这条连接自己登记的待响应请求(轮询 + 透传),不影响同一设备新连接的条目 + pendingRequestManager.removeChannel(deviceId, this); + // 3. 全部以断线结束 + for (IotModbusTx tx : txs) { + tx.fail(MODBUS_DISCONNECTED); + } + } + + /** + * owner:把透传移出在途位置,并按引用移除它登记的待响应请求 + * + * @return false 表示它已经不在途(重复的终态信号直接忽略) + */ + private boolean release(IotModbusTx tx) { + PendingRequest request = sent.remove(tx); + if (request == null) { + return false; + } + pendingRequestManager.removeRequest(request); + return true; + } + + /** + * 构造透传登记的待响应请求:到期时间 = 现在 + requestTimeout + * + * @param tx 事务 + * @param transactionId 事务号;RTU 帧格式为 null + * @return 待响应请求 + */ + private PendingRequest buildPendingRequest(IotModbusTx tx, Integer transactionId) { + byte[] pdu = tx.getPdu(); + int functionCode = pdu[0] & 0xFF; + int registerAddress = ((pdu[1] & 0xFF) << 8) | (pdu[2] & 0xFF); + // registerCount 用于 RTU FIFO 按原逻辑核对 03 / 04 的正常响应 + return new PendingRequest(deviceId, null, null, slaveId, functionCode, registerAddress, + IotModbusCommonUtils.getRequestQuantity(pdu), transactionId, + System.currentTimeMillis() + requestTimeout, this, tx); + } + + /** + * @return 是否是 TCP 帧格式(MBAP,按事务号匹配响应) + */ + private boolean isTcpFormat() { + return frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java index a1cf47a..fc4539d 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java @@ -19,6 +19,7 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream.IotMqt import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; import io.netty.handler.codec.mqtt.MqttQoS; import io.vertx.core.Vertx; @@ -68,6 +69,7 @@ public class IotMqttProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private Vertx vertx; /** * MQTT 服务器 @@ -232,21 +234,22 @@ public class IotMqttProtocol implements IotProtocol { } // 2.1 设置异常和关闭处理器 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(vertx.getOrCreateContext()); // 本连接的 worker 队列:阻塞的消息处理按收到的顺序执行,不同连接之间并行 endpoint.exceptionHandler(ex -> { log.warn("[handleEndpoint][连接异常,客户端 ID: {},地址: {},异常: {}]", clientId, connectionManager.getEndpointAddress(endpoint), ex.getMessage()); endpoint.close(); }); - endpoint.closeHandler(v -> cleanupConnection(endpoint)); // 处理底层连接关闭(网络中断、异常等) + endpoint.closeHandler(v -> cleanupConnection(endpoint, queue)); // 处理底层连接关闭(网络中断、异常等) endpoint.disconnectHandler(v -> { // 处理 MQTT DISCONNECT 报文 log.debug("[handleEndpoint][设备断开连接,客户端 ID: {}]", clientId); - cleanupConnection(endpoint); + cleanupConnection(endpoint, queue); }); // 2.2 设置心跳处理器 endpoint.pingHandler(v -> log.debug("[handleEndpoint][收到客户端心跳,客户端 ID: {}]", clientId)); // 3.1 设置消息处理器 - endpoint.publishHandler(message -> processMessage(endpoint, message)); + endpoint.publishHandler(message -> processMessage(endpoint, queue, message)); // 3.2 设置 QoS 2 消息的 PUBREL 处理器 endpoint.publishReleaseHandler(endpoint::publishComplete); @@ -282,23 +285,24 @@ public class IotMqttProtocol implements IotProtocol { * 处理消息(发布) * * @param endpoint MQTT 连接端点 + * @param queue 本连接的 worker 队列 * @param message 发布消息 */ - private void processMessage(MqttEndpoint endpoint, MqttPublishMessage message) { + private void processMessage(MqttEndpoint endpoint, IotWorkerTaskQueue queue, MqttPublishMessage message) { String clientId = endpoint.clientIdentifier(); - try { - // 1. 处理业务消息 - String topic = message.topicName(); - byte[] payload = message.payload().getBytes(); - upstreamHandler.handleBusinessRequest(endpoint, topic, payload); - - // 2. 根据 QoS 级别发送相应的确认消息 + // 1. 处理业务消息:阻塞的部分在本连接的 worker 队列上按顺序执行,完成后回到 event loop + String topic = message.topicName(); + byte[] payload = message.payload().getBytes(); + upstreamHandler.handleBusinessRequest(endpoint, queue, topic, payload).onComplete(ar -> { + if (ar.failed()) { // 队列已满或已关闭:不确认,断开连接,由设备重连后重发 + log.error("[processMessage][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", + clientId, connectionManager.getEndpointAddress(endpoint), ar.cause().getMessage()); + endpoint.close(); + return; + } + // 2. 处理完成后,根据 QoS 级别发送相应的确认消息:同一连接按收到的顺序完成,确认也按顺序 handleQoSAck(endpoint, message); - } catch (Exception e) { - log.error("[processMessage][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", - clientId, connectionManager.getEndpointAddress(endpoint), e.getMessage()); - endpoint.close(); - } + }); } /** @@ -308,6 +312,10 @@ public class IotMqttProtocol implements IotProtocol { * @param message 发布消息 */ private void handleQoSAck(MqttEndpoint endpoint, MqttPublishMessage message) { + // 处理期间连接已断开:无法确认,设备重连后重发 + if (!endpoint.isConnected()) { + return; + } if (message.qosLevel() == MqttQoS.AT_LEAST_ONCE) { // QoS 1: 发送 PUBACK 确认 endpoint.publishAcknowledge(message.messageId()); @@ -322,23 +330,33 @@ public class IotMqttProtocol implements IotProtocol { * 清理连接 * * @param endpoint MQTT 连接端点 + * @param queue 本连接的 worker 队列 */ - private void cleanupConnection(MqttEndpoint endpoint) { - try { - // 1. 发送设备离线消息 - IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); - if (connectionInfo != null) { - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), - connectionInfo.getDeviceName(), serverId); - } - } catch (Exception e) { - log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", - endpoint.clientIdentifier(), e.getMessage()); - } finally { - // 2. 注销连接,避免发送离线消息失败时残留连接 - connectionManager.unregisterConnection(endpoint); + private void cleanupConnection(MqttEndpoint endpoint, IotWorkerTaskQueue queue) { + // 1. 注销连接:closeHandler、disconnectHandler 都会调用,第二次取不到连接信息,不再处理 + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); + connectionManager.unregisterConnection(endpoint); + if (connectionInfo == null) { + return; } + + // 2. 发送设备离线消息:排在本连接已收到的消息之后;执行时设备已在新连接上线,则不再发送 + queue.submitLast(() -> { + if (connectionManager.getDeviceEndpoint(connectionInfo.getDeviceId()) != null) { + log.info("[cleanupConnection][设备已在新连接上线,不发送离线消息,设备 ID: {}]", connectionInfo.getDeviceId()); + return null; + } + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 + if (connectionManager.getDeviceEndpoint(connectionInfo.getDeviceId()) != null) { + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); + } + return null; + }).onFailure(e -> log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", + endpoint.clientIdentifier(), e.getMessage())); } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandler.java index 18a5a41..1d30529 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandler.java @@ -7,9 +7,13 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnecti import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; + /** * IoT 网关 MQTT 协议:下行消息处理器 * @@ -67,4 +71,28 @@ public class IotMqttDownstreamHandler { } } + /** + * 处理透传下行(raw.push 或编码脚本的输出):原始字节发布到 raw/push 主题 + * + * @param message 下行消息 + * @param bytes 要写出的原始字节 + * @return 提交结果:设备离线或发送失败时失败;成功只表示 publish 已提交,晚到的写出失败由连接管理器记日志 + */ + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes) { + // 1. 检查设备连接 + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId( + message.getDeviceId()); + if (connectionInfo == null) { + log.warn("[handleRawMessage][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + + // 2. 发布到 raw/push 主题 + String topic = IotMqttTopicUtils.buildRawPushTopic(connectionInfo.getProductKey(), connectionInfo.getDeviceName()); + boolean success = connectionManager.sendToDevice(message.getDeviceId(), topic, bytes, + MqttQoS.AT_LEAST_ONCE.value(), false); + return success ? Future.succeededFuture() : Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java index 5f0b547..8930319 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; +import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; /** @@ -28,4 +30,14 @@ public class IotMqttDownstreamSubscriber extends AbstractIotProtocolDownstreamSu downstreamHandler.handle(message); } + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + return downstreamHandler.handleRawMessage(message, bytes); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java index 81a6ece..59e1668 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java @@ -2,14 +2,17 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ArrayUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Future; import io.vertx.mqtt.MqttEndpoint; import lombok.extern.slf4j.Slf4j; - /** * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等) * @@ -29,22 +32,30 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { /** * 处理业务消息 + *

+ * 在 event loop 上校验主题与身份;反序列化、透传解析、发送消息会阻塞,交给本连接的 worker 队列按顺序执行。 + * 处理失败、校验不通过只记录日志(与原来一致,照常确认);只有队列已满或已关闭时返回失败的 Future * * @param endpoint MQTT 连接端点 + * @param queue 本连接的 worker 队列 * @param topic 主题 * @param payload 消息内容 + * @return 处理完成的结果,在 event loop 上完成:调用方在它完成后再确认(PUBACK / PUBREC) */ - public void handleBusinessRequest(MqttEndpoint endpoint, String topic, byte[] payload) { + public Future handleBusinessRequest(MqttEndpoint endpoint, IotWorkerTaskQueue queue, + String topic, byte[] payload) { String clientId = endpoint.clientIdentifier(); + String productKey; + String deviceName; try { // 1.1 基础检查 if (ArrayUtil.isEmpty(payload)) { - return; + return skip(queue); } // 1.2 解析主题,获取 productKey 和 deviceName String[] topicParts = topic.split("/"); - String productKey = ArrayUtil.get(topicParts, 2); - String deviceName = ArrayUtil.get(topicParts, 3); + productKey = ArrayUtil.get(topicParts, 2); + deviceName = ArrayUtil.get(topicParts, 3); Assert.notBlank(productKey, "产品 Key 不能为空"); Assert.notBlank(deviceName, "设备名称不能为空"); // 1.3 校验设备信息,防止伪造设备消息 @@ -55,24 +66,70 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { // 1.4 校验 topic 是否允许发布 if (!IotMqttTopicUtils.isTopicPublishAllowed(topic, productKey, deviceName)) { log.warn("[handleBusinessRequest][topic 不允许发布,客户端 ID: {},主题: {}]", clientId, topic); - return; + return skip(queue); } + } catch (Exception e) { + log.error("[handleBusinessRequest][消息校验失败,客户端 ID: {},主题: {},错误: {}]", + clientId, topic, e.getMessage()); + return skip(queue); + } - // 2.1 反序列化消息 - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); - if (message == null) { - log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); - return; + // 2. 交给本连接的 worker 队列:透传上报的截止时间从收到(入队之前)开始计算 + boolean raw = IotMqttTopicUtils.isRawPostTopic(topic); + long receivedNanos = System.nanoTime(); + return queue.submit(() -> { + try { + if (raw) { + handleRawRequest(productKey, deviceName, payload, receivedNanos); + } else { + handleStandardRequest(clientId, topic, productKey, deviceName, payload); + } + } catch (Exception e) { + log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", + clientId, topic, e.getMessage(), e); } - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) - IotMqttTopicUtils.normalizeReplyMethod(message); + return null; + }); + } - // 3. 处理业务消息 - deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); - log.debug("[handleBusinessRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); - } catch (Exception e) { - log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", - clientId, topic, e.getMessage(), e); + /** + * 不处理的消息:也排在本连接之前的消息之后完成,保证确认(PUBACK / PUBREC)仍按收到的顺序 + */ + private static Future skip(IotWorkerTaskQueue queue) { + return queue.submit(() -> null); + } + + /** + * 处理标准消息(worker 线程):反序列化后发送 + */ + private void handleStandardRequest(String clientId, String topic, String productKey, String deviceName, + byte[] payload) { + // 1.1 反序列化消息 + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); + if (message == null) { + log.warn("[handleStandardRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); + return; + } + // 1.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) + IotMqttTopicUtils.normalizeReplyMethod(message); + + // 2. 处理业务消息 + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); + log.debug("[handleStandardRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); + } + + /** + * 处理透传上报(worker 线程):原始字节按产品的数据解析脚本 decode 后发送;身份已在 event loop 上与连接比对 + * + * @param receivedNanos 收到的时刻(System.nanoTime):入队之前取得,截止时间从此刻开始计算 + */ + private void handleRawRequest(String productKey, String deviceName, byte[] payload, long receivedNanos) { + try { + deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, IotProtocolTypeEnum.MQTT, + receivedNanos); + } catch (ServiceException e) { + log.warn("[handleRawRequest][透传上报处理失败,productKey: {},deviceName: {},数据长度: {} 字节,错误: {}]", + productKey, deviceName, payload.length, e.getMessage()); } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java index a9bb277..a4032ab 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java @@ -142,14 +142,17 @@ public class IotMqttConnectionManager { } /** - * 发送消息到设备 + * 发送消息到设备(标准下行、透传下行共用) + *

+ * 不等待写出完成:返回 true 只表示 publish 已提交(设备在线、没有同步异常),不代表已写出或设备已收到; + * 之后的写出失败(例如连接已关闭)只记日志 * * @param deviceId 设备 ID * @param topic 主题 * @param payload 消息内容 * @param qos 服务质量 * @param retain 是否保留消息 - * @return 是否发送成功 + * @return 是否已提交发送 */ public boolean sendToDevice(Long deviceId, String topic, byte[] payload, int qos, boolean retain) { MqttEndpoint endpoint = deviceEndpointMap.get(deviceId); @@ -159,7 +162,9 @@ public class IotMqttConnectionManager { } try { - endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain); + endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain) + .onFailure(e -> log.warn("[sendToDevice][发送消息失败,设备 ID: {},主题: {},错误: {}]", + deviceId, topic, e.getMessage())); log.debug("[sendToDevice][发送消息成功,设备 ID: {},主题: {},QoS: {}]", deviceId, topic, qos); return true; } catch (Exception e) { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java index 8660e87..c5a1f31 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java @@ -53,6 +53,7 @@ public class IotTcpProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private Vertx vertx; /** * TCP 服务器 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java index 933d56a..01eb282 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java @@ -6,10 +6,16 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import io.vertx.core.Future; import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; + /** * IoT 网关 TCP 下行消息处理器 * @@ -71,4 +77,40 @@ public class IotTcpDownstreamHandler { } } + /** + * 处理透传下行(raw.push 或编码脚本的输出):原始字节按 codec 加帧后写出 + *

+ * 只有认证时声明了 mode=raw 的透传连接才支持;普通 JSON / 二进制连接返回 RAW_NOT_SUPPORTED + * + * @param message 下行消息 + * @param bytes 要写出的原始字节 + * @return 真实的写出结果 + */ + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes) { + // 1. 检查设备连接:之后只写这个检查过的连接(期间设备换成新连接时写出失败,不写到未校验的新连接上) + NetSocket socket = connectionManager.getSocketByDeviceId(message.getDeviceId()); + IotTcpConnectionManager.ConnectionInfo connectionInfo = socket != null ? connectionManager.getConnectionInfo(socket) : null; + if (connectionInfo == null) { + log.warn("[handleRawMessage][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + if (!Boolean.TRUE.equals(connectionInfo.getRawMode())) { + log.warn("[handleRawMessage][非透传连接,不支持透传下发,设备 ID: {},方法: {},消息 ID: {}]", + message.getDeviceId(), message.getMethod(), message.getId()); + return Future.failedFuture(exception(RAW_NOT_SUPPORTED)); + } + + // 2. 帧编码 + 发送到设备 + Buffer frameData; + try { + frameData = codec.encode(bytes); + } catch (Exception e) { + log.warn("[handleRawMessage][帧编码失败,设备 ID: {},数据长度: {} 字节,错误: {}]", + message.getDeviceId(), bytes.length, e.getMessage()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + return connectionManager.sendToConnectionAsync(message.getDeviceId(), socket, frameData.getBytes()); + } + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java index 39a7384..b193a6b 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; +import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; /** @@ -28,4 +30,14 @@ public class IotTcpDownstreamSubscriber extends AbstractIotProtocolDownstreamSub downstreamHandler.handle(message); } + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + return downstreamHandler.handleRawMessage(message, bytes); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java index 5d54758..ba0dded 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/upstream/IotTcpUpstreamHandler.java @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 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; @@ -19,13 +20,18 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnection import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; import io.vertx.core.Handler; +import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.net.NetSocket; import io.vertx.core.parsetools.RecordParser; import lombok.extern.slf4j.Slf4j; import cn.hutool.core.lang.Assert; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; + import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; @@ -40,6 +46,15 @@ public class IotTcpUpstreamHandler implements Handler { private static final String AUTH_METHOD = "auth"; + /** + * 认证参数中的连接模式 Key + */ + private static final String PARAM_KEY_MODE = "mode"; + /** + * 连接模式:透传(认证之后的每一帧都是原始字节) + */ + private static final String MODE_RAW = "raw"; + private final String serverId; /** @@ -82,19 +97,21 @@ public class IotTcpUpstreamHandler implements Handler { log.debug("[handle][设备连接,地址: {}]", remoteAddress); // 1. 设置异常和关闭处理器 + // 本连接的 worker 队列:业务消息、透传帧的发送会阻塞,按收到的顺序在 worker 上执行;离线消息排在它们之后 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(Vertx.currentContext()); socket.exceptionHandler(ex -> { log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); socket.close(); }); socket.closeHandler(v -> { log.debug("[handle][连接关闭,地址: {}]", remoteAddress); - cleanupConnection(socket); + cleanupConnection(socket, queue); }); // 2.1 设置消息处理器 Handler messageHandler = buffer -> { try { - processMessage(buffer, socket); + processMessage(buffer, socket, queue); } catch (Exception e) { log.error("[handle][消息处理失败,地址: {}]", remoteAddress, e); socket.close(); @@ -111,8 +128,16 @@ public class IotTcpUpstreamHandler implements Handler { * * @param buffer 消息 * @param socket 网络连接 + * @param queue 本连接的 worker 队列 */ - private void processMessage(Buffer buffer, NetSocket socket) { + private void processMessage(Buffer buffer, NetSocket socket, IotWorkerTaskQueue queue) { + // 0. 已认证、且认证时声明了 mode=raw 的连接:每一帧都是原始字节(帧仍由 codec 切分),不再反序列化 + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo != null && Boolean.TRUE.equals(connectionInfo.getRawMode())) { + handleRawRequest(buffer.getBytes(), connectionInfo, socket, queue); + return; + } + IotDeviceMessage message = null; try { // 1. 反序列化消息 @@ -131,7 +156,7 @@ public class IotTcpUpstreamHandler implements Handler { handleRegisterRequest(message, socket); } else { // 业务消息 - handleBusinessRequest(message, socket); + handleBusinessRequest(message, socket, queue); } } catch (ServiceException e) { // 业务异常,返回对应的错误码和错误信息 @@ -183,8 +208,8 @@ public class IotTcpUpstreamHandler implements Handler { IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); Assert.notNull(device, "设备不存在"); - // 3.1 注册连接 - registerConnection(socket, device); + // 3.1 注册连接(params.mode = raw 时为透传连接) + registerConnection(socket, device, isRawMode(message.getParams())); // 3.2 发送上线消息 sendOnlineMessage(device); // 3.3 发送成功响应 @@ -220,12 +245,13 @@ public class IotTcpUpstreamHandler implements Handler { } /** - * 处理业务请求 + * 处理业务请求:认证检查在 event loop 上;获取设备、发送总线会阻塞,交给本连接的 worker 队列按收到的顺序执行 * * @param message 消息信息 * @param socket 网络连接 + * @param queue 本连接的 worker 队列 */ - private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket) { + private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket, IotWorkerTaskQueue queue) { // 1. 获取认证信息并处理业务消息 IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); if (connectionInfo == null) { @@ -235,10 +261,73 @@ public class IotTcpUpstreamHandler implements Handler { return; } - // 2. 发送消息到消息总线 - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), - connectionInfo.getDeviceName(), serverId); - log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); + // 2. 发送消息到消息总线(worker):成功不应答(与原来一致);失败回到 event loop 应答错误 + queue.submit(() -> { + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + return null; + }).onComplete(ar -> { + if (ar.succeeded()) { + log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); + return; + } + handleBusinessFailure(message, socket, ar.cause()); + }); + } + + /** + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 + * + * @param message 消息信息 + * @param socket 网络连接 + * @param e 异常 + */ + private void handleBusinessFailure(IotDeviceMessage message, NetSocket socket, Throwable e) { + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 + log.error("[handleBusinessFailure][业务消息积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + socket.close(); + return; + } + if (e instanceof ServiceException) { + log.warn("[handleBusinessFailure][业务异常,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), + ((ServiceException) e).getCode(), e.getMessage()); + return; + } + if (e instanceof IllegalArgumentException) { + log.warn("[handleBusinessFailure][参数校验失败,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), BAD_REQUEST.getCode(), e.getMessage()); + return; + } + // 其他异常:返回 500,并断开连接 + log.error("[handleBusinessFailure][处理消息失败,地址: {}]", socket.remoteAddress(), e); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + socket.close(); + } + + /** + * 处理透传上报:原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给本连接的 worker 队列按顺序执行 + * + * @param payload 原始字节(一帧) + * @param connectionInfo 已认证的连接信息 + * @param socket 网络连接 + * @param queue 本连接的 worker 队列 + */ + private void handleRawRequest(byte[] payload, IotTcpConnectionManager.ConnectionInfo connectionInfo, + NetSocket socket, IotWorkerTaskQueue queue) { + // 截止时间从收到(入队之前)开始计算 + long receivedNanos = System.nanoTime(); + queue.submit(() -> deviceMessageService.sendRawMessage(payload, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId, IotProtocolTypeEnum.TCP, receivedNanos)).onFailure(e -> { + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 + log.error("[handleRawRequest][透传上报积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + socket.close(); + return; + } + log.warn("[handleRawRequest][透传上报处理失败,地址: {},数据长度: {} 字节,错误: {}]", + socket.remoteAddress(), payload.length, e.getMessage()); + }); } /** @@ -246,16 +335,28 @@ public class IotTcpUpstreamHandler implements Handler { * * @param socket 网络连接 * @param device 设备 + * @param rawMode 是否为透传连接 */ - private void registerConnection(NetSocket socket, IotDeviceRespDTO device) { + private void registerConnection(NetSocket socket, IotDeviceRespDTO device, boolean rawMode) { IotTcpConnectionManager.ConnectionInfo connectionInfo = new IotTcpConnectionManager.ConnectionInfo() .setDeviceId(device.getId()) .setProductKey(device.getProductKey()) - .setDeviceName(device.getDeviceName()); + .setDeviceName(device.getDeviceName()) + .setRawMode(rawMode); connectionManager.registerConnection(socket, device.getId(), connectionInfo); } /** + * 认证参数是否声明了透传模式(params.mode = raw) + * + * @param params 认证参数 + * @return 是否为透传模式 + */ + private static boolean isRawMode(Object params) { + return params instanceof Map && MODE_RAW.equals(((Map) params).get(PARAM_KEY_MODE)); + } + + /** * 发送设备上线消息 * * @param device 设备信息 @@ -270,18 +371,31 @@ public class IotTcpUpstreamHandler implements Handler { * 清理连接 * * @param socket 网络连接 + * @param queue 本连接的 worker 队列 */ - private void cleanupConnection(NetSocket socket) { - // 1. 发送离线消息 + private void cleanupConnection(NetSocket socket, IotWorkerTaskQueue queue) { + // 1. 注销连接 IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); - if (connectionInfo != null) { - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), - connectionInfo.getDeviceName(), serverId); + connectionManager.unregisterConnection(socket); + if (connectionInfo == null) { + return; } - // 2. 注销连接 - connectionManager.unregisterConnection(socket); + // 2. 发送离线消息:排在本连接已收到的业务消息、透传帧之后;执行时设备已在新连接上线,则不再发送 + queue.submitLast(() -> { + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { + return null; + } + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); + } + return null; + }).onFailure(e -> log.error("[cleanupConnection][发送离线消息失败,地址: {},错误: {}]", + socket.remoteAddress(), e.getMessage())); } // ===================== 发送响应消息 ===================== diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java index 9349bed..ca2095e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager; +import io.vertx.core.Future; import io.vertx.core.buffer.Buffer; import io.vertx.core.net.NetSocket; import lombok.Data; @@ -10,6 +11,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; + /** * IoT 网关 TCP 连接管理器 *

@@ -129,6 +133,42 @@ public class IotTcpConnectionManager { } /** + * 根据设备 ID 获取当前的连接 + */ + public NetSocket getSocketByDeviceId(Long deviceId) { + return deviceSocketMap.get(deviceId); + } + + /** + * 异步发送消息到设备的指定连接:返回 socket.write 真实的写出结果(透传下发使用) + *

+ * 只写调用方已检查过的连接(例如已确认是透传连接):设备已换成其它连接时直接失败,不改写新连接,避免把原始字节写到未校验的连接上。 + * 成功只表示本地写出完成,不代表设备已收到或已执行;设备未连接、连接已替换或写出失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) + * + * @param deviceId 设备 ID + * @param socket 已检查过的连接 + * @param data 数据(已按帧编码) + * @return 写出结果 + */ + public Future sendToConnectionAsync(Long deviceId, NetSocket socket, byte[] data) { + if (socket == null || deviceSocketMap.get(deviceId) != socket) { + log.warn("[sendToConnectionAsync][设备未连接或连接已替换,设备 ID: {}]", deviceId); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + + try { + return socket.write(Buffer.buffer(data)) + .recover(e -> { + log.warn("[sendToConnectionAsync][发送消息失败,设备 ID: {},错误: {}]", deviceId, e.getMessage()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + }); + } catch (Exception e) { + log.error("[sendToConnectionAsync][发送消息异常,设备 ID: {}]", deviceId, e); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + } + + /** * 关闭所有连接 */ public void closeAll() { @@ -164,6 +204,12 @@ public class IotTcpConnectionManager { * 设备名称 */ private String deviceName; + /** + * 是否为透传连接(认证时 params.mode = raw) + *

+ * 透传连接认证之后的每一帧都是原始字节,按数据解析脚本解析;只有透传连接才支持透传下发 + */ + private Boolean rawMode; } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java index 9df3c8b..db9f16e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java @@ -49,6 +49,7 @@ public class IotUdpProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private Vertx vertx; /** * UDP 服务器 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java index 6caf71a..2478090 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamHandler.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream.IotUdpUpstreamHandler; import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import io.vertx.core.Future; import io.vertx.core.datagram.DatagramSocket; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -62,4 +64,19 @@ public class IotUdpDownstreamHandler { } } + /** + * 处理透传下行(raw.push 或编码脚本的输出):原始字节按透传下行格式包装后发送,不等待 + *

+ * 会话不存在(或已过期)、UDP Socket 不可用时,由 {@link IotUdpSessionManager#sendToDeviceAsync} 返回失败 + * + * @param message 下行消息 + * @param bytes 要写出的原始字节 + * @return 真实的发送结果 + */ + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes) { + DatagramSocket socket = protocol.getUdpSocket(); + return sessionManager.sendToDeviceAsync(message.getDeviceId(), + IotUdpUpstreamHandler.encodeRawDownstream(bytes), socket); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java index cc21df6..2775015 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; +import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; /** @@ -28,4 +30,14 @@ public class IotUdpDownstreamSubscriber extends AbstractIotProtocolDownstreamSub downstreamHandler.handle(message); } + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + return downstreamHandler.handleRawMessage(message, bytes); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java index 4c83a4a..514526e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java @@ -13,27 +13,38 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Context; +import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.core.datagram.DatagramPacket; import io.vertx.core.datagram.DatagramSocket; +import lombok.AllArgsConstructor; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import cn.hutool.core.lang.Assert; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Map; +import java.util.concurrent.RejectedExecutionException; import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; /** @@ -44,6 +55,12 @@ import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVIC * 2. 返回 Token:服务端验证后返回 JWT token * 3. 后续请求:每次请求在 params 中携带 token * 4. 服务端验证:每次请求通过 IotDeviceTokenService.verifyToken() 验证 + *

+ * 透传(raw)报文按首字节区分:'{' 是 JSON,0x7E 是二进制序列化,0x49 0x52("IR")是透传: + *

    + *
  • 上行:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload(1..4096),每个包都携带 token,不凭地址识别设备;
  • + *
  • 下行:0x49 0x52 | 0x01 | 0x0000 | payload。
  • + *
* * @author 芋道源码 */ @@ -53,6 +70,24 @@ public class IotUdpUpstreamHandler { private static final String AUTH_METHOD = "auth"; /** + * 透传报文魔术字:'I'、'R' + */ + private static final byte RAW_MAGIC_0 = 0x49; + private static final byte RAW_MAGIC_1 = 0x52; + /** + * 透传报文版本 + */ + private static final byte RAW_VERSION = 0x01; + /** + * 透传报文头长度:魔术字(2) + 版本(1) + tokenLen(2) + */ + private static final int RAW_HEADER_LENGTH = 5; + /** + * 业务消息、透传上报最多排队的包数:超过时丢弃 + */ + private static final int MAX_PENDING = 1024; + + /** * Token 参数 Key */ private static final String PARAM_KEY_TOKEN = "token"; @@ -77,6 +112,11 @@ public class IotUdpUpstreamHandler { private final IotDeviceTokenService deviceTokenService; private final IotDeviceCommonApi deviceApi; + /** + * 业务消息、透传上报的 worker 队列(无序、有界):在包到达时,按 socket 所在的 context 创建 + */ + private IotWorkerTaskQueue queue; + public IotUdpUpstreamHandler(String serverId, IotUdpSessionManager sessionManager, IotMessageSerializer serializer) { @@ -118,8 +158,14 @@ public class IotUdpUpstreamHandler { if (ArrayUtil.isEmpty(buffer)) { return; } - // 1.2 反序列化消息 - IotDeviceMessage message = serializer.deserialize(buffer.getBytes()); + // 1.2 透传报文("IR" 开头):每个包都携带 token,跳过反序列化,按数据解析脚本解析 + byte[] data = buffer.getBytes(); + if (isRawPacket(data)) { + handleRawRequest(data, senderAddress, socket); + return; + } + // 1.3 反序列化消息 + IotDeviceMessage message = serializer.deserialize(data); if (message == null) { sendErrorResponse(socket, senderAddress, null, null, BAD_REQUEST.getCode(), "消息反序列化失败"); return; @@ -269,26 +315,130 @@ public class IotUdpUpstreamHandler { UNAUTHORIZED.getCode(), "token 无效或已过期"); return; } - // 1.3 获取设备详细信息 - IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), - deviceInfo.getDeviceName()); - if (device == null) { - log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", - addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + // 2. 获取设备、更新会话地址、发送消息到消息总线:会阻塞,交给 worker 执行(UDP 本身不保证顺序,包之间并行); + // 成功不应答(与原来一致),失败回到 event loop 应答错误 + Object params = body; + getQueue().submit(() -> { + // 2.1 获取设备详细信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), + deviceInfo.getDeviceName()); + if (device == null) { + log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", + addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + throw invalidParamException("设备不存在"); + } + // 2.2 更新会话地址(如有变化) + sessionManager.updateSessionAddress(device.getId(), senderAddress); + // 2.3 将 body 设置为实际的 params,发送消息到消息总线 + message.setParams(params); + deviceMessageService.sendDeviceMessage(message, device.getProductKey(), + device.getDeviceName(), serverId); + log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", + device.getId(), message.getMethod(), addressKey); + return null; + }).onFailure(e -> handleBusinessFailure(message, senderAddress, socket, e)); + } + + /** + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 + * + * @param message 消息信息 + * @param senderAddress 发送者地址 + * @param socket UDP Socket + * @param e 异常 + */ + private void handleBusinessFailure(IotDeviceMessage message, InetSocketAddress senderAddress, + DatagramSocket socket, Throwable e) { + String addressKey = sessionManager.buildAddressKey(senderAddress); + if (e instanceof RejectedExecutionException) { // 队列已满:丢弃,应答稍后重试 + log.warn("[handleBusinessFailure][业务消息积压,丢弃,来源: {},错误: {}]", addressKey, e.getMessage()); sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), - BAD_REQUEST.getCode(), "设备不存在"); + TOO_MANY_REQUESTS.getCode(), TOO_MANY_REQUESTS.getMsg()); return; } + if (e instanceof ServiceException) { + log.warn("[handleBusinessFailure][业务异常,来源: {},requestId: {},method: {},错误: {}]", + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + ((ServiceException) e).getCode(), e.getMessage()); + return; + } + if (e instanceof IllegalArgumentException) { + log.warn("[handleBusinessFailure][参数校验失败,来源: {},requestId: {},method: {},错误: {}]", + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + BAD_REQUEST.getCode(), e.getMessage()); + return; + } + log.error("[handleBusinessFailure][处理消息失败,来源: {},requestId: {},method: {}]", + addressKey, message.getRequestId(), message.getMethod(), e); + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + } - // 2. 更新会话地址(如有变化) - sessionManager.updateSessionAddress(device.getId(), senderAddress); + /** + * 处理透传上报:校验报文和 token 后,原始字节按产品的数据解析脚本 decode 后发送 + *

+ * 报文格式不正确、token 无效或已过期、设备不存在:只记日志并丢弃(不回复 JSON 错误响应) + * + * @param data 透传报文 + * @param senderAddress 发送者地址 + * @param socket UDP Socket + */ + private void handleRawRequest(byte[] data, InetSocketAddress senderAddress, DatagramSocket socket) { + String addressKey = sessionManager.buildAddressKey(senderAddress); + // 1.1 解析透传报文 + RawPacket packet; + try { + packet = decodeRawPacket(data); + } catch (IllegalArgumentException e) { + log.warn("[handleRawRequest][透传报文格式不正确,来源: {},错误: {}]", addressKey, e.getMessage()); + return; + } + // 1.2 验证 token,获取设备信息 + IotDeviceIdentity deviceInfo; + try { + deviceInfo = deviceTokenService.verifyToken(packet.getToken()); + } catch (Exception e) { + log.warn("[handleRawRequest][token 无效或已过期,来源: {},错误: {}]", addressKey, e.getMessage()); + return; + } + if (deviceInfo == null) { + log.warn("[handleRawRequest][token 无效或已过期,来源: {}]", addressKey); + return; + } + // 2. 获取设备、更新会话地址、按产品的数据解析脚本 decode 后发送:会阻塞,交给 worker 执行(UDP 本身不保证顺序,包之间并行) + IotDeviceIdentity identity = deviceInfo; + byte[] payload = packet.getPayload(); + // 截止时间从收到(入队之前)开始计算 + long receivedNanos = System.nanoTime(); + getQueue().submit(() -> { + // 2.1 获取设备详细信息:不存在时只记日志并丢弃 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(identity.getProductKey(), + identity.getDeviceName()); + if (device == null) { + log.warn("[handleRawRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", + addressKey, identity.getProductKey(), identity.getDeviceName()); + return null; + } + // 2.2 更新会话地址(如有变化) + sessionManager.updateSessionAddress(device.getId(), senderAddress); + // 2.3 decode 后发送 + return deviceMessageService.sendRawMessage(payload, device.getProductKey(), + device.getDeviceName(), serverId, IotProtocolTypeEnum.UDP, receivedNanos); + }).onFailure(e -> log.warn("[handleRawRequest][透传上报处理失败,来源: {},数据长度: {} 字节,错误: {}]", + addressKey, payload.length, e.getMessage())); + } - // 3. 将 body 设置为实际的 params,发送消息到消息总线 - message.setParams(body); - deviceMessageService.sendDeviceMessage(message, device.getProductKey(), - device.getDeviceName(), serverId); - log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", - device.getId(), message.getMethod(), addressKey); + /** + * 获取 worker 队列:按当前(socket 所在的)context 创建;协议重启后 context 变化时重建 + */ + private IotWorkerTaskQueue getQueue() { + Context context = Vertx.currentContext(); + if (queue == null || queue.getContext() != context) { + queue = new IotWorkerTaskQueue(context, false, MAX_PENDING); + } + return queue; } /** @@ -373,4 +523,75 @@ public class IotUdpUpstreamHandler { } } + // ===================== 透传报文编解码 ===================== + + /** + * 是否为透传报文:以 0x49 0x52("IR")开头 + * + * @param data 数据包 + * @return 是否为透传报文 + */ + public static boolean isRawPacket(byte[] data) { + return data != null && data.length >= 2 && data[0] == RAW_MAGIC_0 && data[1] == RAW_MAGIC_1; + } + + /** + * 解码透传上行报文:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload(1..4096) + * + * @param data 数据包 + * @return 透传报文 + * @throws IllegalArgumentException 格式或长度不正确 + */ + public static RawPacket decodeRawPacket(byte[] data) { + // 1. 校验报文头 + Assert.isTrue(isRawPacket(data), "透传报文魔术字不正确"); + Assert.isTrue(data.length >= RAW_HEADER_LENGTH, "透传报文长度不足"); + Assert.isTrue(data[2] == RAW_VERSION, "透传报文版本不支持: {}", data[2]); + // 2.1 解析 token + int tokenLength = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); + Assert.isTrue(tokenLength > 0, "透传报文缺少 token"); + Assert.isTrue(data.length > RAW_HEADER_LENGTH + tokenLength, "透传报文缺少 payload"); + String token = new String(data, RAW_HEADER_LENGTH, tokenLength, StandardCharsets.UTF_8); + // 2.2 解析 payload + int payloadLength = data.length - RAW_HEADER_LENGTH - tokenLength; + Assert.isTrue(payloadLength <= IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES, + "透传报文 payload 超过 {} 字节", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); + byte[] payload = Arrays.copyOfRange(data, RAW_HEADER_LENGTH + tokenLength, data.length); + return new RawPacket(token, payload); + } + + /** + * 编码透传下行报文:0x49 0x52 | 0x01 | 0x0000 | payload(下行不携带 token) + * + * @param payload 原始字节 + * @return 透传报文 + */ + public static byte[] encodeRawDownstream(byte[] payload) { + byte[] data = new byte[RAW_HEADER_LENGTH + payload.length]; + data[0] = RAW_MAGIC_0; + data[1] = RAW_MAGIC_1; + data[2] = RAW_VERSION; + // data[3]、data[4]:tokenLen = 0 + System.arraycopy(payload, 0, data, RAW_HEADER_LENGTH, payload.length); + return data; + } + + /** + * 透传上行报文 + */ + @Getter + @AllArgsConstructor + public static class RawPacket { + + /** + * 设备 token + */ + private final String token; + /** + * 原始字节 + */ + private final byte[] payload; + + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java index d807bce..41067a9 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager; import cn.hutool.core.util.ObjUtil; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; +import io.vertx.core.Future; import io.vertx.core.buffer.Buffer; import io.vertx.core.datagram.DatagramSocket; import lombok.Data; @@ -12,6 +13,9 @@ import java.net.InetSocketAddress; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; + /** * IoT 网关 UDP 会话管理器 *

@@ -136,6 +140,41 @@ public class IotUdpSessionManager { } /** + * 异步发送消息到设备:直接返回 socket.send 的结果,不等待(透传下发使用) + *

+ * 与 {@link #sendToDevice} 不同,不会同步等待发送结果,可以在 event loop 上调用。 + * 成功只表示数据报已交给本机发送,UDP 不保证送达;会话不存在(或已过期)、发送失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) + * + * @param deviceId 设备 ID + * @param data 数据 + * @param socket UDP Socket + * @return 发送结果 + */ + public Future sendToDeviceAsync(Long deviceId, byte[] data, DatagramSocket socket) { + SessionInfo sessionInfo = deviceSessionCache.getIfPresent(deviceId); + if (sessionInfo == null || sessionInfo.getAddress() == null) { + log.warn("[sendToDeviceAsync][设备会话不存在,设备 ID: {}]", deviceId); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + if (socket == null) { + log.warn("[sendToDeviceAsync][UDP Socket 不可用,设备 ID: {}]", deviceId); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + InetSocketAddress address = sessionInfo.getAddress(); + try { + return socket.send(Buffer.buffer(data), address.getPort(), address.getHostString()) + .recover(e -> { + log.warn("[sendToDeviceAsync][发送消息失败,设备 ID: {},地址: {},错误: {}]", + deviceId, buildAddressKey(address), e.getMessage()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + }); + } catch (Exception e) { + log.error("[sendToDeviceAsync][发送消息异常,设备 ID: {}]", deviceId, e); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + } + + /** * 构建地址 Key(用于日志输出) * * @param address 地址 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java index 083dc32..3f8afd2 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java @@ -52,6 +52,7 @@ public class IotWebSocketProtocol implements IotProtocol { /** * Vert.x 实例 */ + @Getter private Vertx vertx; /** * WebSocket 服务器 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java index 6391fd4..286c4ff 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java @@ -4,6 +4,7 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import io.vertx.core.Future; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -53,4 +54,17 @@ public class IotWebSocketDownstreamHandler { } } + /** + * 处理透传下行(raw.push 或编码脚本的输出):原始字节以 binary 帧写出 + *

+ * 设备未连接时,由 {@link IotWebSocketConnectionManager#sendBinaryToDeviceAsync} 返回失败 + * + * @param message 下行消息 + * @param bytes 要写出的原始字节 + * @return 真实的写出结果 + */ + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes) { + return connectionManager.sendBinaryToDeviceAsync(message.getDeviceId(), bytes); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java index c565be2..f77e5f0 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstrea import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketProtocol; +import io.vertx.core.Future; import lombok.extern.slf4j.Slf4j; /** @@ -28,4 +30,14 @@ public class IotWebSocketDownstreamSubscriber extends AbstractIotProtocolDownstr downstreamHandler.handle(message); } + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + return downstreamHandler.handleRawMessage(message, bytes); + } + + @Override + public boolean supportsRawDownstream() { + return true; + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java index 48de709..5161c5b 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/upstream/IotWebSocketUpstreamHandler.java @@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; 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; @@ -20,11 +21,15 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSock import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; import io.vertx.core.Handler; +import io.vertx.core.Vertx; import io.vertx.core.http.ServerWebSocket; import lombok.extern.slf4j.Slf4j; import cn.hutool.core.lang.Assert; +import java.util.concurrent.RejectedExecutionException; + import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; @@ -77,20 +82,31 @@ public class IotWebSocketUpstreamHandler implements Handler { log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); socket.close(); }); + // 本连接的 worker 队列:业务消息(text 帧)、透传帧(binary 帧)的发送会阻塞,按收到的顺序在 worker 上执行;离线消息排在它们之后 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(Vertx.currentContext()); socket.closeHandler(v -> { log.debug("[handle][连接关闭,地址: {}]", remoteAddress); - cleanupConnection(socket); + cleanupConnection(socket, queue); }); - // 2. 设置消息处理器(仅支持文本帧) + // 2. 设置 text 帧处理器:认证、注册、标准业务消息 socket.textMessageHandler(message -> { try { - processMessage(StrUtil.utf8Bytes(message), socket); + processMessage(StrUtil.utf8Bytes(message), socket, queue); } catch (Exception e) { log.error("[handle][消息解码失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); socket.close(); } }); + // 3. 设置 binary 帧处理器:透传上报,必须先通过 text 帧认证 + socket.binaryMessageHandler(buffer -> { + try { + processRawMessage(buffer.getBytes(), socket, remoteAddress, queue); + } catch (Exception e) { + log.error("[handle][透传消息处理失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); + socket.close(); + } + }); } /** @@ -98,8 +114,9 @@ public class IotWebSocketUpstreamHandler implements Handler { * * @param payload 消息负载 * @param socket WebSocket 连接 + * @param queue 本连接的 worker 队列 */ - private void processMessage(byte[] payload, ServerWebSocket socket) { + private void processMessage(byte[] payload, ServerWebSocket socket, IotWorkerTaskQueue queue) { IotDeviceMessage message = null; try { // 1.1 基础检查 @@ -117,7 +134,7 @@ public class IotWebSocketUpstreamHandler implements Handler { } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) { handleRegisterRequest(message, socket); } else { - handleBusinessRequest(message, socket); + handleBusinessRequest(message, socket, queue); } } catch (ServiceException e) { log.warn("[processMessage][业务异常,错误: {}]", e.getMessage()); @@ -202,12 +219,13 @@ public class IotWebSocketUpstreamHandler implements Handler { } /** - * 处理业务请求 + * 处理业务请求:认证检查在 event loop 上;获取设备、发送总线会阻塞,交给本连接的 worker 队列按收到的顺序执行 * * @param message 消息信息 * @param socket WebSocket 连接 + * @param queue 本连接的 worker 队列 */ - private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket) { + private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket, IotWorkerTaskQueue queue) { // 1. 获取认证信息并处理业务消息 IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); if (connectionInfo == null) { @@ -217,10 +235,81 @@ public class IotWebSocketUpstreamHandler implements Handler { return; } - // 2. 发送消息到消息总线 - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), - connectionInfo.getDeviceName(), serverId); - log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message); + // 2. 发送消息到消息总线(worker):成功不应答(与原来一致);失败回到 event loop 应答错误 + queue.submit(() -> { + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId); + return null; + }).onComplete(ar -> { + if (ar.succeeded()) { + log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message); + return; + } + handleBusinessFailure(message, socket, ar.cause()); + }); + } + + /** + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 + * + * @param message 消息信息 + * @param socket WebSocket 连接 + * @param e 异常 + */ + private void handleBusinessFailure(IotDeviceMessage message, ServerWebSocket socket, Throwable e) { + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 + log.error("[handleBusinessFailure][业务消息积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); + socket.close(); + return; + } + if (e instanceof ServiceException) { + log.warn("[handleBusinessFailure][业务异常,错误: {}]", e.getMessage()); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), + ((ServiceException) e).getCode(), e.getMessage()); + return; + } + if (e instanceof IllegalArgumentException) { + log.warn("[handleBusinessFailure][参数校验失败,错误: {}]", e.getMessage()); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), BAD_REQUEST.getCode(), e.getMessage()); + return; + } + // 其他异常:返回 500,并断开连接 + log.error("[handleBusinessFailure][处理消息失败]", e); + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), INTERNAL_SERVER_ERROR.getCode(), + INTERNAL_SERVER_ERROR.getMsg()); + socket.close(); + } + + /** + * 处理透传上报(binary 帧):原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给本连接的 worker 队列按顺序执行 + * + * @param payload 原始字节 + * @param socket WebSocket 连接 + * @param remoteAddress 远程地址 + * @param queue 本连接的 worker 队列 + */ + private void processRawMessage(byte[] payload, ServerWebSocket socket, String remoteAddress, + IotWorkerTaskQueue queue) { + // 1. 未认证的 binary 帧:断开连接 + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + if (connectionInfo == null) { + log.warn("[processRawMessage][未认证的 binary 帧,断开连接,地址: {}]", remoteAddress); + socket.close(); + return; + } + + // 2. 交给本连接的 worker 队列:截止时间从收到(入队之前)开始计算 + long receivedNanos = System.nanoTime(); + queue.submit(() -> deviceMessageService.sendRawMessage(payload, connectionInfo.getProductKey(), + connectionInfo.getDeviceName(), serverId, IotProtocolTypeEnum.WEBSOCKET, receivedNanos)).onFailure(e -> { + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 + log.error("[processRawMessage][透传上报积压,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage()); + socket.close(); + return; + } + log.warn("[processRawMessage][透传上报处理失败,地址: {},数据长度: {} 字节,错误: {}]", + remoteAddress, payload.length, e.getMessage()); + }); } /** @@ -256,22 +345,30 @@ public class IotWebSocketUpstreamHandler implements Handler { * 清理连接 * * @param socket WebSocket 连接 + * @param queue 本连接的 worker 队列 */ - private void cleanupConnection(ServerWebSocket socket) { - try { - // 1. 发送离线消息(如果已认证) - IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); - if (connectionInfo != null) { - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), - connectionInfo.getDeviceName(), serverId); - } - - // 2. 注销连接 - connectionManager.unregisterConnection(socket); - } catch (Exception e) { - log.error("[cleanupConnection][清理连接失败]", e); + private void cleanupConnection(ServerWebSocket socket, IotWorkerTaskQueue queue) { + // 1. 注销连接 + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); + connectionManager.unregisterConnection(socket); + if (connectionInfo == null) { + return; } + + // 2. 发送离线消息(如果已认证):排在本连接已收到的业务消息、透传帧之后;执行时设备已在新连接上线,则不再发送 + queue.submitLast(() -> { + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { + return null; + } + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); + } + return null; + }).onFailure(e -> log.error("[cleanupConnection][清理连接失败]", e)); } // ===================== 发送响应消息 ===================== diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java index 2dce663..6217b00 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java @@ -1,5 +1,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; import io.vertx.core.http.ServerWebSocket; import lombok.Data; import lombok.experimental.Accessors; @@ -10,6 +12,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; + /** * IoT 网关 WebSocket 连接管理器 *

@@ -121,6 +126,34 @@ public class IotWebSocketConnectionManager { } /** + * 异步发送 binary 消息到设备:返回 writeBinaryMessage 真实的写出结果(透传下发使用) + *

+ * 成功只表示本地写出完成,不代表设备已收到或已执行;设备未连接或写出失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) + * + * @param deviceId 设备 ID + * @param data 原始字节 + * @return 写出结果 + */ + public Future sendBinaryToDeviceAsync(Long deviceId, byte[] data) { + ServerWebSocket socket = deviceSocketMap.get(deviceId); + if (socket == null) { + log.warn("[sendBinaryToDeviceAsync][设备未连接,设备 ID: {}]", deviceId); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + + try { + return socket.writeBinaryMessage(Buffer.buffer(data)) + .recover(e -> { + log.warn("[sendBinaryToDeviceAsync][发送消息失败,设备 ID: {},错误: {}]", deviceId, e.getMessage()); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + }); + } catch (Exception e) { + log.error("[sendBinaryToDeviceAsync][发送消息异常,设备 ID: {}]", deviceId, e); + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); + } + } + + /** * 关闭所有连接 */ public void closeAll() { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java index 7d16a65..0a7a3b3 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java @@ -1,7 +1,11 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; /** * IoT 设备消息 Service 接口 @@ -53,6 +57,8 @@ public interface IotDeviceMessageService { /** * 发送消息 + *

+ * 设备经标准路径上报的消息:不接受透传方法;编号、时间由服务端生成,raw 由服务端清空(只有透传上行才可信赋值) * * @param message 消息 * @param productKey 产品 Key @@ -62,4 +68,68 @@ public interface IotDeviceMessageService { void sendDeviceMessage(IotDeviceMessage message, String productKey, String deviceName, String serverId); + // DONE @AI:deadlineNanos 是不是收敛掉到 service 里?已收进来:协议只传入收到的时刻(入队之前取得), + // 截止 = 收到时刻 + gateway.script.deadline,由本方法计算;协议不再读取截止配置 + /** + * 发送透传(raw)上行:与阿里云一致,按产品的数据解析脚本 decode 为一条标准消息;只投递一条消息 + *

    + *
  • 解析成功:投递解析出的标准消息,raw 字段带上原始字节;
  • + *
  • 正常未解析(功能开关关闭、脚本停用 / 未配置、decode 返回 null): + * 投递一条 thing.raw.post,raw 字段带上原始字节;
  • + *
  • 解析失败(已启用但内容为空、执行失败、超时、繁忙、输出不合法、超过截止):不投递,只记错误日志,抛出对应的错误码。
  • + *
+ * 截止时间 = receivedNanos + gateway.script.deadline,覆盖排队、执行脚本,直到投递。 + * 会阻塞(加载设备信息、执行脚本、发送总线):只能在 worker 线程调用,不能在 event loop 上调用 + * + * @param payload 原始字节 + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @param serverId 设备连接的 serverId + * @param protocol 协议类型 + * @param receivedNanos 协议收到透传上报的时刻(System.nanoTime):在交给 worker 排队之前取得,排队的时间也计入截止 + * @return 投递的消息编号 + * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 载荷长度不合法(不投递); + * 已启用但内容为空、脚本失败、超时、繁忙、输出不合法、超过截止(都不投递) + */ + String sendRawMessage(byte[] payload, String productKey, String deviceName, String serverId, + IotProtocolTypeEnum protocol, long receivedNanos); + + // DONE @AI:deadlineNanos 是不是收敛掉到 service 里?已收进来:下行订阅者只传入收到指令的时刻(按设备排队之前取得), + // 截止 = 收到时刻 + gateway.script.deadline,由本方法计算;订阅者不再读取截止配置 + /** + * 下行指令(property.set / service.invoke)按产品的数据解析脚本 encode:决定走标准路径,还是透传写出 encode 的字节; + * 由协议的下行订阅者按结果下发(本方法不写设备) + *
    + *
  • 设备不存在(或查询失败)、产品脚本停用 / 未配置:走标准路径;
  • + *
  • 不能下发(脚本已启用但内容为空、执行失败、输出不合法、没有输出、超过截止):只记日志,返回 null,不写设备,也不改走标准路径。
  • + *
+ * 只在功能开关开启、协议支持透传下发时调用。截止时间 = receivedNanos + gateway.script.deadline,覆盖排队、执行脚本, + * 直到开始写设备:encode 之后、返回之前再核对一次,调用方拿到结果立即下发。 + * 会阻塞(加载设备信息、执行脚本):只能在 worker 线程调用 + * + * @param message 下行指令 + * @param protocol 协议类型:encode 的入参与字节规则按它区分 + * @param receivedNanos 下行订阅者收到指令的时刻(System.nanoTime):在按设备排队之前取得,排队的时间也计入截止 + * @return encode 的结果;null 表示不下发 + */ + IotDownstreamEncodeRespDTO encodeDownstreamCommand(IotDeviceMessage message, IotProtocolTypeEnum protocol, + long receivedNanos); + + /** + * 回复透传下行(raw.push,或 encode 的指令)的设备实际响应:只投递一条回复,raw 字段带上响应字节 + *
    + *
  • raw.push:直接回复,code / msg 取协议判定的设备错误(没有为成功);
  • + *
  • encode 的指令:用 encode 时固定的脚本执行 decode,得到与原请求一致的一条回复;期间脚本被停用、解析失败只记日志。
  • + *
+ * 只用于设备的实际响应:网关判定的失败(超时、断线、写失败等)由协议只记日志,不调用本方法。 + * 会阻塞(执行脚本、发送总线):只能在 worker 线程调用 + * + * @param request 原请求 + * @param source encode 时固定的脚本;raw.push 为 null + * @param payload 设备的响应字节 + * @param deviceError 协议判定的设备错误(例如 Modbus 异常响应);正常响应为 null。只用于 raw.push 的回复 + * @param serverId 所属协议实例的 serverId + */ + void sendRawReply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError, String serverId); + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java index ee0c4ae..9b7818e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java @@ -2,25 +2,44 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_NOT_EXISTS; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; /** * IoT 设备消息 Service 实现类 + *

+ * 透传(raw)上行、下行指令的 encode、透传回复会阻塞(加载设备信息、执行脚本、发送总线), + * 由各协议在自己的 Vert.x worker 上调用;本类只做编排,不切线程,也不写设备(下行由协议的订阅者按 encode 结果下发) * * @author 芋道源码 */ @@ -37,6 +56,31 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Resource private IotMessageSerializerManager messageSerializerManager; + @Resource + private IotGatewayProperties gatewayProperties; + + /** + * 数据解析脚本执行器:JS 线程池与计时器(执行预算兜底、卡住扫描)由它自己创建和关闭 + */ + private IotScriptExecutor scriptExecutor; + + @PostConstruct + public void init() { + IotGatewayProperties.ScriptProperties script = gatewayProperties.getScript(); + scriptExecutor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options() + .setExecBudgetMillis(script.getExecBudget().toMillis()) + .setMaxInstructions(script.getMaxInstructions())); + // 功能开关关闭:不预热 JS 线程 + if (script.isEnabled()) { + scriptExecutor.warmUp(); + } + } + + @PreDestroy + public void destroy() { + scriptExecutor.shutdown(); + } + @Override public byte[] serializeDeviceMessage(IotDeviceMessage message, String productKey, String deviceName) { @@ -97,32 +141,258 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Override public void sendDeviceMessage(IotDeviceMessage message, String productKey, String deviceName, String serverId) { - // 1. 获取设备信息 - IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); - if (device == null) { - throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); + // 0. 标准 JSON 路径不接受透传方法:thing.raw.* 只能由网关的透传入口创建 + if (IotDeviceMessageUtils.isRawMethod(message.getMethod())) { + throw exception(DEVICE_MESSAGE_RAW_METHOD_RESERVED); } + // 1. 获取设备信息 + IotDeviceRespDTO device = getDevice(productKey, deviceName); - // 2. 发送消息 - appendDeviceMessage(message, device, serverId); - deviceMessageProducer.sendDeviceMessage(message); + // 2. 发送消息:raw 只能由服务端可信赋值,设备自己带上的一律清空 + message.setRaw(null); + sendDeviceMessage(message, device, serverId); } /** - * 补充消息的后端字段 + * 发送消息:编号、时间由服务端生成,身份由可信的 device 注入;不清空 raw(调用方是网关的可信路径) * * @param message 消息 * @param device 设备信息 * @param serverId 设备连接的 serverId */ - private void appendDeviceMessage(IotDeviceMessage message, - IotDeviceRespDTO device, String serverId) { + private void sendDeviceMessage(IotDeviceMessage message, IotDeviceRespDTO device, String serverId) { message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now()) .setDeviceId(device.getId()).setTenantId(device.getTenantId()).setServerId(serverId); // 特殊:如果设备没有指定 requestId,则使用 messageId if (StrUtil.isEmpty(message.getRequestId())) { message.setRequestId(message.getId()); } + deviceMessageProducer.sendDeviceMessage(message); + } + + // ========== 透传上行 ========== + + @Override + public String sendRawMessage(byte[] payload, String productKey, String deviceName, String serverId, + IotProtocolTypeEnum protocol, long receivedNanos) { + // 截止从协议收到时开始计算:排队的时间也计入 + long deadlineNanos = buildDeadlineNanos(receivedNanos); + // 1.1 校验载荷长度 + if (payload == null || payload.length < 1 || payload.length > IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES) { + throw invalidParamException("raw 载荷长度必须在 1~{} 字节之间", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); + } + // 1.2 获取设备信息 + IotDeviceRespDTO device = getDevice(productKey, deviceName); + String raw = IotDeviceMessageUtils.encodeRaw(payload); + // 1.3 排队已超过截止:不执行脚本,不投递,只记错误日志 + if (System.nanoTime() >= deadlineNanos) { + log.error("[sendRawMessage][设备({}/{}) 排队超过截止时间,不投递]", productKey, deviceName); + throw exception(SCRIPT_TIMEOUT); + } + + // 2.1 功能开关关闭:不执行脚本,只记录原始消息 + if (!gatewayProperties.getScript().isEnabled()) { + return sendRawPostMessage(device, serverId, raw); + } + // 2.2 产品脚本(随设备信息缓存):停用或从未配置时只记录原始消息 + if (!Boolean.TRUE.equals(device.getScriptEnabled())) { + return sendRawPostMessage(device, serverId, raw); + } + // 2.3 已启用但内容为空(配置不合法):不投递,只记错误日志 + if (StrUtil.isBlank(device.getScriptContent())) { + log.error("[sendRawMessage][设备({}/{}) 数据解析脚本已启用但内容为空,不投递]", productKey, deviceName); + throw exception(SCRIPT_EXECUTE_FAILED, "已启用但脚本内容为空"); + } + + // 3.1 执行 decode:失败时不投递,只记错误日志 + IotScriptSource source = IotScriptSource.of(device.getProductId(), device.getScriptContent()); + String ctx = IotScriptUtils.buildDecodeContext(protocol.getType(), device.getProductKey(), + device.getDeviceName(), System.currentTimeMillis(), payload, null); + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_DECODE, ctx, deadlineNanos); + if (!result.isSuccess()) { + log.error("[sendRawMessage][设备({}/{}) 脚本执行失败({}):{},不投递]", productKey, deviceName, + result.getStatus(), result.getMessage()); + throw scriptException(result.getStatus(), result.getMessage()); + } + // 3.2 校验输出:不合法时不投递,只记错误日志 + IotDeviceMessage message; + try { + message = IotScriptUtils.validateDecode(result.getJson(), null); + } catch (IotScriptException e) { + log.error("[sendRawMessage][设备({}/{}) 脚本输出不合法:{},不投递]", productKey, deviceName, e.getMessage()); + throw exception(SCRIPT_OUTPUT_INVALID, StrUtil.maxLength(e.getMessage(), 200)); + } + // 3.3 投递之前核对截止:迟到的结果(包括 decode 返回 null)不投递,只记错误日志 + if (System.nanoTime() >= deadlineNanos) { + log.error("[sendRawMessage][设备({}/{}) 脚本完成时已超过截止时间,不投递]", productKey, deviceName); + throw exception(SCRIPT_TIMEOUT); + } + // 3.4 decode 返回 null:没有业务消息,只记录原始消息 + if (message == null) { + return sendRawPostMessage(device, serverId, raw); + } + + // 4. 投递解析出的业务消息,带上原始字节 + message.setRaw(raw); + sendDeviceMessage(message, device, serverId); + return message.getId(); + } + + /** + * 投递一条没有解析为业务消息的原始消息 thing.raw.post:只带原始帧 + * + * @return 消息编号 + */ + private String sendRawPostMessage(IotDeviceRespDTO device, String serverId, String raw) { + IotDeviceMessage message = IotDeviceMessage.of(null, IotDeviceMessageMethodEnum.RAW_POST.getMethod(), + null, null, null, null).setRaw(raw); + sendDeviceMessage(message, device, serverId); + return message.getId(); + } + + // ========== 下行指令 ========== + + @Override + public IotDownstreamEncodeRespDTO encodeDownstreamCommand(IotDeviceMessage message, IotProtocolTypeEnum protocol, + long receivedNanos) { + // 截止从订阅者收到指令时开始计算:排队的时间也计入 + long deadlineNanos = buildDeadlineNanos(receivedNanos); + // 1.1 获取设备:不存在(或查询失败)时与未启用脚本一致,走标准路径 + IotDeviceRespDTO device = getDeviceQuietly(message.getDeviceId()); + if (device == null) { + return IotDownstreamEncodeRespDTO.standard(); + } + // 1.2 产品脚本(随设备信息缓存):停用或从未配置,与未启用脚本一致;已启用但内容为空,不写设备,也不改发 JSON + if (!Boolean.TRUE.equals(device.getScriptEnabled())) { + return IotDownstreamEncodeRespDTO.standard(); + } + if (StrUtil.isBlank(device.getScriptContent())) { + log.warn("[encodeDownstreamCommand][下行({}) 数据解析脚本已启用但内容为空,不写设备]", message.getRequestId()); + return null; + } + + // 2.1 执行 encode + IotScriptSource source = IotScriptSource.of(device.getProductId(), device.getScriptContent()); + String ctx = IotScriptUtils.buildEncodeContext(protocol.getType(), device.getProductKey(), + device.getDeviceName(), message); + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_ENCODE, ctx, deadlineNanos); + if (!result.isSuccess()) { + log.warn("[encodeDownstreamCommand][下行({}) 脚本执行失败({}):{}]", message.getRequestId(), + result.getStatus(), result.getMessage()); + return null; + } + // 2.2 校验输出:结构,以及字节是否符合协议的规则(Modbus 必须是合法的请求 PDU) + IotScriptUtils.EncodeOutput output; + try { + output = IotScriptUtils.validateEncode(result.getJson(), protocol); + } catch (IotScriptException e) { + log.warn("[encodeDownstreamCommand][下行({}) 脚本输出不合法:{}]", message.getRequestId(), e.getMessage()); + return null; + } + if (output == null) { + log.warn("[encodeDownstreamCommand][下行({}) 脚本未生成下行数据,不写设备]", message.getRequestId()); + return null; + } + + // 3. 下发之前核对截止:迟到的结果不写设备,也不改走标准路径 + if (System.nanoTime() >= deadlineNanos) { + log.warn("[encodeDownstreamCommand][下行({}) 超过截止,不写设备]", message.getRequestId()); + return null; + } + return output.isStandard() ? IotDownstreamEncodeRespDTO.standard() + : IotDownstreamEncodeRespDTO.raw(output.getBytes(), source); + } + + // ========== 透传回复 ========== + + @Override + public void sendRawReply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError, String serverId) { + IotDeviceRespDTO device = deviceService.getDeviceFromCache(request.getDeviceId()); + if (device == null) { + log.warn("[sendRawReply][设备({}) 不存在]", request.getDeviceId()); + return; + } + // 情况一:raw.push,直接回复设备的实际响应;结果码由协议判定(例如 Modbus 异常响应) + if (source == null) { + ErrorCode code = deviceError != null ? deviceError : SUCCESS; + IotDeviceMessage reply = IotDeviceMessage.replyOf(request.getRequestId(), request.getMethod(), null, + code.getCode(), code.getMsg()).setRaw(IotDeviceMessageUtils.encodeRaw(payload)); + sendDeviceMessage(reply, device, serverId); + return; + } + + // 情况二:encode 的指令,用 encode 时固定的脚本执行 decode;功能开关已关闭,或设备缓存里已看到脚本停用时,不解析响应 + if (!gatewayProperties.getScript().isEnabled()) { + log.warn("[sendRawReply][下行({}) 期间数据解析脚本功能已关闭,不解析响应]", request.getRequestId()); + return; + } + if (!Boolean.TRUE.equals(device.getScriptEnabled())) { + log.warn("[sendRawReply][下行({}) 期间数据解析脚本已停用,不解析响应]", request.getRequestId()); + return; + } + long deadlineNanos = buildDeadlineNanos(System.nanoTime()); + String ctx = IotScriptUtils.buildDecodeContext(device.getProtocolType(), device.getProductKey(), + device.getDeviceName(), System.currentTimeMillis(), payload, request); + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_DECODE, ctx, deadlineNanos); + if (!result.isSuccess()) { + log.warn("[sendRawReply][下行({}) 脚本执行失败({}):{}]", request.getRequestId(), + result.getStatus(), result.getMessage()); + return; + } + IotDeviceMessage reply; + try { + reply = IotScriptUtils.validateDecode(result.getJson(), request); + } catch (IotScriptException e) { + log.warn("[sendRawReply][下行({}) 脚本输出不合法:{}]", request.getRequestId(), e.getMessage()); + return; + } + reply.setRaw(IotDeviceMessageUtils.encodeRaw(payload)); + sendDeviceMessage(reply, device, serverId); + } + + // ========== 工具方法 ========== + + /** + * 计算截止时间(System.nanoTime):开始时刻 + gateway.script.deadline + * + * @param startNanos 开始计时的时刻(System.nanoTime) + */ + private long buildDeadlineNanos(long startNanos) { + return startNanos + gatewayProperties.getScript().getDeadline().toNanos(); + } + + private IotDeviceRespDTO getDevice(String productKey, String deviceName) { + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); + if (device == null) { + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName); + } + return device; + } + + /** + * 下行获取设备:查询失败时按设备不存在处理(走标准路径,由标准路径自己处理),不丢弃指令 + */ + private IotDeviceRespDTO getDeviceQuietly(Long deviceId) { + try { + return deviceService.getDeviceFromCache(deviceId); + } catch (RuntimeException e) { + log.warn("[getDeviceQuietly][设备({}) 查询失败,按标准路径下发]", deviceId, e); + return null; + } + } + + private static ServiceException scriptException(IotScriptResult.Status status, String detail) { + detail = StrUtil.maxLength(StrUtil.nullToEmpty(detail), 200); + switch (status) { + case TIMEOUT: + return exception(SCRIPT_TIMEOUT); + case REJECTED: + return exception(SCRIPT_REJECTED); + case OUTPUT_INVALID: + return exception(SCRIPT_OUTPUT_INVALID, detail); + default: + return exception(SCRIPT_EXECUTE_FAILED, detail); + } } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/dto/IotDownstreamEncodeRespDTO.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/dto/IotDownstreamEncodeRespDTO.java new file mode 100644 index 0000000..11ebe6b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/dto/IotDownstreamEncodeRespDTO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device.message.dto; + +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 下行指令按数据解析脚本 encode 的结果:走标准路径,或者透传写出 encode 的字节;由协议的下行订阅者执行 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class IotDownstreamEncodeRespDTO { + + private static final IotDownstreamEncodeRespDTO STANDARD = new IotDownstreamEncodeRespDTO(true, null, null); + + /** + * 是否走标准路径:设备不存在、产品脚本停用 / 未配置,或 encode 返回 standard + */ + private final boolean standard; + /** + * 透传写出的字节;走标准路径时为 null + */ + private final byte[] bytes; + /** + * encode 时使用的脚本(固定 hash):协议回复设备的实际响应时用它 decode;走标准路径时为 null + */ + private final IotScriptSource source; + + public static IotDownstreamEncodeRespDTO standard() { + return STANDARD; + } + + public static IotDownstreamEncodeRespDTO raw(byte[] bytes, IotScriptSource source) { + return new IotDownstreamEncodeRespDTO(false, bytes, source); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java index 60e8d6c..a561e7a 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.util; import cn.hutool.core.util.StrUtil; 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.mq.message.IotDeviceMessage; /** @@ -141,4 +142,53 @@ public final class IotMqttTopicUtils { return topic.startsWith(deviceTopicPrefix); } + // ========== 透传主题 ========== + + /** + * 是否为透传上报主题:/sys/{productKey}/{deviceName}/thing/raw/post + *

+ * 只判断主题格式;设备身份由调用方与已认证的连接(或 EMQX ACL)比对 + * + * @param topic 主题 + * @return 是否为透传上报主题 + */ + public static boolean isRawPostTopic(String topic) { + return isRawTopic(topic, IotDeviceMessageMethodEnum.RAW_POST); + } + + /** + * 是否为透传下发主题:/sys/{productKey}/{deviceName}/thing/raw/push + *

+ * 网关发布 raw 下行使用;EMQX 网关订阅到自己发布的该主题时需要忽略(回环) + * + * @param topic 主题 + * @return 是否为透传下发主题 + */ + public static boolean isRawPushTopic(String topic) { + return isRawTopic(topic, IotDeviceMessageMethodEnum.RAW_PUSH); + } + + /** + * 构建透传下发主题:/sys/{productKey}/{deviceName}/thing/raw/push + * + * @param productKey 产品 Key + * @param deviceName 设备名称 + * @return 透传下发主题 + */ + public static String buildRawPushTopic(String productKey, String deviceName) { + return buildTopicByMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), productKey, deviceName, false); + } + + private static boolean isRawTopic(String topic, IotDeviceMessageMethodEnum method) { + if (StrUtil.isBlank(topic)) { + return false; + } + // 格式必须是 /sys/{productKey}/{deviceName}/thing/raw/{post|push}:productKey、deviceName 各占一段且非空 + String[] parts = topic.split("/", -1); + if (parts.length != 7 || StrUtil.hasBlank(parts[2], parts[3])) { + return false; + } + return topic.equals(buildTopicByMethod(method.getMethod(), parts[2], parts[3], false)); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueue.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueue.java new file mode 100644 index 0000000..4c9607d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueue.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.iot.gateway.util; + +import io.vertx.core.Context; +import io.vertx.core.Future; +import lombok.Getter; + +import java.util.concurrent.Callable; +import java.util.concurrent.RejectedExecutionException; + +/** + * 阻塞任务队列:任务在 Vert.x worker 上执行,结果回到所在的 context(event loop) + *

    + *
  • 有序(一个连接一个):按提交顺序逐个执行、逐个完成(MQTT 的 PUBACK 顺序、TCP / WebSocket 的帧顺序); + * 不同连接各自一个队列,互不等待,并行使用 worker 池;
  • + *
  • 无序(UDP、EMQX 这类没有连接的入口):只限制排队数量,任务之间并行;
  • + *
  • 有界:排队中的任务达到上限时拒绝,由调用方决定(例如关闭连接、丢弃)。
  • + *
+ * 只能在所在的 context 线程上调用(Vert.x 的处理器都在这个线程上),所以不需要加锁 + * + * @author 芋道源码 + */ +public class IotWorkerTaskQueue { + + /** + * 默认每个连接最多排队的任务数 + */ + public static final int DEFAULT_MAX_PENDING = 256; + + @Getter + private final Context context; + private final boolean ordered; + private final int maxPending; + + /** + * 最后一个任务的结果:新任务排在它之后 + */ + private Future tail = Future.succeededFuture(); + /** + * 排队中(含执行中)的任务数 + */ + private int pending; + /** + * 是否已关闭:连接关闭后不再接收新任务 + */ + private boolean closed; + + /** + * 创建一个连接的有序队列 + * + * @param context 连接所在的 context + */ + public IotWorkerTaskQueue(Context context) { + this(context, true, DEFAULT_MAX_PENDING); + } + + public IotWorkerTaskQueue(Context context, boolean ordered, int maxPending) { + this.context = context; + this.ordered = ordered; + this.maxPending = maxPending; + } + + /** + * 提交一个阻塞任务:有序时排在之前的任务之后执行(前一个任务失败也照常执行) + * + * @param task 阻塞任务 + * @return 任务结果,在连接所在的 context 上完成;队列已满或已关闭时立即失败({@link RejectedExecutionException}) + */ + public Future submit(Callable task) { + if (closed) { + return Future.failedFuture(new RejectedExecutionException("连接已关闭")); + } + if (pending >= maxPending) { + return Future.failedFuture(new RejectedExecutionException("连接的待处理消息已达上限 " + maxPending)); + } + return enqueue(task); + } + + /** + * 提交连接的最后一个任务(例如离线通知):不受上限限制,之后不再接收新任务 + * + * @param task 阻塞任务 + * @return 任务结果 + */ + public Future submitLast(Callable task) { + if (closed) { + return Future.failedFuture(new RejectedExecutionException("连接已关闭")); + } + closed = true; + return enqueue(task); + } + + /** + * 入队:计入排队数,交给 worker 执行;任务结束(成功或失败)时减去 + * + * @param task 阻塞任务 + * @return 任务结果 + */ + private Future enqueue(Callable task) { + pending++; + // 1. 无序:直接提交,任务之间并行 + if (!ordered) { + Future result; + try { + result = context.executeBlocking(task, false); + } catch (RuntimeException e) { // Vertx 关闭时 worker 池会同步拒绝 + pending--; + return Future.failedFuture(e); + } + return result.andThen(ar -> pending--); + } + // 2. 有序:排在上一个任务(tail)之后,上一个结束(无论成败)再提交; + // executeBlocking 的 ordered = false:同一个 context 上的其它连接不受影响;本连接的顺序由 tail 串起来 + Future result = tail.transform(ignored -> context.executeBlocking(task, false)) + .andThen(ar -> pending--); + tail = result; + return result; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriberTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriberTest.java new file mode 100644 index 0000000..e7e1035 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriberTest.java @@ -0,0 +1,638 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link AbstractIotProtocolDownstreamSubscriber} 的单元测试 + * + * @author 芋道源码 + */ +public class AbstractIotProtocolDownstreamSubscriberTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 1L; + private static final String SERVER_ID = "server-1"; + + @Mock + private IotProtocol protocol; + @Mock + private IotMessageBus messageBus; + @Mock + private Vertx vertx; + @Mock + private IotDeviceMessageService deviceMessageService; + + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); + private MockedStatic springUtilMockedStatic; + private TestSubscriber subscriber; + + @BeforeEach + public void setUp() { + springUtilMockedStatic = mockStatic(SpringUtil.class); + lenient().when(protocol.getType()).thenReturn(IotProtocolTypeEnum.TCP); + subscriber = new TestSubscriber(protocol, messageBus); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + } + + // ========== onMessage:raw.push ========== + + @Test + public void testOnMessage_rawPush() { + // 准备参数:字节在 raw 字段;功能开关关闭时也照常写出 + byte[] bytes = new byte[]{0x01, 0x02, (byte) 0xFF}; + IotDeviceMessage message = buildRawPush(Base64.getEncoder().encodeToString(bytes)); + + // 调用 + subscriber.onMessage(message); + + // 断言:从 raw 解码后直接交给协议写出,不经过脚本、不读开关 + assertEquals(1, subscriber.rawMessages.size()); + assertSame(message, subscriber.rawMessages.get(0)); + assertArrayEquals(bytes, subscriber.rawBytes.get(0)); + assertNull(subscriber.rawSources.get(0)); + assertTrue(subscriber.handledMessages.isEmpty()); + springUtilMockedStatic.verifyNoInteractions(); + verify(protocol, never()).getVertx(); + } + + @Test + public void testOnMessage_rawPushPayloadLimit() { + // 调用:4097 字节、4096 字节(公共层只按通用上限解码,协议自己的字节规则由协议写出前校验) + subscriber.onMessage(buildRawPush(Base64.getEncoder().encodeToString(new byte[4097]))); + subscriber.onMessage(buildRawPush(Base64.getEncoder().encodeToString(new byte[4096]))); + + // 断言:超过上限的不写出(只记日志),不改走标准路径 + assertEquals(1, subscriber.rawBytes.size()); + assertEquals(4096, subscriber.rawBytes.get(0).length); + assertTrue(subscriber.handledMessages.isEmpty()); + } + + @Test + public void testOnMessage_rawPushInvalidRaw() { + // 调用:raw 为空、不是合法的 Base64 + subscriber.onMessage(buildRawPush(null)); + subscriber.onMessage(buildRawPush("!!not-base64!!")); + + // 断言:不写出,也不改走标准路径 + assertTrue(subscriber.rawMessages.isEmpty()); + assertTrue(subscriber.handledMessages.isEmpty()); + } + + @Test + public void testOnMessage_rawPushWriteFailureOnlyLog() { + // 准备参数:协议写出失败(设备离线返回 null、异步失败) + IotDeviceMessage message = buildRawPush(Base64.getEncoder().encodeToString(new byte[]{0x01})); + + // 调用,并断言:只记日志,不抛出 + subscriber.rawResult = null; + assertDoesNotThrow(() -> subscriber.onMessage(message)); + subscriber.rawResult = Future.failedFuture(new IllegalStateException("closed")); + assertDoesNotThrow(() -> subscriber.onMessage(message)); + assertEquals(2, subscriber.rawMessages.size()); + assertTrue(subscriber.handledMessages.isEmpty()); + } + + @Test + public void testOnMessage_rawPushReply() { + // 准备参数:raw.push 的回复(带 code)不是下发 + mockScriptProperties(); + IotDeviceMessage reply = IotDeviceMessage.replyOf("r1", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), + null, 0, null).setDeviceId(DEVICE_ID); + + // 调用 + subscriber.onMessage(reply); + + // 断言:直接走标准路径 + assertEquals(Collections.singletonList(reply), subscriber.handledMessages); + assertTrue(subscriber.rawMessages.isEmpty()); + } + + // ========== onMessage:指令与标准路径 ========== + + @Test + public void testOnMessage_commandSwitchOff() { + // 准备参数:功能开关默认关闭 + mockScriptProperties(); + IotDeviceMessage message = buildPropertySet(); + + // 调用 + subscriber.onMessage(message); + + // 断言:与原来一致,直接走标准路径,不切 worker、不调用设备消息 Service + assertEquals(Collections.singletonList(message), subscriber.handledMessages); + verify(protocol, never()).getVertx(); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testOnMessage_commandSwitchOn() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + mockExecuteBlocking(); + IotDeviceMessage message = buildPropertySet(); + + // 调用 + long beginNanos = System.nanoTime(); + subscriber.onMessage(message); + long endNanos = System.nanoTime(); + + // 断言:切到协议的 worker,交给设备消息 Service 按协议 encode,带上收到指令的时刻 + ArgumentCaptor receivedCaptor = ArgumentCaptor.forClass(Long.class); + verify(deviceMessageService).encodeDownstreamCommand(same(message), eq(IotProtocolTypeEnum.TCP), + receivedCaptor.capture()); + assertTrue(receivedCaptor.getValue() >= beginNanos); + assertTrue(receivedCaptor.getValue() <= endNanos); + assertTrue(subscriber.handledMessages.isEmpty()); + assertTrue(subscriber.rawMessages.isEmpty()); + } + + @Test + public void testOnMessage_commandEncodeResult() { + // 准备参数:encode 结果分别为标准路径、透传字节、不下发 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + mockExecuteBlocking(); + IotDeviceMessage standard = buildPropertySet("c1", DEVICE_ID); + IotDeviceMessage raw = buildPropertySet("c2", DEVICE_ID); + IotDeviceMessage notSend = buildPropertySet("c3", DEVICE_ID); + byte[] bytes = new byte[]{0x01, 0x02}; + IotScriptSource source = IotScriptSource.of(100L, "function encode(ctx) {}"); + when(deviceMessageService.encodeDownstreamCommand(any(), any(), anyLong())).thenAnswer(invocation -> { + IotDeviceMessage message = invocation.getArgument(0); + return message == standard ? IotDownstreamEncodeRespDTO.standard() + : message == raw ? IotDownstreamEncodeRespDTO.raw(bytes, source) : null; + }); + + // 调用 + subscriber.onMessage(standard); + subscriber.onMessage(raw); + subscriber.onMessage(notSend); + + // 断言:标准路径 → handleMessage;透传 → handleRawMessage,带上 encode 时固定的脚本;不下发 → 都不调用 + assertEquals(Collections.singletonList(standard), subscriber.handledMessages); + assertEquals(Collections.singletonList(raw), subscriber.rawMessages); + assertSame(bytes, subscriber.rawBytes.get(0)); + assertSame(source, subscriber.rawSources.get(0)); + verify(deviceMessageService).encodeDownstreamCommand(same(notSend), any(), anyLong()); + } + + @Test + public void testOnMessage_commandNotSupportRaw() { + // 准备参数:协议不支持透传下发(HTTP / CoAP) + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + subscriber.supportsRaw = false; + IotDeviceMessage message = buildPropertySet(); + + // 调用 + subscriber.onMessage(message); + + // 断言:直接走标准路径 + assertEquals(Collections.singletonList(message), subscriber.handledMessages); + verify(protocol, never()).getVertx(); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testOnMessage_notCommand() { + // 准备参数:功能开关开启,但不是指令(配置推送、OTA、属性设置的回复) + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + List messages = new ArrayList<>(); + messages.add(IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.CONFIG_PUSH.getMethod(), + Collections.emptyMap()).setDeviceId(DEVICE_ID)); + messages.add(IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), + Collections.emptyMap()).setDeviceId(DEVICE_ID)); + messages.add(IotDeviceMessage.replyOf("r1", IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), + null, 0, null).setDeviceId(DEVICE_ID)); + + // 调用 + messages.forEach(subscriber::onMessage); + + // 断言:都走标准路径 + assertEquals(messages, subscriber.handledMessages); + verify(protocol, never()).getVertx(); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testOnMessage_blankMethod() { + // 调用 + subscriber.onMessage(new IotDeviceMessage().setDeviceId(DEVICE_ID)); + + // 断言:直接忽略 + assertTrue(subscriber.handledMessages.isEmpty()); + assertTrue(subscriber.rawMessages.isEmpty()); + springUtilMockedStatic.verifyNoInteractions(); + } + + // ========== onMessage:同一设备的指令保序 ========== + + @Test + public void testOnMessage_commandsOrderedPerDevice() throws Exception { + // 准备参数:设备 1 的指令 c1、c2,encode 后走标准路径的 c3;设备 2 的指令 d1 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + Vertx realVertx = Vertx.vertx(); + when(protocol.getVertx()).thenReturn(realVertx); + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID); + IotDeviceMessage d1 = buildPropertySet("d1", 2L); + // mock 方法:c1 的 worker 阻塞,直到放行;记录执行顺序 + List order = new CopyOnWriteArrayList<>(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch otherDevice = new CountDownLatch(1); + doAnswer(invocation -> { + IotDeviceMessage message = invocation.getArgument(0); + if (message == c1) { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + } + if (message == d1) { + otherDevice.countDown(); + } + order.add(message.getRequestId()); + return message == c3 ? IotDownstreamEncodeRespDTO.standard() : null; + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); + + try { + // 调用:c1 阻塞在 worker 上时,依次收到 c2、c3、d1 + subscriber.onMessage(c1); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + long c2BeginNanos = System.nanoTime(); + subscriber.onMessage(c2); + long c2QueuedNanos = System.nanoTime(); + subscriber.onMessage(c3); + subscriber.onMessage(d1); + + // 断言:其它设备不受影响;同一设备的 c2、c3(含标准路径)排在 c1 之后,不越过它 + assertTrue(otherDevice.await(5, TimeUnit.SECONDS)); + assertFalse(order.contains("c2")); + assertFalse(order.contains("c3")); + assertTrue(subscriber.handledMessages.isEmpty()); + + // 放行:按收到的顺序执行 + release.countDown(); + await(() -> subscriber.handledMessages.size() == 1); + List deviceOrder = new ArrayList<>(order); + deviceOrder.remove("d1"); + assertEquals(Arrays.asList("c1", "c2", "c3"), deviceOrder); + assertEquals(Collections.singletonList(c3), subscriber.handledMessages); + // 断言:c2 排在 c1 之后才执行,传给 service 的仍是收到时(排队之前)的时刻,排队的时间计入截止 + ArgumentCaptor receivedCaptor = ArgumentCaptor.forClass(Long.class); + verify(deviceMessageService).encodeDownstreamCommand(same(c2), eq(IotProtocolTypeEnum.TCP), + receivedCaptor.capture()); + assertTrue(receivedCaptor.getValue() >= c2BeginNanos); + assertTrue(receivedCaptor.getValue() <= c2QueuedNanos); + } finally { + release.countDown(); + realVertx.close(); + } + } + + @Test + public void testOnMessage_commandFailureNotBlockNext() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + mockExecuteBlocking(); + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); + // mock 方法:c1 处理失败 + doAnswer(invocation -> { + if (invocation.getArgument(0) == c1) { + throw new IllegalStateException("boom"); + } + return IotDownstreamEncodeRespDTO.standard(); + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); + + // 调用 + subscriber.onMessage(c1); + subscriber.onMessage(c2); + + // 断言:c1 失败只记日志,c2 照常执行 + InOrder inOrder = inOrder(deviceMessageService); + inOrder.verify(deviceMessageService).encodeDownstreamCommand(same(c1), any(), anyLong()); + inOrder.verify(deviceMessageService).encodeDownstreamCommand(same(c2), any(), anyLong()); + assertEquals(Collections.singletonList(c2), subscriber.handledMessages); + // 断言:队列执行完后已移除,之后的指令新建队列照常执行 + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID); + subscriber.onMessage(c3); + assertEquals(Arrays.asList(c2, c3), subscriber.handledMessages); + } + + @Test + public void testOnMessage_commandsCapacity() throws Exception { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + Vertx realVertx = Vertx.vertx(); + when(protocol.getVertx()).thenReturn(realVertx); + IotDeviceMessage first = buildPropertySet("first", DEVICE_ID); + IotDeviceMessage overflow = buildPropertySet("overflow", DEVICE_ID); + // mock 方法:第一条阻塞 + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + if (invocation.getArgument(0) == first) { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + } + return null; + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); + + try { + // 调用:第一条执行中,再排 256 条(上限),第 257 条超出 + subscriber.onMessage(first); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int i = 0; i < 256; i++) { + subscriber.onMessage(buildPropertySet("q" + i, DEVICE_ID)); + } + subscriber.onMessage(overflow); + release.countDown(); + + // 断言:上限内的全部执行,超出的丢弃 + verify(deviceMessageService, timeout(5000).times(257)).encodeDownstreamCommand(any(), any(), anyLong()); + verify(deviceMessageService, never()).encodeDownstreamCommand(same(overflow), any(), anyLong()); + } finally { + release.countDown(); + realVertx.close(); + } + } + + @Test + public void testOnMessage_commandsCapacityConfigured() throws Exception { + // 准备参数:每个设备最多排队 2 条 + gatewayProperties.getScript().setEnabled(true).setMaxPendingCommandsPerDevice(2); + mockScriptProperties(); + mockDeviceMessageService(); + Vertx realVertx = Vertx.vertx(); + when(protocol.getVertx()).thenReturn(realVertx); + IotDeviceMessage first = buildPropertySet("first", DEVICE_ID); + IotDeviceMessage overflow = buildPropertySet("overflow", DEVICE_ID); + // mock 方法:第一条阻塞 + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + if (invocation.getArgument(0) == first) { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + } + return null; + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); + + try { + // 调用:第一条执行中,再排 2 条(上限),第 3 条超出 + subscriber.onMessage(first); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + subscriber.onMessage(buildPropertySet("q0", DEVICE_ID)); + subscriber.onMessage(buildPropertySet("q1", DEVICE_ID)); + subscriber.onMessage(overflow); + release.countDown(); + + // 断言:按配置的上限,执行中 1 条 + 排队 2 条全部执行,超出的丢弃 + verify(deviceMessageService, timeout(5000).times(3)).encodeDownstreamCommand(any(), any(), anyLong()); + verify(deviceMessageService, never()).encodeDownstreamCommand(same(overflow), any(), anyLong()); + } finally { + release.countDown(); + realVertx.close(); + } + } + + @Test + public void testStop_dropPendingCommands() throws Exception { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + Vertx realVertx = Vertx.vertx(); + when(protocol.getVertx()).thenReturn(realVertx); + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); + // mock 方法:c1 阻塞 + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + return null; + }).when(deviceMessageService).encodeDownstreamCommand(same(c1), any(), anyLong()); + + try { + // 调用:c1 执行中、c2 排队时停止 + subscriber.onMessage(c1); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + subscriber.onMessage(c2); + subscriber.stop(); + release.countDown(); + + // 断言:执行中的 c1 照常结束,排队的 c2 不再执行 + verify(deviceMessageService, after(500).never()).encodeDownstreamCommand(same(c2), any(), anyLong()); + verify(messageBus).unregister(same(subscriber)); + } finally { + release.countDown(); + realVertx.close(); + } + } + + @Test + public void testStop_restartNotReviveOldCommands() throws Exception { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + mockScriptProperties(); + mockDeviceMessageService(); + Vertx realVertx = Vertx.vertx(); + when(protocol.getVertx()).thenReturn(realVertx); + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID); + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID); + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID); + // mock 方法:c1 阻塞 + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + if (invocation.getArgument(0) == c1) { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + } + return null; + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong()); + + try { + // 调用:c1 执行中、c2 排队时停止,再启动后收到 c3 + subscriber.onMessage(c1); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + subscriber.onMessage(c2); + subscriber.stop(); + subscriber.start(); + subscriber.onMessage(c3); + + // 断言:c3 新建队列照常执行,不等待旧的 c1 + verify(deviceMessageService, timeout(5000)).encodeDownstreamCommand(same(c3), any(), anyLong()); + // 断言:c1 结束后,旧队列里的 c2 不会因为新队列而复活 + release.countDown(); + verify(deviceMessageService, after(500).never()).encodeDownstreamCommand(same(c2), any(), anyLong()); + } finally { + release.countDown(); + realVertx.close(); + } + } + + // ========== replyRawResult ========== + + @Test + public void testReplyRawResult() { + // 准备参数:设备的异常响应(协议判定的设备错误) + IotDeviceMessage request = buildRawPush(Base64.getEncoder().encodeToString(new byte[]{0x03, 0x00, 0x10, 0x00, 0x02})); + IotScriptSource source = IotScriptSource.of(100L, "function decode(ctx) {}"); + byte[] payload = new byte[]{(byte) 0x83, 0x02}; + // mock 方法 + when(protocol.getServerId()).thenReturn(SERVER_ID); + mockDeviceMessageService(); + mockExecuteBlocking(); + + // 调用 + subscriber.reply(request, source, payload, MODBUS_EXCEPTION); + + // 断言:切到协议的 worker 回复给 biz,serverId 为本协议实例,设备错误原样传入 + verify(vertx).executeBlocking(any(Callable.class), eq(false)); + verify(deviceMessageService).sendRawReply(same(request), same(source), aryEq(payload), same(MODBUS_EXCEPTION), + eq(SERVER_ID)); + } + + // ========== 辅助方法 ========== + + private void mockScriptProperties() { + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties); + } + + private void mockDeviceMessageService() { + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); + } + + /** + * executeBlocking 在当前线程同步执行(SpringUtil 的静态 mock 只在当前线程生效) + */ + @SuppressWarnings("unchecked") + private void mockExecuteBlocking() { + when(protocol.getVertx()).thenReturn(vertx); + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return Future.succeededFuture(callable.call()); + }); + } + + private static IotDeviceMessage buildRawPush(String raw) { + return IotDeviceMessage.requestOf("r1", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), null) + .setDeviceId(DEVICE_ID).setRaw(raw); + } + + private static IotDeviceMessage buildPropertySet() { + return buildPropertySet("r2", DEVICE_ID); + } + + private static IotDeviceMessage buildPropertySet(String requestId, Long deviceId) { + return IotDeviceMessage.requestOf(requestId, IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), + Collections.singletonMap("power", 1)).setDeviceId(deviceId); + } + + /** + * 等待条件成立(最长 5 秒) + */ + private static void await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!condition.getAsBoolean()) { + assertTrue(System.nanoTime() < deadline, "等待超时"); + Thread.sleep(10); + } + } + + /** + * 记录调用的测试订阅者 + */ + private static class TestSubscriber extends AbstractIotProtocolDownstreamSubscriber { + + // worker 上也会调用(指令保序的用例),所以使用线程安全的 List + private final List handledMessages = new CopyOnWriteArrayList<>(); + private final List rawMessages = new CopyOnWriteArrayList<>(); + private final List rawBytes = new CopyOnWriteArrayList<>(); + private final List rawSources = new CopyOnWriteArrayList<>(); + private boolean supportsRaw = true; + private Future rawResult = Future.succeededFuture(); + + TestSubscriber(IotProtocol protocol, IotMessageBus messageBus) { + super(protocol, messageBus); + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + handledMessages.add(message); + } + + @Override + public Future handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { + rawMessages.add(message); + rawBytes.add(bytes); + rawSources.add(source); + return rawResult; + } + + @Override + public boolean supportsRawDownstream() { + return supportsRaw; + } + + void reply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError) { + replyRawResult(request, source, payload, deviceError); + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandlerTest.java new file mode 100644 index 0000000..d1800f1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandlerTest.java @@ -0,0 +1,478 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream; + +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.coap.MediaTypeRegistry; +import org.eclipse.californium.core.coap.Option; +import org.eclipse.californium.core.coap.OptionSet; +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.network.Exchange; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link IotCoapUpstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotCoapUpstreamHandlerTest extends BaseMockitoUnitTest { + + private static final String PRODUCT_KEY = "pk"; + private static final String DEVICE_NAME = "dn"; + private static final String TOKEN = "token"; + private static final String SERVER_ID = "coap-server"; + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03}; + private static final List RAW_POST_PATH = Arrays.asList("topic", "sys", PRODUCT_KEY, DEVICE_NAME, + "thing", "raw", "post"); + private static final List PROPERTY_POST_PATH = Arrays.asList("topic", "sys", PRODUCT_KEY, DEVICE_NAME, + "thing", "property", "post"); + + @Mock + private IotDeviceTokenService deviceTokenService; + @Mock + private IotDeviceMessageService deviceMessageService; + @Mock + private CoapExchange exchange; + @Mock + private Exchange advancedExchange; + @Mock + private OptionSet options; + @Mock + private Option tokenOption; + + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); + private MockedStatic springUtilMockedStatic; + private IotCoapUpstreamHandler handler; + + @BeforeEach + public void setUp() { + springUtilMockedStatic = mockStatic(SpringUtil.class); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceTokenService.class)).thenReturn(deviceTokenService); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties); + handler = new IotCoapUpstreamHandler(SERVER_ID); + } + + @AfterEach + public void tearDown() { + handler.stop(); + springUtilMockedStatic.close(); + } + + @Test + public void testHandle_rawPostConfirmable() { + // 准备参数 + mockRawPostRequest(CoAP.Type.CON); + // mock 方法 + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), + eq(IotProtocolTypeEnum.COAP), anyLong())).thenReturn("M1"); + + // 调用 + handler.handle(exchange); + + // 断言:CON 先回空 ACK,再(在透传 worker 上)处理,最后以分离响应返回一次 JSON 结果,data = {messageId} + Map json = captureResponse(); + InOrder inOrder = inOrder(exchange, deviceMessageService); + inOrder.verify(exchange).accept(); + inOrder.verify(deviceMessageService).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + inOrder.verify(exchange).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); + assertEquals(0, json.get("code")); + @SuppressWarnings("unchecked") + Map data = (Map) json.get("data"); + assertEquals("M1", data.get("messageId")); + assertEquals(1, data.size()); + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt()); + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + @Test + public void testHandle_rawPostNonConfirmable() { + // 准备参数 + mockRawPostRequest(CoAP.Type.NON); + // mock 方法 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M2"); + + // 调用 + handler.handle(exchange); + + // 断言:NON 不回空 ACK,直接响应一次 JSON 结果 + verify(exchange, never()).accept(); + Map json = captureResponse(); + assertEquals(0, json.get("code")); + @SuppressWarnings("unchecked") + Map data = (Map) json.get("data"); + assertEquals("M2", data.get("messageId")); + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt()); + } + + @Test + public void testHandle_rawPostServiceException() { + // 准备参数 + mockRawPostRequest(CoAP.Type.CON); + // mock 方法:处理超时(service 不投递,只记错误日志) + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(exception(SCRIPT_TIMEOUT)); + + // 调用 + handler.handle(exchange); + + // 断言:CON 已先回空 ACK;截止前失败,响应一次错误 JSON + verify(exchange).accept(); + Map json = captureResponse(); + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); + assertEquals(SCRIPT_TIMEOUT.getMsg(), json.get("msg")); + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt()); + } + + @Test + public void testHandle_rawPostDeadline() throws Exception { + // 准备参数:截止时长 100ms + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); + IotCoapUpstreamHandler deadlineHandler = new IotCoapUpstreamHandler(SERVER_ID); + mockRawPostRequest(CoAP.Type.CON); + // mock 方法:处理阻塞,直到放行后才返回(迟到的结果) + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + return "LATE"; + }); + + // mock 方法:与 Californium 一致,非处理线程写的响应排到 Exchange 的串行执行器(owner)上才发出 + ExecutorService owner = Executors.newSingleThreadExecutor(); + List sent = new CopyOnWriteArrayList<>(); + CountDownLatch sentLatch = new CountDownLatch(1); + doAnswer(invocation -> { + String json = invocation.getArgument(1); + owner.execute(() -> { + sent.add(json); + sentLatch.countDown(); + }); + return null; + }).when(exchange).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); + + try { + // 调用:在 owner 上处理请求;处理入口立即返回,不等待阻塞中的发送 + owner.submit(() -> deadlineHandler.handle(exchange)).get(5, TimeUnit.SECONDS); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + + // 断言:发送仍阻塞时,截止计时器的超时响应已经在 owner 上发出 + assertTrue(sentLatch.await(5, TimeUnit.SECONDS)); + assertEquals(1, release.getCount()); + Map json = JsonUtils.parseMap(sent.get(0)); + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); + assertEquals(SCRIPT_TIMEOUT.getMsg(), json.get("msg")); + + // 放行:等透传 worker 处理完,迟到的结果丢弃,不再响应 + release.countDown(); + ThreadPoolExecutor rawExecutor = getRawExecutor(deadlineHandler); + rawExecutor.shutdown(); + assertTrue(rawExecutor.awaitTermination(5, TimeUnit.SECONDS)); + owner.submit(() -> null).get(5, TimeUnit.SECONDS); + assertEquals(1, sent.size()); + verify(exchange).accept(); + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } finally { + release.countDown(); + deadlineHandler.stop(); + owner.shutdownNow(); + } + } + + @Test + public void testHandle_rawPostQueueExpired() throws Exception { + // 准备参数:截止时长 100ms;透传 worker 只留 1 个线程,并被占住 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); + IotCoapUpstreamHandler deadlineHandler = new IotCoapUpstreamHandler(SERVER_ID); + mockRawPostRequest(CoAP.Type.NON); + ThreadPoolExecutor rawExecutor = getRawExecutor(deadlineHandler); + rawExecutor.setCorePoolSize(1); + rawExecutor.setMaximumPoolSize(1); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + rawExecutor.execute(() -> { + blocked.countDown(); + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + // mock 方法:与 service 的截止分支一致(已过截止:不执行脚本、不投递,只记错误日志,并返回超时) + List nowAtCall = new CopyOnWriteArrayList<>(); + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), + eq(IotProtocolTypeEnum.COAP), anyLong())).thenAnswer(invocation -> { + nowAtCall.add(System.nanoTime()); + throw exception(SCRIPT_TIMEOUT); + }); + + try { + // 调用:请求排在 worker 队列中 + long beginNanos = System.nanoTime(); + deadlineHandler.handle(exchange); + long queuedNanos = System.nanoTime(); + + // 断言:排队期间到期,计时器响应一次超时 + Map json = captureResponse(); + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); + + // 放行:排队的请求带着入队之前的收到时刻交给 service(已过截止:不执行脚本、不投递,只记错误日志并抛出超时); + // 协议不再响应第二次 + release.countDown(); + rawExecutor.shutdown(); + assertTrue(rawExecutor.awaitTermination(5, TimeUnit.SECONDS)); + ArgumentCaptor receivedCaptor = ArgumentCaptor.forClass(Long.class); + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), + receivedCaptor.capture()); + assertTrue(receivedCaptor.getValue() >= beginNanos); + assertTrue(receivedCaptor.getValue() <= queuedNanos); + assertTrue(nowAtCall.get(0) >= receivedCaptor.getValue() + TimeUnit.MILLISECONDS.toNanos(100)); + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); + } finally { + release.countDown(); + deadlineHandler.stop(); + } + } + + @Test + public void testHandle_rawPostRejected() { + // 准备参数:透传 worker 拒绝(已关闭) + mockRawPostRequest(CoAP.Type.NON); + getRawExecutor(handler).shutdown(); + + // 调用 + handler.handle(exchange); + + // 断言:立即响应一次 429,不调用发送 + assertEquals(TOO_MANY_REQUESTS.getCode(), captureResponse().get("code")); + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } + + @Test + public void testHandle_rawPostUnknownException() { + // 准备参数 + mockRawPostRequest(CoAP.Type.NON); + // mock 方法:未知异常 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(new IllegalStateException("boom")); + + // 调用 + handler.handle(exchange); + + // 断言:响应一次 500 + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code")); + } + + @Test + public void testHandle_rawPostPayloadInvalid() { + // 准备参数 + mockRawPostRequest(CoAP.Type.NON); + // mock 方法:载荷长度不合法(不投递) + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(invalidParamException("raw 载荷长度必须在 1~{} 字节之间", 4096)); + + // 调用 + handler.handle(exchange); + + // 断言 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); + } + + @Test + public void testHandle_rawPathNotOctetStream() { + // 准备参数:raw/post 路径,但 content-format 不是 42:走 JSON 分支,交给标准路径(由它拒绝透传方法) + mockRequest(RAW_POST_PATH, "{\"method\":\"thing.raw.post\"}".getBytes(StandardCharsets.UTF_8)); + when(options.getContentFormat()).thenReturn(MediaTypeRegistry.APPLICATION_JSON); + + // 调用 + handler.handle(exchange); + + // 断言:不走透传 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + verify(deviceMessageService).sendDeviceMessage(any(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + verify(exchange, never()).accept(); + } + + // ========== 标准上报、认证 ========== + + @Test + public void testHandle_propertyPost() { + // 准备参数:JSON 属性上报 + mockRequest(PROPERTY_POST_PATH, "{\"method\":\"thing.property.post\",\"params\":{\"t\":25}}" + .getBytes(StandardCharsets.UTF_8)); + + // 调用 + handler.handle(exchange); + + // 断言:同步响应成功;按路径的设备身份交给标准路径,不走透传 + assertEquals(0, captureResponse().get("code")); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageService).sendDeviceMessage(messageCaptor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), + eq(SERVER_ID)); + assertEquals("thing.property.post", messageCaptor.getValue().getMethod()); + assertEquals(25, ((Map) messageCaptor.getValue().getParams()).get("t")); + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + verify(exchange, never()).accept(); + } + + @Test + public void testHandle_propertyPostEmptyPayload() { + // 准备参数:没有请求体 + mockRequest(PROPERTY_POST_PATH, new byte[0]); + + // 调用 + handler.handle(exchange); + + // 断言:400,不投递 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + @Test + public void testHandle_methodMismatch() { + // 准备参数:路径是属性上报,请求体是事件上报 + mockRequest(PROPERTY_POST_PATH, "{\"method\":\"thing.event.post\",\"params\":{}}" + .getBytes(StandardCharsets.UTF_8)); + + // 调用 + handler.handle(exchange); + + // 断言:400,不投递 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + @Test + public void testHandle_tokenMissing() { + // 准备参数:没有 token + when(exchange.getRequestOptions()).thenReturn(options); + when(options.getOthers()).thenReturn(Collections.emptyList()); + + // 调用 + handler.handle(exchange); + + // 断言:401,不投递 + assertEquals(UNAUTHORIZED.getCode(), captureResponse().get("code")); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testHandle_tokenInvalid() { + // 准备参数:token 校验不通过 + mockToken(); + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(null); + + // 调用 + handler.handle(exchange); + + // 断言:401,不投递 + assertEquals(UNAUTHORIZED.getCode(), captureResponse().get("code")); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testHandle_deviceMismatch() { + // 准备参数:token 属于其它设备 + mockToken(); + when(options.getUriPath()).thenReturn(PROPERTY_POST_PATH); + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, "other")); + + // 调用 + handler.handle(exchange); + + // 断言:403,不投递 + assertEquals(FORBIDDEN.getCode(), captureResponse().get("code")); + verifyNoInteractions(deviceMessageService); + } + + // ========== 辅助方法 ========== + + private void mockRawPostRequest(CoAP.Type type) { + // 请求:thing/raw/post、content-format 42 + mockRequest(RAW_POST_PATH, PAYLOAD); + when(options.getContentFormat()).thenReturn(MediaTypeRegistry.APPLICATION_OCTET_STREAM); + when(exchange.advanced()).thenReturn(advancedExchange); + when(advancedExchange.getRequest()).thenReturn(new Request(CoAP.Code.POST, type)); + } + + private void mockRequest(List uriPath, byte[] payload) { + // 请求:已认证(token 与路径一致) + mockToken(); + when(options.getUriPath()).thenReturn(uriPath); + when(exchange.getRequestPayload()).thenReturn(payload); + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, DEVICE_NAME)); + } + + private void mockToken() { + // 请求:带 token 的自定义 Option + when(exchange.getRequestOptions()).thenReturn(options); + when(options.getOthers()).thenReturn(Collections.singletonList(tokenOption)); + when(tokenOption.getNumber()).thenReturn(IotCoapAbstractHandler.OPTION_TOKEN); + when(tokenOption.getValue()).thenReturn(TOKEN.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 等待(透传在 worker 上异步响应)并断言只响应一次 JSON,返回它 + */ + private Map captureResponse() { + ArgumentCaptor jsonCaptor = ArgumentCaptor.forClass(String.class); + verify(exchange, timeout(5000)).respond(eq(CoAP.ResponseCode.CONTENT), jsonCaptor.capture(), + eq(MediaTypeRegistry.APPLICATION_JSON)); + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt()); + return JsonUtils.parseMap(jsonCaptor.getValue()); + } + + /** + * 获取处理器的透传 worker + */ + private static ThreadPoolExecutor getRawExecutor(IotCoapUpstreamHandler handler) { + return (ThreadPoolExecutor) ReflectUtil.getFieldValue(handler, "rawExecutor"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocolTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocolTest.java new file mode 100644 index 0000000..9de2af6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocolTest.java @@ -0,0 +1,108 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx; + +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.mqtt.MqttClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotEmqxProtocol} 的单元测试 + * + * @author 芋道源码 + */ +public class IotEmqxProtocolTest extends BaseMockitoUnitTest { + + private static final String RAW_PUSH_TOPIC = "/sys/pk/dn/thing/raw/push"; + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; + + @Mock + private MqttClient mqttClient; + + private MockedStatic springUtilMockedStatic; + private IotEmqxProtocol protocol; + + @BeforeEach + public void setUp() { + // 构造方法里的 handler 通过 SpringUtil 获取 Bean:这里不需要真实的 Bean + springUtilMockedStatic = mockStatic(SpringUtil.class); + IotGatewayProperties.ProtocolProperties properties = new IotGatewayProperties.ProtocolProperties() + .setId("emqx-test").setPort(18083).setEmqx(new IotEmqxConfig()); + protocol = new IotEmqxProtocol(properties); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + } + + @Test + public void testPublishMessageAsync_success() { + // mock 方法:已连接,publish 成功 + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient); + when(mqttClient.isConnected()).thenReturn(true); + when(mqttClient.publish(eq(RAW_PUSH_TOPIC), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) + .thenReturn(Future.succeededFuture(1)); + + // 调用 + Future future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES); + // 断言 + assertTrue(future.succeeded()); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(mqttClient).publish(eq(RAW_PUSH_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)); + assertArrayEquals(BYTES, payloadCaptor.getValue().getBytes()); + } + + @Test + public void testPublishMessageAsync_notConnected() { + // 调用:MQTT Client 未创建 + Future future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + + // mock 方法:MQTT Client 已断开 + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient); + when(mqttClient.isConnected()).thenReturn(false); + // 调用,并断言 + assertFailed(protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES), DEVICE_OFFLINE_OR_WRITE_FAILED); + verify(mqttClient, never()).publish(any(), any(Buffer.class), any(), anyBoolean(), anyBoolean()); + } + + @Test + public void testPublishMessageAsync_publishFailed() { + // mock 方法:已连接,但 publish 异步失败 + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient); + when(mqttClient.isConnected()).thenReturn(true); + when(mqttClient.publish(eq(RAW_PUSH_TOPIC), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) + .thenReturn(Future.failedFuture(new IllegalStateException("Connection is closed"))); + + // 调用 + Future future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES); + // 断言:异步失败转成 106,而不是成功 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + private static void assertFailed(Future future, ErrorCode errorCode) { + assertTrue(future.failed()); + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); + assertEquals(errorCode.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandlerTest.java new file mode 100644 index 0000000..e5db9c6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandlerTest.java @@ -0,0 +1,422 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Context; +import io.vertx.core.Vertx; +import io.vertx.core.VertxOptions; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_OUTPUT_INVALID; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_REJECTED; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotHttpUpstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotHttpUpstreamHandlerTest extends BaseMockitoUnitTest { + + private static final String PRODUCT_KEY = "pk"; + private static final String DEVICE_NAME = "dn"; + private static final String TOKEN = "token"; + private static final String SERVER_ID = "http-server"; + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03}; + + @Mock + private IotDeviceTokenService deviceTokenService; + @Mock + private IotDeviceMessageService deviceMessageService; + @Mock + private IotHttpProtocol protocol; + @Mock + private RoutingContext context; + @Mock + private HttpServerRequest request; + @Mock + private RequestBody requestBody; + + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); + /** + * 响应是否已结束:end 之后 ended() 返回 true,与真实的 HttpServerResponse 一致 + */ + private final AtomicBoolean responseEnded = new AtomicBoolean(); + private HttpServerResponse response; + private MockedStatic springUtilMockedStatic; + private Vertx vertx; + + @BeforeEach + public void setUp() { + springUtilMockedStatic = mockStatic(SpringUtil.class); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceTokenService.class)).thenReturn(deviceTokenService); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties); + when(protocol.getServerId()).thenReturn(SERVER_ID); + // 链式调用(setStatusCode、putHeader)返回自身 + response = mock(HttpServerResponse.class, RETURNS_SELF); + lenient().when(response.ended()).thenAnswer(invocation -> responseEnded.get()); + lenient().doAnswer(invocation -> { + responseEnded.set(true); + return null; + }).when(response).end(anyString()); + vertx = Vertx.vertx(); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + vertx.close(); + } + + @Test + public void testHandle_rawPostSuccess() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法:记录执行线程 + AtomicBoolean onWorker = new AtomicBoolean(); + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), + eq(IotProtocolTypeEnum.HTTP), anyLong())).thenAnswer(invocation -> { + onWorker.set(Context.isOnWorkerThread()); + return "M1"; + }); + + // 调用 + handleOnEventLoop(handler); + + // 断言:只写一次 JSON 结果,data = {messageId} + Map json = captureResponse(); + assertEquals(0, json.get("code")); + @SuppressWarnings("unchecked") + Map data = (Map) json.get("data"); + assertEquals("M1", data.get("messageId")); + assertEquals(1, data.size()); + verify(response, never()).end(any(Buffer.class)); + // 断言:在 worker 上处理 + assertTrue(onWorker.get()); + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + @Test + public void testHandle_rawPostReceivedBeforeQueue() throws Exception { + // 准备参数:worker 池只有 1 个线程,并被占住,请求只能排队 + vertx.close(); + vertx = Vertx.vertx(new VertxOptions().setWorkerPoolSize(1)); + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + vertx.executeBlocking(() -> { + blocked.countDown(); + return release.await(10, TimeUnit.SECONDS); + }, false); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + // mock 方法 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M5"); + + long beginNanos; + long queuedNanos; + try { + // 调用:请求排在 worker 队列中 + beginNanos = System.nanoTime(); + handleOnEventLoop(handler); + queuedNanos = System.nanoTime(); + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } finally { + release.countDown(); + } + + // 断言:放行后照常响应;传给 service 的收到时刻在交给 worker 之前取得(排队的时间计入截止),不是 worker 开始执行时 + assertEquals(0, captureResponse().get("code")); + ArgumentCaptor receivedCaptor = ArgumentCaptor.forClass(Long.class); + verify(deviceMessageService).sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), + eq(IotProtocolTypeEnum.HTTP), receivedCaptor.capture()); + assertTrue(receivedCaptor.getValue() >= beginNanos); + assertTrue(receivedCaptor.getValue() <= queuedNanos); + } + + @Test + public void testHandle_rawPostTimerCancelled() throws Exception { + // 准备参数:同步截止 100ms,处理很快完成 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M2"); + + // 调用 + handleOnEventLoop(handler); + + // 断言:先返回成功;过了截止时间也不再写超时 + assertEquals(0, captureResponse().get("code")); + Thread.sleep(300); + verify(response, times(1)).end(anyString()); + } + + @Test + public void testHandle_rawPostServiceException() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法:脚本繁忙(service 不投递,只记错误日志) + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(exception(SCRIPT_REJECTED)); + + // 调用 + handleOnEventLoop(handler); + + // 断言:返回对应的错误码 + Map json = captureResponse(); + assertEquals(SCRIPT_REJECTED.getCode(), json.get("code")); + assertEquals(SCRIPT_REJECTED.getMsg(), json.get("msg")); + } + + @Test + public void testHandle_rawPostOutputInvalid() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(exception(SCRIPT_OUTPUT_INVALID, "脚本输出 不允许包含字段 raw")); + + // 调用 + handleOnEventLoop(handler); + + // 断言 + Map json = captureResponse(); + assertEquals(SCRIPT_OUTPUT_INVALID.getCode(), json.get("code")); + assertEquals("数据解析脚本输出不合法:脚本输出 不允许包含字段 raw", json.get("msg")); + } + + @Test + public void testHandle_rawPostPayloadInvalid() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法:载荷长度不合法(不投递) + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(invalidParamException("raw 载荷长度必须在 1~{} 字节之间", 4096)); + + // 调用 + handleOnEventLoop(handler); + + // 断言 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code")); + } + + @Test + public void testHandle_rawPostUnknownException() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(new IllegalStateException("boom")); + + // 调用 + handleOnEventLoop(handler); + + // 断言 + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code")); + } + + @Test + public void testHandle_rawPostSyncDeadline() throws Exception { + // 准备参数:同步截止 100ms,处理阻塞到截止之后 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100)); + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + CountDownLatch release = new CountDownLatch(1); + // mock 方法 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { + assertTrue(release.await(10, TimeUnit.SECONDS)); + return "M-late"; + }); + + try { + // 调用 + handleOnEventLoop(handler); + + // 断言:到期先返回 SCRIPT_TIMEOUT + Map json = captureResponse(); + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code")); + } finally { + release.countDown(); + } + // 断言:之后完成的结果不再写(第二次检查 ended() 时丢弃) + verify(response, timeout(5000).times(2)).ended(); + verify(response, times(1)).end(anyString()); + verify(response, never()).end(any(Buffer.class)); + } + + @Test + public void testHandle_rawPostClientClosed() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockRawPostRequest(); + // mock 方法:客户端在处理期间断开 + when(response.closed()).thenReturn(true); + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M3"); + + // 调用 + handleOnEventLoop(handler); + + // 断言:丢弃结果,不写响应 + verify(response, timeout(5000)).closed(); + verify(response, never()).end(anyString()); + verify(response, never()).end(any(Buffer.class)); + } + + @Test + public void testHandle_standardPost() throws Exception { + // 准备参数:标准 JSON 上报 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockStandardPostRequest(); + // mock 方法:记录执行线程 + AtomicBoolean onWorker = new AtomicBoolean(); + doAnswer(invocation -> { + onWorker.set(Context.isOnWorkerThread()); + ((IotDeviceMessage) invocation.getArgument(0)).setId("M4"); + return null; + }).when(deviceMessageService).sendDeviceMessage(any(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + + // 调用 + handleOnEventLoop(handler); + + // 断言:发送完成后才响应,data = {messageId} + Map json = captureResponse(); + assertEquals(0, json.get("code")); + @SuppressWarnings("unchecked") + Map data = (Map) json.get("data"); + assertEquals("M4", data.get("messageId")); + verify(response, times(1)).end(anyString()); + // 断言:获取设备、发送总线在 worker 上执行,不阻塞 event loop + assertTrue(onWorker.get()); + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } + + @Test + public void testHandle_standardPostServiceException() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockStandardPostRequest(); + // mock 方法:设备不存在 + doThrow(invalidParamException("设备不存在")).when(deviceMessageService) + .sendDeviceMessage(any(), any(), any(), any()); + + // 调用 + handleOnEventLoop(handler); + + // 断言:与原来一致,返回 CommonResult 的错误码(HTTP 状态码不变) + Map json = captureResponse(); + assertEquals(BAD_REQUEST.getCode(), json.get("code")); + assertEquals("设备不存在", json.get("msg")); + } + + @Test + public void testHandle_standardPostUnknownException() throws Exception { + // 准备参数 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol); + mockStandardPostRequest(); + // mock 方法 + doThrow(new IllegalStateException("boom")).when(deviceMessageService) + .sendDeviceMessage(any(), any(), any(), any()); + + // 调用 + handleOnEventLoop(handler); + + // 断言 + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code")); + } + + // ========== 辅助方法 ========== + + private void mockRawPostRequest() { + mockRequest("thing/raw/post", "application/octet-stream", PAYLOAD); + when(context.vertx()).thenReturn(vertx); + } + + private void mockStandardPostRequest() { + mockRequest("thing/property/post", "application/json", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}".getBytes()); + when(context.vertx()).thenReturn(vertx); + } + + private void mockRequest(String method, String contentType, byte[] body) { + // 请求:已认证(token 与路径一致) + when(context.request()).thenReturn(request); + when(context.response()).thenReturn(response); + lenient().when(request.path()).thenReturn("/topic/sys/" + PRODUCT_KEY + "/" + DEVICE_NAME + "/" + method); + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(TOKEN); + lenient().when(request.getHeader(HttpHeaders.CONTENT_TYPE)).thenReturn(contentType); + when(context.pathParam("productKey")).thenReturn(PRODUCT_KEY); + when(context.pathParam("deviceName")).thenReturn(DEVICE_NAME); + when(context.pathParam("*")).thenReturn(method); + when(context.body()).thenReturn(requestBody); + when(requestBody.buffer()).thenReturn(Buffer.buffer(body)); + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, DEVICE_NAME)); + } + + /** + * 在 Vert.x event loop 上执行 handle,与真实的 HTTP 请求一致 + */ + private void handleOnEventLoop(IotHttpUpstreamHandler handler) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + vertx.runOnContext(v -> { + try { + handler.handle(context); + } finally { + latch.countDown(); + } + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + + /** + * 等待并返回写出的 JSON 响应(只写一次) + */ + private Map captureResponse() { + ArgumentCaptor jsonCaptor = ArgumentCaptor.forClass(String.class); + verify(response, timeout(5000)).end(jsonCaptor.capture()); + return JsonUtils.parseMap(jsonCaptor.getValue()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtilsTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtilsTest.java new file mode 100644 index 0000000..6fe9fe8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtilsTest.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; + +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import org.junit.jupiter.api.Test; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotModbusCommonUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusCommonUtilsTest { + + @Test + public void testBuildResponsePdu_exception() { + // 准备参数:解码器已把 0x83 还原为原功能码 3,异常码单独存放 + IotModbusFrame frame = new IotModbusFrame().setSlaveId(1).setFunctionCode(3) + .setPdu(new byte[]{0x02}).setExceptionCode(2); + + // 调用 + 断言:还原 0x80 异常位 + assertArrayEquals(HexUtil.decodeHex("8302"), IotModbusCommonUtils.buildResponsePdu(frame)); + } + + @Test + public void testBuildResponsePdu_normal() { + // 准备参数:解码器的 pdu 是功能码之后的数据(含 byteCount) + IotModbusFrame frame = new IotModbusFrame().setSlaveId(1).setFunctionCode(3) + .setPdu(HexUtil.decodeHex("0400FA0258")); + + // 调用 + 断言 + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), IotModbusCommonUtils.buildResponsePdu(frame)); + } + + @Test + public void testIsResponseShapeValid() { + // FC03 读 2 个寄存器:byteCount = 4 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("0300100002"), + HexUtil.decodeHex("030400FA0258"))); + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("0300100002"), + HexUtil.decodeHex("030200FA"))); + // FC01 读 10 个线圈:byteCount = 2 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("010000000A"), + HexUtil.decodeHex("01020103"))); + // FC06:原样回显 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("06001000DC"), + HexUtil.decodeHex("06001000DC"))); + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("06001000DC"), + HexUtil.decodeHex("06001000DD"))); + // FC16:回显地址和数量 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("100010000204000A0102"), + HexUtil.decodeHex("1000100002"))); + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("100010000204000A0102"), + HexUtil.decodeHex("1000100001"))); + } + + @Test + public void testGetRawResponseError() { + // 异常响应 83 02:MODBUS_EXCEPTION,文案带异常码 + ErrorCode error = IotModbusCommonUtils.getRawResponseError(HexUtil.decodeHex("8302")); + assertNotNull(error); + assertEquals(MODBUS_EXCEPTION.getCode(), error.getCode()); + assertEquals(StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2), error.getMsg()); + // 正常响应:没有设备错误 + assertNull(IotModbusCommonUtils.getRawResponseError(HexUtil.decodeHex("030400FA0258"))); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtilsTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtilsTest.java new file mode 100644 index 0000000..4d6d4b1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtilsTest.java @@ -0,0 +1,327 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; + +import cn.hutool.core.util.HexUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; +import com.ghgande.j2mod.modbus.ModbusIOException; +import com.ghgande.j2mod.modbus.ModbusSlaveException; +import com.ghgande.j2mod.modbus.io.AbstractModbusTransport; +import com.ghgande.j2mod.modbus.msg.ModbusRequest; +import com.ghgande.j2mod.modbus.msg.ModbusResponse; +import com.ghgande.j2mod.modbus.net.TCPMasterConnection; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotModbusTcpClientUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusTcpClientUtilsTest { + + private static final int SLAVE_ID = 1; + /** + * 03 00 10 00 02:读 2 个保持寄存器 + */ + private static final String READ_PDU = "0300100002"; + + private Vertx vertx; + private TCPMasterConnection tcpConnection; + private AbstractModbusTransport transport; + private ModbusConnection connection; + + /** + * 写出时请求上的事务号(setRequest 分配) + */ + private final AtomicReference sentTransactionId = new AtomicReference<>(); + /** + * 写出的请求:execute 结束后它的事务号会被 j2mod 自增 + */ + private final AtomicReference sentRequest = new AtomicReference<>(); + + @BeforeEach + public void setUp() throws Exception { + vertx = Vertx.vertx(); + tcpConnection = mock(TCPMasterConnection.class); + transport = mock(AbstractModbusTransport.class); + when(tcpConnection.isConnected()).thenReturn(true); + when(tcpConnection.getModbusTransport()).thenReturn(transport); + when(tcpConnection.getTimeout()).thenReturn(1000); + doAnswer(invocation -> { + ModbusRequest request = invocation.getArgument(0); + sentRequest.set(request); + sentTransactionId.set(request.getTransactionID()); + return null; + }).when(transport).writeRequest(any(ModbusRequest.class)); + connection = new ModbusConnection(); + connection.setTcpConnection(tcpConnection); + connection.setContext(vertx.getOrCreateContext()); + } + + @AfterEach + public void tearDown() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + + @Test + public void testIsTransportFailure() { + // 传输失败:沿 cause 链找到 ModbusIOException / IOException(read / write 会包一层 RuntimeException) + assertTrue(IotModbusTcpClientUtils.isTransportFailure( + new RuntimeException("Modbus 读取失败", new ModbusIOException("Socket timeout reading response - Read timed out")))); + assertTrue(IotModbusTcpClientUtils.isTransportFailure(new RuntimeException(new SocketTimeoutException()))); + assertTrue(IotModbusTcpClientUtils.isTransportFailure(new ConnectException("Connection refused"))); + // 设备异常响应:链路正常 + assertFalse(IotModbusTcpClientUtils.isTransportFailure(new RuntimeException(new ModbusSlaveException(2)))); + // 网关自己判定的失败(排队过期、响应不合法、连接已失效) + assertFalse(IotModbusTcpClientUtils.isTransportFailure(exception(MODBUS_RESPONSE_INVALID))); + assertFalse(IotModbusTcpClientUtils.isTransportFailure(null)); + } + + @Test + public void testToRawFailure() { + // ServiceException:保留错误码 + assertEquals(MODBUS_DISCONNECTED.getCode(), + IotModbusTcpClientUtils.toRawFailure(exception(MODBUS_DISCONNECTED)).getCode()); + // j2mod 读超时(cause 已丢失,只能按消息判断)→ 110 + assertEquals(MODBUS_TIMEOUT.getCode(), IotModbusTcpClientUtils.toRawFailure(new RuntimeException( + new ModbusIOException("Executing transaction 00 failed (tried 1 times) Socket timeout reading response - Read timed out"))) + .getCode()); + // 其它 IO 失败 → 106 + assertEquals(DEVICE_OFFLINE_OR_WRITE_FAILED.getCode(), + IotModbusTcpClientUtils.toRawFailure(new ConnectException("Connection refused")).getCode()); + } + + // ========== 标准读写:保持原逻辑,超时不关闭共享连接 ========== + + @Test + public void testExecuteBlocking_standardTimeoutKeepSharedConnection() throws Exception { + // mock 方法:同一个物理连接上的从站 2 照常回正常响应 + mockResponse(0, SLAVE_ID + 1, "0304" + "00FA0258"); + + // 调用:从站 1 的标准读取超时(与 read 一样,把 j2mod 的 ModbusIOException 包一层 RuntimeException 抛出) + Future timeout = await(connection.executeBlocking(wire -> { + throw new RuntimeException("Modbus 读取失败 [slaveId=1]", + new ModbusIOException("Socket timeout reading response - Read timed out")); + })); + // 调用:之后从站 2 的标准读取 + Future next = await(IotModbusTcpClientUtils.read(connection, SLAVE_ID + 1, 3, 0x10, 2)); + + // 断言:超时照常失败,但不关闭、不重连共享的物理连接;从站 2 的读取照常完成 + assertTrue(timeout.failed()); + assertTrue(IotModbusTcpClientUtils.isTransportFailure(timeout.cause())); + assertTrue(next.succeeded()); + assertArrayEquals(new int[]{0x00FA, 0x0258}, next.result()); + verify(tcpConnection, never()).close(); + verify(tcpConnection, never()).connect(); + } + + @Test + public void testExecuteBlocking_retired() throws Exception { + // 准备参数:连接已被 closeConnection 移出连接池 + connection.setRetired(true); + + // 调用 + Future result = await(IotModbusTcpClientUtils.read(connection, SLAVE_ID, 3, 0x10, 2)); + + // 断言:排队中的标准任务不再执行,直接 115,不会重新连上被关闭的连接 + assertTrue(result.failed()); + assertInstanceOf(ServiceException.class, result.cause()); + assertEquals(MODBUS_DISCONNECTED.getCode(), ((ServiceException) result.cause()).getCode()); + verify(transport, never()).writeRequest(any(ModbusRequest.class)); + verify(tcpConnection, never()).connect(); + } + + // ========== executeRaw:传输失败 ========== + + @Test + public void testExecuteRaw_timeoutCloseInOrderedTask() throws Exception { + // mock 方法:读响应超时(j2mod 的 ModbusTCPTransport 把 SocketTimeoutException 转为 ModbusIOException) + when(transport.readResponse()).thenThrow(new ModbusIOException("Socket timeout reading response - Read timed out")); + + // 调用 + Future result = executeRaw(); + + // 断言:只尝试 1 次;透传的守卫在本有序任务内关闭连接(迟到的响应不会被下一个事务读到),转换为 110 + assertTrue(result.failed()); + assertEquals(MODBUS_TIMEOUT.getCode(), IotModbusTcpClientUtils.toRawFailure(result.cause()).getCode()); + verify(transport, times(1)).writeRequest(any(ModbusRequest.class)); + verify(tcpConnection, times(1)).close(); + } + + // ========== executeRaw:正常响应 ========== + + @Test + public void testExecuteRaw_normalUseSavedIdentity() throws Exception { + // mock 方法:设备按发出的事务号、unit 回正常响应 + mockResponse(0, SLAVE_ID, "0304" + "00FA0258"); + + // 调用 + Future result = executeRaw(); + + // 断言:用 execute 之前保存的身份核对通过;j2mod 已在 execute 之后把请求的事务号自增 + assertTrue(result.succeeded()); + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), result.result()); + assertNotEquals(sentTransactionId.get().intValue(), sentRequest.get().getTransactionID()); + verify(tcpConnection, never()).close(); + // 断言:只尝试 1 次 + verify(transport, times(1)).writeRequest(any(ModbusRequest.class)); + } + + @Test + public void testExecuteRaw_normalUnitMismatch() throws Exception { + // mock 方法:事务号正确,unit 不对(j2mod 不核对 unit) + mockResponse(0, SLAVE_ID + 1, "0304" + "00FA0258"); + + // 调用 + Future result = executeRaw(); + + // 断言:114,并在本有序任务内关闭连接 + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode()); + verify(tcpConnection, times(1)).close(); + } + + @Test + public void testExecuteRaw_normalTransactionIdMismatch() throws Exception { + // mock 方法:事务号不对(j2mod 核对正常响应的事务号,只尝试 1 次就失败) + mockResponse(1, SLAVE_ID, "0304" + "00FA0258"); + + // 调用 + Future result = executeRaw(); + + // 断言:传输失败,守卫在本有序任务内关闭连接;不重发 + assertTrue(result.failed()); + assertFalse(result.cause() instanceof ServiceException); + verify(tcpConnection, times(1)).close(); + verify(transport, times(1)).writeRequest(any(ModbusRequest.class)); + } + + @Test + public void testExecuteRaw_normalFunctionCodeMismatch() throws Exception { + // mock 方法:事务号、unit 正确,功能码不对 + mockResponse(0, SLAVE_ID, "0404" + "00FA0258"); + + // 调用 + Future result = executeRaw(); + + // 断言 + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode()); + verify(tcpConnection, times(1)).close(); + } + + // ========== executeRaw:异常响应 ========== + + @Test + public void testExecuteRaw_exceptionCorrelated() throws Exception { + // mock 方法:设备按发出的事务号、unit 回 83 02 + mockResponse(0, SLAVE_ID, "8302"); + + // 调用 + Future result = executeRaw(); + + // 断言:取 transaction.getResponse() 的真实异常响应,还原为 83 02,作为成功结果;链路正常不关闭 + assertTrue(result.succeeded()); + assertArrayEquals(HexUtil.decodeHex("8302"), result.result()); + verify(tcpConnection, never()).close(); + } + + @Test + public void testExecuteRaw_exceptionTransactionIdMismatch() throws Exception { + // mock 方法:异常响应的事务号不对(j2mod 在核对事务号之前就抛出 ModbusSlaveException) + mockResponse(1, SLAVE_ID, "8302"); + + // 调用 + Future result = executeRaw(); + + // 断言:不按当前功能码 + 异常码拼出归属:114,并关闭连接 + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode()); + verify(tcpConnection, times(1)).close(); + } + + @Test + public void testExecuteRaw_exceptionUnitOrFunctionCodeMismatch() throws Exception { + // mock 方法:unit 不对 + mockResponse(0, SLAVE_ID + 1, "8302"); + + // 调用 + 断言 + assertFailedWith(executeRaw(), MODBUS_RESPONSE_INVALID.getCode()); + verify(tcpConnection, times(1)).close(); + + // mock 方法:功能码不对(FC03 的请求收到 84 02) + mockResponse(0, SLAVE_ID, "8402"); + + // 调用 + 断言 + assertFailedWith(executeRaw(), MODBUS_RESPONSE_INVALID.getCode()); + verify(tcpConnection, times(2)).close(); + } + + // ========== 辅助方法 ========== + + /** + * mock 传输层的读响应:事务号 = 写出的事务号 + transactionIdOffset + * + * @param transactionIdOffset 事务号偏移(0 表示正确) + * @param unitId 响应的 unit + * @param pduHex 响应 PDU + */ + private void mockResponse(int transactionIdOffset, int unitId, String pduHex) throws Exception { + doAnswer(invocation -> { + byte[] pdu = HexUtil.decodeHex(pduHex); + int transactionId = (sentTransactionId.get() + transactionIdOffset) & 0xFFFF; + return readResponse(String.format("%04X0000%04X%02X", transactionId, pdu.length + 1, unitId) + pduHex); + }).when(transport).readResponse(); + } + + private Future executeRaw() throws InterruptedException { + IotModbusTx tx = IotModbusTx.ofClient(null, HexUtil.decodeHex(READ_PDU), + System.nanoTime() + TimeUnit.SECONDS.toNanos(60), null); + return await(IotModbusTcpClientUtils.executeRaw(connection, SLAVE_ID, tx)); + } + + /** + * 等待 Future 完成(成功或失败) + */ + private static Future await(Future result) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + result.onComplete(ar -> latch.countDown()); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + return result; + } + + /** + * 与 ModbusTCPTransport.readResponse 相同:按功能码创建标准响应,再从完整 MBAP 帧读入 + */ + private static ModbusResponse readResponse(String hexFrame) throws IOException { + byte[] frame = HexUtil.decodeHex(hexFrame); + ModbusResponse response = ModbusResponse.createModbusResponse(frame[7] & 0xFF); + response.readFrom(new DataInputStream(new ByteArrayInputStream(frame))); + // ModbusTCPTransport.readResponse 读完后再设置一次事务号 + response.setTransactionID(((frame[0] & 0xFF) << 8) | (frame[1] & 0xFF)); + return response; + } + + private static void assertFailedWith(Future result, Integer code) { + assertTrue(result.failed()); + assertInstanceOf(ServiceException.class, result.cause()); + assertEquals(code, ((ServiceException) result.cause()).getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequestTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequestTest.java new file mode 100644 index 0000000..ed62b1c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequestTest.java @@ -0,0 +1,139 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; + +import cn.hutool.core.util.HexUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import com.ghgande.j2mod.modbus.msg.ModbusResponse; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotModbusRawRequest} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusRawRequestTest { + + @Test + public void testWriteTo_fc03() throws IOException { + // 准备参数 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); + request.setUnitID(1); + request.setTransactionID(7); + + // 调用 + ByteArrayOutputStream out = new ByteArrayOutputStream(); + request.writeTo(new DataOutputStream(out)); + + // 断言:[MBAP: 事务号 7、协议 0、长度 6][unit 1][03 00 10 00 02] + assertArrayEquals(HexUtil.decodeHex("000700000006010300100002"), out.toByteArray()); + // ModbusTCPTransport.writeMessage 写出的是 getMessage():PDU 去掉功能码之后的部分 + assertArrayEquals(HexUtil.decodeHex("00100002"), request.getMessage()); + assertEquals(3, request.getFunctionCode()); + } + + @Test + public void testBuildResponsePdu_fc03() throws IOException { + // 准备参数 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); + ModbusResponse response = readResponse("000700000007010304" + "00FA0258"); + + // 调用 + 断言 + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), request.buildResponsePdu(response)); + } + + @Test + public void testBuildResponsePdu_fc06() throws IOException { + // 准备参数 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("06001000DC")); + ModbusResponse response = readResponse("0007000000060106001000DC"); + + // 调用 + 断言:原样回显 + assertArrayEquals(HexUtil.decodeHex("06001000DC"), request.buildResponsePdu(response)); + } + + @Test + public void testBuildResponsePdu_fc10() throws IOException { + // 准备参数 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("100010000204000A0102")); + ModbusResponse response = readResponse("000700000006011000100002"); + + // 调用 + 断言:回显地址和数量 + assertArrayEquals(HexUtil.decodeHex("1000100002"), request.buildResponsePdu(response)); + } + + @Test + public void testBuildResponsePdu_shapeInvalid() throws IOException { + // 准备参数:读 2 个寄存器,设备只回了 1 个 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); + ModbusResponse response = readResponse("00070000000501030200FA"); + + // 调用 + 断言 + ServiceException ex = assertThrows(ServiceException.class, () -> request.buildResponsePdu(response)); + assertEquals(MODBUS_RESPONSE_INVALID.getCode(), ex.getCode()); + } + + @Test + public void testBuildResponsePdu_exception() throws IOException { + // 准备参数:transaction.getResponse() 拿到的真实异常响应 83 02 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); + ModbusResponse response = readResponse("00070000000301" + "8302"); + + // 调用 + 断言:还原为 0x83 + 异常码 + assertArrayEquals(HexUtil.decodeHex("8302"), request.buildResponsePdu(response)); + } + + @Test + public void testBuildResponsePdu_exceptionFunctionCodeMismatch() throws IOException { + // 准备参数:FC03 的请求收到 84 02 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002")); + ModbusResponse response = readResponse("00070000000301" + "8402"); + + // 调用 + 断言 + ServiceException ex = assertThrows(ServiceException.class, () -> request.buildResponsePdu(response)); + assertEquals(MODBUS_RESPONSE_INVALID.getCode(), ex.getCode()); + } + + @Test + public void testIsResponseOf_normal() throws IOException { + // 准备参数:发出的身份 = 事务号 7、unit 1、FC03 + ModbusResponse response = readResponse("000700000007010304" + "00FA0258"); + + // 调用 + 断言:逐项核对事务号、unit、功能码 + assertTrue(IotModbusRawRequest.isResponseOf(response, 7, 1, 3)); + assertFalse(IotModbusRawRequest.isResponseOf(response, 8, 1, 3)); + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 2, 3)); + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 1, 4)); + assertFalse(IotModbusRawRequest.isResponseOf(null, 7, 1, 3)); + } + + @Test + public void testIsResponseOf_exception() throws IOException { + // 准备参数:j2mod 不核对异常响应的事务号,这里同样逐项核对 + ModbusResponse response = readResponse("00070000000301" + "8302"); + + // 调用 + 断言:异常响应的功能码是 fc | 0x80 + assertTrue(IotModbusRawRequest.isResponseOf(response, 7, 1, 3)); + assertFalse(IotModbusRawRequest.isResponseOf(response, 6, 1, 3)); + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 2, 3)); + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 1, 4)); + } + + /** + * 与 ModbusTCPTransport.readResponse 相同:按功能码创建标准响应,再从完整 MBAP 帧读入 + */ + private static ModbusResponse readResponse(String hexFrame) throws IOException { + byte[] frame = HexUtil.decodeHex(hexFrame); + ModbusResponse response = ModbusResponse.createModbusResponse(frame[7] & 0xFF); + response.readFrom(new DataInputStream(new ByteArrayInputStream(frame))); + return response; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriberTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriberTest.java new file mode 100644 index 0000000..c71f976 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriberTest.java @@ -0,0 +1,191 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; +import io.vertx.core.AsyncResult; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.Callable; +import java.util.concurrent.RejectedExecutionException; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotModbusTcpClientDownstreamSubscriber} 的单元测试 + * + * @author 芋道源码 + */ +@ExtendWith(MockitoExtension.class) +public class IotModbusTcpClientDownstreamSubscriberTest { + + private static final Long DEVICE_ID = 1L; + /** + * 03 00 10 00 02:读 2 个保持寄存器 + */ + private static final byte[] READ_PDU = {0x03, 0x00, 0x10, 0x00, 0x02}; + + @Mock + private IotModbusTcpClientProtocol protocol; + @Mock + private IotModbusTcpClientDownstreamHandler downstreamHandler; + @Mock + private IotModbusTcpClientConnectionManager connectionManager; + @Mock + private IotMessageBus messageBus; + + private IotModbusTcpClientDownstreamSubscriber subscriber; + + @BeforeEach + public void setUp() { + subscriber = new IotModbusTcpClientDownstreamSubscriber(protocol, downstreamHandler, connectionManager, messageBus); + } + + @Test + public void testHandleRawMessage_pduInvalid() { + // 准备参数:开头多带了 slaveId 的 PDU(01 06 00 10 00 DC) + byte[] pdu = {0x01, 0x06, 0x00, 0x10, 0x00, (byte) 0xDC}; + + // 调用 + Future result = subscriber.handleRawMessage(buildMessage(), pdu, null); + + // 断言 + assertFailedWith(result, MODBUS_PDU_INVALID.getCode()); + } + + @Test + public void testHandleRawMessage_notConnected() { + // mock 方法 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(null); + + // 调用 + Future result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); + + // 断言 + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode()); + } + + @Test + public void testHandleRawMessage_rawSlotFull() { + // 准备参数:同一连接已有 32 个 raw 在途或排队 + ModbusConnection connection = new ModbusConnection(); + connection.addDevice(DEVICE_ID, 1); + for (int i = 0; i < IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING; i++) { + assertTrue(connection.tryAcquireRawSlot(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING)); + } + // mock 方法 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); + + // 调用:第 33 个 + Future result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); + + // 断言:立即 113,不进入 executeBlocking(connection 没有 Context),名额不泄漏 + assertFailedWith(result, MODBUS_QUEUE_FULL.getCode()); + assertEquals(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING, connection.getRawOutstanding().get()); + // 释放一个后可以再次占用 + connection.releaseRawSlot(); + assertTrue(connection.tryAcquireRawSlot(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING)); + } + + @Test + @SuppressWarnings("unchecked") + public void testHandleRawMessage_submitRejected() { + // 准备参数:连接的 Context 已关闭,提交 executeBlocking 时同步拒绝 + Context context = mock(Context.class); + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context); + connection.addDevice(DEVICE_ID, 1); + // mock 方法 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); + when(context.executeBlocking(any(Callable.class), eq(true))) + .thenThrow(new RejectedExecutionException("context closed")); + + // 调用:连续 2 次 + for (int i = 0; i < 2; i++) { + Future result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); + + // 断言:返回失败 Future(由调用方记日志),名额只释放一次,不泄漏、不变负 + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode()); + assertEquals(0, connection.getRawOutstanding().get()); + } + // 断言:没有进入事务,不回复 biz + verify(context, times(2)).executeBlocking(any(Callable.class), eq(true)); + verify(protocol, never()).getVertx(); + } + + @Test + @SuppressWarnings("unchecked") + public void testHandleRawMessage_onCompleteRejected() { + // 准备参数:提交成功,但注册完成回调时同步拒绝(Context 已关闭,无法把回调投递到 owner) + Context context = mock(Context.class); + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context); + connection.addDevice(DEVICE_ID, 1); + Future execute = mock(Future.class); + // mock 方法 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); + doReturn(execute).when(context).executeBlocking(any(Callable.class), eq(true)); + when(execute.onComplete(any())).thenThrow(new RejectedExecutionException("event executor terminated")); + + // 调用:连续 2 次 + for (int i = 0; i < 2; i++) { + Future result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); + + // 断言:返回失败 Future,名额释放,不泄漏、不变负 + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode()); + assertEquals(0, connection.getRawOutstanding().get()); + } + // 断言:不回复 biz + verify(protocol, never()).getVertx(); + } + + @Test + @SuppressWarnings("unchecked") + public void testHandleRawMessage_onCompleteThrowsAfterCallback() { + // 准备参数:注册完成回调时,回调已经执行(事务已是终态),之后才抛出异常 + Context context = mock(Context.class); + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context); + connection.addDevice(DEVICE_ID, 1); + Future execute = mock(Future.class); + // mock 方法 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); + doReturn(execute).when(context).executeBlocking(any(Callable.class), eq(true)); + doAnswer(invocation -> { + Handler> handler = invocation.getArgument(0); + handler.handle(Future.failedFuture(new RuntimeException("Connection reset"))); + throw new RejectedExecutionException("event executor terminated"); + }).when(execute).onComplete(any()); + + // 调用 + Future result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null); + + // 断言:事务终态时已成功完成 Future(网关判定的失败只记日志),之后的异常不改变它;名额只释放一次,不变负 + assertTrue(result.succeeded()); + assertEquals(0, connection.getRawOutstanding().get()); + // 断言:不回复 biz + verify(protocol, never()).getVertx(); + } + + private static IotDeviceMessage buildMessage() { + return new IotDeviceMessage().setDeviceId(DEVICE_ID).setRequestId("r1").setMethod("thing.raw.push"); + } + + private static void assertFailedWith(Future result, Integer code) { + assertTrue(result.failed()); + assertInstanceOf(ServiceException.class, result.cause()); + assertEquals(code, ((ServiceException) result.cause()).getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollSchedulerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollSchedulerTest.java index 214db67..2833c60 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollSchedulerTest.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollSchedulerTest.java @@ -1,25 +1,135 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +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.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler.ReadSegment; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Context; +import io.vertx.core.Vertx; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_COILS; import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS; import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_INPUT_REGISTERS; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; /** * {@link IotModbusTcpClientPollScheduler} 的单元测试 * * @author 芋道源码 */ -public class IotModbusTcpClientPollSchedulerTest { +public class IotModbusTcpClientPollSchedulerTest extends BaseMockitoUnitTest { + + private static final long TIMEOUT_SECONDS = 10; + private static final Long DEVICE_ID = 1L; + private static final Long POINT_ID = 1L; + private static final int SLAVE_ID = 1; + private static final String PRODUCT_KEY = "pk"; + private static final String DEVICE_NAME = "dn"; + private static final String SERVER_ID = "modbus-tcp-client"; + + @Mock + private IotModbusTcpClientConnectionManager connectionManager; + @Mock + private IotModbusTcpClientConfigCacheService configCacheService; + @Mock + private IotDeviceMessageService messageService; + @Mock + private ModbusConnection connection; + + @Test + public void testPollPoint_reportNotBlockConnectionContext() throws Exception { + Vertx vertx = Vertx.vertx(); + try { + // 准备参数:连接的 context 与它上面的上报队列;点位 FC03 读 1 个寄存器 + Context context = vertx.getOrCreateContext(); + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + IotModbusPointRespDTO point = randomPoint(POINT_ID, FC_READ_HOLDING_REGISTERS, 0, 1, 1000); + point.setIdentifier("temperature"); + point.setRawDataType("INT16"); + IotModbusDeviceConfigRespDTO config = randomConfig(point); + config.setProductKey(PRODUCT_KEY); + config.setDeviceName(DEVICE_NAME); + IotModbusTcpClientPollScheduler pollScheduler = new IotModbusTcpClientPollScheduler(vertx, connectionManager, + new IotModbusTcpClientUpstreamHandler(messageService, SERVER_ID), configCacheService); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch delivered = new CountDownLatch(2); + AtomicBoolean onWorker = new AtomicBoolean(true); + // mock 方法:配置、连接、slave ID + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(config); + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection); + when(connectionManager.getSlaveId(DEVICE_ID)).thenReturn(SLAVE_ID); + when(connection.getUpstreamQueue()).thenReturn(queue); + // mock 方法:与真实连接一样,读取在连接 context 的有序 executeBlocking 上完成,结果回调回到 context;两次读到 250、251 + when(connection.executeBlocking(any())).thenAnswer(invocation -> context.executeBlocking(() -> new int[]{250}, true)) + .thenAnswer(invocation -> context.executeBlocking(() -> new int[]{251}, true)); + // mock 方法:发送到消息总线,第一条阻塞直到放行 + doAnswer(invocation -> { + onWorker.compareAndSet(true, Context.isOnWorkerThread()); + entered.countDown(); + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待放行超时"); + } + delivered.countDown(); + return null; + }).doAnswer(invocation -> { + onWorker.compareAndSet(true, Context.isOnWorkerThread()); + delivered.countDown(); + return null; + }).when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + + try { + // 调用:第一次轮询,发送阻塞在 worker 上 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + // 调用:发送仍阻塞时,第二次轮询;再在连接的 context 上执行一个哨兵任务 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); + CompletableFuture sentinel = new CompletableFuture<>(); + context.runOnContext(v -> sentinel.complete(true)); + + // 断言:context 没有被发送阻塞,哨兵及时执行;第二条上报排在第一条之后,还没有发送 + assertTrue(sentinel.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + verify(connection, timeout(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)).times(2)).executeBlocking(any()); + verify(messageService, times(1)).sendDeviceMessage(any(), any(), any(), any()); + } finally { + release.countDown(); + } + // 断言:放行后两条上报按读取顺序各投递一次,都在 worker 上发送 + assertTrue(delivered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(messageService, times(2)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + assertTrue(onWorker.get()); + List messages = captor.getAllValues(); + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), messages.get(0).getMethod()); + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{250}, point)), + messages.get(0).getParams()); + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{251}, point)), + messages.get(1).getParams()); + } finally { + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } @Test public void testBuildReadSegments_mergeContinuousPoints() { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriberTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriberTest.java new file mode 100644 index 0000000..848c0f8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriberTest.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.concurrent.Callable; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_TIMEOUT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotModbusTcpServerDownstreamSubscriber} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusTcpServerDownstreamSubscriberTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 1L; + private static final String SERVER_ID = "server-1"; + /** + * 06 00 10 00 01:写单个寄存器 + */ + private static final byte[] WRITE_PDU = {0x06, 0x00, 0x10, 0x00, 0x01}; + + @Mock + private IotModbusTcpServerProtocol protocol; + @Mock + private IotModbusTcpServerDownstreamHandler downstreamHandler; + @Mock + private IotModbusTcpServerConnectionManager connectionManager; + @Mock + private IotMessageBus messageBus; + @Mock + private IotModbusTcpServerTransactionChannel channel; + @Mock + private Vertx vertx; + @Mock + private IotDeviceMessageService deviceMessageService; + + private MockedStatic springUtilMockedStatic; + private IotModbusTcpServerDownstreamSubscriber subscriber; + + private final IotScriptSource source = IotScriptSource.of(100L, "function decode(ctx) {}"); + + @BeforeEach + public void setUp() { + springUtilMockedStatic = mockStatic(SpringUtil.class); + subscriber = new IotModbusTcpServerDownstreamSubscriber(protocol, downstreamHandler, connectionManager, messageBus); + when(connectionManager.getConnectionInfoByDeviceId(DEVICE_ID)).thenReturn(new ConnectionInfo().setChannel(channel)); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testHandleRawMessage_deviceExceptionResponse() { + // 准备参数 + IotDeviceMessage message = buildMessage(); + byte[] responsePdu = new byte[]{(byte) 0x86, 0x02}; + // mock 方法:回复在 worker 上执行(这里在当前线程同步执行) + when(protocol.getServerId()).thenReturn(SERVER_ID); + when(protocol.getVertx()).thenReturn(vertx); + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return Future.succeededFuture(callable.call()); + }); + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService); + + // 调用:提交事务,事务终态之前 Future 不完成 + Future result = subscriber.handleRawMessage(message, WRITE_PDU, source); + assertFalse(result.isComplete()); + // 调用:设备返回异常响应 86 02 + captureSubmittedTx().complete(responsePdu); + + // 断言:Future 在事务终态成功完成 + assertTrue(result.succeeded()); + // 断言:设备的实际响应回复给 biz,设备错误为 MODBUS_EXCEPTION(带异常码),带上 encode 时固定的脚本 + ArgumentCaptor errorCaptor = ArgumentCaptor.forClass(ErrorCode.class); + verify(deviceMessageService).sendRawReply(same(message), same(source), aryEq(responsePdu), + errorCaptor.capture(), eq(SERVER_ID)); + assertEquals(MODBUS_EXCEPTION.getCode(), errorCaptor.getValue().getCode()); + assertEquals(StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2), errorCaptor.getValue().getMsg()); + } + + @Test + public void testHandleRawMessage_gatewayFailureOnlyLog() { + // 调用:网关判定的失败(超时)、响应为空 + Future timeout = subscriber.handleRawMessage(buildMessage(), WRITE_PDU, source); + captureSubmittedTx().fail(MODBUS_TIMEOUT); + Future empty = subscriber.handleRawMessage(buildMessage(), WRITE_PDU, null); + captureSubmittedTx().complete(new byte[0]); + + // 断言:Future 照常在事务终态成功完成;只记日志,不给 biz 构造回复 + assertTrue(timeout.succeeded()); + assertTrue(empty.succeeded()); + verify(protocol, never()).getVertx(); + springUtilMockedStatic.verifyNoInteractions(); + verifyNoInteractions(deviceMessageService); + } + + private IotModbusTx captureSubmittedTx() { + ArgumentCaptor captor = ArgumentCaptor.forClass(IotModbusTx.class); + verify(channel, atLeastOnce()).submitRaw(captor.capture()); + return captor.getValue(); + } + + private static IotDeviceMessage buildMessage() { + return new IotDeviceMessage().setDeviceId(DEVICE_ID).setRequestId("r1").setMethod("thing.property.set"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandlerTest.java new file mode 100644 index 0000000..acab0f8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandlerTest.java @@ -0,0 +1,303 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.upstream; + +import cn.hutool.core.util.HexUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.net.NetSocket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotModbusTcpServerUpstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusTcpServerUpstreamHandlerTest extends BaseMockitoUnitTest { + + private static final long TIMEOUT_SECONDS = 10; + private static final Long DEVICE_ID = 1L; + private static final Long POINT_ID = 10L; + private static final int SLAVE_ID = 1; + private static final String PRODUCT_KEY = "pk"; + private static final String DEVICE_NAME = "dn"; + private static final String SERVER_ID = "modbus-tcp-server"; + + @Mock + private IotDeviceCommonApi deviceApi; + @Mock + private IotDeviceMessageService messageService; + @Mock + private IotModbusTcpServerConfigCacheService configCacheService; + @Mock + private IotModbusTcpServerPollScheduler pollScheduler; + @Mock + private IotDeviceService deviceService; + @Mock + private IotModbusTcpServerTransactionChannel channel; + @Mock + private NetSocket socket; + + private final IotModbusTcpServerConnectionManager connectionManager = new IotModbusTcpServerConnectionManager(); + private final IotModbusTcpServerPendingRequestManager pendingRequestManager = new IotModbusTcpServerPendingRequestManager(); + private IotModbusTcpServerUpstreamHandler upstreamHandler; + private ConnectionInfo connectionInfo; + private Vertx vertx; + /** + * 连接的 owner(event loop Context) + */ + private Context owner; + + @BeforeEach + public void setUp() { + upstreamHandler = new IotModbusTcpServerUpstreamHandler(deviceApi, messageService, new IotModbusFrameEncoder(65), + connectionManager, configCacheService, pendingRequestManager, pollScheduler, deviceService, + SERVER_ID, new AtomicInteger(), 60_000); + // 已认证的 RTU 连接 + connectionInfo = new ConnectionInfo().setDeviceId(DEVICE_ID).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME) + .setSlaveId(SLAVE_ID).setFrameFormat(IotModbusFrameFormatEnum.MODBUS_RTU).setChannel(channel); + connectionManager.registerConnection(socket, connectionInfo); + vertx = Vertx.vertx(); + owner = vertx.getOrCreateContext(); + } + + @AfterEach + public void tearDown() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + @Test + public void testHandleFrame_pollingReportNotBlockOwner() throws Exception { + // 准备参数:本连接的轮询请求 P(FC03 读 1 个)、透传请求 R(FC04 读 1 个)都在等响应 + IotModbusPointRespDTO point = buildPoint(); + PendingRequest poll = buildPollRequest(); + PendingRequest raw = buildRawRequest(); + pendingRequestManager.addRequest(poll); + pendingRequestManager.addRequest(raw); + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(owner); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch delivered = new CountDownLatch(1); + AtomicBoolean onWorker = new AtomicBoolean(); + // mock 方法:事务通道的 worker 队列(在 owner 上);点位配置;发送到消息总线阻塞,直到放行 + when(channel.getUpstreamQueue()).thenReturn(queue); + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(buildConfig(point)); + doAnswer(invocation -> { + onWorker.set(Context.isOnWorkerThread()); + entered.countDown(); + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待放行超时"); + } + delivered.countDown(); + return null; + }).when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + + IotModbusFrame rawResponse = buildReadResponseFrame(4, 0x0001); + try { + // 调用:owner 上收到 P 的响应,发送阻塞在 worker 上 + runOnOwner(() -> upstreamHandler.handleFrame(socket, buildReadResponseFrame(3, 0x00FA), + IotModbusFrameFormatEnum.MODBUS_RTU)); + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + // 调用:发送仍阻塞时,owner 上收到 R 的响应,再执行一个哨兵任务 + runOnOwner(() -> upstreamHandler.handleFrame(socket, rawResponse, IotModbusFrameFormatEnum.MODBUS_RTU)); + Boolean sentinel = callOnOwner(() -> Boolean.TRUE); + + // 断言:owner 没有被发送阻塞:R 的响应照常匹配并交给事务通道收尾,哨兵及时执行 + verify(channel).onRawResponse(same(rawResponse), same(raw)); + assertTrue(sentinel); + assertEquals(1, delivered.getCount()); + } finally { + release.countDown(); + } + // 断言:放行后属性上报只投递一次,在 worker 上发送 + assertTrue(delivered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(messageService, times(1)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + assertTrue(onWorker.get()); + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), captor.getValue().getMethod()); + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{0x00FA}, point)), + captor.getValue().getParams()); + } + + @Test + public void testSendOfflineMessage_afterPollingReport() throws Exception { + // 准备参数:本连接的轮询请求 P + pendingRequestManager.addRequest(buildPollRequest()); + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(owner); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // mock 方法:事务通道的 worker 队列;点位配置;第一条(属性上报)阻塞直到放行 + when(channel.getUpstreamQueue()).thenReturn(queue); + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(buildConfig(buildPoint())); + doAnswer(invocation -> { + entered.countDown(); + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待放行超时"); + } + return null; + }).doNothing().when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), + eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + + Future offline; + try { + // 调用:owner 上收到 P 的响应,发送阻塞在 worker 上 + runOnOwner(() -> upstreamHandler.handleFrame(socket, buildReadResponseFrame(3, 0x00FA), + IotModbusFrameFormatEnum.MODBUS_RTU)); + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + // 调用:连接关闭(closeHandler 在 owner 上移除连接、提交下线消息),不等待发送 + offline = callOnOwner(() -> { + connectionManager.removeConnection(socket); + return upstreamHandler.sendOfflineMessage(connectionInfo); + }); + + // 断言:下线消息排在被阻塞的属性上报之后,还没有发送;之后本连接不再接收新任务 + assertFalse(offline.isComplete()); + verify(messageService, times(1)).sendDeviceMessage(any(), any(), any(), any()); + assertTrue(callOnOwner(() -> queue.submit(() -> null)).failed()); + } finally { + release.countDown(); + } + // 断言:放行后先发属性上报,再发下线消息,各一次 + await(offline); + assertTrue(offline.succeeded()); + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(messageService, times(2)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + List messages = captor.getAllValues(); + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), messages.get(0).getMethod()); + assertEquals(IotDeviceMessage.buildStateOffline().getMethod(), messages.get(1).getMethod()); + assertEquals(IotDeviceMessage.buildStateOffline().getParams(), messages.get(1).getParams()); + } + + @Test + public void testSendOfflineMessage_skipWhenReconnected() throws Exception { + // 准备参数:旧连接关闭后,设备已在新连接上认证注册 + NetSocket newSocket = mock(NetSocket.class); + connectionManager.removeConnection(socket); + connectionManager.registerConnection(newSocket, new ConnectionInfo().setDeviceId(DEVICE_ID) + .setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME).setSlaveId(SLAVE_ID) + .setFrameFormat(IotModbusFrameFormatEnum.MODBUS_RTU).setChannel(mock(IotModbusTcpServerTransactionChannel.class))); + // mock 方法:旧连接事务通道的 worker 队列 + when(channel.getUpstreamQueue()).thenReturn(new IotWorkerTaskQueue(owner)); + + // 调用:旧连接的下线消息 + Future offline = callOnOwner(() -> upstreamHandler.sendOfflineMessage(connectionInfo)); + await(offline); + + // 断言:执行时设备已在新连接上线,不发送下线消息 + assertTrue(offline.succeeded()); + verify(messageService, never()).sendDeviceMessage(any(), any(), any(), any()); + } + + // ========== 辅助方法 ========== + + /** + * 点位:FC03 读 1 个寄存器(INT16) + */ + private static IotModbusPointRespDTO buildPoint() { + IotModbusPointRespDTO point = new IotModbusPointRespDTO(); + point.setId(POINT_ID); + point.setIdentifier("temperature"); + point.setFunctionCode(3); + point.setRegisterAddress(0); + point.setRegisterCount(1); + point.setRawDataType("INT16"); + return point; + } + + private static IotModbusDeviceConfigRespDTO buildConfig(IotModbusPointRespDTO point) { + IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO(); + config.setDeviceId(DEVICE_ID); + config.setPoints(Collections.singletonList(point)); + return config; + } + + private PendingRequest buildPollRequest() { + return new PendingRequest(DEVICE_ID, POINT_ID, "temperature", SLAVE_ID, 3, 0, 1, + null, System.currentTimeMillis() + 60_000, channel, null); + } + + private PendingRequest buildRawRequest() { + IotModbusTx tx = IotModbusTx.ofClient(null, HexUtil.decodeHex("0400000001"), + System.nanoTime() + TimeUnit.SECONDS.toNanos(60), null); + return new PendingRequest(DEVICE_ID, null, null, SLAVE_ID, 4, 0, 1, + null, System.currentTimeMillis() + 60_000, channel, tx); + } + + /** + * RTU 读响应帧:读 1 个寄存器(pdu = byteCount + 值) + */ + private static IotModbusFrame buildReadResponseFrame(int functionCode, int value) { + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode) + .setPdu(new byte[]{0x02, (byte) (value >> 8), (byte) value}); + } + + /** + * 在 owner 上执行(与 RecordParser、closeHandler 回调一致),等待执行完;owner 被阻塞时超时失败 + */ + private void runOnOwner(Runnable action) throws Exception { + callOnOwner(() -> { + action.run(); + return null; + }); + } + + private T callOnOwner(Callable action) throws Exception { + CompletableFuture result = new CompletableFuture<>(); + owner.runOnContext(v -> { + try { + result.complete(action.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + }); + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + /** + * 等待 Future 完成(成功或失败) + */ + private static void await(Future future) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + future.onComplete(ar -> latch.countDown()); + assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManagerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManagerTest.java new file mode 100644 index 0000000..fae1eec --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManagerTest.java @@ -0,0 +1,318 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.hutool.core.util.HexUtil; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link IotModbusTcpServerPendingRequestManager} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusTcpServerPendingRequestManagerTest { + + private static final Long DEVICE_ID = 1L; + private static final int SLAVE_ID = 1; + + private final IotModbusTcpServerPendingRequestManager pendingRequestManager = new IotModbusTcpServerPendingRequestManager(); + + /** + * 旧连接 A 被新连接 B 替换 + */ + private final IotModbusTcpServerTransactionChannel channelA = mock(IotModbusTcpServerTransactionChannel.class); + private final IotModbusTcpServerTransactionChannel channelB = mock(IotModbusTcpServerTransactionChannel.class); + + // ========== TCP 帧:按 transactionId ========== + + @Test + public void testMatchResponse_tcpOnlyMatchCurrentChannel() { + // 准备参数:A、B 两条连接上各有一个事务号相同的轮询请求 + PendingRequest requestA = buildPollRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest requestB = buildPollRequest(channelB, 5, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(requestA); + pendingRequestManager.addRequest(requestB); + + // 调用:B 收到响应 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2), + IotModbusFrameFormatEnum.MODBUS_TCP, channelB); + + // 断言:只配给 B 的请求,A 的请求不被取走 + assertSame(requestB, matched); + assertSame(requestA, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2), + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); + } + + @Test + public void testMatchResponse_tcpRawUnitOrFunctionCodeMismatch() { + // 准备参数 + PendingRequest request = buildRawRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(request); + + // 调用 + 断言:事务号相同,但功能码或 unit 不同:不出队 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 4, 2), + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2).setSlaveId(SLAVE_ID + 1), + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); + assertSame(request, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2), + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); + } + + @Test + public void testMatchResponse_tcpExceptionOnlyMatchRaw() { + // 准备参数:轮询请求 5 号、透传请求 6 号 + PendingRequest poll = buildPollRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest raw = buildRawRequest(channelA, 6, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(poll); + pendingRequestManager.addRequest(raw); + + // 调用 + 断言:轮询的异常响应照旧丢弃,不出队 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(5, 3, 2), + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); + // 调用 + 断言:透传的异常响应配给透传请求 + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(6, 3, 2), + IotModbusFrameFormatEnum.MODBUS_TCP, channelA)); + // 断言:轮询请求仍在,留给到期清理 + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 5)); + } + + // ========== RTU 帧:原有的 FIFO(slaveId + functionCode + registerCount) ========== + + @Test + public void testMatchResponse_rtuFifoBySlaveFunctionCodeCount() { + // 准备参数:同功能码、数量不同的两个轮询请求 + PendingRequest request2 = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest request1 = buildPollRequest(channelA, null, 3, 1, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(request2); + pendingRequestManager.addRequest(request1); + + // 调用 + 断言:unit 不同不匹配 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1).setSlaveId(SLAVE_ID + 1), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + // 调用 + 断言:按数量配给对应的请求,与先后顺序无关 + assertSame(request1, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + assertSame(request2, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + /** + * 原有逻辑的限制(不是安全保证):RTU 帧没有事务号,同 unit、同功能码、同数量的请求无法区分,响应按 FIFO 配给排在前面的请求 + */ + @Test + public void testMatchResponse_rtuSameShapeMatchFirst_originalLimit() { + // 准备参数:轮询请求 P 在前,同形状的透传请求 R 在后 + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(poll); + pendingRequestManager.addRequest(raw); + + // 调用:无论这个响应实际是谁的,都配给排在前面的 P + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); + + // 断言:下一个同形状的正常响应才配给 R + assertSame(poll, matched); + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + @Test + public void testMatchResponse_rtuRawThenPollNormal() { + // 准备参数:透传请求 R 在前,同形状的轮询请求 P 在后 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(raw); + pendingRequestManager.addRequest(poll); + + // 调用 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); + + // 断言:按 FIFO 先配给 R,下一个再配给 P + assertSame(raw, matched); + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + @Test + public void testMatchResponse_rtuSkipOldConnection() { + // 准备参数:旧连接 A 的请求排在前面,新连接 B 发出同 unit、同功能码、同数量的请求 + PendingRequest requestA = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest requestB = buildPollRequest(channelB, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(requestA); + pendingRequestManager.addRequest(requestB); + + // 调用:B 收到响应 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelB); + + // 断言:响应配给 B 的请求,不是 A 的旧条目 + assertSame(requestB, matched); + } + + @Test + public void testMatchResponse_rtuPollThenRawException() { + // 准备参数:FC03,轮询请求 P(读 1 个)在前,透传请求 R(读 2 个)在后 + PendingRequest poll = buildPollRequest(channelA, null, 3, 1, System.currentTimeMillis() + 60_000); + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(poll); + pendingRequestManager.addRequest(raw); + + // 调用:设备回 83 02(解码器已还原原功能码,pdu[0] 是异常码) + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); + + // 断言:按 FIFO 属于排在前面的 P:照旧丢弃,不跳过 P 去收尾 R;P、R 都不出队 + assertNull(matched); + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + // 调用 + 断言:P 出队后 R 排在最前面,下一个异常响应收尾 R + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + @Test + public void testMatchResponse_rtuRawThenPollException() { + // 准备参数:FC03,透传请求 R 在前,轮询请求 P 在后 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(raw); + pendingRequestManager.addRequest(poll); + + // 调用:设备回 83 02 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); + + // 断言:R 在最前面,由异常响应收尾;P 不被消费 + assertSame(raw, matched); + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + @Test + public void testMatchResponse_rtuExceptionIgnoreOtherUnitOrFunctionCode() { + // 准备参数:排在前面的轮询请求是其它 unit、其它功能码,透传请求 R 在后 + PendingRequest otherUnit = new PendingRequest(DEVICE_ID, 10L, "temperature", SLAVE_ID + 1, 3, 0, 2, + null, System.currentTimeMillis() + 60_000, channelA, null); + PendingRequest otherFunctionCode = buildPollRequest(channelA, null, 4, 2, System.currentTimeMillis() + 60_000); + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(otherUnit); + pendingRequestManager.addRequest(otherFunctionCode); + pendingRequestManager.addRequest(raw); + + // 调用:设备回 83 02 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); + + // 断言:只按同 unit、同功能码排队,R 是其中最前面的 + assertSame(raw, matched); + } + + @Test + public void testMatchResponse_rtuExceptionWithoutRawDrop() { + // 准备参数:只有轮询请求 + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(poll); + + // 调用:轮询的异常响应 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA); + + // 断言:照旧丢弃,轮询请求留给到期清理 + assertNull(matched); + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + // ========== 清理 ========== + + @Test + public void testCleanupExpired() { + // 准备参数 + PendingRequest expired = buildPollRequest(channelA, 1, 3, 2, System.currentTimeMillis() - 1); + PendingRequest alive = buildPollRequest(channelA, 2, 3, 2, System.currentTimeMillis() + 60_000); + pendingRequestManager.addRequest(expired); + pendingRequestManager.addRequest(alive); + + // 调用 + pendingRequestManager.cleanupExpired(); + + // 断言:只移除到期的条目 + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 1)); + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 2)); + } + + @Test + public void testRemoveChannel_keepNewChannel() { + // 准备参数:旧连接 A、新连接 B 各有一个条目 + pendingRequestManager.addRequest(buildPollRequest(channelA, 1, 3, 2, System.currentTimeMillis() + 60_000)); + pendingRequestManager.addRequest(buildRawRequest(channelB, 2, 3, 2, System.currentTimeMillis() + 60_000)); + + // 调用:旧连接关闭 + pendingRequestManager.removeChannel(DEVICE_ID, channelA); + + // 断言:只删 A 的条目,B 的条目仍可匹配 + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 1)); + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelB, 2)); + } + + @Test + public void testRemoveRequest_byReference() { + // 准备参数:两个字段完全相同的条目(PendingRequest 的 equals 按字段比较) + long expireAt = System.currentTimeMillis() + 60_000; + PendingRequest request1 = buildPollRequest(channelA, null, 3, 2, expireAt); + PendingRequest request2 = buildPollRequest(channelA, null, 3, 2, expireAt); + pendingRequestManager.addRequest(request1); + pendingRequestManager.addRequest(request2); + + // 调用 + pendingRequestManager.removeRequest(request1); + + // 断言:只移除 request1 这个引用 + assertSame(request2, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2), + IotModbusFrameFormatEnum.MODBUS_RTU, channelA)); + } + + // ========== 辅助方法 ========== + + private static PendingRequest buildPollRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId, + int functionCode, int registerCount, long expireAt) { + return new PendingRequest(DEVICE_ID, 10L, "temperature", SLAVE_ID, functionCode, 0, registerCount, + transactionId, expireAt, channel, null); + } + + private static PendingRequest buildRawRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId, + int functionCode, int registerCount, long expireAt) { + when(channel.getRequestTimeout()).thenReturn(60_000); + byte[] pdu = HexUtil.decodeHex(String.format("%02X0000%04X", functionCode, registerCount)); + IotModbusTx tx = IotModbusTx.ofServer(channel, pdu, null, null); + return new PendingRequest(DEVICE_ID, null, null, SLAVE_ID, functionCode, 0, registerCount, + transactionId, expireAt, channel, tx); + } + + private static IotModbusFrame buildReadFrame(Integer transactionId, int functionCode, int registerCount) { + byte[] pdu = new byte[1 + registerCount * 2]; + pdu[0] = (byte) (registerCount * 2); + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode) + .setPdu(pdu).setTransactionId(transactionId); + } + + /** + * 异常响应帧:解码器已还原原功能码,pdu[0] 是异常码 + */ + private static IotModbusFrame buildExceptionFrame(Integer transactionId, int functionCode, int exceptionCode) { + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode) + .setPdu(new byte[]{(byte) exceptionCode}).setExceptionCode(exceptionCode) + .setTransactionId(transactionId); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannelTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannelTest.java new file mode 100644 index 0000000..a56ba2d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannelTest.java @@ -0,0 +1,573 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.HexUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream.IotModbusTcpServerDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotModbusTcpServerTransactionChannel} 的单元测试 + * + * @author 芋道源码 + */ +public class IotModbusTcpServerTransactionChannelTest { + + private static final Long DEVICE_ID = 1L; + private static final int SLAVE_ID = 1; + private static final int REQUEST_TIMEOUT = 60_000; + private static final Long POINT_ID = 10L; + + private Vertx vertx; + private Context owner; + private NetSocket socket; + private RecordingPendingRequestManager pendingRequestManager; + + /** + * socket.write 被调用时所在的线程 + */ + private final List writeThreads = Collections.synchronizedList(new ArrayList<>()); + + @BeforeEach + public void setUp() { + vertx = Vertx.vertx(); + owner = vertx.getOrCreateContext(); + socket = mock(NetSocket.class); + when(socket.write(any(Buffer.class))).thenAnswer(invocation -> { + writeThreads.add(Thread.currentThread()); + return Future.succeededFuture(); + }); + pendingRequestManager = new RecordingPendingRequestManager(); + } + + @AfterEach + public void tearDown() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + + // ========== 透传:提交与名额 ========== + + @Test + public void testSubmitRaw_singleOwner() throws Exception { + // 准备参数 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + new AtomicInteger(), REQUEST_TIMEOUT); + Thread ownerThread = getOwnerThread(); + + // 调用:4 个非 owner 线程并发提交,共 20 个透传 + List submitters = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + Thread submitter = new Thread(() -> { + for (int j = 0; j < 5; j++) { + channel.submitRaw(newTx(channel, new TxResult())); + } + }); + submitters.add(submitter); + submitter.start(); + } + for (Thread submitter : submitters) { + submitter.join(); + } + awaitOwner(); + + // 断言:写出和登记待响应请求都只发生在 owner 上 + assertEquals(20, writeThreads.size()); + writeThreads.forEach(thread -> assertSame(ownerThread, thread)); + assertEquals(20, pendingRequestManager.addThreads.size()); + pendingRequestManager.addThreads.forEach(thread -> assertSame(ownerThread, thread)); + } + + @Test + public void testSubmitRaw_queueFull() throws Exception { + // 准备参数 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + new AtomicInteger(), REQUEST_TIMEOUT); + TxResult result = new TxResult(); + + // 调用:设备不回应,第 33 个时排队 + 在途已满 + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING + 1; i++) { + channel.submitRaw(newTx(channel, result)); + } + awaitOwner(); + + // 断言 + assertEquals(IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING, writeThreads.size()); + assertFailures(result, MODBUS_QUEUE_FULL); + } + + @Test + public void testAllocateTransactionId_skipPending() throws Exception { + // 准备参数:计数器从 65535 绕回 0 + AtomicInteger transactionIdCounter = new AtomicInteger(65535); + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + transactionIdCounter, REQUEST_TIMEOUT); + channel.submitRaw(newTx(channel, new TxResult())); + awaitOwner(); + // 本连接还有一个 2 号的轮询请求 + pendingRequestManager.addRequest(buildPollRequest(channel, 2)); + + // 调用 + 断言:0 号仍被在途透传占用,取下一个空闲编号 1 + assertEquals(0, pendingRequestManager.added.get(0).getTransactionId()); + assertEquals(0, captureWrites(1).get(0).getUnsignedShort(0)); + transactionIdCounter.set(65535); + assertEquals(1, channel.allocateTransactionId()); + // 调用 + 断言:2 号被轮询请求占用,跳过 + assertEquals(3, channel.allocateTransactionId()); + // 调用 + 断言:RTU 帧格式没有事务号 + assertNull(createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, transactionIdCounter, REQUEST_TIMEOUT) + .allocateTransactionId()); + } + + // ========== 标准轮询、标准写:保持原逻辑,不受透传影响 ========== + + @Test + public void testPollPoint_notLimitedByRawQueue() throws Exception { + // 准备参数:RTU 下 1 个透传在途、31 个排队,第 33 个被拒 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, + new AtomicInteger(), REQUEST_TIMEOUT); + TxResult result = new TxResult(); + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING + 1; i++) { + channel.submitRaw(newTx(channel, result)); + } + awaitOwner(); + assertFailures(result, MODBUS_QUEUE_FULL); + IotModbusTcpServerPollScheduler pollScheduler = createPollScheduler(channel, IotModbusFrameFormatEnum.MODBUS_RTU); + + // 调用:照常轮询两次 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); + + // 断言:轮询不排队、不受透传名额限制,直接写出,并按原逻辑登记属于本连接的待响应请求 + assertEquals(3, writeThreads.size()); + List polls = pendingRequestManager.getPolls(); + assertEquals(2, polls.size()); + polls.forEach(poll -> assertSame(channel, poll.getChannel())); + } + + @Test + public void testOnCleanup_rtuTimeoutNotCloseAndPollContinues() throws Exception { + // 准备参数:RTU 下轮询 P、透传 R 都已发出 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, + new AtomicInteger(), REQUEST_TIMEOUT); + IotModbusTcpServerPollScheduler pollScheduler = createPollScheduler(channel, IotModbusFrameFormatEnum.MODBUS_RTU); + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); + TxResult result = new TxResult(); + channel.submitRaw(newTx(channel, result)); + awaitOwner(); + // 设备都不回应:两个待响应请求到期 + pendingRequestManager.added.forEach(request -> request.setExpireAt(System.currentTimeMillis() - 1)); + + // 调用:同一个清理周期,先按原逻辑清理待响应请求,再通知通道;之后清理信号重复到达 + pendingRequestManager.cleanupExpired(); + channel.onCleanup(); + channel.onCleanup(); + awaitOwner(); + + // 断言:R 只以 110 收尾一次,不断开连接 + assertFailures(result, MODBUS_TIMEOUT); + verify(socket, never()).close(); + // 调用 + 断言:下一次轮询照常写出;R 的在途位置已释放,下一个透传也照常写出 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID); + channel.submitRaw(newTx(channel, new TxResult())); + awaitOwner(); + assertEquals(4, writeThreads.size()); + } + + @Test + public void testWriteProperty_logOnSocketWriteWithoutWaitingResponse() throws Exception { + // 准备参数 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + new AtomicInteger(), REQUEST_TIMEOUT); + IotModbusTcpServerConnectionManager connectionManager = createConnectionManager(channel, + IotModbusFrameFormatEnum.MODBUS_TCP); + IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler( + connectionManager, mockConfigCacheService(), new IotModbusFrameEncoder(65)); + IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), + MapUtil.of("temperature", 220)); + message.setDeviceId(DEVICE_ID); + + // 调用 + downstreamHandler.handle(message); + + // 断言:写出 FC06 请求(socket 写出成功即记成功);不登记待响应请求,不等待设备回显 + List writes = captureWrites(1); + assertArrayEquals(HexUtil.decodeHex("06000000DC"), writes.get(0).getBytes(7, 12)); + assertTrue(pendingRequestManager.added.isEmpty()); + } + + // ========== 透传:响应关联 ========== + + @Test + public void testOnRawResponse_tcpNormalAndException() throws Exception { + // 准备参数:两个透传同时在途 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + new AtomicInteger(), REQUEST_TIMEOUT); + TxResult result1 = new TxResult(); + TxResult result2 = new TxResult(); + channel.submitRaw(newTx(channel, result1)); + channel.submitRaw(newTx(channel, result2)); + awaitOwner(); + int transactionId1 = pendingRequestManager.added.get(0).getTransactionId(); + int transactionId2 = pendingRequestManager.added.get(1).getTransactionId(); + + // 调用:先回第 2 个的异常响应,再回第 1 个的正常响应 + receive(channel, IotModbusFrameFormatEnum.MODBUS_TCP, exceptionFrame(transactionId2)); + receive(channel, IotModbusFrameFormatEnum.MODBUS_TCP, readResponseFrame(transactionId1)); + + // 断言:按事务号各自回到原请求 + assertResponse(result1, "030400FA0258"); + assertResponse(result2, "8302"); + } + + @Test + public void testOnRawResponse_rtuDeviceException() throws Exception { + // 准备参数:RTU 下透传 R 在途,之后才发出轮询 P(同功能码) + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, + new AtomicInteger(), REQUEST_TIMEOUT); + TxResult result = new TxResult(); + channel.submitRaw(newTx(channel, result)); + awaitOwner(); + PendingRequest poll = buildPollRequest(channel, null); + pendingRequestManager.addRequest(poll); + + // 调用:设备回 83 02 + receive(channel, IotModbusFrameFormatEnum.MODBUS_RTU, exceptionFrame(null)); + + // 断言:R 排在最前面,设备的真实异常响应回到透传请求;轮询的待响应请求不被提前消费 + assertResponse(result, "8302"); + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null), + IotModbusFrameFormatEnum.MODBUS_RTU, channel)); + } + + @Test + public void testOnRawResponse_rtuPollExceptionNotSkipped() throws Exception { + // 准备参数:RTU 下轮询 P(同功能码)在前,透传 R 在途 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, + new AtomicInteger(), REQUEST_TIMEOUT); + PendingRequest poll = buildPollRequest(channel, null); + pendingRequestManager.addRequest(poll); + TxResult result = new TxResult(); + channel.submitRaw(newTx(channel, result)); + awaitOwner(); + + // 调用:设备回 83 02(按 FIFO 是 P 的异常响应) + AtomicReference matched = new AtomicReference<>(); + owner.runOnContext(v -> matched.set(pendingRequestManager.matchResponse(DEVICE_ID, exceptionFrame(null), + IotModbusFrameFormatEnum.MODBUS_RTU, channel))); + awaitOwner(); + + // 断言:照旧丢弃,不跳过 P 去收尾 R;R 仍在途 + assertNull(matched.get()); + assertTrue(result.responses.isEmpty()); + assertTrue(result.failures.isEmpty()); + // 调用 + 断言:P 照旧按 FIFO 先出队;之后 R 的响应照常回到 R + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null), + IotModbusFrameFormatEnum.MODBUS_RTU, channel)); + receive(channel, IotModbusFrameFormatEnum.MODBUS_RTU, readResponseFrame(null)); + assertResponse(result, "030400FA0258"); + } + + // ========== 透传:唯一终态 ========== + + @Test + public void testOnRawWriteFailed_releaseOnceAndKeepConnection() throws Exception { + // 准备参数:透传写出失败 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + new AtomicInteger(), REQUEST_TIMEOUT); + when(socket.write(any(Buffer.class))).thenReturn(Future.failedFuture("写出失败")); + TxResult result = new TxResult(); + + // 调用 + channel.submitRaw(newTx(channel, result)); + awaitOwner(); + + // 断言:只以 106 收尾一次,按引用移除待响应请求,不主动关闭连接 + assertFailures(result, DEVICE_OFFLINE_OR_WRITE_FAILED); + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channel, + pendingRequestManager.added.get(0).getTransactionId())); + verify(socket, never()).close(); + // 调用 + 断言:名额已释放,写出恢复后可以再提交满 MAX_RAW_OUTSTANDING 个 + when(socket.write(any(Buffer.class))).thenReturn(Future.succeededFuture()); + TxResult next = new TxResult(); + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING; i++) { + channel.submitRaw(newTx(channel, next)); + } + awaitOwner(); + assertTrue(next.failures.isEmpty()); + } + + @Test + public void testOnCleanup_queuedExpired() throws Exception { + // 准备参数:RTU 下 R1 在途、R2 排队,超时时间很短 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, + new AtomicInteger(), 200); + TxResult result1 = new TxResult(); + TxResult result2 = new TxResult(); + channel.submitRaw(newTx(channel, result1)); + channel.submitRaw(newTx(channel, result2)); + awaitOwner(); + Thread.sleep(300); + + // 调用:清理周期到达两次 + channel.onCleanup(); + channel.onCleanup(); + awaitOwner(); + + // 断言:在途的 R1、排队的 R2 都只以 110 收尾一次;R2 从未写出 + assertFailures(result1, MODBUS_TIMEOUT); + assertFailures(result2, MODBUS_TIMEOUT); + assertEquals(1, writeThreads.size()); + } + + @Test + public void testOnClose_idempotent() throws Exception { + // 准备参数:RTU 下 A 在途,B、C 排队;本连接还有一个轮询请求 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, + new AtomicInteger(), REQUEST_TIMEOUT); + pendingRequestManager.addRequest(buildPollRequest(channel, null)); + TxResult resultA = new TxResult(); + TxResult resultB = new TxResult(); + TxResult resultC = new TxResult(); + channel.submitRaw(newTx(channel, resultA)); + channel.submitRaw(newTx(channel, resultB)); + channel.submitRaw(newTx(channel, resultC)); + awaitOwner(); + + // 调用:closeHandler 与主动关闭各通知一次,之后清理信号迟到 + channel.onClose(); + channel.onClose(); + channel.onCleanup(); + awaitOwner(); + + // 断言:每个透传都只以 115 收尾一次;本连接的待响应请求全部清理 + for (TxResult result : Arrays.asList(resultA, resultB, resultC)) { + assertFailures(result, MODBUS_DISCONNECTED); + } + verify(socket, times(1)).write(any(Buffer.class)); + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null), + IotModbusFrameFormatEnum.MODBUS_RTU, channel)); + // 调用 + 断言:关闭之后提交的透传直接 115,不写出 + TxResult late = new TxResult(); + channel.submitRaw(newTx(channel, late)); + awaitOwner(); + assertFailures(late, MODBUS_DISCONNECTED); + verify(socket, times(1)).write(any(Buffer.class)); + } + + @Test + public void testOnClose_oldNotClearNew() throws Exception { + // 准备参数:同一设备的旧连接 A、新连接 B 共享计数器,各有一个透传在途 + AtomicInteger transactionIdCounter = new AtomicInteger(); + IotModbusTcpServerTransactionChannel channelA = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + transactionIdCounter, REQUEST_TIMEOUT); + IotModbusTcpServerTransactionChannel channelB = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP, + transactionIdCounter, REQUEST_TIMEOUT); + TxResult resultA = new TxResult(); + TxResult resultB = new TxResult(); + channelA.submitRaw(newTx(channelA, resultA)); + channelB.submitRaw(newTx(channelB, resultB)); + awaitOwner(); + int transactionIdB = pendingRequestManager.added.get(1).getTransactionId(); + + // 调用:旧连接 A 关闭 + channelA.onClose(); + awaitOwner(); + + // 断言:A 以 115 收尾;B 的待响应请求仍在,响应照常回到 B 的透传 + assertFailures(resultA, MODBUS_DISCONNECTED); + receive(channelB, IotModbusFrameFormatEnum.MODBUS_TCP, readResponseFrame(transactionIdB)); + assertResponse(resultB, "030400FA0258"); + } + + // ========== 辅助方法 ========== + + private IotModbusTcpServerTransactionChannel createChannel(IotModbusFrameFormatEnum frameFormat, + AtomicInteger transactionIdCounter, + int requestTimeout) { + return new IotModbusTcpServerTransactionChannel(owner, socket, DEVICE_ID, SLAVE_ID, frameFormat, + new IotModbusFrameEncoder(65), pendingRequestManager, transactionIdCounter, requestTimeout); + } + + private IotModbusTcpServerConnectionManager createConnectionManager(IotModbusTcpServerTransactionChannel channel, + IotModbusFrameFormatEnum frameFormat) { + IotModbusTcpServerConnectionManager connectionManager = new IotModbusTcpServerConnectionManager(); + connectionManager.registerConnection(socket, new ConnectionInfo().setDeviceId(DEVICE_ID) + .setSlaveId(SLAVE_ID).setFrameFormat(frameFormat).setChannel(channel)); + return connectionManager; + } + + private IotModbusTcpServerPollScheduler createPollScheduler(IotModbusTcpServerTransactionChannel channel, + IotModbusFrameFormatEnum frameFormat) { + return new IotModbusTcpServerPollScheduler(vertx, createConnectionManager(channel, frameFormat), + new IotModbusFrameEncoder(65), pendingRequestManager, REQUEST_TIMEOUT, mockConfigCacheService()); + } + + /** + * 设备配置:一个 FC03 读 1 个寄存器的点位(INT16,可写,单值用 FC06) + */ + private static IotModbusTcpServerConfigCacheService mockConfigCacheService() { + IotModbusPointRespDTO point = new IotModbusPointRespDTO(); + point.setId(POINT_ID); + point.setIdentifier("temperature"); + point.setFunctionCode(3); + point.setRegisterAddress(0); + point.setRegisterCount(1); + point.setRawDataType("INT16"); + IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO(); + config.setDeviceId(DEVICE_ID); + config.setPoints(Collections.singletonList(point)); + IotModbusTcpServerConfigCacheService configCacheService = mock(IotModbusTcpServerConfigCacheService.class); + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(config); + return configCacheService; + } + + private static IotModbusTx newTx(IotModbusTcpServerTransactionChannel channel, TxResult result) { + return IotModbusTx.ofServer(channel, HexUtil.decodeHex("0300100002"), null, + (responsePdu, failure) -> { + if (failure != null) { + result.failures.add(failure); + } else { + result.responses.add(responsePdu); + } + }); + } + + private static PendingRequest buildPollRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId) { + return new PendingRequest(DEVICE_ID, POINT_ID, "temperature", SLAVE_ID, 3, 0x10, 2, + transactionId, System.currentTimeMillis() + REQUEST_TIMEOUT, channel, null); + } + + private static IotModbusFrame readResponseFrame(Integer transactionId) { + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(3) + .setPdu(new byte[]{0x04, 0x00, (byte) 0xFA, 0x02, 0x58}).setTransactionId(transactionId); + } + + /** + * 异常响应帧 83 02:解码器已还原原功能码,pdu[0] 是异常码 + */ + private static IotModbusFrame exceptionFrame(Integer transactionId) { + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(3) + .setPdu(new byte[]{0x02}).setExceptionCode(2).setTransactionId(transactionId); + } + + /** + * 在 owner 上模拟 handlePollingResponse:先匹配出队,命中透传就交给通道 + */ + private void receive(IotModbusTcpServerTransactionChannel channel, IotModbusFrameFormatEnum frameFormat, + IotModbusFrame frame) throws InterruptedException { + owner.runOnContext(v -> { + PendingRequest request = pendingRequestManager.matchResponse(DEVICE_ID, frame, frameFormat, channel); + assertNotNull(request); + assertNotNull(request.getTx()); + channel.onRawResponse(frame, request); + }); + awaitOwner(); + } + + private List captureWrites(int times) { + ArgumentCaptor captor = ArgumentCaptor.forClass(Buffer.class); + verify(socket, times(times)).write(captor.capture()); + return captor.getAllValues(); + } + + private static void assertFailures(TxResult result, ErrorCode expected) { + assertEquals(1, result.failures.size()); + assertEquals(expected.getCode(), result.failures.get(0).getCode()); + } + + private static void assertResponse(TxResult result, String expectedHex) { + assertTrue(result.failures.isEmpty()); + assertEquals(1, result.responses.size()); + assertArrayEquals(HexUtil.decodeHex(expectedHex), result.responses.get(0)); + } + + /** + * 等待 owner 上已经排队的任务全部执行完:同一个 Context 上的任务按提交顺序执行 + */ + private void awaitOwner() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + owner.runOnContext(v -> latch.countDown()); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } + + private Thread getOwnerThread() throws InterruptedException { + AtomicReference thread = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + owner.runOnContext(v -> { + thread.set(Thread.currentThread()); + latch.countDown(); + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + return thread.get(); + } + + /** + * 透传事务的终态结果 + */ + private static class TxResult { + + private final List responses = Collections.synchronizedList(new ArrayList<>()); + private final List failures = Collections.synchronizedList(new ArrayList<>()); + + } + + /** + * 记录 addRequest 调用线程与条目的待响应请求管理器 + */ + private static class RecordingPendingRequestManager extends IotModbusTcpServerPendingRequestManager { + + private final List addThreads = Collections.synchronizedList(new ArrayList<>()); + private final List added = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void addRequest(PendingRequest request) { + addThreads.add(Thread.currentThread()); + added.add(request); + super.addRequest(request); + } + + private List getPolls() { + List polls = new ArrayList<>(); + for (PendingRequest request : added) { + if (request.getTx() == null) { + polls.add(request); + } + } + return polls; + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandlerTest.java new file mode 100644 index 0000000..38a26ff --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandlerTest.java @@ -0,0 +1,206 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.downstream; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +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.gateway.protocol.mqtt.manager.IotMqttConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; +import io.vertx.mqtt.MqttEndpoint; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotMqttDownstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotMqttDownstreamHandlerTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 1024L; + private static final String RAW_PUSH_TOPIC = "/sys/pk/dn/thing/raw/push"; + private static final String PROPERTY_SET_TOPIC = "/sys/pk/dn/thing/property/set"; + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; + + @Mock + private IotDeviceMessageService deviceMessageService; + @Mock + private MqttEndpoint endpoint; + + private IotMqttConnectionManager connectionManager; + private IotMqttDownstreamHandler downstreamHandler; + + @BeforeEach + public void setUp() { + connectionManager = new IotMqttConnectionManager(); + downstreamHandler = new IotMqttDownstreamHandler(deviceMessageService, connectionManager); + } + + @Test + public void testHandle_propertySet() { + // mock 方法:设备在线,序列化成功 + registerConnection(); + IotDeviceMessage message = buildPropertySetMessage(); + byte[] payload = "{\"id\":\"m1\"}".getBytes(); + when(deviceMessageService.serializeDeviceMessage(message, "pk", "dn")).thenReturn(payload); + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) + .thenReturn(Future.succeededFuture(1)); + + // 调用 + downstreamHandler.handle(message); + // 断言:发布到 property/set 主题,载荷为序列化结果 + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(endpoint).publish(eq(PROPERTY_SET_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)); + assertArrayEquals(payload, payloadCaptor.getValue().getBytes()); + } + + @Test + public void testHandle_offline() { + // 调用:设备未连接 + downstreamHandler.handle(buildPropertySetMessage()); + // 断言:不序列化,也不发布 + verifyNoInteractions(deviceMessageService, endpoint); + } + + @Test + public void testHandle_serializeEmpty() { + // mock 方法:设备在线,但序列化结果为空 + registerConnection(); + IotDeviceMessage message = buildPropertySetMessage(); + when(deviceMessageService.serializeDeviceMessage(message, "pk", "dn")).thenReturn(new byte[0]); + + // 调用:异常只记录日志,不向外抛出 + assertDoesNotThrow(() -> downstreamHandler.handle(message)); + // 断言:不发布 + verify(endpoint, never()).publish(anyString(), any(Buffer.class), any(MqttQoS.class), anyBoolean(), anyBoolean()); + } + + @Test + public void testHandleRaw_success() { + // mock 方法:设备在线,publish 成功 + registerConnection(); + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) + .thenReturn(Future.succeededFuture(1)); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言:成功,并发布到 raw/push 主题,载荷不变 + assertTrue(future.succeeded()); + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(endpoint).publish(eq(RAW_PUSH_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)); + assertArrayEquals(BYTES, payloadCaptor.getValue().getBytes()); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testHandleRaw_offline() { + // 调用:设备未连接 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + @Test + public void testHandleRaw_writeFailed() { + // mock 方法:设备在线,publish 已提交,写出结果稍后才返回 + registerConnection(); + Promise write = Promise.promise(); + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) + .thenReturn(write.future()); + ListAppender appender = attachAppender(); + try { + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言:不等待写出,提交即返回成功 + assertTrue(future.succeeded()); + assertTrue(getWarnMessages(appender).isEmpty()); + + // 调用:写出失败(例如写出前连接已关闭) + write.fail(new IllegalStateException("Connection is closed")); + // 断言:晚到的写出失败记 warn 日志(设备、主题、原因) + List warnMessages = getWarnMessages(appender); + assertEquals(1, warnMessages.size()); + assertTrue(warnMessages.get(0).contains("发送消息失败")); + assertTrue(warnMessages.get(0).contains(String.valueOf(DEVICE_ID))); + assertTrue(warnMessages.get(0).contains(RAW_PUSH_TOPIC)); + assertTrue(warnMessages.get(0).contains("Connection is closed")); + } finally { + detachAppender(appender); + } + } + + @Test + public void testHandleRaw_publishThrows() { + // mock 方法:设备在线,但 publish 同步抛出异常 + registerConnection(); + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false))) + .thenThrow(new IllegalStateException("Connection not accepted yet")); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + private void registerConnection() { + connectionManager.registerConnection(endpoint, new IotMqttConnectionManager.ConnectionInfo() + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRemoteAddress("127.0.0.1:1883")); + } + + private static IotDeviceMessage buildMessage() { + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) + .setMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); + } + + private static IotDeviceMessage buildPropertySetMessage() { + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()); + } + + private static ListAppender attachAppender() { + ListAppender appender = new ListAppender<>(); + appender.start(); + ((Logger) LoggerFactory.getLogger(IotMqttConnectionManager.class)).addAppender(appender); + return appender; + } + + private static void detachAppender(ListAppender appender) { + ((Logger) LoggerFactory.getLogger(IotMqttConnectionManager.class)).detachAppender(appender); + appender.stop(); + } + + private static List getWarnMessages(ListAppender appender) { + return appender.list.stream().filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList()); + } + + private static void assertFailed(Future future, ErrorCode errorCode) { + assertTrue(future.failed()); + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); + assertEquals(errorCode.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandlerTest.java new file mode 100644 index 0000000..d2bce16 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandlerTest.java @@ -0,0 +1,282 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.mqtt.MqttEndpoint; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotMqttUpstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotMqttUpstreamHandlerTest extends BaseMockitoUnitTest { + + private static final long TIMEOUT_SECONDS = 10; + private static final String PRODUCT_KEY = "pk"; + private static final String DEVICE_NAME = "dn"; + private static final String SERVER_ID = "mqtt-server"; + private static final String RAW_POST_TOPIC = "/sys/pk/dn/thing/raw/post"; + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03}; + + @Mock + private IotDeviceMessageService deviceMessageService; + @Mock + private MqttEndpoint endpoint; + + private final IotMqttConnectionManager connectionManager = new IotMqttConnectionManager(); + private IotMqttUpstreamHandler upstreamHandler; + private Vertx vertx; + private Context context; + + @BeforeEach + public void setUp() { + upstreamHandler = new IotMqttUpstreamHandler(connectionManager, deviceMessageService, SERVER_ID); + lenient().when(endpoint.clientIdentifier()).thenReturn("client-1"); + connectionManager.registerConnection(endpoint, new IotMqttConnectionManager.ConnectionInfo() + .setDeviceId(1L).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME).setRemoteAddress("127.0.0.1:1883")); + vertx = Vertx.vertx(); + context = vertx.getOrCreateContext(); + } + + @AfterEach + public void tearDown() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + @Test + public void testHandleBusinessRequest_rawPostOrderedAck() throws Exception { + // 准备参数 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + List onWorker = new CopyOnWriteArrayList<>(); + // mock 方法:第一条透传上报阻塞,第二条立即完成;记录执行线程 + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID), + eq(IotProtocolTypeEnum.MQTT), anyLong())).thenAnswer(invocation -> { + onWorker.add(Context.isOnWorkerThread()); + entered.countDown(); + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待释放超时"); + } + return "M1"; + }).thenAnswer(invocation -> { + onWorker.add(Context.isOnWorkerThread()); + return "M2"; + }); + + // 调用:记录每条消息交给 handler 前后的时刻 + long firstBeginNanos = System.nanoTime(); + Future first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + long firstEndNanos = System.nanoTime(); + long secondBeginNanos = System.nanoTime(); + Future second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + long secondEndNanos = System.nanoTime(); + + try { + // 断言:处理完成之前不完成(不确认);第二条排在第一条之后 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertFalse(first.isComplete()); + assertFalse(second.isComplete()); + } finally { + release.countDown(); + } + // 断言:释放后按顺序完成;在 worker 上处理 + await(first); + await(second); + assertEquals(Arrays.asList(true, true), onWorker); + // 断言:收到的时刻在交给 handler 时(入队之前)取得:第二条在第一条阻塞期间排队,它的时刻仍在入队前后之间 + ArgumentCaptor receivedCaptor = ArgumentCaptor.forClass(Long.class); + verify(deviceMessageService, times(2)).sendRawMessage(any(), any(), any(), any(), any(), receivedCaptor.capture()); + assertBetween(firstBeginNanos, receivedCaptor.getAllValues().get(0), firstEndNanos); + assertBetween(secondBeginNanos, receivedCaptor.getAllValues().get(1), secondEndNanos); + verify(deviceMessageService, never()).deserializeDeviceMessage(any(), any(), any()); + } + + @Test + public void testHandleBusinessRequest_rawPostFailureStillAck() throws Exception { + // 准备参数 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + // mock 方法:处理失败(脚本超时、未知异常) + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())) + .thenThrow(exception(SCRIPT_TIMEOUT)) + .thenThrow(new IllegalStateException("boom")); + + // 调用 + Future first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + Future second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + + // 断言:只记日志,照常完成(照常确认),与原来一致 + await(first); + await(second); + assertTrue(first.succeeded()); + assertTrue(second.succeeded()); + } + + @Test + public void testHandleBusinessRequest_standard() throws Exception { + // 准备参数 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + String topic = "/sys/pk/dn/thing/property/post"; + byte[] payload = "{\"method\":\"thing.property.post\"}".getBytes(); + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1)); + // mock 方法 + when(deviceMessageService.deserializeDeviceMessage(payload, PRODUCT_KEY, DEVICE_NAME)).thenReturn(message); + + // 调用 + await(callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, topic, payload))); + + // 断言:反序列化后按标准路径发送,不走透传 + verify(deviceMessageService).sendDeviceMessage(same(message), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID)); + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } + + @Test + public void testHandleBusinessRequest_invalidRequest() throws Exception { + // 准备参数 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + MqttEndpoint unknownEndpoint = mock(MqttEndpoint.class); + + // 调用:伪造其它设备的主题、未认证的连接、空载荷 + Future forged = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, + "/sys/pk/other/thing/raw/post", PAYLOAD)); + Future unknown = callOnContext(() -> upstreamHandler.handleBusinessRequest(unknownEndpoint, queue, + RAW_POST_TOPIC, PAYLOAD)); + Future empty = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, + RAW_POST_TOPIC, new byte[0])); + + // 断言:只记日志,照常完成(照常确认),不处理 + await(forged); + await(unknown); + await(empty); + assertTrue(forged.succeeded()); + assertTrue(unknown.succeeded()); + assertTrue(empty.succeeded()); + verifyNoInteractions(deviceMessageService); + } + + @Test + public void testHandleBusinessRequest_invalidRequestOrderedAck() throws Exception { + // 准备参数 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // mock 方法:第一条透传上报阻塞 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { + entered.countDown(); + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待释放超时"); + } + return "M1"; + }); + + // 调用:第二条是空载荷,不处理 + Future first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + Future empty = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, + RAW_POST_TOPIC, new byte[0])); + + try { + // 断言:不处理的消息也排在前一条之后完成,确认(PUBACK)不越过前一条 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertFalse(first.isComplete()); + assertFalse(empty.isComplete()); + } finally { + release.countDown(); + } + await(first); + await(empty); + assertTrue(empty.succeeded()); + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } + + @Test + public void testHandleBusinessRequest_queueFull() throws Exception { + // 准备参数:队列最多 1 个待处理任务 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 1); + CountDownLatch release = new CountDownLatch(1); + // mock 方法:第一条阻塞 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> { + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待释放超时"); + } + return "M1"; + }); + + try { + // 调用 + Future first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + Future second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD)); + + // 断言:超过上限立即失败(调用方断开连接,不确认) + assertTrue(second.failed()); + assertInstanceOf(RejectedExecutionException.class, second.cause()); + assertFalse(first.isComplete()); + release.countDown(); + await(first); + assertTrue(first.succeeded()); + // 断言:被拒绝的那条不处理 + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong()); + } finally { + release.countDown(); + } + } + + // ========== 辅助方法 ========== + + /** + * 在连接所在的 context 线程上执行(与 MQTT 的 publishHandler 一致),等待并返回结果 + */ + private T callOnContext(Callable action) throws Exception { + CompletableFuture result = new CompletableFuture<>(); + context.runOnContext(v -> { + try { + result.complete(action.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + }); + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + private static void await(Future future) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + future.onComplete(ar -> latch.countDown()); + assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } + + private static void assertBetween(long beginNanos, long actualNanos, long endNanos) { + assertTrue(actualNanos >= beginNanos && actualNanos <= endNanos, + String.format("%d 不在 [%d, %d] 之间", actualNanos, beginNanos, endNanos)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandlerTest.java new file mode 100644 index 0000000..bf46370 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandlerTest.java @@ -0,0 +1,199 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +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.gateway.protocol.tcp.codec.IotTcpFrameCodec; +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotTcpDownstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotTcpDownstreamHandlerTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 1024L; + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; + /** + * 帧编码后的数据(模拟 codec 加了帧头) + */ + private static final byte[] FRAME = new byte[]{0x00, 0x03, 0x01, 0x02, 0x03}; + + @Mock + private IotTcpFrameCodec codec; + @Mock + private IotMessageSerializer serializer; + @Mock + private NetSocket socket; + @Mock + private NetSocket jsonSocket; + + private IotTcpConnectionManager connectionManager; + private IotTcpDownstreamHandler downstreamHandler; + + @BeforeEach + public void setUp() { + connectionManager = new IotTcpConnectionManager(10); + downstreamHandler = new IotTcpDownstreamHandler(connectionManager, codec, serializer); + } + + @Test + public void testHandle_propertySet() { + // mock 方法:普通连接,序列化 + 帧编码成功 + registerConnection(false); + IotDeviceMessage message = buildPropertySetMessage(); + when(serializer.serialize(message)).thenReturn(BYTES); + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME)); + + // 调用 + downstreamHandler.handle(message); + // 断言:写出 codec 加帧后的数据 + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(socket).write(dataCaptor.capture()); + assertArrayEquals(FRAME, dataCaptor.getValue().getBytes()); + } + + @Test + public void testHandle_notPropertySet() { + // mock 方法:普通连接 + registerConnection(false); + + // 调用:非属性设置消息(例如服务调用) + downstreamHandler.handle(new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) + .setMethod(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod())); + // 断言:忽略,不序列化也不写出 + verifyNoInteractions(serializer, codec); + verify(socket, never()).write(any(Buffer.class)); + } + + @Test + public void testHandle_offline() { + // 调用:设备未连接 + downstreamHandler.handle(buildPropertySetMessage()); + // 断言:不序列化也不写出 + verifyNoInteractions(serializer, codec, socket); + } + + @Test + public void testHandleRaw_success() { + // mock 方法:透传连接,write 成功 + registerConnection(true); + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME)); + when(socket.write(any(Buffer.class))).thenReturn(Future.succeededFuture()); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言:成功,并写出 codec 加帧后的数据 + assertTrue(future.succeeded()); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(socket).write(dataCaptor.capture()); + assertArrayEquals(FRAME, dataCaptor.getValue().getBytes()); + verifyNoInteractions(serializer); + } + + @Test + public void testHandleRaw_offline() { + // 调用:设备未连接 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + verifyNoInteractions(codec); + } + + @Test + public void testHandleRaw_notRawConnection() { + // mock 方法:普通(非透传)连接 + registerConnection(false); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言:105,不写出 + assertFailed(future, RAW_NOT_SUPPORTED); + verifyNoInteractions(codec); + verify(socket, never()).write(any(Buffer.class)); + } + + @Test + public void testHandleRaw_writeFailed() { + // mock 方法:透传连接,但 write 异步失败(例如写出前对端已关闭) + registerConnection(true); + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME)); + when(socket.write(any(Buffer.class))).thenReturn(Future.failedFuture(new IllegalStateException("Connection is closed"))); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言:异步失败转成 106,而不是成功 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + @Test + public void testHandleRaw_encodeFailed() { + // mock 方法:透传连接,但帧编码失败(例如超过固定长度) + registerConnection(true); + when(codec.encode(BYTES)).thenThrow(new IllegalArgumentException("数据长度 3 超过固定长度 2")); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + verify(socket, never()).write(any(Buffer.class)); + } + + @Test + public void testHandleRaw_connectionReplaced() { + // mock 方法:透传连接;检查之后、写出之前,设备换成了普通 JSON 连接 + registerConnection(true); + when(codec.encode(BYTES)).thenAnswer(invocation -> { + connectionManager.registerConnection(jsonSocket, DEVICE_ID, new IotTcpConnectionManager.ConnectionInfo() + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRawMode(false)); + return Buffer.buffer(FRAME); + }); + + // 调用 + Future future = downstreamHandler.handleRawMessage(buildMessage(), BYTES); + // 断言:写出失败(106),不写到新的 JSON 连接上,也不重试 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + verify(jsonSocket, never()).write(any(Buffer.class)); + verify(socket, never()).write(any(Buffer.class)); + assertSame(jsonSocket, connectionManager.getSocketByDeviceId(DEVICE_ID)); + } + + private void registerConnection(boolean rawMode) { + connectionManager.registerConnection(socket, DEVICE_ID, new IotTcpConnectionManager.ConnectionInfo() + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRawMode(rawMode)); + } + + private static IotDeviceMessage buildMessage() { + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) + .setMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); + } + + private static IotDeviceMessage buildPropertySetMessage() { + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID) + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()); + } + + private static void assertFailed(Future future, ErrorCode errorCode) { + assertTrue(future.failed()); + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); + assertEquals(errorCode.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandlerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandlerTest.java new file mode 100644 index 0000000..6b55435 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandlerTest.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotUdpUpstreamHandler} 的单元测试 + * + * @author 芋道源码 + */ +public class IotUdpUpstreamHandlerTest { + + private static final String TOKEN = "eyJhbGciOiJIUzI1NiJ9.e30.token"; + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x7E, 0x7B}; + + @Test + public void testIsRawPacket() { + assertTrue(IotUdpUpstreamHandler.isRawPacket(buildUpstreamPacket(TOKEN, PAYLOAD))); + // JSON、二进制序列化、长度不足、空 + assertFalse(IotUdpUpstreamHandler.isRawPacket(StrUtil.utf8Bytes("{\"method\":\"auth\"}"))); + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[]{0x7E, 0x01, 0x02})); + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[]{0x49})); + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[0])); + assertFalse(IotUdpUpstreamHandler.isRawPacket(null)); + } + + @Test + public void testDecodeRawPacket_success() { + // 准备参数 + byte[] data = buildUpstreamPacket(TOKEN, PAYLOAD); + + // 调用 + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(data); + // 断言 + assertEquals(TOKEN, packet.getToken()); + assertArrayEquals(PAYLOAD, packet.getPayload()); + } + + @Test + public void testDecodeRawPacket_maxPayload() { + // 准备参数:payload 正好 4096 字节 + byte[] payload = new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]; + payload[payload.length - 1] = 0x55; + + // 调用 + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(buildUpstreamPacket(TOKEN, payload)); + // 断言 + assertArrayEquals(payload, packet.getPayload()); + } + + @Test + public void testDecodeRawPacket_invalid() { + byte[] valid = buildUpstreamPacket(TOKEN, PAYLOAD); + // 魔术字不正确 + assertThrows(IllegalArgumentException.class, + () -> IotUdpUpstreamHandler.decodeRawPacket(StrUtil.utf8Bytes("{\"method\":\"auth\"}"))); + // 报文头不完整 + assertThrows(IllegalArgumentException.class, + () -> IotUdpUpstreamHandler.decodeRawPacket(new byte[]{0x49, 0x52, 0x01, 0x00})); + // 版本不支持 + byte[] wrongVersion = valid.clone(); + wrongVersion[2] = 0x02; + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(wrongVersion)); + // 缺少 token(tokenLen = 0,例如误把下行报文发上来) + assertThrows(IllegalArgumentException.class, + () -> IotUdpUpstreamHandler.decodeRawPacket(IotUdpUpstreamHandler.encodeRawDownstream(PAYLOAD))); + // tokenLen 超过报文长度 + byte[] tokenTooLong = valid.clone(); + tokenTooLong[3] = (byte) 0xFF; + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(tokenTooLong)); + // 缺少 payload + assertThrows(IllegalArgumentException.class, + () -> IotUdpUpstreamHandler.decodeRawPacket(buildUpstreamPacket(TOKEN, new byte[0]))); + // payload 超过 4096 字节 + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket( + buildUpstreamPacket(TOKEN, new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES + 1]))); + } + + @Test + public void testEncodeRawDownstream() { + // 调用 + byte[] data = IotUdpUpstreamHandler.encodeRawDownstream(PAYLOAD); + // 断言:0x49 0x52 | 0x01 | 0x0000 | payload + assertArrayEquals(ArrayUtil.addAll(new byte[]{0x49, 0x52, 0x01, 0x00, 0x00}, PAYLOAD), data); + assertTrue(IotUdpUpstreamHandler.isRawPacket(data)); + } + + @Test + public void testEncodeDecode_roundTrip() { + // 准备参数:设备上行的报文 + byte[] upstream = buildUpstreamPacket(TOKEN, PAYLOAD); + + // 调用:解码上行,再把解出的 payload 编码为下行(例如 ack 回显) + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(upstream); + byte[] downstream = IotUdpUpstreamHandler.encodeRawDownstream(packet.getPayload()); + // 断言:payload 原样保留,下行报文头 tokenLen = 0 + assertEquals(TOKEN, packet.getToken()); + assertArrayEquals(ArrayUtil.sub(upstream, upstream.length - PAYLOAD.length, upstream.length), + ArrayUtil.sub(downstream, 5, downstream.length)); + assertEquals(0, downstream[3]); + assertEquals(0, downstream[4]); + } + + /** + * 按协议构建上行报文:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload + */ + private static byte[] buildUpstreamPacket(String token, byte[] payload) { + byte[] tokenBytes = StrUtil.utf8Bytes(token); + byte[] header = new byte[]{0x49, 0x52, 0x01, + (byte) ((tokenBytes.length >> 8) & 0xFF), (byte) (tokenBytes.length & 0xFF)}; + return ArrayUtil.addAll(header, tokenBytes, payload); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManagerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManagerTest.java new file mode 100644 index 0000000..3585627 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManagerTest.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.datagram.DatagramSocket; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import java.net.InetSocketAddress; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotUdpSessionManager} 的单元测试 + * + * @author 芋道源码 + */ +public class IotUdpSessionManagerTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 1024L; + private static final byte[] DATA = new byte[]{0x49, 0x52, 0x01, 0x00, 0x00, 0x01}; + private static final InetSocketAddress ADDRESS = new InetSocketAddress("127.0.0.1", 18093); + + @Mock + private DatagramSocket socket; + + private IotUdpSessionManager sessionManager; + + @BeforeEach + public void setUp() { + sessionManager = new IotUdpSessionManager(10, 60000L); + } + + @Test + public void testSendToDeviceAsync_success() { + // mock 方法:会话存在,send 成功 + registerSession(); + when(socket.send(any(Buffer.class), anyInt(), anyString())).thenReturn(Future.succeededFuture()); + + // 调用 + Future future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); + // 断言:发送到会话地址 + assertTrue(future.succeeded()); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(socket).send(dataCaptor.capture(), eq(ADDRESS.getPort()), eq(ADDRESS.getHostString())); + assertArrayEquals(DATA, dataCaptor.getValue().getBytes()); + } + + @Test + public void testSendToDeviceAsync_notWait() { + // mock 方法:会话存在,send 一直未完成 + registerSession(); + Promise promise = Promise.promise(); + when(socket.send(any(Buffer.class), anyInt(), anyString())).thenReturn(promise.future()); + + // 调用:不等待发送结果,立即返回未完成的 Future(不走 sendToDevice 的 5 秒同步等待) + Future future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); + assertFalse(future.isComplete()); + // 发送失败后,转成 106 + promise.fail(new IllegalStateException("Socket closed")); + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + @Test + public void testSendToDeviceAsync_sessionNotExists() { + // 调用:会话不存在(或已过期) + Future future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + verifyNoInteractions(socket); + } + + @Test + public void testSendToDeviceAsync_socketNotAvailable() { + // mock 方法:会话存在,但 UDP Socket 不可用 + registerSession(); + + // 调用,并断言 + assertFailed(sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, null), DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + @Test + public void testSendToDeviceAsync_sendFailed() { + // mock 方法:会话存在,但 send 异步失败 + registerSession(); + when(socket.send(any(Buffer.class), anyInt(), anyString())) + .thenReturn(Future.failedFuture(new IllegalStateException("Socket closed"))); + + // 调用 + Future future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket); + // 断言:异步失败转成 106,而不是成功 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + private void registerSession() { + sessionManager.registerSession(DEVICE_ID, new IotUdpSessionManager.SessionInfo() + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setAddress(ADDRESS)); + } + + private static void assertFailed(Future future, ErrorCode errorCode) { + assertTrue(future.failed()); + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); + assertEquals(errorCode.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManagerTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManagerTest.java new file mode 100644 index 0000000..8fdea30 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManagerTest.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.ServerWebSocket; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link IotWebSocketConnectionManager} 的单元测试 + * + * @author 芋道源码 + */ +public class IotWebSocketConnectionManagerTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 1024L; + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03}; + + @Mock + private ServerWebSocket socket; + + private IotWebSocketConnectionManager connectionManager; + + @BeforeEach + public void setUp() { + connectionManager = new IotWebSocketConnectionManager(); + } + + @Test + public void testSendBinaryToDeviceAsync_success() { + // mock 方法:设备在线,writeBinaryMessage 成功 + registerConnection(); + when(socket.writeBinaryMessage(any(Buffer.class))).thenReturn(Future.succeededFuture()); + + // 调用 + Future future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES); + // 断言:以 binary 帧写出原始字节,不写 text 帧 + assertTrue(future.succeeded()); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Buffer.class); + verify(socket).writeBinaryMessage(dataCaptor.capture()); + assertArrayEquals(BYTES, dataCaptor.getValue().getBytes()); + verify(socket, never()).writeTextMessage(anyString()); + } + + @Test + public void testSendBinaryToDeviceAsync_offline() { + // 调用:设备未连接 + Future future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES); + // 断言 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + verifyNoInteractions(socket); + } + + @Test + public void testSendBinaryToDeviceAsync_writeFailed() { + // mock 方法:设备在线,但 writeBinaryMessage 异步失败(例如写出前连接已关闭) + registerConnection(); + when(socket.writeBinaryMessage(any(Buffer.class))) + .thenReturn(Future.failedFuture(new IllegalStateException("WebSocket is closed"))); + + // 调用 + Future future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES); + // 断言:异步失败转成 106,而不是成功 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED); + } + + private void registerConnection() { + connectionManager.registerConnection(socket, DEVICE_ID, new IotWebSocketConnectionManager.ConnectionInfo() + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn")); + } + + private static void assertFailed(Future future, ErrorCode errorCode) { + assertTrue(future.failed()); + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause()); + assertEquals(errorCode.getCode(), exception.getCode()); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImplTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImplTest.java new file mode 100644 index 0000000..9a0aab0 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImplTest.java @@ -0,0 +1,919 @@ +package cn.iocoder.yudao.module.iot.gateway.service.device.message; + +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +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.script.IotScriptExecutor; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link IotDeviceMessageServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { + + private static final Long DEVICE_ID = 10L; + private static final Long TENANT_ID = 1L; + private static final Long PRODUCT_ID = 100L; + private static final String PRODUCT_KEY = "pk"; + private static final String DEVICE_NAME = "dn"; + private static final String SERVER_ID = "server-1"; + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, (byte) 0xFF}; + private static final String RAW = Base64.getEncoder().encodeToString(PAYLOAD); + + private static final String SCRIPT = "function decode(ctx) {}"; + + private static final String DECODE_PROPERTY_POST = "{\"method\":\"thing.property.post\",\"params\":{\"temperature\":25.5}}"; + + @InjectMocks + private IotDeviceMessageServiceImpl deviceMessageService; + + @Mock + private IotDeviceService deviceService; + @Mock + private IotDeviceMessageProducer deviceMessageProducer; + @Mock + private IotMessageSerializerManager messageSerializerManager; + @Mock + private IotScriptExecutor scriptExecutor; + + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties(); + private final IotDeviceRespDTO device = new IotDeviceRespDTO().setId(DEVICE_ID).setTenantId(TENANT_ID) + .setProductId(PRODUCT_ID).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME) + .setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType()) + .setScriptEnabled(true).setScriptContent(SCRIPT); + private final IotScriptSource source = IotScriptSource.of(PRODUCT_ID, SCRIPT); + + @BeforeEach + public void setUp() { + ReflectUtil.setFieldValue(deviceMessageService, "gatewayProperties", gatewayProperties); + ReflectUtil.setFieldValue(deviceMessageService, "scriptExecutor", scriptExecutor); + } + + // ========== sendRawMessage ========== + + @Test + public void testSendRawMessage_decodeSuccess() { + // 准备参数:截止 = 收到的时刻 + gateway.script.deadline + gatewayProperties.getScript().setEnabled(true); + long receivedNanos = System.nanoTime(); + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos(); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))) + .thenReturn(IotScriptResult.success(DECODE_PROPERTY_POST, 1)); + + // 调用 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, receivedNanos); + + // 断言:只投递 1 条解析出的标准消息,raw 带上原始帧 + IotDeviceMessage message = captureSentMessage(); + assertEquals(messageId, message.getId()); + assertNotNull(message.getId()); + assertEquals(message.getId(), message.getRequestId()); // 没有 requestId 时默认 = id + assertNotNull(message.getReportTime()); + assertEquals("thing.property.post", message.getMethod()); + assertEquals(Collections.singletonMap("temperature", 25.5), message.getParams()); + assertEquals(RAW, message.getRaw()); + assertDeviceIdentity(message); + // 断言:decode 的入参 + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(String.class); + verify(scriptExecutor).execute(sameScript(), eq("decode"), ctxCaptor.capture(), eq(deadlineNanos)); + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue()); + assertEquals("decode", ctx.get("direction").asText()); + assertEquals(IotProtocolTypeEnum.TCP.getType(), ctx.get("protocol").asText()); + assertEquals(PRODUCT_KEY, ctx.get("productKey").asText()); + assertEquals(DEVICE_NAME, ctx.get("deviceName").asText()); + assertEquals("0102FF", ctx.get("hex").asText()); + assertEquals(255, ctx.get("bytes").get(2).asInt()); + assertNull(ctx.get("request")); + } + + @Test + public void testSendRawMessage_decodeKeepRequestId() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success("{\"method\":\"thing.event.post\",\"requestId\":\"dev-1\"," + + "\"params\":{\"identifier\":\"alarm\",\"value\":{\"level\":2}}}", 1)); + + // 调用 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.MQTT, System.nanoTime()); + + // 断言:脚本返回的 requestId 保留,id 由服务端生成 + IotDeviceMessage message = captureSentMessage(); + assertEquals(messageId, message.getId()); + assertEquals("dev-1", message.getRequestId()); + assertNotEquals("dev-1", message.getId()); + assertEquals("thing.event.post", message.getMethod()); + assertEquals(RAW, message.getRaw()); + assertDeviceIdentity(message); + } + + @Test + public void testSendRawMessage_decodeReturnNull() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success(null, 1)); + + // 调用 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:只投递 1 条 thing.raw.post + IotDeviceMessage message = captureSentMessage(); + assertEquals(messageId, message.getId()); + assertRawPost(message); + } + + @Test + public void testSendRawMessage_scriptError() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.error("boom", 1)); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_EXECUTE_FAILED, "boom"); + // 断言:解析失败不投递任何消息(只记错误日志) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_scriptTimeout() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.timeout("超过执行预算")); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_TIMEOUT); + // 断言:解析失败不投递任何消息(只记错误日志) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_scriptRejected() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.rejected("已熔断")); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_REJECTED); + // 断言:解析失败不投递任何消息(只记错误日志) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_scriptResultOutputInvalid() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + // mock 方法:执行器自己判定输出不合法(例如结果过大) + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.of(IotScriptResult.Status.OUTPUT_INVALID, "输出过大", -1, 1)); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_OUTPUT_INVALID, "输出过大"); + // 断言:解析失败不投递任何消息(只记错误日志) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_outputInvalid() { + // 准备参数:脚本试图伪造可信字段(raw、deviceId、id、tenantId、serverId、reportTime)、返回 messages 包装、ack、 + // 非对象、不支持的方法、缺少 params + gatewayProperties.getScript().setEnabled(true); + List outputs = Arrays.asList( + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"raw\":\"AQID\"}", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"deviceId\":999}", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"id\":\"forged\"}", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"tenantId\":999}", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"serverId\":\"other\"}", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"reportTime\":1}", + "{\"messages\":[{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}]}", + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"ack\":[1]}", + "[{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}]", + "{\"method\":\"thing.raw.post\",\"params\":{\"t\":1}}", + "{\"method\":\"thing.property.post\"}"); + // mock 方法 + mockDevice(); + for (String output : outputs) { + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success(output, 1)); + + // 调用 + ServiceException exception = assertThrows(ServiceException.class, () -> deviceMessageService.sendRawMessage( + PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, IotProtocolTypeEnum.TCP, System.nanoTime()), output); + + // 断言:抛出 SCRIPT_OUTPUT_INVALID + assertEquals(SCRIPT_OUTPUT_INVALID.getCode(), exception.getCode(), output); + } + // 断言:解析失败不投递任何消息(伪造的可信字段也不会被投递出去) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_switchOff() { + // 准备参数:功能开关默认关闭 + // mock 方法 + mockDevice(); + + // 调用 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:只投递 1 条 thing.raw.post;不执行脚本 + IotDeviceMessage message = captureSentMessage(); + assertEquals(messageId, message.getId()); + assertRawPost(message); + verifyNoInteractions(scriptExecutor); + } + + @Test + public void testSendRawMessage_scriptDisabled() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + device.setScriptEnabled(false); + // mock 方法 + mockDevice(); + + // 调用 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:只投递 1 条 thing.raw.post;不执行脚本 + IotDeviceMessage message = captureSentMessage(); + assertEquals(messageId, message.getId()); + assertRawPost(message); + verifyNoInteractions(scriptExecutor); + } + + @Test + public void testSendRawMessage_scriptNotConfigured() { + // 准备参数:产品从未配置脚本 + gatewayProperties.getScript().setEnabled(true); + device.setScriptEnabled(null).setScriptContent(null); + // mock 方法 + mockDevice(); + + // 调用 + deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:与停用一致,只投递 1 条 thing.raw.post;不执行脚本 + assertRawPost(captureSentMessage()); + verifyNoInteractions(scriptExecutor); + } + + @Test + public void testSendRawMessage_scriptContentEmpty() { + // 准备参数:已启用,但脚本内容为空(配置不合法) + gatewayProperties.getScript().setEnabled(true); + // mock 方法 + mockDevice(); + for (String content : Arrays.asList(null, "", " ")) { + device.setScriptContent(content); + + // 调用,并断言异常:fail-closed,不当作停用 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_EXECUTE_FAILED, "已启用但脚本内容为空"); + } + // 断言:不投递任何消息(只记错误日志),也不执行脚本 + verifyNoInteractions(deviceMessageProducer, scriptExecutor); + } + + @Test + public void testSendRawMessage_queuedPastDeadline() { + // 准备参数:开启功能,收到的时刻已早于截止时长之前(在 worker 队列里排队期间到期) + gatewayProperties.getScript().setEnabled(true); + long receivedNanos = System.nanoTime() - gatewayProperties.getScript().getDeadline().toNanos() - 1; + // mock 方法 + mockDevice(); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT); + // 断言:不投递任何消息(只记错误日志),也不执行脚本 + verifyNoInteractions(deviceMessageProducer, scriptExecutor); + } + + @Test + public void testSendRawMessage_scriptFinishedPastDeadline() { + // 准备参数:截止 500ms,脚本执行到截止之后才返回 + gatewayProperties.getScript().setEnabled(true).setDeadline(Duration.ofMillis(500)); + long receivedNanos = System.nanoTime(); + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> { + sleepUntil(deadlineNanos); + return IotScriptResult.success(DECODE_PROPERTY_POST, 300); + }); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT); + // 断言:迟到的业务消息不投递,也不投递原始消息(只记错误日志) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_scriptReturnNullPastDeadline() { + // 准备参数:截止 500ms,脚本执行到截止之后才返回 null + gatewayProperties.getScript().setEnabled(true).setDeadline(Duration.ofMillis(500)); + long receivedNanos = System.nanoTime(); + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500); + // mock 方法 + mockDevice(); + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> { + sleepUntil(deadlineNanos); + return IotScriptResult.success(null, 300); + }); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT); + // 断言:迟到的 null 结果不记为 SUCCESS,不投递任何消息(只记错误日志) + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawMessage_payloadInvalid() { + // 调用,并断言:长度 0、4097、null 都直接拒绝,不查设备、不投递 + for (byte[] payload : Arrays.asList(new byte[0], new byte[4097], null)) { + ServiceException exception = assertThrows(ServiceException.class, () -> deviceMessageService.sendRawMessage( + payload, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, IotProtocolTypeEnum.TCP, System.nanoTime())); + assertEquals(BAD_REQUEST.getCode(), exception.getCode()); + } + verifyNoInteractions(deviceService, deviceMessageProducer, scriptExecutor); + } + + @Test + public void testSendRawMessage_maxPayload() { + // 准备参数:正好 4096 字节 + byte[] payload = new byte[4096]; + payload[4095] = 0x55; + // mock 方法 + mockDevice(); + + // 调用 + deviceMessageService.sendRawMessage(payload, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:原始帧完整保留 + IotDeviceMessage message = captureSentMessage(); + assertArrayEquals(payload, Base64.getDecoder().decode(message.getRaw())); + } + + @Test + public void testSendRawMessage_deviceNotExists() { + // 调用,并断言异常:不投递 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, + IotProtocolTypeEnum.TCP, System.nanoTime()), DEVICE_NOT_EXISTS, PRODUCT_KEY, DEVICE_NAME); + verifyNoInteractions(deviceMessageProducer, scriptExecutor); + } + + // ========== sendDeviceMessage ========== + + @Test + public void testSendDeviceMessage_clearForgedRaw() { + // 准备参数:设备经标准路径上报,试图伪造 raw、编号、时间、身份 + LocalDateTime forgedTime = LocalDateTime.of(2000, 1, 1, 0, 0); + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1)) + .setId("forged-id").setReportTime(forgedTime).setRaw(RAW) + .setDeviceId(999L).setTenantId(999L).setServerId("forged-server"); + // mock 方法 + mockDevice(); + + // 调用 + deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID); + + // 断言:raw 被清空;编号、时间、身份由服务端设置 + IotDeviceMessage sent = captureSentMessage(); + assertSame(message, sent); + assertNull(sent.getRaw()); + assertNotEquals("forged-id", sent.getId()); + assertNotEquals(forgedTime, sent.getReportTime()); + assertEquals(sent.getId(), sent.getRequestId()); + assertDeviceIdentity(sent); + } + + @Test + public void testSendDeviceMessage_keepRequestId() { + // 准备参数 + IotDeviceMessage message = IotDeviceMessage.requestOf("req-1", "thing.property.post", + Collections.singletonMap("t", 1)); + // mock 方法 + mockDevice(); + + // 调用 + deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID); + + // 断言:设备的 requestId 保留 + IotDeviceMessage sent = captureSentMessage(); + assertEquals("req-1", sent.getRequestId()); + assertNull(sent.getRaw()); + } + + @Test + public void testSendDeviceMessage_rawMethodReserved() { + // 调用,并断言异常:标准路径不接受 thing.raw.post / thing.raw.push + for (IotDeviceMessageMethodEnum method : Arrays.asList(IotDeviceMessageMethodEnum.RAW_POST, + IotDeviceMessageMethodEnum.RAW_PUSH)) { + IotDeviceMessage message = IotDeviceMessage.requestOf(method.getMethod(), null).setRaw(RAW); + assertServiceException(() -> deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, + SERVER_ID), DEVICE_MESSAGE_RAW_METHOD_RESERVED); + } + // 断言:不查设备、不投递 + verifyNoInteractions(deviceService, deviceMessageProducer); + } + + @Test + public void testSendDeviceMessage_deviceNotExists() { + // 准备参数 + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1)); + + // 调用,并断言异常 + assertServiceException(() -> deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID), + DEVICE_NOT_EXISTS, PRODUCT_KEY, DEVICE_NAME); + verifyNoInteractions(deviceMessageProducer); + } + + // ========== encodeDownstreamCommand ========== + + @Test + public void testEncodeDownstreamCommand_deviceQueryFailed() { + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenThrow(new IllegalStateException("rpc 失败")); + + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:按设备不存在处理,走标准路径,不丢弃指令 + assertTrue(result.isStandard()); + verifyNoInteractions(scriptExecutor, deviceMessageProducer); + } + + @Test + public void testEncodeDownstreamCommand_deviceNotExists() { + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:与未启用脚本一致,走标准路径 + assertTrue(result.isStandard()); + verifyNoInteractions(scriptExecutor, deviceMessageProducer); + } + + @Test + public void testEncodeDownstreamCommand_scriptDisabledOrNotConfigured() { + for (Boolean scriptEnabled : Arrays.asList(false, null)) { + // mock 方法 + device.setScriptEnabled(scriptEnabled); + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:走标准路径,不执行脚本 + assertTrue(result.isStandard(), String.valueOf(scriptEnabled)); + } + verifyNoInteractions(scriptExecutor, deviceMessageProducer); + } + + @Test + public void testEncodeDownstreamCommand_scriptContentEmpty() { + // mock 方法:已启用,但脚本内容为空(配置不合法) + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + for (String content : Arrays.asList(null, "", " ")) { + device.setScriptContent(content); + + // 调用,并断言:不下发(不写设备,也不改发 JSON) + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, System.nanoTime()), String.valueOf(content)); + } + verifyNoInteractions(scriptExecutor, deviceMessageProducer); + } + + @Test + public void testEncodeDownstreamCommand_encodeBytes() { + // 准备参数:截止 = 收到的时刻 + gateway.script.deadline + IotDeviceMessage message = buildPropertySet(); + long receivedNanos = System.nanoTime(); + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos(); + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))) + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2,255]}", 1)); + + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(message, + IotProtocolTypeEnum.TCP, receivedNanos); + + // 断言:透传 encode 的字节,带上固定的脚本 + assertFalse(result.isStandard()); + assertArrayEquals(new byte[]{1, 2, (byte) 0xFF}, result.getBytes()); + assertEquals(source.getKey(), result.getSource().getKey()); + verifyNoInteractions(deviceMessageProducer); + // 断言:encode 的入参 + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(String.class); + verify(scriptExecutor).execute(sameScript(), eq("encode"), ctxCaptor.capture(), eq(deadlineNanos)); + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue()); + assertEquals("encode", ctx.get("direction").asText()); + assertEquals(IotProtocolTypeEnum.TCP.getType(), ctx.get("protocol").asText()); + assertEquals("thing.property.set", ctx.get("message").get("method").asText()); + assertEquals("req-1", ctx.get("message").get("requestId").asText()); + assertEquals(1, ctx.get("message").get("params").get("power").asInt()); + } + + @Test + public void testEncodeDownstreamCommand_encodeStandard() { + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success("{\"standard\":true}", 1)); + + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, System.nanoTime()); + + // 断言:走标准路径 + assertTrue(result.isStandard()); + assertNull(result.getBytes()); + } + + @Test + public void testEncodeDownstreamCommand_encodeNotWrite() { + // 准备参数:encode 返回 null、执行失败、超时、繁忙、输出不合法(未知键、bytes 为空、超过 255、同时有两种输出) + List results = Arrays.asList(IotScriptResult.success(null, 1), + IotScriptResult.error("boom", 1), IotScriptResult.timeout("slow"), IotScriptResult.rejected("full"), + IotScriptResult.success("{\"other\":1}", 1), IotScriptResult.success("{\"bytes\":[]}", 1), + IotScriptResult.success("{\"bytes\":[256]}", 1), + IotScriptResult.success("{\"bytes\":[1],\"standard\":true}", 1)); + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + for (IotScriptResult result : results) { + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())).thenReturn(result); + + // 调用,并断言:不下发(不写设备,也不改走标准路径) + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, System.nanoTime()), String.valueOf(result.getJson())); + } + // 断言:不给 biz 构造失败回复 + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testEncodeDownstreamCommand_modbusPdu() { + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2]}", 1), + IotScriptResult.success("{\"bytes\":[6,0,16,0,1]}", 1)); + + // 调用,并断言:Modbus 协议,encode 输出不是合法的请求 PDU → 不下发 + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, System.nanoTime())); + // 调用,并断言:合法的请求 PDU → 透传 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, System.nanoTime()); + assertArrayEquals(new byte[]{6, 0, 16, 0, 1}, result.getBytes()); + } + + @Test + public void testEncodeDownstreamCommand_encodeFinishedPastDeadline() { + // 准备参数:截止 500ms,encode 执行到截止之后才返回 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(500)); + long receivedNanos = System.nanoTime(); + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500); + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> { + sleepUntil(deadlineNanos); + return IotScriptResult.success("{\"bytes\":[1,2]}", 300); + }); + + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, receivedNanos); + + // 断言:迟到的结果不下发(不写设备,也不改走标准路径) + assertNull(result); + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testEncodeDownstreamCommand_queuedPastDeadline() { + // 准备参数:收到指令的时刻已早于截止时长之前(在设备的指令队列里排队期间到期) + long receivedNanos = System.nanoTime() - gatewayProperties.getScript().getDeadline().toNanos() - 1; + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos(); + // mock 方法:脚本执行器拿到已过的截止时间(真实的执行器不执行脚本,直接返回超时);即使这里返回了字节,也不下发 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))) + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2]}", 1)); + + // 调用 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(), + IotProtocolTypeEnum.TCP, receivedNanos); + + // 断言:不下发(不写设备,也不改走标准路径) + assertNull(result); + verifyNoInteractions(deviceMessageProducer); + } + + // ========== sendRawReply ========== + + @Test + public void testSendRawReply_rawPushResponse() { + // 准备参数:raw.push 的设备正常响应 + IotDeviceMessage request = buildRawPush(); + byte[] responsePdu = new byte[]{0x03, 0x04, 0x00, 0x01, 0x00, 0x02}; + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + + // 调用 + deviceMessageService.sendRawReply(request, null, responsePdu, null, SERVER_ID); + + // 断言:回复 code = 0,raw = 响应 PDU;不执行脚本 + IotDeviceMessage reply = captureSentMessage(); + assertEquals("req-raw", reply.getRequestId()); + assertNotEquals("req-raw", reply.getId()); + assertEquals("thing.raw.push", reply.getMethod()); + assertEquals(SUCCESS.getCode(), reply.getCode()); + assertEquals(SUCCESS.getMsg(), reply.getMsg()); + assertNull(reply.getParams()); + assertNull(reply.getData()); + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); + assertDeviceIdentity(reply); + verifyNoInteractions(scriptExecutor); + } + + @Test + public void testSendRawReply_rawPushDeviceError() { + // 准备参数:raw.push 的设备异常响应(83 02),结果码由协议判定 + IotDeviceMessage request = buildRawPush(); + byte[] responsePdu = new byte[]{(byte) 0x83, 0x02}; + ErrorCode deviceError = new ErrorCode(MODBUS_EXCEPTION.getCode(), StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2)); + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + + // 调用 + deviceMessageService.sendRawReply(request, null, responsePdu, deviceError, SERVER_ID); + + // 断言:回复协议判定的结果码与文案;raw = 响应 PDU + IotDeviceMessage reply = captureSentMessage(); + assertEquals("req-raw", reply.getRequestId()); + assertEquals(deviceError.getCode(), reply.getCode()); + assertEquals(deviceError.getMsg(), reply.getMsg()); + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); + verifyNoInteractions(scriptExecutor); + } + + @Test + public void testSendRawReply_encodeResponse() { + // 准备参数:encode 指令的设备响应;设备缓存里已是新版本脚本,必须用 encode 时固定的脚本 + gatewayProperties.getScript().setEnabled(true); + IotDeviceMessage request = buildPropertySet(); + byte[] responsePdu = new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}; + device.setScriptContent("function decode(ctx) { return null; }"); + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success("{\"method\":\"thing.property.set\",\"requestId\":\"req-1\"," + + "\"code\":0,\"data\":{\"power\":1}}", 1)); + + // 调用 + long beginNanos = System.nanoTime(); + deviceMessageService.sendRawReply(request, source, responsePdu, null, SERVER_ID); + + // 断言:用固定的脚本 decode,入参带原请求、截止时间 = 现在 + deadline + ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor deadlineCaptor = ArgumentCaptor.forClass(Long.class); + verify(scriptExecutor).execute(same(source), eq("decode"), ctxCaptor.capture(), deadlineCaptor.capture()); + verify(scriptExecutor, never()).execute(argThat(script -> script != source), any(), any(), anyLong()); + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue()); + assertEquals(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType(), ctx.get("protocol").asText()); + assertEquals("0600100001", ctx.get("hex").asText()); + assertEquals("thing.property.set", ctx.get("request").get("method").asText()); + assertEquals("req-1", ctx.get("request").get("requestId").asText()); + assertTrue(deadlineCaptor.getValue() - beginNanos + >= gatewayProperties.getScript().getDeadline().toNanos()); + // 断言:回复带上 raw = 响应 PDU + IotDeviceMessage reply = captureSentMessage(); + assertEquals("req-1", reply.getRequestId()); + assertEquals("thing.property.set", reply.getMethod()); + assertEquals(SUCCESS.getCode(), reply.getCode()); + assertEquals(Collections.singletonMap("power", 1), reply.getData()); + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); + assertDeviceIdentity(reply); + } + + @Test + public void testSendRawReply_scriptDisabledBeforeResponse() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + for (Boolean scriptEnabled : Arrays.asList(false, null)) { + // mock 方法:等待响应期间,设备缓存里已看到脚本停用(或未配置) + device.setScriptEnabled(scriptEnabled); + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + + // 调用 + deviceMessageService.sendRawReply(buildPropertySet(), source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, + null, SERVER_ID); + } + + // 断言:不解析响应,不回复 + verifyNoInteractions(scriptExecutor, deviceMessageProducer); + } + + @Test + public void testSendRawReply_switchOffBeforeResponse() { + // 准备参数:等待响应期间功能开关被关闭;产品脚本仍是启用状态 + gatewayProperties.getScript().setEnabled(false); + assertTrue(device.getScriptEnabled()); + byte[] responsePdu = new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}; + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + + // 调用:encode 指令的设备响应(固定的脚本) + deviceMessageService.sendRawReply(buildPropertySet(), source, responsePdu, null, SERVER_ID); + // 断言:不执行脚本,不回复 + verifyNoInteractions(scriptExecutor, deviceMessageProducer); + + // 调用:raw.push 的设备响应(没有脚本) + deviceMessageService.sendRawReply(buildRawPush(), null, responsePdu, null, SERVER_ID); + // 断言:照常直接回复设备的实际响应 + IotDeviceMessage reply = captureSentMessage(); + assertEquals("req-raw", reply.getRequestId()); + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw()); + verifyNoInteractions(scriptExecutor); + } + + @Test + public void testSendRawReply_decodeRequestIdMismatch() { + // 准备参数 + gatewayProperties.getScript().setEnabled(true); + IotDeviceMessage request = buildPropertySet(); + // mock 方法:decode 返回的 requestId 与原请求不一致 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())) + .thenReturn(IotScriptResult.success("{\"method\":\"thing.property.set\",\"requestId\":\"other\"," + + "\"code\":0}", 1)); + + // 调用 + deviceMessageService.sendRawReply(request, source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, null, SERVER_ID); + + // 断言:不回复 + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawReply_decodeFailure() { + // 准备参数:decode 执行失败、返回 null(指令的响应必须有回执)、返回请求而不是回执 + gatewayProperties.getScript().setEnabled(true); + List results = Arrays.asList(IotScriptResult.error("boom", 1), IotScriptResult.success(null, 1), + IotScriptResult.success("{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}", 1)); + // mock 方法 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device); + for (IotScriptResult result : results) { + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())).thenReturn(result); + + // 调用 + deviceMessageService.sendRawReply(buildPropertySet(), source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, + null, SERVER_ID); + } + + // 断言:只记日志,不回复 + verifyNoInteractions(deviceMessageProducer); + } + + @Test + public void testSendRawReply_deviceNotExists() { + // 调用 + deviceMessageService.sendRawReply(buildRawPush(), null, new byte[]{0x03, 0x02, 0x00, 0x01}, null, SERVER_ID); + + // 断言:不回复 + verifyNoInteractions(deviceMessageProducer, scriptExecutor); + } + + // ========== 辅助方法 ========== + + private void mockDevice() { + when(deviceService.getDeviceFromCache(PRODUCT_KEY, DEVICE_NAME)).thenReturn(device); + } + + /** + * 匹配按设备信息构造的脚本:服务每次构造新的 {@link IotScriptSource},按 key(productId:hash)匹配 + */ + private IotScriptSource sameScript() { + return argThat(script -> script != null && source.getKey().equals(script.getKey())); + } + + private static IotDeviceMessage buildPropertySet() { + return IotDeviceMessage.requestOf("req-1", IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), + Collections.singletonMap("power", 1)).setDeviceId(DEVICE_ID); + } + + private static IotDeviceMessage buildRawPush() { + return IotDeviceMessage.requestOf("req-raw", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), null) + .setDeviceId(DEVICE_ID).setRaw(Base64.getEncoder().encodeToString(new byte[]{0x03, 0x00, 0x10, 0x00, 0x02})); + } + + /** + * 断言 producer 恰好发送了 1 条消息,并返回它 + */ + private IotDeviceMessage captureSentMessage() { + ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessage.class); + verify(deviceMessageProducer, times(1)).sendDeviceMessage(captor.capture()); + return captor.getValue(); + } + + private static void assertDeviceIdentity(IotDeviceMessage message) { + assertEquals(DEVICE_ID, message.getDeviceId()); + assertEquals(TENANT_ID, message.getTenantId()); + assertEquals(SERVER_ID, message.getServerId()); + } + + /** + * 断言是一条 thing.raw.post:params、data 为空,raw 为原始帧,requestId = id + */ + private static void assertRawPost(IotDeviceMessage message) { + assertEquals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), message.getMethod()); + assertNull(message.getParams()); + assertNull(message.getCode()); + assertEquals(RAW, message.getRaw()); + assertNotNull(message.getId()); + assertEquals(message.getId(), message.getRequestId()); + assertNotNull(message.getReportTime()); + assertDeviceIdentity(message); + assertNull(message.getData()); + } + + private static void sleepUntil(long deadlineNanos) throws InterruptedException { + long remaining; + while ((remaining = deadlineNanos - System.nanoTime()) >= 0) { + TimeUnit.NANOSECONDS.sleep(remaining + TimeUnit.MILLISECONDS.toNanos(1)); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java index 55dcf2a..0d40eef 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java @@ -4,6 +4,8 @@ import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * {@link IotMqttTopicUtils} 的单元测试 @@ -34,4 +36,57 @@ public class IotMqttTopicUtilsTest { assertEquals("/sys/pk/dn/thing/service/invoke_reply", topic); } + @Test + public void testBuildRawPushTopic() { + // 调用 + String topic = IotMqttTopicUtils.buildRawPushTopic("pk", "dn"); + // 断言 + assertEquals("/sys/pk/dn/thing/raw/push", topic); + } + + @Test + public void testIsRawPostTopic() { + // 正确格式 + assertTrue(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post")); + // 其它主题 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/push")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/property/post")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post_reply")); + // 格式不正确:缺少 / 多出段、productKey 或 deviceName 为空、前缀不对 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/thing/raw/post")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/x/thing/raw/post")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys//dn/thing/raw/post")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk//thing/raw/post")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/abc/pk/dn/thing/raw/post")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post/")); + assertFalse(IotMqttTopicUtils.isRawPostTopic("")); + assertFalse(IotMqttTopicUtils.isRawPostTopic(null)); + } + + @Test + public void testIsRawPushTopic() { + assertTrue(IotMqttTopicUtils.isRawPushTopic("/sys/pk/dn/thing/raw/push")); + assertFalse(IotMqttTopicUtils.isRawPushTopic("/sys/pk/dn/thing/raw/post")); + assertFalse(IotMqttTopicUtils.isRawPushTopic("/sys/pk/thing/raw/push")); + assertFalse(IotMqttTopicUtils.isRawPushTopic(null)); + } + + @Test + public void testIsTopicPublishAllowed_rawPost() { + // 自己的 raw/post:允许 + assertTrue(IotMqttTopicUtils.isTopicPublishAllowed("/sys/pk/dn/thing/raw/post", "pk", "dn")); + // 别的设备的 raw/post:拒绝 + assertFalse(IotMqttTopicUtils.isTopicPublishAllowed("/sys/pk/other/thing/raw/post", "pk", "dn")); + assertFalse(IotMqttTopicUtils.isTopicPublishAllowed("/sys/other/dn/thing/raw/post", "pk", "dn")); + } + + @Test + public void testIsTopicSubscribeAllowed_rawPush() { + // 自己的 raw/push:允许 + assertTrue(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/pk/dn/thing/raw/push", "pk", "dn")); + // 别的设备的 raw/push:拒绝 + assertFalse(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/pk/other/thing/raw/push", "pk", "dn")); + assertFalse(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/+/+/thing/raw/push", "pk", "dn")); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueueTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueueTest.java new file mode 100644 index 0000000..961e3fa --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueueTest.java @@ -0,0 +1,291 @@ +package cn.iocoder.yudao.module.iot.gateway.util; + +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotWorkerTaskQueue} 的单元测试 + * + * @author 芋道源码 + */ +public class IotWorkerTaskQueueTest { + + private static final long TIMEOUT_SECONDS = 10; + + private Vertx vertx; + private Context context; + /** + * context 所在的 event loop 线程 + */ + private Thread contextThread; + + @BeforeEach + public void setUp() throws Exception { + vertx = Vertx.vertx(); + context = vertx.getOrCreateContext(); + contextThread = callOnContext(Thread::currentThread); + } + + @AfterEach + public void tearDown() throws Exception { + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + @Test + public void testSubmit_orderedSequential() throws Exception { + // 准备参数:越早提交的任务执行越久;第 3 个任务失败 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context); + int count = 5; + List started = new CopyOnWriteArrayList<>(); + List completed = new CopyOnWriteArrayList<>(); + AtomicInteger running = new AtomicInteger(); + AtomicInteger maxRunning = new AtomicInteger(); + AtomicBoolean completedOnContext = new AtomicBoolean(true); + CountDownLatch done = new CountDownLatch(count); + + // 调用:在 context 上依次提交 + List> futures = callOnContext(() -> { + List> list = new ArrayList<>(); + for (int i = 0; i < count; i++) { + int index = i; + Future future = queue.submit(() -> { + maxRunning.accumulateAndGet(running.incrementAndGet(), Math::max); + started.add(index); + try { + Thread.sleep((count - index) * 30L); + if (index == 2) { + throw new IllegalStateException("task-2 failed"); + } + return index; + } finally { + running.decrementAndGet(); + } + }); + future.onComplete(ar -> { + if (Thread.currentThread() != contextThread) { + completedOnContext.set(false); + } + completed.add(index); + done.countDown(); + }); + list.add(future); + } + return list; + }); + assertTrue(done.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + // 断言:按提交顺序逐个执行(同一时刻只有一个在执行)、逐个完成,结果回到 context 线程 + assertEquals(Arrays.asList(0, 1, 2, 3, 4), started); + assertEquals(Arrays.asList(0, 1, 2, 3, 4), completed); + assertEquals(1, maxRunning.get()); + assertTrue(completedOnContext.get()); + // 断言:第 3 个任务失败,之后的任务照常执行 + for (int i = 0; i < count; i++) { + Future future = futures.get(i); + if (i == 2) { + assertTrue(future.failed()); + assertInstanceOf(IllegalStateException.class, future.cause()); + assertEquals("task-2 failed", future.cause().getMessage()); + } else { + assertTrue(future.succeeded()); + assertEquals(Integer.valueOf(i), future.result()); + } + } + } + + @Test + public void testSubmit_queuesParallel() throws Exception { + // 准备参数:同一个 context 上的两个队列(两个连接) + IotWorkerTaskQueue queueA = new IotWorkerTaskQueue(context); + IotWorkerTaskQueue queueB = new IotWorkerTaskQueue(context); + CountDownLatch releaseA = new CountDownLatch(1); + CountDownLatch startedA = new CountDownLatch(1); + + // 调用:A 的第一个任务阻塞,A 的第二个任务排在它之后 + List> futuresA = callOnContext(() -> Arrays.asList( + queueA.submit(() -> { + startedA.countDown(); + return block(releaseA, "A1"); + }), + queueA.submit(() -> "A2"))); + assertTrue(startedA.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)); + // 调用:B 提交一个任务 + Future futureB = callOnContext(() -> queueB.submit(() -> "B")); + + try { + // 断言:A 阻塞时 B 照常完成;A 的第二个任务仍在等待第一个 + assertEquals("B", await(futureB)); + assertFalse(futuresA.get(0).isComplete()); + assertFalse(futuresA.get(1).isComplete()); + } finally { + releaseA.countDown(); + } + // 断言:释放后 A 按顺序完成 + assertEquals("A1", await(futuresA.get(0))); + assertEquals("A2", await(futuresA.get(1))); + } + + @Test + public void testSubmit_maxPending() throws Exception { + // 准备参数:最多 2 个待处理任务 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 2); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean rejectedImmediately = new AtomicBoolean(); + + // 调用:前两个任务阻塞,第三个超过上限 + List> futures = callOnContext(() -> { + List> list = Arrays.asList( + queue.submit(() -> block(release, "1")), + queue.submit(() -> block(release, "2")), + queue.submit(() -> "3")); + rejectedImmediately.set(list.get(2).failed()); + return list; + }); + + try { + // 断言:第三个立即失败为 RejectedExecutionException + assertTrue(rejectedImmediately.get()); + assertInstanceOf(RejectedExecutionException.class, futures.get(2).cause()); + assertFalse(futures.get(0).isComplete()); + assertFalse(futures.get(1).isComplete()); + } finally { + release.countDown(); + } + // 断言:前两个完成后名额释放,可以再次提交 + assertEquals("1", await(futures.get(0))); + assertEquals("2", await(futures.get(1))); + assertEquals("4", await(callOnContext(() -> queue.submit(() -> "4")))); + } + + @Test + public void testSubmitLast() throws Exception { + // 准备参数:最多 1 个待处理任务 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 1); + CountDownLatch release = new CountDownLatch(1); + List executed = new CopyOnWriteArrayList<>(); + AtomicBoolean lastRejectedImmediately = new AtomicBoolean(true); + + // 调用:第一个任务阻塞;队列已满时 submitLast 不受上限;之后 submit / submitLast 都失败 + List> futures = callOnContext(() -> { + List> list = Arrays.asList( + queue.submit(() -> { + block(release, "1"); + executed.add("1"); + return "1"; + }), + queue.submit(() -> "full"), + queue.submitLast(() -> { + executed.add("last"); + return "last"; + }), + queue.submit(() -> "closed"), + queue.submitLast(() -> "last-again")); + lastRejectedImmediately.set(list.get(2).failed()); + return list; + }); + + try { + // 断言:已满的 submit 被拒绝;submitLast 被接受,排在阻塞任务之后 + assertRejected(futures.get(1)); + assertFalse(lastRejectedImmediately.get()); + assertFalse(futures.get(2).isComplete()); + // 断言:关闭之后 submit、submitLast 都被拒绝 + assertRejected(futures.get(3)); + assertRejected(futures.get(4)); + } finally { + release.countDown(); + } + // 断言:最后一个任务在之前的任务之后执行 + assertEquals("1", await(futures.get(0))); + assertEquals("last", await(futures.get(2))); + assertEquals(Arrays.asList("1", "last"), executed); + // 断言:已关闭,之后的任务都被拒绝 + assertRejected(callOnContext(() -> queue.submit(() -> "after"))); + } + + @Test + public void testSubmit_unordered() throws Exception { + // 准备参数:无序,最多 2 个待处理任务 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, false, 2); + CountDownLatch release = new CountDownLatch(1); + + try { + // 调用:第一个任务阻塞 + Future blocked = callOnContext(() -> queue.submit(() -> block(release, "A"))); + // 断言:之后的任务不排在它之后,直接完成 + assertEquals("B", await(callOnContext(() -> queue.submit(() -> "B")))); + assertFalse(blocked.isComplete()); + + // 调用:A 仍在执行,再提交 C 后达到上限,D 被拒绝 + List> futures = callOnContext(() -> Arrays.asList( + queue.submit(() -> block(release, "C")), + queue.submit(() -> "D"))); + // 断言:只限制数量 + assertRejected(futures.get(1)); + assertFalse(futures.get(0).isComplete()); + + // 调用:释放 + release.countDown(); + // 断言 + assertEquals("A", await(blocked)); + assertEquals("C", await(futures.get(0))); + } finally { + release.countDown(); + } + } + + // ========== 辅助方法 ========== + + /** + * 在 context 线程上执行(与 Vert.x 处理器一致),等待并返回结果 + */ + private T callOnContext(Callable action) throws Exception { + CompletableFuture result = new CompletableFuture<>(); + context.runOnContext(v -> { + try { + result.complete(action.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + }); + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + private static T await(Future future) throws Exception { + return future.toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + /** + * worker 线程上阻塞,直到释放 + */ + private static String block(CountDownLatch release, String value) throws InterruptedException { + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("等待释放超时"); + } + return value; + } + + private static void assertRejected(Future future) { + assertTrue(future.failed()); + assertInstanceOf(RejectedExecutionException.class, future.cause()); + } + +}