Commit 971c5a651511e244187cc6b3b0eadf9d9f29c573
1 parent
30a0175e
feat(iot): 支持设备消息透传与 JavaScript 编解码
支持按产品配置数据解析脚本及在线调试,网关统一处理透传上下行。 原始报文随业务消息保存,兼容 IoTDB、TDengine,补充协议与脚本执行回归。 简化同步消息服务、任务终态与 Modbus 事务字段,统一 encode 输出校验。
Showing
133 changed files
with
13016 additions
and
274 deletions
Too many changes.
To preserve performance only 100 of 133 files are displayed.
yudao-dependencies/pom.xml
| ... | ... | @@ -76,6 +76,7 @@ |
| 76 | 76 | <okhttp.version>4.12.0</okhttp.version> |
| 77 | 77 | <californium.version>3.14.0</californium.version> |
| 78 | 78 | <j2mod.version>3.4.0</j2mod.version> |
| 79 | + <rhino.version>1.7.15.1</rhino.version> | |
| 79 | 80 | <!-- 三方云服务相关 --> |
| 80 | 81 | <awssdk.version>2.57.3</awssdk.version> |
| 81 | 82 | <justauth.version>1.16.7</justauth.version> |
| ... | ... | @@ -660,6 +661,13 @@ |
| 660 | 661 | <version>${j2mod.version}</version> |
| 661 | 662 | </dependency> |
| 662 | 663 | |
| 664 | + <!-- JS 脚本引擎 --> | |
| 665 | + <dependency> | |
| 666 | + <groupId>org.mozilla</groupId> | |
| 667 | + <artifactId>rhino</artifactId> | |
| 668 | + <version>${rhino.version}</version> | |
| 669 | + </dependency> | |
| 670 | + | |
| 663 | 671 | <!-- 三方云服务相关 --> |
| 664 | 672 | <dependency> |
| 665 | 673 | <groupId>software.amazon.awssdk</groupId> | ... | ... |
yudao-module-iot/pom.xml
| ... | ... | @@ -18,8 +18,8 @@ |
| 18 | 18 | |
| 19 | 19 | <name>${project.artifactId}</name> |
| 20 | 20 | <description> |
| 21 | - 物联网模块 | |
| 22 | - <!-- TODO 芋艿:需要补充下说明! --> | |
| 21 | + 物联网大模块,由 biz 业务、gateway 设备网关、core 核心等组成 | |
| 22 | + 例如说:产品、物模型、设备、规则引擎、OTA 升级、设备接入等等 | |
| 23 | 23 | </description> |
| 24 | 24 | |
| 25 | 25 | </project> |
| 26 | 26 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/pom.xml
| ... | ... | @@ -13,8 +13,11 @@ |
| 13 | 13 | |
| 14 | 14 | <name>${project.artifactId}</name> |
| 15 | 15 | <description> |
| 16 | - 物联网 模块,主要实现 产品管理、设备管理、协议管理等功能。 | |
| 17 | - <!-- TODO 芋艿:后续补充下 --> | |
| 16 | + iot 模块下,物联网的业务模块,提供管理后台与平台侧的处理。例如说: | |
| 17 | + 1. 产品管理:产品分类、产品、物模型、数据解析脚本 | |
| 18 | + 2. 设备管理:设备、设备分组、设备消息、设备属性、设备影子、Modbus 点位,以及 OTA 升级 | |
| 19 | + 3. 规则引擎:数据流转(数据规则、数据目的)、场景联动、告警 | |
| 20 | + 4. 数据统计,以及提供给 iot-gateway 的设备 API;设备消息与属性的时序数据可存储到 TDengine、IoTDB | |
| 18 | 21 | </description> |
| 19 | 22 | |
| 20 | 23 | <dependencies> |
| ... | ... | @@ -96,11 +99,10 @@ |
| 96 | 99 | </dependency> |
| 97 | 100 | |
| 98 | 101 | <!-- 消息队列相关 --> |
| 99 | - <!-- TODO @芋艿:临时打开 --> | |
| 100 | 102 | <dependency> |
| 101 | 103 | <groupId>org.apache.rocketmq</groupId> |
| 102 | 104 | <artifactId>rocketmq-spring-boot-starter</artifactId> |
| 103 | -<!-- <optional>true</optional>--> | |
| 105 | + <optional>true</optional> | |
| 104 | 106 | </dependency> |
| 105 | 107 | <dependency> |
| 106 | 108 | <groupId>org.springframework.kafka</groupId> | ... | ... |
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; |
| 30 | 30 | import org.springframework.web.bind.annotation.RestController; |
| 31 | 31 | |
| 32 | 32 | import java.util.ArrayList; |
| 33 | +import java.util.Collections; | |
| 33 | 34 | import java.util.List; |
| 34 | 35 | import java.util.Map; |
| 35 | 36 | import java.util.Set; |
| ... | ... | @@ -74,7 +75,10 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { |
| 74 | 75 | return success(BeanUtils.toBean(device, IotDeviceRespDTO.class, deviceDTO -> { |
| 75 | 76 | IotProductDO product = productService.getProductFromCache(deviceDTO.getProductId()); |
| 76 | 77 | if (product != null) { |
| 77 | - deviceDTO.setProtocolType(product.getProtocolType()).setSerializeType(product.getSerializeType()); | |
| 78 | + // 数据解析脚本随设备信息返回(网关按设备缓存),只在启用时返回内容 | |
| 79 | + deviceDTO.setProtocolType(product.getProtocolType()).setSerializeType(product.getSerializeType()) | |
| 80 | + .setScriptEnabled(product.getScriptEnabled()) | |
| 81 | + .setScriptContent(Boolean.TRUE.equals(product.getScriptEnabled()) ? product.getScriptContent() : null); | |
| 78 | 82 | } |
| 79 | 83 | })); |
| 80 | 84 | } |
| ... | ... | @@ -114,10 +118,8 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { |
| 114 | 118 | } |
| 115 | 119 | } |
| 116 | 120 | // 3.3 获取启用的点位列表 |
| 117 | - List<IotDeviceModbusPointDO> pointList = pointMap.get(config.getDeviceId()); | |
| 118 | - if (CollUtil.isEmpty(pointList)) { | |
| 119 | - continue; | |
| 120 | - } | |
| 121 | + // 点位为空也返回:连接配置有效即可认证、收发透传(与脚本开关无关);没有点位时,轮询调度器本身会跳过 | |
| 122 | + List<IotDeviceModbusPointDO> pointList = pointMap.getOrDefault(config.getDeviceId(), Collections.emptyList()); | |
| 121 | 123 | |
| 122 | 124 | // 3.4 构建 IotModbusDeviceConfigRespDTO 对象 |
| 123 | 125 | IotModbusDeviceConfigRespDTO configDTO = BeanUtils.toBean(config, IotModbusDeviceConfigRespDTO.class, o -> | ... | ... |
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; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 5 | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 6 | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 7 | +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; | |
| 7 | 8 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespPairVO; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageRespVO; |
| 10 | 11 | import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageSendReqVO; |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 11 | 13 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 12 | 14 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 13 | 15 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| ... | ... | @@ -24,9 +26,12 @@ import org.springframework.web.bind.annotation.*; |
| 24 | 26 | import java.util.List; |
| 25 | 27 | import java.util.Map; |
| 26 | 28 | |
| 29 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; | |
| 30 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 27 | 31 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 28 | 32 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 29 | 33 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| 34 | +import static cn.iocoder.yudao.module.iot.enums.IotPermissionConstants.DEVICE_MESSAGE_RAW_SEND; | |
| 30 | 35 | |
| 31 | 36 | @Tag(name = "管理后台 - IoT 设备消息") |
| 32 | 37 | @RestController |
| ... | ... | @@ -40,6 +45,8 @@ public class IotDeviceMessageController { |
| 40 | 45 | private IotDeviceService deviceService; |
| 41 | 46 | @Resource |
| 42 | 47 | private IotThingModelService thingModelService; |
| 48 | + @Resource | |
| 49 | + private SecurityFrameworkService securityFrameworkService; | |
| 43 | 50 | |
| 44 | 51 | @GetMapping("/page") |
| 45 | 52 | @Operation(summary = "获得设备消息分页") |
| ... | ... | @@ -82,6 +89,12 @@ public class IotDeviceMessageController { |
| 82 | 89 | @Operation(summary = "发送消息", description = "可用于设备模拟") |
| 83 | 90 | @PreAuthorize("@ss.hasPermission('iot:device:message-send')") |
| 84 | 91 | public CommonResult<Boolean> sendDeviceMessage(@Valid @RequestBody IotDeviceMessageSendReqVO sendReqVO) { |
| 92 | + // 1. 校验透传下发的权限:透传下发是新增的危险能力(可以直接驱动 Modbus 写),需要独立的按钮权限;raw.post 不提供模拟(会改写在线状态和路由) | |
| 93 | + if (IotDeviceMessageMethodEnum.RAW_PUSH.getMethod().equals(sendReqVO.getMethod()) | |
| 94 | + && !securityFrameworkService.hasPermission(DEVICE_MESSAGE_RAW_SEND)) { | |
| 95 | + throw exception(FORBIDDEN); | |
| 96 | + } | |
| 97 | + // 2. 发送消息 | |
| 85 | 98 | deviceMessageService.sendDeviceMessage(BeanUtils.toBean(sendReqVO, IotDeviceMessage.class)); |
| 86 | 99 | return success(true); |
| 87 | 100 | } | ... | ... |
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 { |
| 34 | 34 | @Schema(description = "标识符", example = "temperature") |
| 35 | 35 | private String identifier; |
| 36 | 36 | |
| 37 | + @Schema(description = "是否有原始帧:true 只看带原始帧(raw 非空)的消息,false 只看没有原始帧的消息", example = "true") | |
| 38 | + private Boolean raw; | |
| 39 | + | |
| 37 | 40 | @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.NOT_REQUIRED) |
| 38 | 41 | @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) |
| 39 | 42 | @Size(min = 2, max = 2, message = "请选择时间范围") | ... | ... |
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 { |
| 53 | 53 | @Schema(description = "响应提示", example = "操作成功") |
| 54 | 54 | private String msg; |
| 55 | 55 | |
| 56 | + @Schema(description = "与设备实际收发的原始字节(Base64);为空表示没有原始帧", example = "AQMAAAAK") | |
| 57 | + private String raw; | |
| 58 | + | |
| 56 | 59 | } |
| 57 | 60 | \ No newline at end of file | ... | ... |
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; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductRespVO; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; |
| 13 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 14 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 15 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 13 | 16 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductCategoryDO; |
| 14 | 17 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 15 | 18 | import cn.iocoder.yudao.module.iot.service.product.IotProductCategoryService; |
| ... | ... | @@ -69,6 +72,22 @@ public class IotProductController { |
| 69 | 72 | return success(true); |
| 70 | 73 | } |
| 71 | 74 | |
| 75 | + @PutMapping("/update-script") | |
| 76 | + @Operation(summary = "更新产品数据解析脚本", description = "部分更新:只修改本次提交的字段;保存即生效") | |
| 77 | + @PreAuthorize("@ss.hasPermission('iot:product:update')") | |
| 78 | + public CommonResult<Boolean> updateProductScript(@Valid @RequestBody IotProductScriptUpdateReqVO updateReqVO) { | |
| 79 | + productService.updateProductScript(updateReqVO); | |
| 80 | + return success(true); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @PostMapping("/debug-script") | |
| 84 | + @Operation(summary = "调试产品数据解析脚本", description = "执行当前编辑内容;不保存、不发消息、不改属性") | |
| 85 | + @PreAuthorize("@ss.hasPermission('iot:product:update')") | |
| 86 | + public CommonResult<IotProductScriptDebugRespVO> debugProductScript( | |
| 87 | + @Valid @RequestBody IotProductScriptDebugReqVO debugReqVO) { | |
| 88 | + return success(productService.debugProductScript(debugReqVO)); | |
| 89 | + } | |
| 90 | + | |
| 72 | 91 | @DeleteMapping("/delete") |
| 73 | 92 | @Operation(summary = "删除产品") |
| 74 | 93 | @Parameter(name = "id", description = "编号", required = true) | ... | ... |
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 { |
| 77 | 77 | @DictFormat(DictTypeConstants.SERIALIZE_TYPE) |
| 78 | 78 | private String serializeType; |
| 79 | 79 | |
| 80 | + @Schema(description = "数据解析脚本(JS ES5)") | |
| 81 | + private String scriptContent; | |
| 82 | + | |
| 83 | + @Schema(description = "是否启用数据解析脚本", example = "false") | |
| 84 | + private Boolean scriptEnabled; | |
| 85 | + | |
| 80 | 86 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) |
| 81 | 87 | @ExcelProperty("创建时间") |
| 82 | 88 | private LocalDateTime createTime; | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import jakarta.validation.constraints.NotEmpty; | |
| 5 | +import jakarta.validation.constraints.NotNull; | |
| 6 | +import jakarta.validation.constraints.Pattern; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +import java.util.Map; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 管理后台 - IoT 产品数据解析脚本调试 Request VO | |
| 13 | + * <p> | |
| 14 | + * 执行当前编辑内容:不保存、不发消息、不改属性 | |
| 15 | + * | |
| 16 | + * @author 芋道源码 | |
| 17 | + */ | |
| 18 | +@Schema(description = "管理后台 - IoT 产品数据解析脚本调试 Request VO") | |
| 19 | +@Data | |
| 20 | +public class IotProductScriptDebugReqVO { | |
| 21 | + | |
| 22 | + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") | |
| 23 | + @NotNull(message = "产品编号不能为空") | |
| 24 | + private Long id; | |
| 25 | + | |
| 26 | + @Schema(description = "数据解析脚本(JS ES5)", requiredMode = Schema.RequiredMode.REQUIRED) | |
| 27 | + @NotEmpty(message = "脚本内容不能为空") | |
| 28 | + private String scriptContent; | |
| 29 | + | |
| 30 | + @Schema(description = "方向:decode 上行解码,encode 下行编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "decode") | |
| 31 | + @NotEmpty(message = "调试方向不能为空") | |
| 32 | + @Pattern(regexp = "decode|encode", message = "调试方向只能是 decode 或 encode") | |
| 33 | + private String direction; | |
| 34 | + | |
| 35 | + @Schema(description = "上行原始字节的 HEX(decode 时必填,允许空格)", example = "01 03 00 00") | |
| 36 | + private String inputHex; | |
| 37 | + | |
| 38 | + @Schema(description = "下行指令(encode 时必填):{method, requestId?, params?}") | |
| 39 | + private Map<String, Object> message; | |
| 40 | + | |
| 41 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptDebugRespVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +import java.util.Map; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 管理后台 - IoT 产品数据解析脚本调试 Response VO | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +@Schema(description = "管理后台 - IoT 产品数据解析脚本调试 Response VO") | |
| 14 | +@Data | |
| 15 | +public class IotProductScriptDebugRespVO { | |
| 16 | + | |
| 17 | + @Schema(description = "是否成功", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") | |
| 18 | + private Boolean success; | |
| 19 | + | |
| 20 | + @Schema(description = "执行耗时(毫秒)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") | |
| 21 | + private Long durationMs; | |
| 22 | + | |
| 23 | + @Schema(description = "decode:解析出的消息;为空表示没有业务消息(只记录原始消息)") | |
| 24 | + private Map<String, Object> message; | |
| 25 | + | |
| 26 | + @Schema(description = "encode:是否走标准 JSON 下发", example = "false") | |
| 27 | + private Boolean standard; | |
| 28 | + | |
| 29 | + @Schema(description = "encode:下行字节的 HEX", example = "0103000A0001") | |
| 30 | + private String outputHex; | |
| 31 | + | |
| 32 | + @Schema(description = "失败信息") | |
| 33 | + private ErrorInfo error; | |
| 34 | + | |
| 35 | + @Schema(description = "调试失败信息") | |
| 36 | + @Data | |
| 37 | + public static class ErrorInfo { | |
| 38 | + | |
| 39 | + @Schema(description = "类型:ERROR、TIMEOUT、REJECTED、OUTPUT_INVALID、ENCODE_EMPTY", example = "ERROR") | |
| 40 | + private String type; | |
| 41 | + | |
| 42 | + @Schema(description = "错误信息") | |
| 43 | + private String message; | |
| 44 | + | |
| 45 | + @Schema(description = "出错行号", example = "3") | |
| 46 | + private Integer line; | |
| 47 | + | |
| 48 | + } | |
| 49 | + | |
| 50 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/vo/product/IotProductScriptUpdateReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.controller.admin.product.vo.product; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.annotation.JsonIgnore; | |
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 5 | +import jakarta.validation.constraints.AssertTrue; | |
| 6 | +import jakarta.validation.constraints.NotNull; | |
| 7 | +import lombok.Data; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * 管理后台 - IoT 产品数据解析脚本更新 Request VO | |
| 11 | + * <p> | |
| 12 | + * 部分更新:scriptContent、scriptEnabled 为 null 时不修改 | |
| 13 | + * | |
| 14 | + * @author 芋道源码 | |
| 15 | + */ | |
| 16 | +@Schema(description = "管理后台 - IoT 产品数据解析脚本更新 Request VO") | |
| 17 | +@Data | |
| 18 | +public class IotProductScriptUpdateReqVO { | |
| 19 | + | |
| 20 | + @Schema(description = "产品编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") | |
| 21 | + @NotNull(message = "产品编号不能为空") | |
| 22 | + private Long id; | |
| 23 | + | |
| 24 | + @Schema(description = "数据解析脚本(JS ES5);空字符串表示清空,仅在停用时允许") | |
| 25 | + private String scriptContent; | |
| 26 | + | |
| 27 | + @Schema(description = "是否启用数据解析脚本", example = "true") | |
| 28 | + private Boolean scriptEnabled; | |
| 29 | + | |
| 30 | + @AssertTrue(message = "脚本内容和启用状态至少提交一个") | |
| 31 | + @JsonIgnore | |
| 32 | + public boolean isAnyFieldPresent() { | |
| 33 | + return scriptContent != null || scriptEnabled != null; | |
| 34 | + } | |
| 35 | + | |
| 36 | +} | ... | ... |
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 { |
| 106 | 106 | */ |
| 107 | 107 | private String msg; |
| 108 | 108 | |
| 109 | + // ========== 透传(raw)字段 ========== | |
| 110 | + | |
| 111 | + /** | |
| 112 | + * 与设备之间实际收发的原始字节(Base64) | |
| 113 | + * | |
| 114 | + * 只由服务端可信赋值,见 {@link IotDeviceMessage#getRaw()};为空表示没有原始帧 | |
| 115 | + */ | |
| 116 | + private String raw; | |
| 117 | + | |
| 109 | 118 | } | ... | ... |
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 { |
| 90 | 90 | */ |
| 91 | 91 | private String serializeType; |
| 92 | 92 | |
| 93 | + /** | |
| 94 | + * 数据解析脚本(JS ES5),UTF-8 不超过 32KB | |
| 95 | + */ | |
| 96 | + private String scriptContent; | |
| 97 | + /** | |
| 98 | + * 是否启用数据解析脚本 | |
| 99 | + */ | |
| 100 | + private Boolean scriptEnabled; | |
| 101 | + | |
| 93 | 102 | } |
| 94 | 103 | \ No newline at end of file | ... | ... |
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 { |
| 41 | 41 | .setParams(encodeString(toJsonString(message.getParams()))) |
| 42 | 42 | .setData(encodeString(toJsonString(message.getData()))) |
| 43 | 43 | .setMsg(encodeString(message.getMsg())).setReportTime(encodeString(message.getReportTime())) |
| 44 | - .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())); | |
| 44 | + .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())) | |
| 45 | + .setRaw(encodeString(message.getRaw())); | |
| 45 | 46 | } |
| 46 | 47 | |
| 47 | 48 | public static IotDeviceMessageDO decode(IotDeviceMessageIoTDBRow row) { |
| ... | ... | @@ -57,7 +58,8 @@ public class IotDeviceMessageIoTDBCodec { |
| 57 | 58 | .setMsg(decodeString(row.getMsg())) |
| 58 | 59 | .setReportTime(reportTime != null ? Long.valueOf(reportTime) : null) |
| 59 | 60 | .setTenantId(tenantId != null ? Long.valueOf(tenantId) : null) |
| 60 | - .setCode(code != null ? Integer.valueOf(code) : null); | |
| 61 | + .setCode(code != null ? Integer.valueOf(code) : null) | |
| 62 | + .setRaw(decodeString(row.getRaw())); | |
| 61 | 63 | } |
| 62 | 64 | |
| 63 | 65 | /** | ... | ... |
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 { |
| 50 | 50 | .put("method", "STRING").put("identifier", "STRING").put("request_id", "STRING").put("server_id", "STRING") |
| 51 | 51 | .put("params", "STRING").put("data", "STRING").put("msg", "STRING") |
| 52 | 52 | .put("report_time", "STRING").put("tenant_id", "STRING").put("code", "STRING") |
| 53 | + .put("raw", "STRING") | |
| 53 | 54 | .build(); |
| 54 | 55 | |
| 55 | 56 | @Resource |
| ... | ... | @@ -152,15 +153,20 @@ public class IotDeviceMessageIoTDBDAO implements IotDeviceMessageDAO { |
| 152 | 153 | Integer upstream = reqVO.getUpstream() != null ? encodeBoolean(reqVO.getUpstream()) : null; |
| 153 | 154 | Integer reply = reqVO.getReply() != null ? encodeBoolean(reqVO.getReply()) : null; |
| 154 | 155 | String identifier = StrUtil.isNotEmpty(reqVO.getIdentifier()) ? encodeString(reqVO.getIdentifier()) : null; |
| 156 | + // 是否有原始帧:true 为 v: 开头;false 为 n: | |
| 157 | + Boolean raw = reqVO.getRaw(); | |
| 158 | + String rawValuePattern = PREFIX_VALUE + "%"; | |
| 155 | 159 | try { |
| 156 | 160 | // 2.1 查询总数 |
| 157 | - Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier); | |
| 161 | + Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier, | |
| 162 | + raw, PREFIX_NULL, rawValuePattern); | |
| 158 | 163 | if (total == null || total == 0) { |
| 159 | 164 | return PageResult.empty(); |
| 160 | 165 | } |
| 161 | 166 | // 2.2 查询列表:pageSize 为 -1 时,不分页 |
| 162 | 167 | boolean pageable = ObjUtil.notEqual(reqVO.getPageSize(), PageParam.PAGE_SIZE_NONE); |
| 163 | 168 | List<IotDeviceMessageIoTDBRow> rows = deviceMessageMapper.selectList(deviceId, method, upstream, reply, identifier, |
| 169 | + raw, PREFIX_NULL, rawValuePattern, | |
| 164 | 170 | pageable ? PageUtils.getStart(reqVO) : null, pageable ? reqVO.getPageSize() : null); |
| 165 | 171 | return new PageResult<>(convertList(rows, IotDeviceMessageIoTDBCodec::decode), total); |
| 166 | 172 | } catch (Exception exception) { | ... | ... |
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 { |
| 32 | 32 | |
| 33 | 33 | void insert(IotDeviceMessageIoTDBRow row); |
| 34 | 34 | |
| 35 | + /** | |
| 36 | + * 分页查询 | |
| 37 | + * | |
| 38 | + * @param raw 是否有原始帧:true 为 raw 匹配 rawValuePattern;false 为 raw 等于 rawNull | |
| 39 | + * @param rawNull raw 为 null 的存储值 | |
| 40 | + * @param rawValuePattern raw 非 null 的存储值的 LIKE 模式 | |
| 41 | + */ | |
| 35 | 42 | List<IotDeviceMessageIoTDBRow> selectList(@Param("deviceId") String deviceId, |
| 36 | 43 | @Param("method") String method, |
| 37 | 44 | @Param("upstream") Integer upstream, |
| 38 | 45 | @Param("reply") Integer reply, |
| 39 | 46 | @Param("identifier") String identifier, |
| 47 | + @Param("raw") Boolean raw, | |
| 48 | + @Param("rawNull") String rawNull, | |
| 49 | + @Param("rawValuePattern") String rawValuePattern, | |
| 40 | 50 | @Param("offset") Integer offset, |
| 41 | 51 | @Param("limit") Integer limit); |
| 42 | 52 | |
| ... | ... | @@ -44,7 +54,10 @@ public interface IotDeviceMessageIoTDBMapper { |
| 44 | 54 | @Param("method") String method, |
| 45 | 55 | @Param("upstream") Integer upstream, |
| 46 | 56 | @Param("reply") Integer reply, |
| 47 | - @Param("identifier") String identifier); | |
| 57 | + @Param("identifier") String identifier, | |
| 58 | + @Param("raw") Boolean raw, | |
| 59 | + @Param("rawNull") String rawNull, | |
| 60 | + @Param("rawValuePattern") String rawValuePattern); | |
| 48 | 61 | |
| 49 | 62 | List<IotDeviceMessageIoTDBRow> selectListByRequestIdsAndReply(@Param("deviceId") String deviceId, |
| 50 | 63 | @Param("requestIds") Collection<String> requestIds, | ... | ... |
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 { |
| 38 | 38 | |
| 39 | 39 | // ========== 以下字段,统一使用 n:/v: 编码 ========== |
| 40 | 40 | |
| 41 | + /** | |
| 42 | + * 请求方法 | |
| 43 | + */ | |
| 41 | 44 | private String method; |
| 45 | + /** | |
| 46 | + * 标识符 | |
| 47 | + */ | |
| 42 | 48 | private String identifier; |
| 49 | + /** | |
| 50 | + * 请求编号 | |
| 51 | + */ | |
| 43 | 52 | private String requestId; |
| 53 | + /** | |
| 54 | + * 服务编号,该消息由哪个 server 发送 | |
| 55 | + */ | |
| 44 | 56 | private String serverId; |
| 57 | + /** | |
| 58 | + * 请求参数(JSON) | |
| 59 | + */ | |
| 45 | 60 | private String params; |
| 61 | + /** | |
| 62 | + * 响应结果(JSON) | |
| 63 | + */ | |
| 46 | 64 | private String data; |
| 65 | + /** | |
| 66 | + * 响应提示 | |
| 67 | + */ | |
| 47 | 68 | private String msg; |
| 69 | + /** | |
| 70 | + * 上报时间戳(毫秒) | |
| 71 | + */ | |
| 48 | 72 | private String reportTime; |
| 73 | + /** | |
| 74 | + * 租户编号 | |
| 75 | + */ | |
| 49 | 76 | private String tenantId; |
| 77 | + /** | |
| 78 | + * 响应错误码 | |
| 79 | + */ | |
| 50 | 80 | private String code; |
| 81 | + /** | |
| 82 | + * 原始帧(Base64) | |
| 83 | + */ | |
| 84 | + private String raw; | |
| 51 | 85 | |
| 52 | 86 | } | ... | ... |
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; |
| 8 | 8 | import java.util.List; |
| 9 | 9 | import java.util.Map; |
| 10 | 10 | |
| 11 | -// TODO DONE @AI:storage 包下,是不是加个 package-info.java,说明这个包的作用?已新增 package-info.java | |
| 12 | 11 | /** |
| 13 | 12 | * IoT 设备【属性】历史数据的存储 DAO 接口 |
| 14 | 13 | * | ... | ... |
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 { |
| 51 | 51 | ErrorCode DEVICE_PROPERTY_FIELD_NAME_INVALID = new ErrorCode(1_050_003_301, "属性标识符({}) 不合法,无法作为存储字段"); |
| 52 | 52 | ErrorCode DEVICE_PROPERTY_FIELD_NAME_RESERVED = new ErrorCode(1_050_003_302, "属性标识符({}) 与存储保留字段({}) 冲突"); |
| 53 | 53 | ErrorCode DEVICE_PROPERTY_FIELD_NAME_DUPLICATE = new ErrorCode(1_050_003_303, "属性标识符({}) 与({}) 的存储字段({}) 重复"); |
| 54 | + // 设备消息相关错误码 1-050-003-400 | |
| 55 | + ErrorCode DEVICE_MESSAGE_RAW_PAYLOAD_INVALID = new ErrorCode(1_050_003_400, "透传载荷不合法:{}"); | |
| 56 | + ErrorCode DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED = new ErrorCode(1_050_003_401, "协议({}) 不支持透传下发"); | |
| 57 | + ErrorCode DEVICE_MESSAGE_RAW_METHOD_RESERVED = new ErrorCode(1_050_003_402, "透传上报只能由网关创建"); | |
| 54 | 58 | |
| 55 | 59 | // ========== 产品分类 1-050-004-000 ========== |
| 56 | 60 | ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); |
| ... | ... | @@ -96,6 +100,12 @@ public interface ErrorCodeConstants { |
| 96 | 100 | ErrorCode OTA_TASK_RECORD_CANCEL_FAIL_STATUS_ERROR = new ErrorCode(1_050_008_201, "取消 OTA 升级记录失败,原因:记录状态不是进行中"); |
| 97 | 101 | ErrorCode OTA_TASK_RECORD_UPDATE_PROGRESS_FAIL_NO_EXISTS = new ErrorCode(1_050_008_202, "更新 OTA 升级记录进度失败,原因:该设备没有进行中的升级记录"); |
| 98 | 102 | |
| 103 | + // ========== IoT 产品数据解析脚本 1-050-009-000 ========== | |
| 104 | + ErrorCode PRODUCT_SCRIPT_TOO_LONG = new ErrorCode(1_050_009_000, "数据解析脚本不能超过 {} 字节"); | |
| 105 | + ErrorCode PRODUCT_SCRIPT_COMPILE_FAILED = new ErrorCode(1_050_009_001, "数据解析脚本语法错误:{}"); | |
| 106 | + ErrorCode PRODUCT_SCRIPT_EMPTY = new ErrorCode(1_050_009_002, "启用数据解析脚本时,脚本内容不能为空"); | |
| 107 | + ErrorCode PRODUCT_SCRIPT_DEBUG_BUSY = new ErrorCode(1_050_009_003, "脚本调试繁忙或已熔断,请稍后再试"); | |
| 108 | + | |
| 99 | 109 | // ========== IoT 数据流转规则 1-050-010-000 ========== |
| 100 | 110 | ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); |
| 101 | 111 | ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在"); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/IotPermissionConstants.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.enums; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * IoT 权限标识常量 | |
| 5 | + * | |
| 6 | + * 只收录业务代码中动态校验的权限标识,与菜单配置的权限标识保持一致;接口上的 {@code @PreAuthorize} 仍直接书写权限标识 | |
| 7 | + * | |
| 8 | + * @author 芋道源码 | |
| 9 | + */ | |
| 10 | +public interface IotPermissionConstants { | |
| 11 | + | |
| 12 | + /** | |
| 13 | + * 设备消息的透传下发(raw.push):可以直接驱动 Modbus 写,需要独立的按钮权限 | |
| 14 | + */ | |
| 15 | + String DEVICE_MESSAGE_RAW_SEND = "iot:device:message-raw-send"; | |
| 16 | + | |
| 17 | +} | ... | ... |
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; |
| 3 | 3 | import org.springframework.boot.context.properties.EnableConfigurationProperties; |
| 4 | 4 | import org.springframework.context.annotation.Configuration; |
| 5 | 5 | |
| 6 | -// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除重复的装配单测 IotStorageConfigurationTest,实际装配由实库验收覆盖 | |
| 7 | 6 | /** |
| 8 | 7 | * IoT 时序存储的配置类 |
| 9 | 8 | * | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java
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 |
| 16 | 16 | import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; |
| 17 | 17 | import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; |
| 18 | 18 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 19 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 19 | 20 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 20 | 21 | import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; |
| 21 | 22 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| ... | ... | @@ -25,11 +26,13 @@ import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqD |
| 25 | 26 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 26 | 27 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; |
| 27 | 28 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 29 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 28 | 30 | import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; |
| 29 | 31 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 30 | 32 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 31 | 33 | import cn.iocoder.yudao.module.iot.service.device.shadow.IotDeviceShadowService; |
| 32 | 34 | import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; |
| 35 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 33 | 36 | import com.google.common.base.Objects; |
| 34 | 37 | import jakarta.annotation.Resource; |
| 35 | 38 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -46,6 +49,10 @@ import java.util.Map; |
| 46 | 49 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 47 | 50 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 48 | 51 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_DOWNSTREAM_FAILED_SERVER_ID_NULL; |
| 52 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_METHOD_RESERVED; | |
| 53 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_PAYLOAD_INVALID; | |
| 54 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED; | |
| 55 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.PRODUCT_NOT_EXISTS; | |
| 49 | 56 | |
| 50 | 57 | /** |
| 51 | 58 | * IoT 设备消息 Service 实现类 |
| ... | ... | @@ -67,6 +74,9 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 67 | 74 | @Resource |
| 68 | 75 | @Lazy // 延迟加载,避免循环依赖 |
| 69 | 76 | private IotDeviceShadowService deviceShadowService; |
| 77 | + @Resource | |
| 78 | + @Lazy // 延迟加载,避免循环依赖 | |
| 79 | + private IotProductService productService; | |
| 70 | 80 | |
| 71 | 81 | @Resource |
| 72 | 82 | private IotDeviceMessageDAO deviceMessageDAO; |
| ... | ... | @@ -116,6 +126,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 116 | 126 | |
| 117 | 127 | @Override |
| 118 | 128 | public IotDeviceMessage sendDeviceMessage(IotDeviceMessage message, IotDeviceDO device, String serverId) { |
| 129 | + // 0. 透传:raw.post 只能由网关创建;raw.push 校验后改写为 raw,网关从 raw 取字节;其它消息不能携带 raw | |
| 130 | + byte[] rawPayload = validateRawMessage(message, device); | |
| 131 | + message.setRaw(rawPayload != null ? IotDeviceMessageUtils.encodeRaw(rawPayload) : null); | |
| 132 | + if (rawPayload != null) { | |
| 133 | + message.setParams(null); | |
| 134 | + } | |
| 119 | 135 | // 1. 补充信息 |
| 120 | 136 | appendDeviceMessage(message, device); |
| 121 | 137 | |
| ... | ... | @@ -147,6 +163,44 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 147 | 163 | } |
| 148 | 164 | |
| 149 | 165 | /** |
| 166 | + * 校验透传消息:raw.post 只能由网关创建;raw.push 校验 params.payload 与产品协议 | |
| 167 | + * | |
| 168 | + * @param message 消息 | |
| 169 | + * @param device 设备信息 | |
| 170 | + * @return raw.push 请求的载荷;其它消息返回 null | |
| 171 | + */ | |
| 172 | + private byte[] validateRawMessage(IotDeviceMessage message, IotDeviceDO device) { | |
| 173 | + // 1.1 raw.post 只能由网关创建 | |
| 174 | + if (IotDeviceMessageMethodEnum.RAW_POST.getMethod().equals(message.getMethod())) { | |
| 175 | + throw exception(DEVICE_MESSAGE_RAW_METHOD_RESERVED); | |
| 176 | + } | |
| 177 | + // 1.2 只校验 raw.push 请求,其它消息直接跳过 | |
| 178 | + if (!Objects.equal(message.getMethod(), IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()) | |
| 179 | + || IotDeviceMessageUtils.isReplyMessage(message)) { | |
| 180 | + return null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + // 2. 校验产品协议:HTTP、CoAP 没有长连接,不支持透传下发 | |
| 184 | + IotProductDO product = productService.getProductFromCache(device.getProductId()); | |
| 185 | + if (product == null) { | |
| 186 | + throw exception(PRODUCT_NOT_EXISTS); | |
| 187 | + } | |
| 188 | + IotProtocolTypeEnum protocol = IotProtocolTypeEnum.of(product.getProtocolType()); | |
| 189 | + if (protocol == IotProtocolTypeEnum.HTTP || protocol == IotProtocolTypeEnum.COAP) { | |
| 190 | + throw exception(DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED, product.getProtocolType()); | |
| 191 | + } | |
| 192 | + | |
| 193 | + // 3. 校验载荷:按产品协议的字节规则,与网关 encode 输出、脚本调试是同一份 | |
| 194 | + try { | |
| 195 | + byte[] payload = IotDeviceMessageUtils.decodeRawPayload(message.getParams(), IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 196 | + IotDeviceMessageUtils.validateRawPayload(protocol, payload); | |
| 197 | + return payload; | |
| 198 | + } catch (IllegalArgumentException e) { | |
| 199 | + throw exception(DEVICE_MESSAGE_RAW_PAYLOAD_INVALID, e.getMessage()); | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + /** | |
| 150 | 204 | * 补充消息的后端字段 |
| 151 | 205 | * |
| 152 | 206 | * @param message 消息 |
| ... | ... | @@ -180,8 +234,10 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 180 | 234 | getSelf().createDeviceLogAsync(message); |
| 181 | 235 | |
| 182 | 236 | // 3. 回复消息。前提:非 _reply 消息、非禁用回复的消息 |
| 237 | + // 另外:带原始帧(raw)的是网关透传解析出的消息,不自动回复;设备经标准路径上报的 raw 由网关清空,不能伪造 | |
| 183 | 238 | if (IotDeviceMessageUtils.isReplyMessage(message) |
| 184 | 239 | || IotDeviceMessageMethodEnum.isReplyDisabled(message.getMethod()) |
| 240 | + || message.getRaw() != null | |
| 185 | 241 | || StrUtil.isEmpty(message.getServerId())) { |
| 186 | 242 | return; |
| 187 | 243 | } |
| ... | ... | @@ -335,6 +391,8 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { |
| 335 | 391 | |
| 336 | 392 | @Override |
| 337 | 393 | public PageResult<IotDeviceMessageDO> getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) { |
| 394 | + // 先校验设备属于当前租户(MySQL 租户插件生效);时序库查询本身只按 deviceId 限定 | |
| 395 | + deviceService.validateDeviceExists(pageReqVO.getDeviceId()); | |
| 338 | 396 | return deviceMessageDAO.selectPage(pageReqVO); |
| 339 | 397 | } |
| 340 | 398 | ... | ... |
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; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; |
| 6 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 7 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 8 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 6 | 9 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 7 | 10 | import jakarta.validation.Valid; |
| 8 | 11 | |
| ... | ... | @@ -37,6 +40,21 @@ public interface IotProductService { |
| 37 | 40 | void updateProduct(@Valid IotProductSaveReqVO updateReqVO); |
| 38 | 41 | |
| 39 | 42 | /** |
| 43 | + * 更新产品数据解析脚本:只修改非 null 的字段(脚本内容、是否启用),最后保存者生效 | |
| 44 | + * | |
| 45 | + * @param updateReqVO 更新信息 | |
| 46 | + */ | |
| 47 | + void updateProductScript(@Valid IotProductScriptUpdateReqVO updateReqVO); | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * 调试产品数据解析脚本:执行当前编辑内容,不保存、不发消息、不改属性 | |
| 51 | + * | |
| 52 | + * @param debugReqVO 调试信息 | |
| 53 | + * @return 调试结果 | |
| 54 | + */ | |
| 55 | + IotProductScriptDebugRespVO debugProductScript(@Valid IotProductScriptDebugReqVO debugReqVO); | |
| 56 | + | |
| 57 | + /** | |
| 40 | 58 | * 删除产品 |
| 41 | 59 | * |
| 42 | 60 | * @param id 编号 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.product; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.bean.BeanUtil; | |
| 3 | 4 | import cn.hutool.core.collection.CollUtil; |
| 5 | +import cn.hutool.core.map.MapUtil; | |
| 6 | +import cn.hutool.core.util.HexUtil; | |
| 7 | +import cn.hutool.core.util.ObjUtil; | |
| 8 | +import cn.hutool.core.util.ReUtil; | |
| 9 | +import cn.hutool.core.util.StrUtil; | |
| 4 | 10 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | 11 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 6 | 12 | import cn.hutool.core.util.IdUtil; |
| 7 | 13 | import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; |
| 8 | 14 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO; |
| 9 | 15 | import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; |
| 16 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 17 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 18 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 19 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 20 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 21 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 22 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; | |
| 23 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; | |
| 24 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 25 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 26 | +import cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor; | |
| 27 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 10 | 28 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 11 | 29 | import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; |
| 12 | 30 | import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; |
| ... | ... | @@ -14,6 +32,8 @@ import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; |
| 14 | 32 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 15 | 33 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 16 | 34 | import com.baomidou.dynamic.datasource.annotation.DSTransactional; |
| 35 | +import jakarta.annotation.PostConstruct; | |
| 36 | +import jakarta.annotation.PreDestroy; | |
| 17 | 37 | import jakarta.annotation.Resource; |
| 18 | 38 | import lombok.extern.slf4j.Slf4j; |
| 19 | 39 | import org.springframework.cache.annotation.CacheEvict; |
| ... | ... | @@ -25,9 +45,12 @@ import org.springframework.validation.annotation.Validated; |
| 25 | 45 | import java.time.LocalDateTime; |
| 26 | 46 | import java.util.Collection; |
| 27 | 47 | import java.util.List; |
| 48 | +import java.util.Map; | |
| 28 | 49 | import java.util.Objects; |
| 50 | +import java.util.concurrent.TimeUnit; | |
| 29 | 51 | |
| 30 | 52 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 53 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 31 | 54 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; |
| 32 | 55 | |
| 33 | 56 | /** |
| ... | ... | @@ -40,6 +63,23 @@ import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; |
| 40 | 63 | @Validated |
| 41 | 64 | public class IotProductServiceImpl implements IotProductService { |
| 42 | 65 | |
| 66 | + /** | |
| 67 | + * 数据解析脚本的最大字节数(UTF-8) | |
| 68 | + */ | |
| 69 | + private static final int SCRIPT_MAX_BYTES = 32 * 1024; | |
| 70 | + /** | |
| 71 | + * 调试时 ctx.deviceName 的固定值:ctx 中不带任何真实设备的信息或秘密 | |
| 72 | + */ | |
| 73 | + private static final String DEBUG_DEVICE_NAME = "debug-device"; | |
| 74 | + /** | |
| 75 | + * 调试的截止时间:覆盖排队与执行,也是请求线程的最长等待时间 | |
| 76 | + */ | |
| 77 | + private static final long DEBUG_DEADLINE_MILLIS = 2000; | |
| 78 | + /** | |
| 79 | + * 保存时语法检查的最长等待时间 | |
| 80 | + */ | |
| 81 | + private static final long COMPILE_TIMEOUT_MILLIS = 2000; | |
| 82 | + | |
| 43 | 83 | @Resource |
| 44 | 84 | private IotProductMapper productMapper; |
| 45 | 85 | |
| ... | ... | @@ -49,6 +89,24 @@ public class IotProductServiceImpl implements IotProductService { |
| 49 | 89 | @Lazy // 延迟加载,避免循环依赖 |
| 50 | 90 | private IotDevicePropertyService devicePropertyDataService; |
| 51 | 91 | |
| 92 | + /** | |
| 93 | + * 调试池:2 个线程、队列 4;请求线程阻塞等待结果,最长到截止时间 | |
| 94 | + * <p> | |
| 95 | + * Rhino 下死循环会被预算中断、线程可复用;不经过检查点的原生长操作仍可能卡住线程 → 熔断调试功能,需要重启 biz | |
| 96 | + */ | |
| 97 | + private IotScriptExecutor debugScriptExecutor; | |
| 98 | + | |
| 99 | + @PostConstruct | |
| 100 | + public void initDebugScriptExecutor() { | |
| 101 | + debugScriptExecutor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options().setName("iot-script-debug") | |
| 102 | + .setPoolSize(2).setQueueCapacity(4).setMaxStuckThreads(2)); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @PreDestroy | |
| 106 | + public void destroyDebugScriptExecutor() { | |
| 107 | + debugScriptExecutor.shutdown(); | |
| 108 | + } | |
| 109 | + | |
| 52 | 110 | @Override |
| 53 | 111 | public Long createProduct(IotProductSaveReqVO createReqVO) { |
| 54 | 112 | // 1. 校验 ProductKey |
| ... | ... | @@ -204,4 +262,157 @@ public class IotProductServiceImpl implements IotProductService { |
| 204 | 262 | } |
| 205 | 263 | } |
| 206 | 264 | |
| 265 | + // ========== 数据解析脚本 ========== | |
| 266 | + | |
| 267 | + @Override | |
| 268 | + @CacheEvict(value = RedisKeyConstants.PRODUCT, key = "#updateReqVO.id") | |
| 269 | + public void updateProductScript(IotProductScriptUpdateReqVO updateReqVO) { | |
| 270 | + // 1.1 校验存在 | |
| 271 | + IotProductDO product = validateProductExists(updateReqVO.getId()); | |
| 272 | + // 1.2 校验脚本:UTF-8 不超过 32KB;语法只编译、不执行 | |
| 273 | + if (updateReqVO.getScriptContent() != null) { | |
| 274 | + validateProductScript(updateReqVO.getScriptContent()); | |
| 275 | + } | |
| 276 | + // 1.3 启用时内容不能为空:没提交的字段取库里的值 | |
| 277 | + String content = ObjUtil.defaultIfNull(updateReqVO.getScriptContent(), product.getScriptContent()); | |
| 278 | + Boolean enabled = ObjUtil.defaultIfNull(updateReqVO.getScriptEnabled(), product.getScriptEnabled()); | |
| 279 | + if (Boolean.TRUE.equals(enabled) && StrUtil.isBlank(content)) { | |
| 280 | + throw exception(PRODUCT_SCRIPT_EMPTY); | |
| 281 | + } | |
| 282 | + | |
| 283 | + // 2. 部分更新:只写本次提交的字段(null 跳过),保存内容不会顺带改启用状态 | |
| 284 | + IotProductDO updateObj = BeanUtils.toBean(updateReqVO, IotProductDO.class); | |
| 285 | + productMapper.updateById(updateObj); | |
| 286 | + } | |
| 287 | + | |
| 288 | + private void validateProductScriptLength(String content) { | |
| 289 | + if (StrUtil.utf8Bytes(content).length > SCRIPT_MAX_BYTES) { | |
| 290 | + throw exception(PRODUCT_SCRIPT_TOO_LONG, SCRIPT_MAX_BYTES); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + | |
| 294 | + private void validateProductScript(String content) { | |
| 295 | + validateProductScriptLength(content); | |
| 296 | + // 空字符串表示清空 | |
| 297 | + if (StrUtil.isEmpty(content)) { | |
| 298 | + return; | |
| 299 | + } | |
| 300 | + // 在调试池线程上编译:只做语法检查,不执行任何用户代码 | |
| 301 | + IotScriptResult result = debugScriptExecutor.compileOnly(content, COMPILE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); | |
| 302 | + if (result.getStatus() == IotScriptResult.Status.REJECTED || result.isTimeout()) { | |
| 303 | + throw exception(PRODUCT_SCRIPT_DEBUG_BUSY); | |
| 304 | + } | |
| 305 | + if (!result.isSuccess()) { | |
| 306 | + throw exception(PRODUCT_SCRIPT_COMPILE_FAILED, result.getLineNumber() > 0 | |
| 307 | + ? StrUtil.format("第 {} 行:{}", result.getLineNumber(), result.getMessage()) : result.getMessage()); | |
| 308 | + } | |
| 309 | + } | |
| 310 | + | |
| 311 | + @Override | |
| 312 | + public IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO debugReqVO) { | |
| 313 | + // 1.1 校验当前租户内的产品 | |
| 314 | + IotProductDO product = validateProductExists(debugReqVO.getId()); | |
| 315 | + // 1.2 校验脚本长度 | |
| 316 | + validateProductScriptLength(debugReqVO.getScriptContent()); | |
| 317 | + // 1.3 校验调试输入,构建 ctx | |
| 318 | + String ctx = buildDebugContext(debugReqVO, product); | |
| 319 | + | |
| 320 | + // 2.1 执行:与网关相同的运行时和预算;请求线程阻塞等待,最长到截止时间 | |
| 321 | + IotScriptResult result = debugScriptExecutor.execute(IotScriptSource.ofDebug(debugReqVO.getScriptContent()), | |
| 322 | + debugReqVO.getDirection(), ctx, System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DEBUG_DEADLINE_MILLIS)); | |
| 323 | + // 2.2 构建结果:与网关相同的输出校验 | |
| 324 | + return buildDebugResult(debugReqVO, product, result); | |
| 325 | + } | |
| 326 | + | |
| 327 | + /** | |
| 328 | + * 构建调试的 ctx:与网关的结构一致,但设备名固定为 {@link #DEBUG_DEVICE_NAME},不带任何真实设备的信息或秘密 | |
| 329 | + * | |
| 330 | + * @param reqVO 调试请求 | |
| 331 | + * @param product 产品 | |
| 332 | + * @return ctx(JSON) | |
| 333 | + */ | |
| 334 | + private static String buildDebugContext(IotProductScriptDebugReqVO reqVO, IotProductDO product) { | |
| 335 | + // 1. decode:上行原始字节 | |
| 336 | + if (IotScriptUtils.FUNCTION_DECODE.equals(reqVO.getDirection())) { | |
| 337 | + // 1.1 校验 HEX(允许空格) | |
| 338 | + String hex = StrUtil.cleanBlank(reqVO.getInputHex()); | |
| 339 | + if (StrUtil.isEmpty(hex) || hex.length() % 2 != 0 || !ReUtil.isMatch("[0-9a-fA-F]+", hex)) { | |
| 340 | + throw invalidParamException("上行数据必须是偶数位的 HEX"); | |
| 341 | + } | |
| 342 | + // 1.2 校验长度:与透传上报的上限一致 | |
| 343 | + byte[] bytes = HexUtil.decodeHex(hex); | |
| 344 | + if (bytes.length > IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES) { | |
| 345 | + throw invalidParamException("上行数据不能超过 {} 字节", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 346 | + } | |
| 347 | + // 1.3 构建 decode 的 ctx | |
| 348 | + return IotScriptUtils.buildDecodeContext(product.getProtocolType(), product.getProductKey(), | |
| 349 | + DEBUG_DEVICE_NAME, System.currentTimeMillis(), bytes, null); | |
| 350 | + } | |
| 351 | + | |
| 352 | + // 2.1 encode:与网关一致,只编码 property.set / service.invoke 请求 | |
| 353 | + Map<String, Object> message = reqVO.getMessage(); | |
| 354 | + if (message == null) { | |
| 355 | + throw invalidParamException("下行指令不能为空"); | |
| 356 | + } | |
| 357 | + IotDeviceMessage request = IotDeviceMessage.requestOf( | |
| 358 | + StrUtil.blankToDefault(MapUtil.getStr(message, "requestId"), "debug"), | |
| 359 | + MapUtil.getStr(message, "method"), message.get("params")); | |
| 360 | + if (!IotDeviceMessageUtils.isCommandRequest(request)) { | |
| 361 | + throw invalidParamException("下行编码只支持 thing.property.set、thing.service.invoke"); | |
| 362 | + } | |
| 363 | + // 2.2 构建 encode 的 ctx | |
| 364 | + return IotScriptUtils.buildEncodeContext(product.getProtocolType(), product.getProductKey(), | |
| 365 | + DEBUG_DEVICE_NAME, request); | |
| 366 | + } | |
| 367 | + | |
| 368 | + /** | |
| 369 | + * 构建调试结果:输出按与网关相同的规则校验 | |
| 370 | + * | |
| 371 | + * @param reqVO 调试请求 | |
| 372 | + * @param product 产品:encode 输出的透传字节,按产品协议的规则校验 | |
| 373 | + * @param result 脚本的执行结果 | |
| 374 | + * @return 调试结果 | |
| 375 | + */ | |
| 376 | + private static IotProductScriptDebugRespVO buildDebugResult(IotProductScriptDebugReqVO reqVO, IotProductDO product, | |
| 377 | + IotScriptResult result) { | |
| 378 | + // 1. 执行失败:异常、超时、拒绝 | |
| 379 | + IotProductScriptDebugRespVO respVO = new IotProductScriptDebugRespVO().setDurationMs(result.getDurationMillis()); | |
| 380 | + if (!result.isSuccess()) { | |
| 381 | + return failDebugResult(respVO, result.getStatus().name(), result.getMessage(), result.getLineNumber()); | |
| 382 | + } | |
| 383 | + | |
| 384 | + // 2. 校验输出:不合法时返回 OUTPUT_INVALID 等错误 | |
| 385 | + try { | |
| 386 | + if (IotScriptUtils.FUNCTION_DECODE.equals(reqVO.getDirection())) { | |
| 387 | + // 2.1 decode | |
| 388 | + // 与网关一致:decode 未定义或返回 null,表示没有业务消息(只记录原始消息),message 为空 | |
| 389 | + IotDeviceMessage message = IotScriptUtils.validateDecode(result.getJson(), null); | |
| 390 | + respVO.setMessage(message != null ? buildDebugMessage(message) : null); | |
| 391 | + } else { | |
| 392 | + // 2.2 encode | |
| 393 | + // 与网关一致:字节按产品协议的规则校验(Modbus 必须是合法的请求 PDU) | |
| 394 | + IotScriptUtils.EncodeOutput output = IotScriptUtils.validateEncode(result.getJson(), | |
| 395 | + IotProtocolTypeEnum.of(product.getProtocolType())); | |
| 396 | + if (output == null) { // 与网关一致:未生成下行数据 | |
| 397 | + return failDebugResult(respVO, "ENCODE_EMPTY", "数据解析脚本未生成下行数据", -1); | |
| 398 | + } | |
| 399 | + respVO.setStandard(output.isStandard()); | |
| 400 | + respVO.setOutputHex(output.getBytes() != null ? HexUtil.encodeHexStr(output.getBytes(), false) : null); | |
| 401 | + } | |
| 402 | + } catch (IotScriptException e) { | |
| 403 | + return failDebugResult(respVO, e.getStatus().name(), e.getMessage(), e.getLineNumber()); | |
| 404 | + } | |
| 405 | + // 3. 成功 | |
| 406 | + return respVO.setSuccess(true); | |
| 407 | + } | |
| 408 | + | |
| 409 | + private static IotProductScriptDebugRespVO failDebugResult(IotProductScriptDebugRespVO respVO, String type, String message, int lineNumber) { | |
| 410 | + return respVO.setSuccess(false).setError(new IotProductScriptDebugRespVO.ErrorInfo() | |
| 411 | + .setType(type).setMessage(message).setLine(lineNumber > 0 ? lineNumber : null)); | |
| 412 | + } | |
| 413 | + | |
| 414 | + private static Map<String, Object> buildDebugMessage(IotDeviceMessage message) { | |
| 415 | + return BeanUtil.beanToMap(message, "method", "requestId", "params", "data", "code", "msg"); | |
| 416 | + } | |
| 417 | + | |
| 207 | 418 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDeviceMessageMapper.xml
| ... | ... | @@ -19,7 +19,8 @@ |
| 19 | 19 | params VARCHAR(8192), |
| 20 | 20 | data VARCHAR(8192), |
| 21 | 21 | code INT, |
| 22 | - msg NCHAR(256) | |
| 22 | + msg NCHAR(256), | |
| 23 | + raw VARCHAR(8192) | |
| 23 | 24 | ) TAGS ( |
| 24 | 25 | device_id BIGINT |
| 25 | 26 | ) |
| ... | ... | @@ -33,21 +34,21 @@ |
| 33 | 34 | INSERT INTO device_message_${deviceId} ( |
| 34 | 35 | ts, id, report_time, tenant_id, server_id, |
| 35 | 36 | upstream, reply, identifier, request_id, method, |
| 36 | - params, data, code, msg | |
| 37 | + params, data, code, msg, raw | |
| 37 | 38 | ) |
| 38 | 39 | USING device_message |
| 39 | 40 | TAGS (#{deviceId}) |
| 40 | 41 | VALUES ( |
| 41 | 42 | #{ts}, #{id}, #{reportTime}, #{tenantId}, #{serverId}, |
| 42 | - #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, | |
| 43 | - #{params}, #{data}, #{code}, #{msg} | |
| 43 | + #{upstream}, #{reply}, #{identifier}, #{requestId}, #{method}, | |
| 44 | + #{params}, #{data}, #{code}, #{msg}, #{raw} | |
| 44 | 45 | ) |
| 45 | 46 | </insert> |
| 46 | 47 | |
| 47 | 48 | <select id="selectPage" resultType="cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO"> |
| 48 | 49 | SELECT ts, id, report_time, tenant_id, server_id, |
| 49 | - upstream, reply, identifier, request_id, method, | |
| 50 | - params, data, code, msg | |
| 50 | + upstream, reply, identifier, request_id, method, | |
| 51 | + params, data, code, msg, raw | |
| 51 | 52 | FROM device_message_${reqVO.deviceId} |
| 52 | 53 | <where> |
| 53 | 54 | <if test="reqVO.method != null and reqVO.method != ''"> |
| ... | ... | @@ -62,14 +63,21 @@ |
| 62 | 63 | <if test="reqVO.identifier != null and reqVO.identifier != ''"> |
| 63 | 64 | AND identifier = #{reqVO.identifier} |
| 64 | 65 | </if> |
| 66 | + <!-- 是否有原始帧:按 raw 列是否为空过滤,不再按 method 过滤 --> | |
| 67 | + <if test="reqVO.raw != null and reqVO.raw"> | |
| 68 | + AND raw IS NOT NULL | |
| 69 | + </if> | |
| 70 | + <if test="reqVO.raw != null and !reqVO.raw"> | |
| 71 | + AND raw IS NULL | |
| 72 | + </if> | |
| 65 | 73 | </where> |
| 66 | 74 | ORDER BY ts DESC, id DESC |
| 67 | 75 | </select> |
| 68 | 76 | |
| 69 | 77 | <select id="selectListByRequestIdsAndReply" resultType="cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO"> |
| 70 | 78 | SELECT ts, id, report_time, tenant_id, server_id, |
| 71 | - upstream, reply, identifier, request_id, method, | |
| 72 | - params, data, code, msg | |
| 79 | + upstream, reply, identifier, request_id, method, | |
| 80 | + params, data, code, msg, raw | |
| 73 | 81 | FROM device_message_${deviceId} |
| 74 | 82 | WHERE reply = #{reply} |
| 75 | 83 | AND request_id IN | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDeviceMessageIoTDBMapper.xml
| ... | ... | @@ -32,7 +32,8 @@ |
| 32 | 32 | msg STRING FIELD, |
| 33 | 33 | report_time STRING FIELD, |
| 34 | 34 | tenant_id STRING FIELD, |
| 35 | - code STRING FIELD | |
| 35 | + code STRING FIELD, | |
| 36 | + raw STRING FIELD | |
| 36 | 37 | ) |
| 37 | 38 | </update> |
| 38 | 39 | |
| ... | ... | @@ -40,19 +41,22 @@ |
| 40 | 41 | INSERT INTO device_message ( |
| 41 | 42 | time, device_id, id, upstream, reply, |
| 42 | 43 | method, identifier, request_id, server_id, params, |
| 43 | - data, msg, report_time, tenant_id, code | |
| 44 | + data, msg, report_time, tenant_id, code, | |
| 45 | + raw | |
| 44 | 46 | ) |
| 45 | 47 | VALUES ( |
| 46 | 48 | #{ts}, #{deviceId}, #{id}, #{upstream}, #{reply}, |
| 47 | 49 | #{method}, #{identifier}, #{requestId}, #{serverId}, #{params}, |
| 48 | - #{data}, #{msg}, #{reportTime}, #{tenantId}, #{code} | |
| 50 | + #{data}, #{msg}, #{reportTime}, #{tenantId}, #{code}, | |
| 51 | + #{raw} | |
| 49 | 52 | ) |
| 50 | 53 | </insert> |
| 51 | 54 | |
| 52 | 55 | <sql id="selectColumns"> |
| 53 | 56 | SELECT time AS ts, device_id, id, upstream, reply, |
| 54 | 57 | method, identifier, request_id, server_id, params, |
| 55 | - data, msg, report_time, tenant_id, code | |
| 58 | + data, msg, report_time, tenant_id, code, | |
| 59 | + raw | |
| 56 | 60 | FROM device_message |
| 57 | 61 | </sql> |
| 58 | 62 | |
| ... | ... | @@ -70,6 +74,13 @@ |
| 70 | 74 | <if test="identifier != null"> |
| 71 | 75 | AND identifier = #{identifier} |
| 72 | 76 | </if> |
| 77 | + <!-- 是否有原始帧:有值为 v: 开头;没有为 n: --> | |
| 78 | + <if test="raw != null and raw"> | |
| 79 | + AND raw LIKE #{rawValuePattern} | |
| 80 | + </if> | |
| 81 | + <if test="raw != null and !raw"> | |
| 82 | + AND raw = #{rawNull} | |
| 83 | + </if> | |
| 73 | 84 | </sql> |
| 74 | 85 | |
| 75 | 86 | <select id="selectList" resultType="cn.iocoder.yudao.module.iot.dal.iotdb.IotDeviceMessageIoTDBRow"> | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImplTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.api.device; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | |
| 4 | +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 9 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; | |
| 10 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 11 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; | |
| 12 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; | |
| 13 | +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; | |
| 14 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 15 | +import jakarta.validation.Validation; | |
| 16 | +import org.junit.jupiter.api.BeforeEach; | |
| 17 | +import org.junit.jupiter.api.Test; | |
| 18 | +import org.mockito.InjectMocks; | |
| 19 | +import org.mockito.Mock; | |
| 20 | +import org.springframework.aop.support.AopUtils; | |
| 21 | +import org.springframework.validation.beanvalidation.MethodValidationPostProcessor; | |
| 22 | + | |
| 23 | +import static org.junit.jupiter.api.Assertions.*; | |
| 24 | +import static org.mockito.ArgumentMatchers.eq; | |
| 25 | +import static org.mockito.Mockito.*; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * {@link IoTDeviceApiImpl} 的单元测试:经过真实的 Spring 方法校验代理(与 @Validated 的 Bean 在容器中一致) | |
| 29 | + * | |
| 30 | + * @author 芋道源码 | |
| 31 | + */ | |
| 32 | +public class IoTDeviceApiImplTest extends BaseMockitoUnitTest { | |
| 33 | + | |
| 34 | + @InjectMocks | |
| 35 | + private IoTDeviceApiImpl deviceApiImpl; | |
| 36 | + | |
| 37 | + @Mock | |
| 38 | + private IotDeviceService deviceService; | |
| 39 | + @Mock | |
| 40 | + private IotProductService productService; | |
| 41 | + @Mock | |
| 42 | + private IotDeviceModbusConfigService modbusConfigService; | |
| 43 | + @Mock | |
| 44 | + private IotDeviceModbusPointService modbusPointService; | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * 方法校验代理:参数约束继承不合法时,代理上的任何方法调用都会抛 ConstraintDeclarationException(HV000151) | |
| 48 | + */ | |
| 49 | + private IotDeviceCommonApi deviceApi; | |
| 50 | + | |
| 51 | + @BeforeEach | |
| 52 | + public void setUp() { | |
| 53 | + MethodValidationPostProcessor processor = new MethodValidationPostProcessor(); | |
| 54 | + processor.setValidator(Validation.buildDefaultValidatorFactory().getValidator()); | |
| 55 | + processor.afterPropertiesSet(); | |
| 56 | + deviceApi = (IotDeviceCommonApi) processor.postProcessAfterInitialization(deviceApiImpl, "ioTDeviceApiImpl"); | |
| 57 | + assertTrue(AopUtils.isAopProxy(deviceApi)); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Test | |
| 61 | + public void testAuthDevice() { | |
| 62 | + // 准备参数 | |
| 63 | + IotDeviceAuthReqDTO reqDTO = new IotDeviceAuthReqDTO("client", "dn&pk", "password"); | |
| 64 | + // mock 方法 | |
| 65 | + when(deviceService.authDevice(same(reqDTO))).thenReturn(true); | |
| 66 | + | |
| 67 | + // 调用 | |
| 68 | + CommonResult<Boolean> result = deviceApi.authDevice(reqDTO); | |
| 69 | + // 断言:方法校验代理正常放行 | |
| 70 | + assertTrue(result.isSuccess()); | |
| 71 | + assertTrue(result.getData()); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + public void testGetDevice_scriptEnabled() { | |
| 76 | + // 准备参数 | |
| 77 | + IotDeviceGetReqDTO reqDTO = new IotDeviceGetReqDTO().setId(10L); | |
| 78 | + // mock 方法:产品启用数据解析脚本 | |
| 79 | + when(deviceService.getDeviceFromCache(eq(10L))).thenReturn(IotDeviceDO.builder().id(10L).productId(1L) | |
| 80 | + .productKey("pk").deviceName("dn").build()); | |
| 81 | + when(productService.getProductFromCache(eq(1L))).thenReturn(IotProductDO.builder().id(1L) | |
| 82 | + .protocolType("tcp").serializeType("json") | |
| 83 | + .scriptEnabled(true).scriptContent("function decode(ctx) {}").build()); | |
| 84 | + | |
| 85 | + // 调用 | |
| 86 | + CommonResult<IotDeviceRespDTO> result = deviceApi.getDevice(reqDTO); | |
| 87 | + // 断言:产品字段随设备信息返回,启用时带上脚本内容 | |
| 88 | + assertTrue(result.isSuccess()); | |
| 89 | + IotDeviceRespDTO device = result.getData(); | |
| 90 | + assertEquals(10L, device.getId()); | |
| 91 | + assertEquals(1L, device.getProductId()); | |
| 92 | + assertEquals("tcp", device.getProtocolType()); | |
| 93 | + assertEquals("json", device.getSerializeType()); | |
| 94 | + assertTrue(device.getScriptEnabled()); | |
| 95 | + assertEquals("function decode(ctx) {}", device.getScriptContent()); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Test | |
| 99 | + public void testGetDevice_scriptDisabled() { | |
| 100 | + // 准备参数 | |
| 101 | + IotDeviceGetReqDTO reqDTO = new IotDeviceGetReqDTO().setProductKey("pk").setDeviceName("dn"); | |
| 102 | + // mock 方法:产品停用数据解析脚本,但保留了脚本内容 | |
| 103 | + when(deviceService.getDeviceFromCache(eq("pk"), eq("dn"))).thenReturn(IotDeviceDO.builder().id(10L) | |
| 104 | + .productId(1L).productKey("pk").deviceName("dn").build()); | |
| 105 | + when(productService.getProductFromCache(eq(1L))).thenReturn(IotProductDO.builder().id(1L) | |
| 106 | + .scriptEnabled(false).scriptContent("function decode(ctx) {}").build()); | |
| 107 | + | |
| 108 | + // 调用 | |
| 109 | + CommonResult<IotDeviceRespDTO> result = deviceApi.getDevice(reqDTO); | |
| 110 | + // 断言:停用时不返回脚本内容 | |
| 111 | + assertTrue(result.isSuccess()); | |
| 112 | + assertFalse(result.getData().getScriptEnabled()); | |
| 113 | + assertNull(result.getData().getScriptContent()); | |
| 114 | + } | |
| 115 | + | |
| 116 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodecTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.dal.iotdb; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; | |
| 4 | +import org.junit.jupiter.api.Test; | |
| 5 | + | |
| 6 | +import static org.junit.jupiter.api.Assertions.*; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * {@link IotDeviceMessageIoTDBCodec} 的单元测试 | |
| 10 | + * | |
| 11 | + * @author 芋道源码 | |
| 12 | + */ | |
| 13 | +public class IotDeviceMessageIoTDBCodecTest { | |
| 14 | + | |
| 15 | + @Test | |
| 16 | + public void testEncode_raw() { | |
| 17 | + // 调用 + 断言:有原始帧 | |
| 18 | + IotDeviceMessageIoTDBRow row = IotDeviceMessageIoTDBCodec.encode(buildMessage().setRaw("AQID")); | |
| 19 | + assertEquals("v:AQID", row.getRaw()); | |
| 20 | + // 调用 + 断言:没有原始帧,也写入非 NULL 值,保证同一 (device_id, time) 整行替换 | |
| 21 | + IotDeviceMessageIoTDBRow nullRow = IotDeviceMessageIoTDBCodec.encode(buildMessage()); | |
| 22 | + assertEquals("n:", nullRow.getRaw()); | |
| 23 | + } | |
| 24 | + | |
| 25 | + @Test | |
| 26 | + public void testDecode_raw() { | |
| 27 | + // 调用 + 断言:v: 为值,n: 为 null | |
| 28 | + assertEquals("AQID", IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("v:AQID")).getRaw()); | |
| 29 | + assertNull(IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("n:")).getRaw()); | |
| 30 | + } | |
| 31 | + | |
| 32 | + @Test | |
| 33 | + public void testDecode_rawCorrupted() { | |
| 34 | + // 调用 + 断言:不是本系统的编码,按损坏处理,不静默 | |
| 35 | + assertThrows(IllegalArgumentException.class, | |
| 36 | + () -> IotDeviceMessageIoTDBCodec.decode(buildRow().setRaw("AQID"))); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + public void testEncodeDecode_roundTrip() { | |
| 41 | + // 准备参数 | |
| 42 | + IotDeviceMessageDO message = buildMessage().setRaw("AQID"); | |
| 43 | + | |
| 44 | + // 调用 | |
| 45 | + IotDeviceMessageDO result = IotDeviceMessageIoTDBCodec.decode(IotDeviceMessageIoTDBCodec.encode(message)); | |
| 46 | + // 断言 | |
| 47 | + assertEquals(message.getRaw(), result.getRaw()); | |
| 48 | + assertEquals(message.getId(), result.getId()); | |
| 49 | + assertEquals(message.getDeviceId(), result.getDeviceId()); | |
| 50 | + assertEquals(message.getMethod(), result.getMethod()); | |
| 51 | + assertEquals(message.getUpstream(), result.getUpstream()); | |
| 52 | + assertNull(result.getParams()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + private static IotDeviceMessageDO buildMessage() { | |
| 56 | + return new IotDeviceMessageDO().setTs(1000L).setDeviceId(2L).setId("msg-1") | |
| 57 | + .setMethod("thing.property.post").setUpstream(true).setReply(false); | |
| 58 | + } | |
| 59 | + | |
| 60 | + private static IotDeviceMessageIoTDBRow buildRow() { | |
| 61 | + return new IotDeviceMessageIoTDBRow().setTs(1000L).setDeviceId("2").setId("msg-1") | |
| 62 | + .setUpstream(1).setReply(0).setMethod("v:thing.property.post").setIdentifier("n:") | |
| 63 | + .setRequestId("n:").setServerId("n:").setParams("n:").setData("n:").setMsg("n:") | |
| 64 | + .setReportTime("n:").setTenantId("n:").setCode("n:"); | |
| 65 | + } | |
| 66 | + | |
| 67 | +} | ... | ... |
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 { |
| 85 | 85 | verifyNoInteractions(devicePropertyService, deviceMessageService); |
| 86 | 86 | } |
| 87 | 87 | |
| 88 | + @Test | |
| 89 | + public void testOnMessage_replyForceOnline() { | |
| 90 | + // 准备参数:设备实际返回的回复(例如 Modbus 异常响应),按原逻辑作为设备活动 | |
| 91 | + IotDeviceMessage message = new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) | |
| 92 | + .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) | |
| 93 | + .setCode(1_051_003_111); | |
| 94 | + // mock 方法:设备离线 | |
| 95 | + IotDeviceDO device = new IotDeviceDO().setId(message.getDeviceId()) | |
| 96 | + .setState(IotDeviceStateEnum.OFFLINE.getState()); | |
| 97 | + when(deviceService.getDeviceFromCache(eq(message.getDeviceId()))).thenReturn(device); | |
| 98 | + | |
| 99 | + // 调用 | |
| 100 | + deviceMessageSubscriber.onMessage(message); | |
| 101 | + // 断言:更新上报时间、连接 server,并强制上线 | |
| 102 | + verify(devicePropertyService).updateDeviceReportTimeAsync(eq(device.getId()), any(LocalDateTime.class)); | |
| 103 | + verify(devicePropertyService).updateDeviceServerIdAsync(eq(device.getId()), eq(message.getServerId())); | |
| 104 | + verify(deviceMessageService).sendDeviceMessage(argThat((IotDeviceMessage m) -> | |
| 105 | + IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod().equals(m.getMethod()))); | |
| 106 | + verify(deviceMessageService).handleUpstreamDeviceMessage(eq(message), eq(device)); | |
| 107 | + } | |
| 108 | + | |
| 88 | 109 | private static IotDeviceMessage buildPropertyPostMessage() { |
| 89 | 110 | return new IotDeviceMessage().setDeviceId(randomLongId()).setTenantId(randomLongId()) |
| 90 | 111 | .setServerId("test-server").setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.device.message; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.date.LocalDateTimeUtil; |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.enums.DateIntervalEnum; |
| 6 | 7 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| ... | ... | @@ -13,24 +14,31 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 13 | 14 | import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; |
| 14 | 15 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; |
| 15 | 16 | import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; |
| 17 | +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; | |
| 16 | 18 | import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; |
| 17 | 19 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 18 | 20 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 19 | 21 | import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; |
| 22 | +import cn.iocoder.yudao.module.iot.service.product.IotProductService; | |
| 20 | 23 | import org.junit.jupiter.api.Test; |
| 21 | 24 | import org.mockito.ArgumentCaptor; |
| 22 | 25 | import org.mockito.InjectMocks; |
| 23 | 26 | import org.mockito.Mock; |
| 27 | +import org.mockito.MockedStatic; | |
| 24 | 28 | |
| 25 | 29 | import java.sql.Timestamp; |
| 26 | 30 | import java.time.LocalDateTime; |
| 27 | 31 | import java.util.Arrays; |
| 32 | +import java.util.Base64; | |
| 28 | 33 | import java.util.Collections; |
| 29 | 34 | import java.util.HashMap; |
| 30 | 35 | import java.util.List; |
| 31 | 36 | |
| 37 | +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | |
| 38 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; | |
| 32 | 39 | import static org.junit.jupiter.api.Assertions.*; |
| 33 | 40 | import static org.mockito.ArgumentMatchers.any; |
| 41 | +import static org.mockito.ArgumentMatchers.eq; | |
| 34 | 42 | import static org.mockito.ArgumentMatchers.isNull; |
| 35 | 43 | import static org.mockito.ArgumentMatchers.same; |
| 36 | 44 | import static org.mockito.Mockito.*; |
| ... | ... | @@ -40,7 +48,7 @@ import static org.mockito.Mockito.*; |
| 40 | 48 | * |
| 41 | 49 | * 注:时序库没有 embedded 替代,存储 DAO 与依赖 service 走 mock; |
| 42 | 50 | * handleUpstreamDeviceMessage 与 sendDeviceMessage 下行成功路径依赖 SpringUtil.getBean 的自调用 |
| 43 | - * createDeviceLogAsync,更适合放到集成测试,本类不展开。 | |
| 51 | + * createDeviceLogAsync,通过 mockStatic 返回本实例(同步执行)。 | |
| 44 | 52 | * |
| 45 | 53 | * @author 芋道源码 |
| 46 | 54 | */ |
| ... | ... | @@ -59,6 +67,8 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { |
| 59 | 67 | private IotDeviceMessageDAO deviceMessageDAO; |
| 60 | 68 | @Mock |
| 61 | 69 | private IotDeviceMessageProducer deviceMessageProducer; |
| 70 | + @Mock | |
| 71 | + private IotProductService productService; | |
| 62 | 72 | |
| 63 | 73 | // ========== createDeviceLogAsync ========== |
| 64 | 74 | |
| ... | ... | @@ -245,6 +255,208 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { |
| 245 | 255 | assertEquals(1, list.get(0).getDownstreamCount()); |
| 246 | 256 | } |
| 247 | 257 | |
| 258 | + // ========== 透传校验 ========== | |
| 259 | + | |
| 260 | + @Test | |
| 261 | + public void testSendDeviceMessage_rawPostReserved() { | |
| 262 | + // 准备参数:raw.post 只能由网关 raw 入口创建 | |
| 263 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_POST.getMethod()); | |
| 264 | + | |
| 265 | + // 调用,并断言异常 | |
| 266 | + assertServiceException(() -> service.sendDeviceMessage(message, buildDevice(), null), | |
| 267 | + DEVICE_MESSAGE_RAW_METHOD_RESERVED); | |
| 268 | + verifyNoInteractions(deviceMessageProducer); | |
| 269 | + } | |
| 270 | + | |
| 271 | + @Test | |
| 272 | + public void testSendDeviceMessage_rawPushProtocolNotSupported() { | |
| 273 | + // 准备参数 | |
| 274 | + IotDeviceMessage message = buildRawPush("AQID"); | |
| 275 | + // mock 方法:HTTP 产品 | |
| 276 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("http").build()); | |
| 277 | + | |
| 278 | + // 调用,并断言异常 | |
| 279 | + assertServiceException(() -> service.sendDeviceMessage(message, buildDevice(), null), | |
| 280 | + DEVICE_MESSAGE_RAW_PROTOCOL_NOT_SUPPORTED, "http"); | |
| 281 | + verifyNoInteractions(deviceMessageProducer); | |
| 282 | + } | |
| 283 | + | |
| 284 | + @Test | |
| 285 | + public void testSendDeviceMessage_rawPushPayloadInvalid() { | |
| 286 | + // mock 方法:TCP 产品 | |
| 287 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("tcp").build()); | |
| 288 | + | |
| 289 | + // 调用,并断言异常:非法 Base64、空载荷 | |
| 290 | + assertRawPayloadInvalid(buildRawPush("not base64!")); | |
| 291 | + assertRawPayloadInvalid(buildRawPush("")); | |
| 292 | + verifyNoInteractions(deviceMessageProducer); | |
| 293 | + } | |
| 294 | + | |
| 295 | + @Test | |
| 296 | + public void testSendDeviceMessage_rawPushModbusPduInvalid() { | |
| 297 | + // mock 方法:Modbus 产品 | |
| 298 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("modbus_tcp_client").build()); | |
| 299 | + | |
| 300 | + // 调用,并断言异常:功能码 08 不在白名单;03 数量 126 超限 | |
| 301 | + assertRawPayloadInvalid(buildRawPush(Base64.getEncoder().encodeToString(new byte[]{8, 0, 0, 0, 1}))); | |
| 302 | + assertRawPayloadInvalid(buildRawPush(Base64.getEncoder().encodeToString(new byte[]{3, 0, 0, 0, 126}))); | |
| 303 | + verifyNoInteractions(deviceMessageProducer); | |
| 304 | + } | |
| 305 | + | |
| 306 | + @Test | |
| 307 | + public void testGetDeviceMessagePage_validateDevice() { | |
| 308 | + // 准备参数 | |
| 309 | + IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); | |
| 310 | + reqVO.setDeviceId(2L); | |
| 311 | + // mock 方法:设备不属于当前租户 | |
| 312 | + when(deviceService.validateDeviceExists(2L)).thenThrow(new ServiceException(DEVICE_NOT_EXISTS)); | |
| 313 | + | |
| 314 | + // 调用,并断言异常:不查时序库 | |
| 315 | + assertServiceException(() -> service.getDeviceMessagePage(reqVO), DEVICE_NOT_EXISTS); | |
| 316 | + verifyNoInteractions(deviceMessageDAO); | |
| 317 | + } | |
| 318 | + | |
| 319 | + @Test | |
| 320 | + public void testSendDeviceMessage_rawPushRewriteToRaw() { | |
| 321 | + // 准备参数:未带 padding 的 Base64,规范化为标准 Base64 | |
| 322 | + IotDeviceMessage message = buildRawPush("AQI"); | |
| 323 | + // mock 方法:TCP 产品,设备已连接网关 | |
| 324 | + when(productService.getProductFromCache(3L)).thenReturn(IotProductDO.builder().id(3L).protocolType("tcp").build()); | |
| 325 | + when(devicePropertyService.getDeviceServerId(2L)).thenReturn("server-1"); | |
| 326 | + | |
| 327 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 328 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 329 | + // 调用 | |
| 330 | + service.sendDeviceMessage(message, buildDevice(), null); | |
| 331 | + } | |
| 332 | + // 断言:发给网关的消息,载荷在 raw,params 置空 | |
| 333 | + ArgumentCaptor<IotDeviceMessage> messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 334 | + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), messageCaptor.capture()); | |
| 335 | + assertEquals(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), messageCaptor.getValue().getMethod()); | |
| 336 | + assertEquals("AQI=", messageCaptor.getValue().getRaw()); | |
| 337 | + assertNull(messageCaptor.getValue().getParams()); | |
| 338 | + // 断言:消息日志带上 raw | |
| 339 | + ArgumentCaptor<IotDeviceMessageDO> logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); | |
| 340 | + verify(deviceMessageDAO).insert(logCaptor.capture()); | |
| 341 | + assertEquals("AQI=", logCaptor.getValue().getRaw()); | |
| 342 | + assertNull(logCaptor.getValue().getParams()); | |
| 343 | + assertFalse(logCaptor.getValue().getUpstream()); | |
| 344 | + } | |
| 345 | + | |
| 346 | + @Test | |
| 347 | + public void testSendDeviceMessage_clearForgedRaw() { | |
| 348 | + // 准备参数:非 raw.push 的下行消息,携带伪造的 raw | |
| 349 | + HashMap<String, Object> params = new HashMap<>(); | |
| 350 | + params.put("power", 1); | |
| 351 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod()) | |
| 352 | + .setParams(params).setRaw("AQID"); | |
| 353 | + // mock 方法:设备已连接网关 | |
| 354 | + when(devicePropertyService.getDeviceServerId(2L)).thenReturn("server-1"); | |
| 355 | + | |
| 356 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 357 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 358 | + // 调用 | |
| 359 | + service.sendDeviceMessage(message, buildDevice(), null); | |
| 360 | + } | |
| 361 | + // 断言:raw 被清空,params 保持不变 | |
| 362 | + ArgumentCaptor<IotDeviceMessage> messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 363 | + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), messageCaptor.capture()); | |
| 364 | + assertNull(messageCaptor.getValue().getRaw()); | |
| 365 | + assertSame(params, messageCaptor.getValue().getParams()); | |
| 366 | + verifyNoInteractions(productService); | |
| 367 | + } | |
| 368 | + | |
| 369 | + @Test | |
| 370 | + public void testSendDeviceMessage_upstreamClearForgedRaw() { | |
| 371 | + // 准备参数:模拟上报,携带伪造的 raw(否则可绕过标准自动回复) | |
| 372 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()).setRaw("AQID"); | |
| 373 | + | |
| 374 | + // 调用 | |
| 375 | + service.sendDeviceMessage(message, buildDevice(), null); | |
| 376 | + // 断言:raw 被清空后再发到消息总线 | |
| 377 | + verify(deviceMessageProducer).sendDeviceMessage(same(message)); | |
| 378 | + assertNull(message.getRaw()); | |
| 379 | + } | |
| 380 | + | |
| 381 | + // ========== handleUpstreamDeviceMessage ========== | |
| 382 | + | |
| 383 | + @Test | |
| 384 | + public void testHandleUpstreamDeviceMessage_reply() { | |
| 385 | + // 准备参数:设备经标准路径上报的属性(没有 raw) | |
| 386 | + IotDeviceDO device = buildDevice(); | |
| 387 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) | |
| 388 | + .setRequestId("req-1").setServerId("server-1"); | |
| 389 | + | |
| 390 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 391 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 392 | + // 调用 | |
| 393 | + service.handleUpstreamDeviceMessage(message, device); | |
| 394 | + } | |
| 395 | + // 断言:保存属性,并自动回复 | |
| 396 | + verify(devicePropertyService).saveDeviceProperty(same(device), same(message)); | |
| 397 | + ArgumentCaptor<IotDeviceMessage> replyCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class); | |
| 398 | + verify(deviceMessageProducer).sendDeviceMessageToGateway(eq("server-1"), replyCaptor.capture()); | |
| 399 | + assertEquals("req-1", replyCaptor.getValue().getRequestId()); | |
| 400 | + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), replyCaptor.getValue().getMethod()); | |
| 401 | + assertEquals(0, replyCaptor.getValue().getCode()); | |
| 402 | + assertNull(replyCaptor.getValue().getRaw()); | |
| 403 | + } | |
| 404 | + | |
| 405 | + @Test | |
| 406 | + public void testHandleUpstreamDeviceMessage_rawNoReply() { | |
| 407 | + // 准备参数:网关透传解析出的属性(带原始帧) | |
| 408 | + IotDeviceDO device = buildDevice(); | |
| 409 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()) | |
| 410 | + .setRequestId("req-1").setServerId("server-1").setRaw("AQID"); | |
| 411 | + | |
| 412 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 413 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 414 | + // 调用 | |
| 415 | + service.handleUpstreamDeviceMessage(message, device); | |
| 416 | + } | |
| 417 | + // 断言:照常保存属性,日志带上 raw;不自动回复 | |
| 418 | + verify(devicePropertyService).saveDeviceProperty(same(device), same(message)); | |
| 419 | + ArgumentCaptor<IotDeviceMessageDO> logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); | |
| 420 | + verify(deviceMessageDAO).insert(logCaptor.capture()); | |
| 421 | + assertEquals("AQID", logCaptor.getValue().getRaw()); | |
| 422 | + assertTrue(logCaptor.getValue().getUpstream()); | |
| 423 | + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); | |
| 424 | + } | |
| 425 | + | |
| 426 | + @Test | |
| 427 | + public void testHandleUpstreamDeviceMessage_rawPostNoReply() { | |
| 428 | + // 准备参数:网关未解析的透传上报 | |
| 429 | + IotDeviceDO device = buildDevice(); | |
| 430 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_POST.getMethod()) | |
| 431 | + .setParams(null).setServerId("server-1").setRaw("AQID"); | |
| 432 | + | |
| 433 | + try (MockedStatic<SpringUtil> springUtilMock = mockStatic(SpringUtil.class)) { | |
| 434 | + springUtilMock.when(() -> SpringUtil.getBean(IotDeviceMessageServiceImpl.class)).thenReturn(service); | |
| 435 | + // 调用 | |
| 436 | + service.handleUpstreamDeviceMessage(message, device); | |
| 437 | + } | |
| 438 | + // 断言:只记录日志(带 raw),不自动回复 | |
| 439 | + ArgumentCaptor<IotDeviceMessageDO> logCaptor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); | |
| 440 | + verify(deviceMessageDAO).insert(logCaptor.capture()); | |
| 441 | + assertEquals("AQID", logCaptor.getValue().getRaw()); | |
| 442 | + assertNull(logCaptor.getValue().getParams()); | |
| 443 | + verify(deviceMessageProducer, never()).sendDeviceMessageToGateway(any(), any()); | |
| 444 | + } | |
| 445 | + | |
| 446 | + private void assertRawPayloadInvalid(IotDeviceMessage message) { | |
| 447 | + ServiceException ex = assertThrows(ServiceException.class, | |
| 448 | + () -> service.sendDeviceMessage(message, buildDevice(), null)); | |
| 449 | + assertEquals(DEVICE_MESSAGE_RAW_PAYLOAD_INVALID.getCode(), ex.getCode()); | |
| 450 | + } | |
| 451 | + | |
| 452 | + private IotDeviceMessage buildRawPush(String payload) { | |
| 453 | + IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); | |
| 454 | + HashMap<String, Object> params = new HashMap<>(); | |
| 455 | + params.put("payload", payload); | |
| 456 | + message.setParams(params); | |
| 457 | + return message; | |
| 458 | + } | |
| 459 | + | |
| 248 | 460 | // ========== 辅助方法 ========== |
| 249 | 461 | |
| 250 | 462 | /** 构造一条最简消息(指定 method 决定上下行分支) */ |
| ... | ... | @@ -259,7 +471,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { |
| 259 | 471 | |
| 260 | 472 | /** 构造最简设备 */ |
| 261 | 473 | private IotDeviceDO buildDevice() { |
| 262 | - return IotDeviceDO.builder().id(2L).build(); | |
| 474 | + return IotDeviceDO.builder().id(2L).productId(3L).build(); | |
| 263 | 475 | } |
| 264 | 476 | |
| 265 | 477 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImplTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.product; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 3 | 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; |
| 6 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductSaveReqVO; | |
| 7 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugReqVO; | |
| 8 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptDebugRespVO; | |
| 9 | +import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductScriptUpdateReqVO; | |
| 4 | 10 | import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; |
| 5 | 11 | import cn.iocoder.yudao.module.iot.dal.mysql.product.IotProductMapper; |
| 6 | 12 | import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; |
| 7 | 13 | import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; |
| 8 | 14 | import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; |
| 9 | 15 | import org.junit.jupiter.api.Test; |
| 16 | +import org.mockito.ArgumentCaptor; | |
| 10 | 17 | import org.mockito.InOrder; |
| 11 | 18 | import org.mockito.InjectMocks; |
| 12 | 19 | import org.mockito.Mock; |
| 13 | 20 | |
| 14 | 21 | import java.io.Serializable; |
| 22 | +import java.util.Collections; | |
| 23 | +import java.util.HashMap; | |
| 24 | +import java.util.Map; | |
| 15 | 25 | |
| 16 | 26 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 17 | 27 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; |
| 28 | +import static org.junit.jupiter.api.Assertions.assertEquals; | |
| 29 | +import static org.junit.jupiter.api.Assertions.assertFalse; | |
| 30 | +import static org.junit.jupiter.api.Assertions.assertNotNull; | |
| 31 | +import static org.junit.jupiter.api.Assertions.assertNull; | |
| 18 | 32 | import static org.junit.jupiter.api.Assertions.assertSame; |
| 19 | 33 | import static org.junit.jupiter.api.Assertions.assertThrows; |
| 34 | +import static org.junit.jupiter.api.Assertions.assertTrue; | |
| 20 | 35 | import static org.mockito.ArgumentMatchers.any; |
| 21 | 36 | import static org.mockito.Mockito.*; |
| 22 | 37 | |
| ... | ... | @@ -127,6 +142,231 @@ public class IotProductServiceImplTest extends BaseMockitoUnitTest { |
| 127 | 142 | verify(productMapper, times(2)).deleteById(id); |
| 128 | 143 | } |
| 129 | 144 | |
| 145 | + @Test | |
| 146 | + public void testUpdateProduct_notOverwriteScript() { | |
| 147 | + // 准备参数:普通产品表单不含脚本字段 | |
| 148 | + IotProductSaveReqVO reqVO = new IotProductSaveReqVO(); | |
| 149 | + reqVO.setId(1L); | |
| 150 | + reqVO.setName("温湿度"); | |
| 151 | + // mock 方法 | |
| 152 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, IotProductStatusEnum.UNPUBLISHED.getStatus())); | |
| 153 | + | |
| 154 | + // 调用 | |
| 155 | + productService.updateProduct(reqVO); | |
| 156 | + // 断言:脚本两列为 null,按默认 NOT_NULL 策略不会写回 | |
| 157 | + IotProductDO updateObj = captureUpdate(); | |
| 158 | + assertEquals("温湿度", updateObj.getName()); | |
| 159 | + assertNull(updateObj.getScriptContent()); | |
| 160 | + assertNull(updateObj.getScriptEnabled()); | |
| 161 | + } | |
| 162 | + | |
| 163 | + @Test | |
| 164 | + public void testUpdateProductScript_contentOnly() { | |
| 165 | + // 准备参数 | |
| 166 | + productService.initDebugScriptExecutor(); | |
| 167 | + try { | |
| 168 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 169 | + reqVO.setId(1L); | |
| 170 | + reqVO.setScriptContent("function decode(ctx) { return {method: 'thing.property.post', params: {}}; }"); | |
| 171 | + // mock 方法:已启用旧脚本 | |
| 172 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptEnabled(true).setScriptContent("old")); | |
| 173 | + | |
| 174 | + // 调用 | |
| 175 | + productService.updateProductScript(reqVO); | |
| 176 | + // 断言:只写内容,不顺带改启用状态 | |
| 177 | + IotProductDO updateObj = captureUpdate(); | |
| 178 | + assertEquals(reqVO.getScriptContent(), updateObj.getScriptContent()); | |
| 179 | + assertNull(updateObj.getScriptEnabled()); | |
| 180 | + assertNull(updateObj.getName()); | |
| 181 | + } finally { | |
| 182 | + productService.destroyDebugScriptExecutor(); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + @Test | |
| 187 | + public void testUpdateProductScript_enableOnly() { | |
| 188 | + // 准备参数 | |
| 189 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 190 | + reqVO.setId(1L); | |
| 191 | + reqVO.setScriptEnabled(true); | |
| 192 | + // mock 方法:库里已有内容 | |
| 193 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptContent("function decode(ctx) {}")); | |
| 194 | + | |
| 195 | + // 调用 | |
| 196 | + productService.updateProductScript(reqVO); | |
| 197 | + // 断言:只写启用状态 | |
| 198 | + IotProductDO updateObj = captureUpdate(); | |
| 199 | + assertEquals(true, updateObj.getScriptEnabled()); | |
| 200 | + assertNull(updateObj.getScriptContent()); | |
| 201 | + } | |
| 202 | + | |
| 203 | + @Test | |
| 204 | + public void testUpdateProductScript_enableWithEmptyContent() { | |
| 205 | + // 准备参数 | |
| 206 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 207 | + reqVO.setId(1L); | |
| 208 | + reqVO.setScriptEnabled(true); | |
| 209 | + // mock 方法:库里没有内容 | |
| 210 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); | |
| 211 | + | |
| 212 | + // 调用,并断言异常 | |
| 213 | + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_EMPTY); | |
| 214 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 215 | + } | |
| 216 | + | |
| 217 | + @Test | |
| 218 | + public void testUpdateProductScript_clearWhileEnabled() { | |
| 219 | + // 准备参数:启用状态下清空内容 | |
| 220 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 221 | + reqVO.setId(1L); | |
| 222 | + reqVO.setScriptContent(""); | |
| 223 | + // mock 方法 | |
| 224 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setScriptEnabled(true).setScriptContent("x")); | |
| 225 | + | |
| 226 | + // 调用,并断言异常 | |
| 227 | + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_EMPTY); | |
| 228 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 229 | + } | |
| 230 | + | |
| 231 | + @Test | |
| 232 | + public void testUpdateProductScript_tooLong() { | |
| 233 | + // 准备参数:超过 32KB(UTF-8) | |
| 234 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 235 | + reqVO.setId(1L); | |
| 236 | + reqVO.setScriptContent(StrUtil.repeat('a', 32 * 1024 + 1)); | |
| 237 | + // mock 方法 | |
| 238 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); | |
| 239 | + | |
| 240 | + // 调用,并断言异常 | |
| 241 | + assertServiceException(() -> productService.updateProductScript(reqVO), PRODUCT_SCRIPT_TOO_LONG, 32 * 1024); | |
| 242 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 243 | + } | |
| 244 | + | |
| 245 | + @Test | |
| 246 | + public void testUpdateProductScript_compileFailed() { | |
| 247 | + // 准备参数 | |
| 248 | + productService.initDebugScriptExecutor(); | |
| 249 | + try { | |
| 250 | + IotProductScriptUpdateReqVO reqVO = new IotProductScriptUpdateReqVO(); | |
| 251 | + reqVO.setId(1L); | |
| 252 | + reqVO.setScriptContent("function decode(ctx) {\n return {;\n}"); | |
| 253 | + // mock 方法 | |
| 254 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null)); | |
| 255 | + | |
| 256 | + // 调用,并断言异常:只编译、不执行,语法错误带行号 | |
| 257 | + ServiceException ex = assertThrows(ServiceException.class, () -> productService.updateProductScript(reqVO)); | |
| 258 | + assertEquals(PRODUCT_SCRIPT_COMPILE_FAILED.getCode(), ex.getCode()); | |
| 259 | + verify(productMapper, never()).updateById(any(IotProductDO.class)); | |
| 260 | + } finally { | |
| 261 | + productService.destroyDebugScriptExecutor(); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + @Test | |
| 266 | + public void testDebugProductScript_decodeMessage() { | |
| 267 | + // 准备参数:decode 返回一个标准消息对象 | |
| 268 | + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO( | |
| 269 | + "function decode(ctx) { return {method: 'thing.property.post', params: {temperature: ctx.bytes[0]}}; }"); | |
| 270 | + | |
| 271 | + // 调用 | |
| 272 | + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); | |
| 273 | + // 断言:单条消息 | |
| 274 | + assertTrue(respVO.getSuccess()); | |
| 275 | + assertNull(respVO.getError()); | |
| 276 | + assertNotNull(respVO.getMessage()); | |
| 277 | + assertEquals("thing.property.post", respVO.getMessage().get("method")); | |
| 278 | + assertEquals(25, ((Map<?, ?>) respVO.getMessage().get("params")).get("temperature")); | |
| 279 | + } | |
| 280 | + | |
| 281 | + @Test | |
| 282 | + public void testDebugProductScript_decodeNull() { | |
| 283 | + // 准备参数:decode 返回 null,表示没有业务消息 | |
| 284 | + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO("function decode(ctx) { return null; }"); | |
| 285 | + | |
| 286 | + // 调用 | |
| 287 | + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); | |
| 288 | + // 断言:成功,没有消息 | |
| 289 | + assertTrue(respVO.getSuccess()); | |
| 290 | + assertNull(respVO.getError()); | |
| 291 | + assertNull(respVO.getMessage()); | |
| 292 | + } | |
| 293 | + | |
| 294 | + @Test | |
| 295 | + public void testDebugProductScript_decodeOutputInvalid() { | |
| 296 | + // 准备参数:旧的 messages 包装,不再支持 | |
| 297 | + IotProductScriptDebugReqVO reqVO = buildDecodeDebugReqVO( | |
| 298 | + "function decode(ctx) { return {messages: [{method: 'thing.property.post', params: {}}]}; }"); | |
| 299 | + | |
| 300 | + // 调用 | |
| 301 | + IotProductScriptDebugRespVO respVO = debugProductScript(reqVO); | |
| 302 | + // 断言:输出不合法 | |
| 303 | + assertFalse(respVO.getSuccess()); | |
| 304 | + assertNull(respVO.getMessage()); | |
| 305 | + assertEquals("OUTPUT_INVALID", respVO.getError().getType()); | |
| 306 | + } | |
| 307 | + | |
| 308 | + @Test | |
| 309 | + public void testDebugProductScript_encodeModbusPduInvalid() { | |
| 310 | + // 准备参数:Modbus 产品,encode 输出的字节不是合法的请求 PDU(缺数量);合法的 06 写单个寄存器 | |
| 311 | + IotProductScriptDebugReqVO invalidReqVO = buildEncodeDebugReqVO("function encode(ctx) { return {bytes: [6, 0, 16]}; }"); | |
| 312 | + IotProductScriptDebugReqVO validReqVO = buildEncodeDebugReqVO( | |
| 313 | + "function encode(ctx) { return {bytes: [6, 0, 16, 0, ctx.message.params.power]}; }"); | |
| 314 | + | |
| 315 | + // 调用 | |
| 316 | + IotProductScriptDebugRespVO invalidRespVO = debugProductScript(invalidReqVO, "modbus_tcp_client"); | |
| 317 | + IotProductScriptDebugRespVO validRespVO = debugProductScript(validReqVO, "modbus_tcp_client"); | |
| 318 | + // 断言:与网关一致,按产品协议的字节规则判定输出不合法(带原规则的错误信息);合法的 PDU 照常输出 | |
| 319 | + assertFalse(invalidRespVO.getSuccess()); | |
| 320 | + assertEquals("OUTPUT_INVALID", invalidRespVO.getError().getType()); | |
| 321 | + assertEquals("PDU 长度必须在 5~253 字节之间", invalidRespVO.getError().getMessage()); | |
| 322 | + assertTrue(validRespVO.getSuccess()); | |
| 323 | + assertEquals("0600100001", validRespVO.getOutputHex()); | |
| 324 | + } | |
| 325 | + | |
| 326 | + private IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO reqVO) { | |
| 327 | + return debugProductScript(reqVO, "tcp"); | |
| 328 | + } | |
| 329 | + | |
| 330 | + /** | |
| 331 | + * 执行调试:真实的调试池;mock 产品 | |
| 332 | + */ | |
| 333 | + private IotProductScriptDebugRespVO debugProductScript(IotProductScriptDebugReqVO reqVO, String protocolType) { | |
| 334 | + when(productMapper.selectById(1L)).thenReturn(buildProduct(1L, null).setProtocolType(protocolType).setProductKey("pk")); | |
| 335 | + productService.initDebugScriptExecutor(); | |
| 336 | + try { | |
| 337 | + return productService.debugProductScript(reqVO); | |
| 338 | + } finally { | |
| 339 | + productService.destroyDebugScriptExecutor(); | |
| 340 | + } | |
| 341 | + } | |
| 342 | + | |
| 343 | + private static IotProductScriptDebugReqVO buildDecodeDebugReqVO(String scriptContent) { | |
| 344 | + IotProductScriptDebugReqVO reqVO = new IotProductScriptDebugReqVO(); | |
| 345 | + reqVO.setId(1L); | |
| 346 | + reqVO.setScriptContent(scriptContent); | |
| 347 | + reqVO.setDirection("decode"); | |
| 348 | + reqVO.setInputHex("19 01"); | |
| 349 | + return reqVO; | |
| 350 | + } | |
| 351 | + | |
| 352 | + private static IotProductScriptDebugReqVO buildEncodeDebugReqVO(String scriptContent) { | |
| 353 | + IotProductScriptDebugReqVO reqVO = new IotProductScriptDebugReqVO(); | |
| 354 | + reqVO.setId(1L); | |
| 355 | + reqVO.setScriptContent(scriptContent); | |
| 356 | + reqVO.setDirection("encode"); | |
| 357 | + Map<String, Object> message = new HashMap<>(); | |
| 358 | + message.put("method", "thing.property.set"); | |
| 359 | + message.put("params", Collections.singletonMap("power", 1)); | |
| 360 | + reqVO.setMessage(message); | |
| 361 | + return reqVO; | |
| 362 | + } | |
| 363 | + | |
| 364 | + private IotProductDO captureUpdate() { | |
| 365 | + ArgumentCaptor<IotProductDO> captor = ArgumentCaptor.forClass(IotProductDO.class); | |
| 366 | + verify(productMapper).updateById(captor.capture()); | |
| 367 | + return captor.getValue(); | |
| 368 | + } | |
| 369 | + | |
| 130 | 370 | private void assertNoDelete() { |
| 131 | 371 | verifyNoInteractions(devicePropertyDataService); |
| 132 | 372 | verify(productMapper, never()).deleteById(any(Serializable.class)); | ... | ... |
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" ( |
| 24 | 24 | "pic_url" varchar(255) DEFAULT NULL, |
| 25 | 25 | "protocol_type" varchar(100) DEFAULT NULL, |
| 26 | 26 | "serialize_type" varchar(100) DEFAULT NULL, |
| 27 | + "script_content" text DEFAULT NULL, | |
| 28 | + "script_enabled" bit NOT NULL DEFAULT FALSE, | |
| 27 | 29 | "category_id" bigint DEFAULT NULL, |
| 28 | 30 | "description" varchar(500) DEFAULT NULL, |
| 29 | 31 | "data_format" tinyint NOT NULL DEFAULT '0', | ... | ... |
yudao-module-iot/yudao-module-iot-core/pom.xml
| ... | ... | @@ -17,6 +17,7 @@ |
| 17 | 17 | iot 模块下,提供 iot-biz 和 iot-gateway 模块的核心功能。例如说: |
| 18 | 18 | 1. 消息总线:跨 iot-biz 和 iot-gateway 的设备消息。可选择使用 spring event、redis stream、rocketmq、kafka、rabbitmq 等。 |
| 19 | 19 | 2. 查询设备信息的通用 API |
| 20 | + 3. 数据解析脚本:执行产品配置的 JS 脚本,供 iot-biz 和 iot-gateway 共用 | |
| 20 | 21 | </description> |
| 21 | 22 | |
| 22 | 23 | <dependencies> |
| ... | ... | @@ -61,6 +62,12 @@ |
| 61 | 62 | <optional>true</optional> |
| 62 | 63 | </dependency> |
| 63 | 64 | |
| 65 | + <!-- JS 脚本引擎 --> | |
| 66 | + <dependency> | |
| 67 | + <groupId>org.mozilla</groupId> | |
| 68 | + <artifactId>rhino</artifactId> | |
| 69 | + </dependency> | |
| 70 | + | |
| 64 | 71 | <!-- 测试依赖 --> |
| 65 | 72 | <dependency> |
| 66 | 73 | <groupId>org.springframework.boot</groupId> | ... | ... |
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 { |
| 41 | 41 | * 序列化类型 |
| 42 | 42 | */ |
| 43 | 43 | private String serializeType; |
| 44 | + /** | |
| 45 | + * 是否启用数据解析脚本 | |
| 46 | + */ | |
| 47 | + private Boolean scriptEnabled; | |
| 48 | + /** | |
| 49 | + * 数据解析脚本(JS ES5):只在启用时返回 | |
| 50 | + */ | |
| 51 | + private String scriptContent; | |
| 44 | 52 | |
| 45 | 53 | } |
| 46 | 54 | \ No newline at end of file | ... | ... |
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<String> { |
| 71 | 71 | |
| 72 | 72 | SHADOW_UPDATE("thing.shadow.update", "设备影子更新", true), |
| 73 | 73 | |
| 74 | + // ========== 透传 ========== | |
| 75 | + | |
| 76 | + RAW_POST("thing.raw.post", "透传上报", true), // 只能由网关的 raw 入口创建 | |
| 77 | + RAW_PUSH("thing.raw.push", "透传下发", false), | |
| 78 | + | |
| 74 | 79 | ; |
| 75 | 80 | |
| 76 | 81 | public static final String[] ARRAYS = Arrays.stream(values()).map(IotDeviceMessageMethodEnum::getMethod) |
| ... | ... | @@ -82,7 +87,8 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable<String> { |
| 82 | 87 | public static final Set<String> REPLY_DISABLED = SetUtils.asSet( |
| 83 | 88 | STATE_UPDATE.getMethod(), |
| 84 | 89 | OTA_PROGRESS.getMethod(), // 参考阿里云,OTA 升级进度上报,不进行回复 |
| 85 | - SHADOW_UPDATE.getMethod() // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) | |
| 90 | + SHADOW_UPDATE.getMethod(), // 设备影子由影子 Service 主动下发回复(thing/shadow/update_reply) | |
| 91 | + RAW_POST.getMethod() // 透传原始消息(未解析为业务消息)只记日志 | |
| 86 | 92 | ); |
| 87 | 93 | |
| 88 | 94 | private final String method; | ... | ... |
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<String> { |
| 44 | 44 | return ArrayUtil.firstMatch(e -> e.getType().equals(type), values()); |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | + /** | |
| 48 | + * @return 是否是 Modbus 协议(client、server):透传字节按请求 PDU 校验 | |
| 49 | + */ | |
| 50 | + public boolean isModbus() { | |
| 51 | + return this == MODBUS_TCP_CLIENT || this == MODBUS_TCP_SERVER; | |
| 52 | + } | |
| 53 | + | |
| 47 | 54 | } | ... | ... |
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 { |
| 94 | 94 | */ |
| 95 | 95 | private String msg; |
| 96 | 96 | |
| 97 | + // ========== 透传(raw)相关字段 ========== | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * 与设备之间实际收发的原始字节(Base64) | |
| 101 | + * | |
| 102 | + * 只由服务端可信赋值:网关的透传上行(解析成功的业务消息、或未解析的 thing.raw.post)、Modbus 设备的实际响应, | |
| 103 | + * 以及 biz 下发的 thing.raw.push;设备经标准路径上报的消息,网关会清空该字段 | |
| 104 | + */ | |
| 105 | + private String raw; | |
| 106 | + | |
| 97 | 107 | // ========== 基础方法:只传递"serialize(序列化)相关字段" ========== |
| 98 | 108 | |
| 99 | 109 | public static IotDeviceMessage requestOf(String method) { | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptException.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 4 | +import lombok.Getter; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 数据解析脚本异常:脚本执行错误({@link IotScriptResult.Status#ERROR}),或输出不合法({@link IotScriptResult.Status#OUTPUT_INVALID}) | |
| 8 | + * | |
| 9 | + * @author 芋道源码 | |
| 10 | + */ | |
| 11 | +@Getter | |
| 12 | +public class IotScriptException extends Exception { | |
| 13 | + | |
| 14 | + private final IotScriptResult.Status status; | |
| 15 | + /** | |
| 16 | + * 出错行号;未知时为 -1 | |
| 17 | + */ | |
| 18 | + private final int lineNumber; | |
| 19 | + | |
| 20 | + public IotScriptException(IotScriptResult.Status status, String message, int lineNumber) { | |
| 21 | + super(message, null, false, false); | |
| 22 | + this.status = status; | |
| 23 | + this.lineNumber = lineNumber; | |
| 24 | + } | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * 创建输出不合法的异常 | |
| 28 | + * | |
| 29 | + * @param message 错误信息 | |
| 30 | + * @return 异常 | |
| 31 | + */ | |
| 32 | + public static IotScriptException outputInvalid(String message) { | |
| 33 | + return new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, message, -1); | |
| 34 | + } | |
| 35 | + | |
| 36 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptExecutor.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | + | |
| 6 | +import java.util.concurrent.TimeUnit; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * IoT 数据解析脚本执行器:网关的 decode / encode,biz 的保存语法检查、调试共用 | |
| 10 | + * <p> | |
| 11 | + * 目前只有 Rhino 实现 {@link cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor},由使用方直接创建 | |
| 12 | + * | |
| 13 | + * @author 芋道源码 | |
| 14 | + */ | |
| 15 | +public interface IotScriptExecutor { | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * 执行一次脚本调用:调用方线程阻塞等待,最长到截止时间 | |
| 19 | + * | |
| 20 | + * @param source 脚本 | |
| 21 | + * @param function "decode" 或 "encode" | |
| 22 | + * @param ctxJson 平台构造的入参 JSON | |
| 23 | + * @param deadlineNanos 截止时间(System.nanoTime):覆盖排队与执行 | |
| 24 | + * @return 结果,状态见 {@link IotScriptResult.Status} | |
| 25 | + */ | |
| 26 | + IotScriptResult execute(IotScriptSource source, String function, String ctxJson, long deadlineNanos); | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * 只做语法检查(不执行任何用户代码):调用方线程有界等待 | |
| 30 | + * | |
| 31 | + * @param content 脚本内容 | |
| 32 | + * @param timeout 等待时间 | |
| 33 | + * @param unit 时间单位 | |
| 34 | + * @return 结果:SUCCESS / ERROR(带行号)/ REJECTED / TIMEOUT | |
| 35 | + */ | |
| 36 | + IotScriptResult compileOnly(String content, long timeout, TimeUnit unit); | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * 预热:启动时、接收流量之前调用一次 | |
| 40 | + */ | |
| 41 | + void warmUp(); | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * 停止:不再接收新任务 | |
| 45 | + */ | |
| 46 | + void shutdown(); | |
| 47 | + | |
| 48 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtils.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.HexUtil; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 11 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 12 | +import lombok.AccessLevel; | |
| 13 | +import lombok.AllArgsConstructor; | |
| 14 | +import lombok.Getter; | |
| 15 | +import lombok.NoArgsConstructor; | |
| 16 | + | |
| 17 | +import java.nio.charset.StandardCharsets; | |
| 18 | +import java.util.Arrays; | |
| 19 | +import java.util.HashSet; | |
| 20 | +import java.util.Iterator; | |
| 21 | +import java.util.LinkedHashMap; | |
| 22 | +import java.util.Map; | |
| 23 | +import java.util.Objects; | |
| 24 | +import java.util.Set; | |
| 25 | +import java.util.stream.IntStream; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * IoT 数据解析脚本的契约:入参构造与输出校验 | |
| 29 | + * <p> | |
| 30 | + * 网关所有协议和 biz 调试共用同一份规则,结果一致。任何一项不合法就整体拒绝({@link IotScriptResult.Status#OUTPUT_INVALID}),不截断、不部分写出 | |
| 31 | + * | |
| 32 | + * @author 芋道源码 | |
| 33 | + */ | |
| 34 | +@NoArgsConstructor(access = AccessLevel.PRIVATE) | |
| 35 | +public final class IotScriptUtils { | |
| 36 | + | |
| 37 | + public static final String FUNCTION_DECODE = "decode"; | |
| 38 | + public static final String FUNCTION_ENCODE = "encode"; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 消息 params / data 序列化后的最大 UTF-8 字节数 | |
| 42 | + */ | |
| 43 | + public static final int MAX_FIELD_BYTES = 8192; | |
| 44 | + public static final int MAX_REQUEST_ID_LENGTH = 50; | |
| 45 | + public static final int MAX_IDENTIFIER_LENGTH = 100; | |
| 46 | + public static final int MAX_MSG_LENGTH = 256; | |
| 47 | + | |
| 48 | + private static final Set<String> TOP_ENCODE_KEYS = new HashSet<>(Arrays.asList("bytes", "standard")); | |
| 49 | + /** | |
| 50 | + * decode 输出的消息允许的键:身份、路由与可信字段(deviceId、tenantId、serverId、productKey、deviceName、id、reportTime、raw) | |
| 51 | + * 和其它未知键都不允许 | |
| 52 | + */ | |
| 53 | + private static final Set<String> MESSAGE_KEYS = new HashSet<>(Arrays.asList( | |
| 54 | + "method", "params", "requestId", "code", "msg", "data")); | |
| 55 | + /** | |
| 56 | + * decode 允许的请求方法 | |
| 57 | + */ | |
| 58 | + private static final Set<String> DECODE_REQUEST_METHODS = new HashSet<>(Arrays.asList( | |
| 59 | + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), IotDeviceMessageMethodEnum.EVENT_POST.getMethod())); | |
| 60 | + | |
| 61 | + // ========== 入参构造 ========== | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * 构造 decode 的入参 JSON | |
| 65 | + * | |
| 66 | + * @param protocol 协议类型 | |
| 67 | + * @param productKey 产品标识 | |
| 68 | + * @param deviceName 设备名称 | |
| 69 | + * @param receivedAt 接收时间(毫秒时间戳) | |
| 70 | + * @param bytes 原始字节 | |
| 71 | + * @param request encode 下行的原请求(协议回复设备的实际响应时);普通上行为 null | |
| 72 | + * @return JSON | |
| 73 | + */ | |
| 74 | + public static String buildDecodeContext(String protocol, String productKey, String deviceName, | |
| 75 | + long receivedAt, byte[] bytes, IotDeviceMessage request) { | |
| 76 | + Map<String, Object> ctx = new LinkedHashMap<>(); | |
| 77 | + ctx.put("direction", FUNCTION_DECODE); | |
| 78 | + ctx.put("protocol", protocol); | |
| 79 | + ctx.put("productKey", productKey); | |
| 80 | + ctx.put("deviceName", deviceName); | |
| 81 | + ctx.put("receivedAt", receivedAt); | |
| 82 | + ctx.put("bytes", toUnsignedArray(bytes)); | |
| 83 | + ctx.put("hex", HexUtil.encodeHexStr(bytes, false)); | |
| 84 | + if (request != null) { | |
| 85 | + ctx.put("request", buildMessageContext(request)); | |
| 86 | + } | |
| 87 | + return JsonUtils.toJsonString(ctx); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * 构造 encode 的入参 JSON | |
| 92 | + * | |
| 93 | + * @param protocol 协议类型 | |
| 94 | + * @param productKey 产品标识 | |
| 95 | + * @param deviceName 设备名称 | |
| 96 | + * @param message 下行指令(property.set / service.invoke) | |
| 97 | + * @return JSON | |
| 98 | + */ | |
| 99 | + public static String buildEncodeContext(String protocol, String productKey, String deviceName, | |
| 100 | + IotDeviceMessage message) { | |
| 101 | + Map<String, Object> ctx = new LinkedHashMap<>(); | |
| 102 | + ctx.put("direction", FUNCTION_ENCODE); | |
| 103 | + ctx.put("protocol", protocol); | |
| 104 | + ctx.put("productKey", productKey); | |
| 105 | + ctx.put("deviceName", deviceName); | |
| 106 | + ctx.put("message", buildMessageContext(message)); | |
| 107 | + return JsonUtils.toJsonString(ctx); | |
| 108 | + } | |
| 109 | + | |
| 110 | + private static Map<String, Object> buildMessageContext(IotDeviceMessage message) { | |
| 111 | + Map<String, Object> map = new LinkedHashMap<>(); | |
| 112 | + map.put("method", message.getMethod()); | |
| 113 | + map.put("requestId", message.getRequestId()); | |
| 114 | + map.put("params", message.getParams()); | |
| 115 | + return map; | |
| 116 | + } | |
| 117 | + | |
| 118 | + private static int[] toUnsignedArray(byte[] bytes) { | |
| 119 | + return IntStream.range(0, bytes.length).map(i -> bytes[i] & 0xFF).toArray(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + // ========== 输出校验 ========== | |
| 123 | + | |
| 124 | + /** | |
| 125 | + * 校验 decode 输出:一个标准消息对象(method、params、requestId、code、msg、data),与阿里云 rawDataToProtocol 一致 | |
| 126 | + * | |
| 127 | + * @param json 脚本输出;null 表示没有定义 decode,或返回了 null / undefined,即没有业务消息 | |
| 128 | + * @param request encode 下行的原请求(协议回复设备的实际响应时):此时必须返回回执,方法和 requestId 必须与原请求一致;普通上行为 null | |
| 129 | + * @return 业务消息(编号、时间、身份、raw 由调用方可信设置);null 表示没有业务消息(只有普通上行允许) | |
| 130 | + * @throws IotScriptException 输出不合法 | |
| 131 | + */ | |
| 132 | + public static IotDeviceMessage validateDecode(String json, IotDeviceMessage request) throws IotScriptException { | |
| 133 | + // 1. 没有业务消息:普通上行允许(只记录原始消息);事务响应必须有回执 | |
| 134 | + if (json == null) { | |
| 135 | + if (request != null) { | |
| 136 | + throw IotScriptException.outputInvalid("事务响应必须返回回执"); | |
| 137 | + } | |
| 138 | + return null; | |
| 139 | + } | |
| 140 | + // 2. 校验消息 | |
| 141 | + IotDeviceMessage message = validateMessage(parseObject(json, MESSAGE_KEYS)); | |
| 142 | + if (request != null) { | |
| 143 | + validateTransactionReply(message, request); | |
| 144 | + } | |
| 145 | + return message; | |
| 146 | + } | |
| 147 | + | |
| 148 | + /** | |
| 149 | + * 校验 encode 输出:脚本输出的结构,以及透传字节是否符合协议的规则({@link IotDeviceMessageUtils#validateRawPayload},Modbus 必须是合法的请求 PDU) | |
| 150 | + * <p> | |
| 151 | + * 网关的 encode、biz 的脚本调试共用;raw.push 不经过 encode,由调用方自己校验 | |
| 152 | + * | |
| 153 | + * @param json 脚本输出;null 表示没有定义 encode,或返回了 null / undefined | |
| 154 | + * @param protocol 协议类型:透传字节按它的规则校验 | |
| 155 | + * @return null 表示没有生成下行数据(调用方只记日志,不写设备) | |
| 156 | + * @throws IotScriptException 输出不合法 | |
| 157 | + */ | |
| 158 | + public static EncodeOutput validateEncode(String json, IotProtocolTypeEnum protocol) throws IotScriptException { | |
| 159 | + // 1. 没有生成下行数据 | |
| 160 | + if (json == null) { | |
| 161 | + return null; | |
| 162 | + } | |
| 163 | + // 2. 解析对象:只允许 bytes、standard,且只能返回一个 | |
| 164 | + JsonNode root = parseObject(json, TOP_ENCODE_KEYS); | |
| 165 | + JsonNode standard = root.get("standard"); | |
| 166 | + JsonNode bytesNode = root.get("bytes"); | |
| 167 | + if (standard != null && bytesNode != null) { | |
| 168 | + throw IotScriptException.outputInvalid("bytes 和 standard 只能返回一个"); | |
| 169 | + } | |
| 170 | + // 3.1 走现有的标准路径 | |
| 171 | + if (standard != null) { | |
| 172 | + if (!standard.isBoolean() || !standard.booleanValue()) { | |
| 173 | + throw IotScriptException.outputInvalid("standard 只能是 true"); | |
| 174 | + } | |
| 175 | + return new EncodeOutput(true, null); | |
| 176 | + } | |
| 177 | + // 3.2 透传字节:按协议的规则校验 | |
| 178 | + byte[] bytes = parseBytes(bytesNode, "bytes"); | |
| 179 | + try { | |
| 180 | + IotDeviceMessageUtils.validateRawPayload(protocol, bytes); | |
| 181 | + } catch (IllegalArgumentException e) { | |
| 182 | + throw IotScriptException.outputInvalid(e.getMessage()); | |
| 183 | + } | |
| 184 | + return new EncodeOutput(false, bytes); | |
| 185 | + } | |
| 186 | + | |
| 187 | + private static JsonNode parseObject(String json, Set<String> allowedKeys) throws IotScriptException { | |
| 188 | + // 1. 解析 JSON | |
| 189 | + JsonNode root; | |
| 190 | + try { | |
| 191 | + root = JsonUtils.parseTree(json); | |
| 192 | + } catch (RuntimeException e) { | |
| 193 | + throw IotScriptException.outputInvalid("脚本输出不是合法的 JSON"); | |
| 194 | + } | |
| 195 | + // 2. 必须是对象,且只包含允许的键 | |
| 196 | + if (root == null || !root.isObject()) { | |
| 197 | + throw IotScriptException.outputInvalid("脚本输出必须是对象"); | |
| 198 | + } | |
| 199 | + validateKeys(root, allowedKeys, "脚本输出"); | |
| 200 | + return root; | |
| 201 | + } | |
| 202 | + | |
| 203 | + private static void validateKeys(JsonNode node, Set<String> allowedKeys, String name) throws IotScriptException { | |
| 204 | + Iterator<String> it = node.fieldNames(); | |
| 205 | + while (it.hasNext()) { | |
| 206 | + String key = it.next(); | |
| 207 | + if (!allowedKeys.contains(key)) { | |
| 208 | + throw IotScriptException.outputInvalid(name + " 不允许包含字段 " + StrUtil.maxLength(key, 50)); | |
| 209 | + } | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + private static IotDeviceMessage validateMessage(JsonNode node) throws IotScriptException { | |
| 214 | + // 1.1 method 必填 | |
| 215 | + String name = "message"; | |
| 216 | + String method = textOrNull(node, "method", name); | |
| 217 | + if (StrUtil.isEmpty(method)) { | |
| 218 | + throw IotScriptException.outputInvalid(name + ".method 不能为空"); | |
| 219 | + } | |
| 220 | + // 1.2 requestId、msg 的长度 | |
| 221 | + String requestId = textOrNull(node, "requestId", name); | |
| 222 | + if (requestId != null && requestId.length() > MAX_REQUEST_ID_LENGTH) { | |
| 223 | + throw IotScriptException.outputInvalid(name + ".requestId 不能超过 " + MAX_REQUEST_ID_LENGTH + " 个字符"); | |
| 224 | + } | |
| 225 | + String msg = textOrNull(node, "msg", name); | |
| 226 | + if (msg != null && msg.length() > MAX_MSG_LENGTH) { | |
| 227 | + throw IotScriptException.outputInvalid(name + ".msg 不能超过 " + MAX_MSG_LENGTH + " 个字符"); | |
| 228 | + } | |
| 229 | + // 1.3 params、data 序列化后的大小 | |
| 230 | + JsonNode params = nullIfJsonNull(node.get("params")); | |
| 231 | + JsonNode data = nullIfJsonNull(node.get("data")); | |
| 232 | + validateFieldSize(params, name + ".params"); | |
| 233 | + validateFieldSize(data, name + ".data"); | |
| 234 | + // 1.4 code:有值表示回执 | |
| 235 | + JsonNode codeNode = nullIfJsonNull(node.get("code")); | |
| 236 | + Integer code = null; | |
| 237 | + if (codeNode != null) { | |
| 238 | + if (!codeNode.isIntegralNumber() || !codeNode.canConvertToInt()) { | |
| 239 | + throw IotScriptException.outputInvalid(name + ".code 必须是整数"); | |
| 240 | + } | |
| 241 | + code = codeNode.intValue(); | |
| 242 | + } | |
| 243 | + // 2. 按请求、回执分别校验方法 | |
| 244 | + if (code == null) { | |
| 245 | + // 2.1 请求:属性上报、事件上报;事件必须带 identifier | |
| 246 | + if (!DECODE_REQUEST_METHODS.contains(method)) { | |
| 247 | + throw IotScriptException.outputInvalid(name + ".method 不支持 " + StrUtil.maxLength(method, 50)); | |
| 248 | + } | |
| 249 | + if (params == null || !params.isObject()) { | |
| 250 | + throw IotScriptException.outputInvalid(name + ".params 必须是对象"); | |
| 251 | + } | |
| 252 | + if (IotDeviceMessageMethodEnum.EVENT_POST.getMethod().equals(method)) { | |
| 253 | + JsonNode identifier = params.get("identifier"); | |
| 254 | + if (identifier == null || !identifier.isTextual() || StrUtil.isEmpty(identifier.textValue()) | |
| 255 | + || identifier.textValue().length() > MAX_IDENTIFIER_LENGTH) { | |
| 256 | + throw IotScriptException.outputInvalid(name + ".params.identifier 必须是 1~" + MAX_IDENTIFIER_LENGTH + " 个字符"); | |
| 257 | + } | |
| 258 | + } | |
| 259 | + } else { | |
| 260 | + // 2.2 回执:属性设置、服务调用;requestId 不能为空 | |
| 261 | + if (!IotDeviceMessageUtils.isCommandMethod(method)) { | |
| 262 | + throw IotScriptException.outputInvalid(name + ".method 不支持回执 " + StrUtil.maxLength(method, 50)); | |
| 263 | + } | |
| 264 | + if (StrUtil.isEmpty(requestId)) { | |
| 265 | + throw IotScriptException.outputInvalid(name + ".requestId 不能为空"); | |
| 266 | + } | |
| 267 | + } | |
| 268 | + return IotDeviceMessage.of(requestId, method, toValue(params), toValue(data), code, msg); | |
| 269 | + } | |
| 270 | + | |
| 271 | + private static void validateTransactionReply(IotDeviceMessage reply, IotDeviceMessage request) | |
| 272 | + throws IotScriptException { | |
| 273 | + if (reply.getCode() == null || !Objects.equals(reply.getMethod(), request.getMethod()) | |
| 274 | + || !Objects.equals(reply.getRequestId(), request.getRequestId())) { | |
| 275 | + throw IotScriptException.outputInvalid("事务响应的回执方法和 requestId 必须与原请求一致"); | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + private static String textOrNull(JsonNode node, String field, String name) throws IotScriptException { | |
| 280 | + JsonNode value = nullIfJsonNull(node.get(field)); | |
| 281 | + if (value == null) { | |
| 282 | + return null; | |
| 283 | + } | |
| 284 | + if (!value.isTextual()) { | |
| 285 | + throw IotScriptException.outputInvalid(name + "." + field + " 必须是字符串"); | |
| 286 | + } | |
| 287 | + return value.textValue(); | |
| 288 | + } | |
| 289 | + | |
| 290 | + private static void validateFieldSize(JsonNode value, String name) throws IotScriptException { | |
| 291 | + if (value != null && value.toString().getBytes(StandardCharsets.UTF_8).length > MAX_FIELD_BYTES) { | |
| 292 | + throw IotScriptException.outputInvalid(name + " 不能超过 " + MAX_FIELD_BYTES + " 字节"); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * 解析字节数组:每个元素都是 0~255 的整数;长度之后按协议的规则校验(脚本输出最多 65536 字符,数组不会过大) | |
| 298 | + */ | |
| 299 | + private static byte[] parseBytes(JsonNode node, String name) throws IotScriptException { | |
| 300 | + node = nullIfJsonNull(node); | |
| 301 | + if (node == null) { | |
| 302 | + throw IotScriptException.outputInvalid(name + " 不能为空"); | |
| 303 | + } | |
| 304 | + if (!node.isArray()) { | |
| 305 | + throw IotScriptException.outputInvalid(name + " 必须是数组"); | |
| 306 | + } | |
| 307 | + byte[] bytes = new byte[node.size()]; | |
| 308 | + for (int i = 0; i < node.size(); i++) { | |
| 309 | + JsonNode item = node.get(i); | |
| 310 | + if (!item.isIntegralNumber() || !item.canConvertToInt() || item.intValue() < 0 || item.intValue() > 255) { | |
| 311 | + throw IotScriptException.outputInvalid(name + "[" + i + "] 必须是 0~255 的整数"); | |
| 312 | + } | |
| 313 | + bytes[i] = (byte) item.intValue(); | |
| 314 | + } | |
| 315 | + return bytes; | |
| 316 | + } | |
| 317 | + | |
| 318 | + private static JsonNode nullIfJsonNull(JsonNode node) { | |
| 319 | + return node == null || node.isNull() ? null : node; | |
| 320 | + } | |
| 321 | + | |
| 322 | + private static Object toValue(JsonNode node) { | |
| 323 | + return node == null ? null : JsonUtils.getObjectMapper().convertValue(node, Object.class); | |
| 324 | + } | |
| 325 | + | |
| 326 | + /** | |
| 327 | + * encode 输出:二选一 | |
| 328 | + */ | |
| 329 | + @Getter | |
| 330 | + @AllArgsConstructor | |
| 331 | + public static final class EncodeOutput { | |
| 332 | + | |
| 333 | + /** | |
| 334 | + * true = 走现有的标准路径 | |
| 335 | + */ | |
| 336 | + private final boolean standard; | |
| 337 | + /** | |
| 338 | + * 下行字节 | |
| 339 | + */ | |
| 340 | + private final byte[] bytes; | |
| 341 | + | |
| 342 | + } | |
| 343 | + | |
| 344 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptResult.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.dto; | |
| 2 | + | |
| 3 | +import lombok.AccessLevel; | |
| 4 | +import lombok.AllArgsConstructor; | |
| 5 | +import lombok.Getter; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * IoT 数据解析脚本的一次调用结果 | |
| 9 | + * <p> | |
| 10 | + * core 不依赖 biz / gateway 的错误码:由调用方按 {@link Status} 翻译为各自的 ErrorCode | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Getter | |
| 15 | +@AllArgsConstructor(access = AccessLevel.PRIVATE) | |
| 16 | +public final class IotScriptResult { | |
| 17 | + | |
| 18 | + /** | |
| 19 | + * 调用结果的状态 | |
| 20 | + */ | |
| 21 | + public enum Status { | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * 执行成功(json 可能为 null:没有定义该函数,或返回了 null / undefined) | |
| 25 | + */ | |
| 26 | + SUCCESS, | |
| 27 | + /** | |
| 28 | + * 脚本异常:语法错误、运行错误、throw、超出栈深等 | |
| 29 | + */ | |
| 30 | + ERROR, | |
| 31 | + /** | |
| 32 | + * 超时:排队超过截止时间、超过执行预算(时间或指令) | |
| 33 | + */ | |
| 34 | + TIMEOUT, | |
| 35 | + /** | |
| 36 | + * 拒绝:JS 线程池已满、已熔断 | |
| 37 | + */ | |
| 38 | + REJECTED, | |
| 39 | + /** | |
| 40 | + * 输出不合法 | |
| 41 | + */ | |
| 42 | + OUTPUT_INVALID | |
| 43 | + | |
| 44 | + } | |
| 45 | + | |
| 46 | + // DONE @AI:字段注释,还是要写下 | |
| 47 | + /** | |
| 48 | + * 状态 | |
| 49 | + */ | |
| 50 | + private final Status status; | |
| 51 | + /** | |
| 52 | + * 结果 JSON;只有 SUCCESS 时可能非空 | |
| 53 | + */ | |
| 54 | + private final String json; | |
| 55 | + /** | |
| 56 | + * 错误信息 | |
| 57 | + */ | |
| 58 | + private final String message; | |
| 59 | + /** | |
| 60 | + * 出错行号;未知时为 -1 | |
| 61 | + */ | |
| 62 | + private final int lineNumber; | |
| 63 | + /** | |
| 64 | + * 执行耗时(毫秒) | |
| 65 | + */ | |
| 66 | + private final long durationMillis; | |
| 67 | + | |
| 68 | + public static IotScriptResult success(String json, long durationMillis) { | |
| 69 | + return new IotScriptResult(Status.SUCCESS, json, null, -1, durationMillis); | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static IotScriptResult error(String message, long durationMillis) { | |
| 73 | + return new IotScriptResult(Status.ERROR, null, message, -1, durationMillis); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public static IotScriptResult timeout(String message) { | |
| 77 | + return new IotScriptResult(Status.TIMEOUT, null, message, -1, 0); | |
| 78 | + } | |
| 79 | + | |
| 80 | + public static IotScriptResult rejected(String message) { | |
| 81 | + return new IotScriptResult(Status.REJECTED, null, message, -1, 0); | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static IotScriptResult of(Status status, String message, int lineNumber, long durationMillis) { | |
| 85 | + return new IotScriptResult(status, null, message, lineNumber, durationMillis); | |
| 86 | + } | |
| 87 | + | |
| 88 | + public boolean isSuccess() { | |
| 89 | + return status == Status.SUCCESS; | |
| 90 | + } | |
| 91 | + | |
| 92 | + public boolean isTimeout() { | |
| 93 | + return status == Status.TIMEOUT; | |
| 94 | + } | |
| 95 | + | |
| 96 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/dto/IotScriptSource.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.dto; | |
| 2 | + | |
| 3 | +import cn.hutool.crypto.digest.DigestUtil; | |
| 4 | +import lombok.Getter; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 数据解析脚本:不可变对象 {productId, hash, content} | |
| 8 | + * <p> | |
| 9 | + * hash = 源码 UTF-8 的完整 SHA-256(64 位 hex),作为缓存和编译身份;{@link #getShortHash()} 只用于日志和界面。 | |
| 10 | + * 源码字符串可以被多个线程只读共享,编译结果不共享 | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +@Getter | |
| 15 | +public final class IotScriptSource { | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * 产品编号;调试时为 null | |
| 19 | + */ | |
| 20 | + private final Long productId; | |
| 21 | + /** | |
| 22 | + * 源码 UTF-8 的完整 SHA-256 | |
| 23 | + */ | |
| 24 | + private final String hash; | |
| 25 | + /** | |
| 26 | + * 源码 | |
| 27 | + */ | |
| 28 | + private final String content; | |
| 29 | + /** | |
| 30 | + * 编译缓存的 key:productId:hash;调试时为 debug:hash | |
| 31 | + */ | |
| 32 | + private final String key; | |
| 33 | + | |
| 34 | + private IotScriptSource(Long productId, String content) { | |
| 35 | + this.productId = productId; | |
| 36 | + this.content = content; | |
| 37 | + this.hash = DigestUtil.sha256Hex(content); | |
| 38 | + this.key = (productId != null ? productId.toString() : "debug") + ":" + hash; | |
| 39 | + } | |
| 40 | + | |
| 41 | + public static IotScriptSource of(Long productId, String content) { | |
| 42 | + return new IotScriptSource(productId, content); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public static IotScriptSource ofDebug(String content) { | |
| 46 | + return new IotScriptSource(null, content); | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * @return hash 前 12 位:只用于日志和界面,不作为唯一标识 | |
| 51 | + */ | |
| 52 | + public String getShortHash() { | |
| 53 | + return hash.substring(0, 12); | |
| 54 | + } | |
| 55 | + | |
| 56 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/package-info.java
0 → 100644
| 1 | +/** | |
| 2 | + * IoT 数据解析脚本:网关所有协议与 biz 调试共用 | |
| 3 | + * <ul> | |
| 4 | + * <li>本包:定义。{@link cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor} 执行器接口、 | |
| 5 | + * {@link cn.iocoder.yudao.module.iot.core.script.IotScriptUtils} 脚本契约(入参构造、输出校验)、 | |
| 6 | + * {@link cn.iocoder.yudao.module.iot.core.script.IotScriptException} 脚本异常;</li> | |
| 7 | + * <li>dto:调用双方传递的数据。{@link cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource} 脚本、 | |
| 8 | + * {@link cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult} 结果;</li> | |
| 9 | + * <li>rhino:Rhino 实现。{@link cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor}, | |
| 10 | + * 包内其余类(运行时、ContextFactory、执行预算)为包私有的内部实现。</li> | |
| 11 | + * </ul> | |
| 12 | + */ | |
| 13 | +package cn.iocoder.yudao.module.iot.core.script; | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutor.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import cn.hutool.core.thread.NamedThreadFactory; | |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 9 | +import lombok.Data; | |
| 10 | +import lombok.Getter; | |
| 11 | +import lombok.Setter; | |
| 12 | +import lombok.experimental.Accessors; | |
| 13 | +import lombok.extern.slf4j.Slf4j; | |
| 14 | + | |
| 15 | +import java.util.ArrayDeque; | |
| 16 | +import java.util.ArrayList; | |
| 17 | +import java.util.Deque; | |
| 18 | +import java.util.List; | |
| 19 | +import java.util.Map; | |
| 20 | +import java.util.Set; | |
| 21 | +import java.util.concurrent.ArrayBlockingQueue; | |
| 22 | +import java.util.concurrent.CompletableFuture; | |
| 23 | +import java.util.concurrent.ConcurrentHashMap; | |
| 24 | +import java.util.concurrent.CyclicBarrier; | |
| 25 | +import java.util.concurrent.ExecutionException; | |
| 26 | +import java.util.concurrent.Future; | |
| 27 | +import java.util.concurrent.RejectedExecutionException; | |
| 28 | +import java.util.concurrent.ScheduledFuture; | |
| 29 | +import java.util.concurrent.ScheduledThreadPoolExecutor; | |
| 30 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 31 | +import java.util.concurrent.TimeUnit; | |
| 32 | +import java.util.concurrent.TimeoutException; | |
| 33 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 34 | + | |
| 35 | +// DONE @AI:字段注释; | |
| 36 | +/** | |
| 37 | + * IoT 数据解析脚本执行器:同一个 JVM 内执行,不是沙箱 | |
| 38 | + * <p> | |
| 39 | + * Rhino 下,经过指令检查点的超限会真正中断执行、释放线程;不经过检查点的原生长操作只能被标记为超时,线程计入卡住检测。 | |
| 40 | + * 本节点的熔断(按 productId:hash,或卡住线程过多时全局)只在内存中生效,不写库,重启后复位 | |
| 41 | + * | |
| 42 | + * @author 芋道源码 | |
| 43 | + */ | |
| 44 | +@Slf4j | |
| 45 | +public class IotRhinoScriptExecutor implements IotScriptExecutor { | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * 熔断:1 分钟内超时达到该次数 | |
| 49 | + */ | |
| 50 | + private static final int BREAKER_TIMEOUT_COUNT = 3; | |
| 51 | + // DONE @AI:字段注释; | |
| 52 | + /** | |
| 53 | + * 熔断:统计超时次数的时间窗口(1 分钟) | |
| 54 | + */ | |
| 55 | + private static final long BREAKER_TIMEOUT_WINDOW_NANOS = TimeUnit.MINUTES.toNanos(1); | |
| 56 | + | |
| 57 | + // DONE @AI:字段注释; | |
| 58 | + /** | |
| 59 | + * IoT 专用的 Rhino ContextFactory:所有 JS 线程共用,不使用全局 ContextFactory | |
| 60 | + */ | |
| 61 | + private final IotScriptContextFactory contextFactory = new IotScriptContextFactory(); | |
| 62 | + // DONE @AI:字段注释; | |
| 63 | + /** | |
| 64 | + * 每个 JS 线程独占的 Rhino 运行时:预热或首次调用时创建,不跨线程共享 | |
| 65 | + */ | |
| 66 | + private final ThreadLocal<IotScriptRuntime> runtimes = new ThreadLocal<>(); | |
| 67 | + | |
| 68 | + // DONE @AI:字段注释; | |
| 69 | + /** | |
| 70 | + * 执行器名称:线程名前缀,也用于日志 | |
| 71 | + */ | |
| 72 | + private final String name; | |
| 73 | + /** | |
| 74 | + * JS 线程池:核心数 = 最大数,有界队列,AbortPolicy | |
| 75 | + */ | |
| 76 | + private final ThreadPoolExecutor jsPool; | |
| 77 | + /** | |
| 78 | + * 单线程计时器:执行预算兜底、卡住扫描;不执行业务回调。由本执行器创建,{@link #shutdown()} 时关闭 | |
| 79 | + */ | |
| 80 | + private final ScheduledThreadPoolExecutor timer; | |
| 81 | + /** | |
| 82 | + * 单次执行的时间预算(纳秒):实际预算取它与截止时间中较早的一个 | |
| 83 | + */ | |
| 84 | + private final long execBudgetNanos; | |
| 85 | + /** | |
| 86 | + * 单次执行的指令上限 | |
| 87 | + */ | |
| 88 | + private final long maxInstructions; | |
| 89 | + /** | |
| 90 | + * 运行超过该时长视为卡住(纳秒) | |
| 91 | + */ | |
| 92 | + private final long stuckNanos; | |
| 93 | + /** | |
| 94 | + * 卡住的线程达到该数量时全局熔断 | |
| 95 | + */ | |
| 96 | + private final int maxStuckThreads; | |
| 97 | + /** | |
| 98 | + * 卡住扫描的计时任务:每秒一次,{@link #shutdown()} 时取消 | |
| 99 | + */ | |
| 100 | + private final ScheduledFuture<?> stuckScanner; | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 正在执行的任务 → 开始时间 | |
| 104 | + */ | |
| 105 | + private final Map<IotScriptTask, Long> running = new ConcurrentHashMap<>(); | |
| 106 | + /** | |
| 107 | + * 已熔断的脚本 key(productId:hash) | |
| 108 | + */ | |
| 109 | + private final Set<String> openKeys = ConcurrentHashMap.newKeySet(); | |
| 110 | + /** | |
| 111 | + * 最近的执行超时时间:key → 时间(nanoTime) | |
| 112 | + */ | |
| 113 | + private final Map<String, Deque<Long>> recentTimeouts = new ConcurrentHashMap<>(); | |
| 114 | + /** | |
| 115 | + * 当前卡住的线程数:每秒扫描一次 | |
| 116 | + */ | |
| 117 | + private volatile int stuckThreads; | |
| 118 | + /** | |
| 119 | + * 预热失败:全局熔断,需要重启 | |
| 120 | + */ | |
| 121 | + private volatile boolean warmUpFailed; | |
| 122 | + | |
| 123 | + public IotRhinoScriptExecutor(Options options) { | |
| 124 | + this.name = options.getName(); | |
| 125 | + this.jsPool = new ThreadPoolExecutor(options.getPoolSize(), options.getPoolSize(), 0L, TimeUnit.MILLISECONDS, | |
| 126 | + new ArrayBlockingQueue<>(options.getQueueCapacity()), new NamedThreadFactory(options.getName() + "-", true), | |
| 127 | + new ThreadPoolExecutor.AbortPolicy()); | |
| 128 | + this.timer = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory(options.getName() + "-timer-", true)); | |
| 129 | + this.timer.setRemoveOnCancelPolicy(true); // 被取消的计时任务立即移出队列,不留到原截止时间 | |
| 130 | + this.execBudgetNanos = TimeUnit.MILLISECONDS.toNanos(options.getExecBudgetMillis()); | |
| 131 | + this.maxInstructions = options.getMaxInstructions(); | |
| 132 | + this.stuckNanos = TimeUnit.MILLISECONDS.toNanos(options.getStuckMillis()); | |
| 133 | + this.maxStuckThreads = Math.max(1, options.getMaxStuckThreads()); | |
| 134 | + this.stuckScanner = timer.scheduleWithFixedDelay(this::scanStuck, 1, 1, TimeUnit.SECONDS); | |
| 135 | + } | |
| 136 | + | |
| 137 | + /** | |
| 138 | + * 执行一次脚本调用:调用方线程(网关为 Vert.x worker、biz 调试为请求线程)阻塞等待,最长到截止时间 | |
| 139 | + * <p> | |
| 140 | + * 脚本在 JS 线程池执行,受执行预算(时间、指令)约束;等待到期时,还在排队的任务被终结,之后不会再启动 | |
| 141 | + * | |
| 142 | + * @param source 脚本 | |
| 143 | + * @param function "decode" 或 "encode" | |
| 144 | + * @param ctxJson 平台构造的入参 JSON | |
| 145 | + * @param deadlineNanos 截止时间(System.nanoTime):覆盖排队与执行 | |
| 146 | + * @return 结果:SUCCESS / ERROR / TIMEOUT / REJECTED | |
| 147 | + */ | |
| 148 | + @Override | |
| 149 | + public IotScriptResult execute(IotScriptSource source, String function, String ctxJson, long deadlineNanos) { | |
| 150 | + if (isOpen(source)) { | |
| 151 | + return IotScriptResult.rejected("脚本已熔断"); | |
| 152 | + } | |
| 153 | + long remain = deadlineNanos - System.nanoTime(); | |
| 154 | + if (remain <= 0) { | |
| 155 | + return IotScriptResult.timeout("进入 JS 池前已超过截止时间"); | |
| 156 | + } | |
| 157 | + IotScriptTask task = new IotScriptTask(source); | |
| 158 | + try { | |
| 159 | + jsPool.execute(() -> run(task, function, ctxJson, deadlineNanos)); | |
| 160 | + } catch (RejectedExecutionException e) { | |
| 161 | + return IotScriptResult.rejected("脚本线程池已满"); | |
| 162 | + } | |
| 163 | + try { | |
| 164 | + return task.getResult().get(remain, TimeUnit.NANOSECONDS); | |
| 165 | + } catch (TimeoutException e) { | |
| 166 | + // 到期:排队中 PENDING → DONE(结果为超时),之后 markRunning 失败,不会启动;执行中的由预算中断或计入卡住检测 | |
| 167 | + finish(task, IotScriptResult.timeout("超过截止时间")); | |
| 168 | + } catch (InterruptedException e) { | |
| 169 | + Thread.currentThread().interrupt(); | |
| 170 | + finish(task, IotScriptResult.error("等待脚本结果被中断", 0)); | |
| 171 | + } catch (ExecutionException e) { | |
| 172 | + finish(task, IotScriptResult.error(StrUtil.maxLength(String.valueOf(e.getCause()), 200), 0)); | |
| 173 | + } | |
| 174 | + return task.getResult().join(); // finish 只会成功一次:这里拿到的是最终终结的那个结果 | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** | |
| 178 | + * 只做语法检查(不执行任何用户代码):在 JS 线程上编译,调用方线程有界等待 | |
| 179 | + * | |
| 180 | + * @param content 脚本内容 | |
| 181 | + * @param timeout 等待时间 | |
| 182 | + * @param unit 时间单位 | |
| 183 | + * @return 结果:SUCCESS / ERROR(带行号)/ REJECTED / TIMEOUT | |
| 184 | + */ | |
| 185 | + @Override | |
| 186 | + public IotScriptResult compileOnly(String content, long timeout, TimeUnit unit) { | |
| 187 | + if (isGlobalOpen()) { | |
| 188 | + return IotScriptResult.rejected("脚本已熔断"); | |
| 189 | + } | |
| 190 | + Future<IotScriptResult> future; | |
| 191 | + try { | |
| 192 | + future = jsPool.submit(() -> { | |
| 193 | + try { | |
| 194 | + runtime().compileOnly(content); | |
| 195 | + return IotScriptResult.success(null, 0); | |
| 196 | + } catch (IotScriptException e) { | |
| 197 | + return IotScriptResult.of(e.getStatus(), e.getMessage(), e.getLineNumber(), 0); | |
| 198 | + } | |
| 199 | + }); | |
| 200 | + } catch (RejectedExecutionException e) { | |
| 201 | + return IotScriptResult.rejected("脚本线程池已满"); | |
| 202 | + } | |
| 203 | + try { | |
| 204 | + return future.get(timeout, unit); | |
| 205 | + } catch (TimeoutException e) { | |
| 206 | + future.cancel(false); | |
| 207 | + return IotScriptResult.timeout("编译超时"); | |
| 208 | + } catch (InterruptedException e) { | |
| 209 | + Thread.currentThread().interrupt(); | |
| 210 | + return IotScriptResult.error("编译被中断", 0); | |
| 211 | + } catch (ExecutionException e) { | |
| 212 | + return IotScriptResult.error(StrUtil.maxLength(String.valueOf(e.getCause()), 200), 0); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + private void run(IotScriptTask task, String function, String ctxJson, long deadlineNanos) { | |
| 217 | + if (System.nanoTime() >= deadlineNanos) { // 出队时已过期(等待方通常已先终结;finish 只会成功一次) | |
| 218 | + finish(task, IotScriptResult.timeout("排队期间超过截止时间")); | |
| 219 | + return; | |
| 220 | + } | |
| 221 | + if (!task.markRunning()) { // 已被超时或拒绝终结:不启动 | |
| 222 | + return; | |
| 223 | + } | |
| 224 | + long start = System.nanoTime(); | |
| 225 | + long budgetDeadline = Math.min(start + execBudgetNanos, deadlineNanos); | |
| 226 | + // 兜底:观察器到不了的原生长操作 | |
| 227 | + try { | |
| 228 | + task.setBudgetTimer(timer.schedule(() -> finishTimeoutRunning(task, "执行超过预算"), | |
| 229 | + Math.max(0L, budgetDeadline - start), TimeUnit.NANOSECONDS)); | |
| 230 | + } catch (RejectedExecutionException e) { // 停机时 timer 已关闭:不执行,直接终结 | |
| 231 | + finish(task, IotScriptResult.rejected("脚本执行器已关闭")); | |
| 232 | + return; | |
| 233 | + } | |
| 234 | + running.put(task, start); | |
| 235 | + try { | |
| 236 | + IotScriptRuntime runtime = runtime(); // 正常已在启动时预热;线程被替换后为冷启动,耗时计入本次预算 | |
| 237 | + if (System.nanoTime() >= budgetDeadline || task.isFinished()) { // 实际调用前再校验一次 | |
| 238 | + finish(task, IotScriptResult.timeout("调用前已超过截止时间")); | |
| 239 | + return; | |
| 240 | + } | |
| 241 | + // 顶层代码、入参原生化、调用、序列化共用这一个预算 | |
| 242 | + String json = runtime.invoke(task.getSource(), function, ctxJson, | |
| 243 | + new IotScriptBudget(budgetDeadline, maxInstructions)); | |
| 244 | + finish(task, IotScriptResult.success(json, elapsedMillis(start))); | |
| 245 | + } catch (IotScriptBudget.BudgetExceededError e) { | |
| 246 | + finishTimeoutRunning(task, e.getMessage()); // 观察器中断:线程已经回到这里,可以继续使用 | |
| 247 | + } catch (IotScriptException e) { | |
| 248 | + finish(task, IotScriptResult.of(e.getStatus(), e.getMessage(), e.getLineNumber(), elapsedMillis(start))); | |
| 249 | + } catch (StackOverflowError e) { | |
| 250 | + finish(task, IotScriptResult.error("调用层级过深", elapsedMillis(start))); // 原生递归可能绕过解释器栈深限制 | |
| 251 | + } catch (RuntimeException e) { | |
| 252 | + finish(task, IotScriptResult.error(StrUtil.maxLength(e.getMessage(), 200), elapsedMillis(start))); | |
| 253 | + } finally { | |
| 254 | + running.remove(task); // OutOfMemoryError 等其它 Error 不捕获,交给 JVM 现有的处理 | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + /** | |
| 259 | + * 执行中的超时:计入熔断统计 | |
| 260 | + */ | |
| 261 | + private void finishTimeoutRunning(IotScriptTask task, String message) { | |
| 262 | + if (finish(task, IotScriptResult.timeout(message))) { | |
| 263 | + recordTimeout(task.getSource()); | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + /** | |
| 268 | + * 只终结一次:终结的那个结果交给等待中的调用方 | |
| 269 | + * | |
| 270 | + * @return 是否由本次调用终结 | |
| 271 | + */ | |
| 272 | + private boolean finish(IotScriptTask task, IotScriptResult result) { | |
| 273 | + if (!task.finish()) { | |
| 274 | + return false; // 已终结:迟到的结果直接丢弃 | |
| 275 | + } | |
| 276 | + task.getResult().complete(result); | |
| 277 | + return true; | |
| 278 | + } | |
| 279 | + | |
| 280 | + private IotScriptRuntime runtime() { | |
| 281 | + IotScriptRuntime runtime = runtimes.get(); | |
| 282 | + if (runtime == null) { // 只有预热,或线程被替换后的首次调用才会进入:创建封存的标准对象 | |
| 283 | + runtime = new IotScriptRuntime(contextFactory); | |
| 284 | + runtimes.set(runtime); | |
| 285 | + } | |
| 286 | + return runtime; | |
| 287 | + } | |
| 288 | + | |
| 289 | + /** | |
| 290 | + * 启动时调用一次,在接收流量之前:N 个任务通过屏障同时占住 N 个不同的 worker,保证每个 worker 都实际创建了 runtime | |
| 291 | + * <p> | |
| 292 | + * 失败 → 全局熔断(记 ERROR),之后的调用都按「已熔断」拒绝 | |
| 293 | + */ | |
| 294 | + @Override | |
| 295 | + public void warmUp() { | |
| 296 | + int n = jsPool.getCorePoolSize(); | |
| 297 | + try { | |
| 298 | + jsPool.prestartAllCoreThreads(); | |
| 299 | + CyclicBarrier barrier = new CyclicBarrier(n); | |
| 300 | + List<Future<?>> futures = new ArrayList<>(n); | |
| 301 | + for (int i = 0; i < n; i++) { | |
| 302 | + futures.add(jsPool.submit(() -> { | |
| 303 | + barrier.await(10, TimeUnit.SECONDS); | |
| 304 | + return runtime(); | |
| 305 | + })); | |
| 306 | + } | |
| 307 | + for (Future<?> future : futures) { | |
| 308 | + future.get(30, TimeUnit.SECONDS); | |
| 309 | + } | |
| 310 | + } catch (Exception e) { | |
| 311 | + if (e instanceof InterruptedException) { | |
| 312 | + Thread.currentThread().interrupt(); | |
| 313 | + } | |
| 314 | + warmUpFailed = true; | |
| 315 | + log.error("[warmUp][{} 预热失败,本节点脚本执行全局熔断]", name, e); | |
| 316 | + } | |
| 317 | + } | |
| 318 | + | |
| 319 | + /** | |
| 320 | + * 停止:不再接收新任务,正在执行的脚本由预算中断后退出;同时关闭本执行器的计时器(停机后提交的预算计时被拒绝,任务直接终结) | |
| 321 | + */ | |
| 322 | + @Override | |
| 323 | + public void shutdown() { | |
| 324 | + stuckScanner.cancel(false); | |
| 325 | + jsPool.shutdown(); | |
| 326 | + timer.shutdownNow(); | |
| 327 | + } | |
| 328 | + | |
| 329 | + // ========== 熔断(只在本节点生效,不写库) ========== | |
| 330 | + | |
| 331 | + /** | |
| 332 | + * 是否已熔断:预热失败、卡住的线程过多(全局),或该脚本已被熔断 | |
| 333 | + */ | |
| 334 | + public boolean isOpen(IotScriptSource source) { | |
| 335 | + return isGlobalOpen() || openKeys.contains(source.getKey()); | |
| 336 | + } | |
| 337 | + | |
| 338 | + private boolean isGlobalOpen() { | |
| 339 | + return warmUpFailed || stuckThreads >= maxStuckThreads; | |
| 340 | + } | |
| 341 | + | |
| 342 | + private void recordTimeout(IotScriptSource source) { | |
| 343 | + long now = System.nanoTime(); | |
| 344 | + Deque<Long> times = recentTimeouts.computeIfAbsent(source.getKey(), key -> new ArrayDeque<>()); | |
| 345 | + synchronized (times) { | |
| 346 | + times.addLast(now); | |
| 347 | + while (!times.isEmpty() && now - times.peekFirst() > BREAKER_TIMEOUT_WINDOW_NANOS) { | |
| 348 | + times.pollFirst(); | |
| 349 | + } | |
| 350 | + if (times.size() < BREAKER_TIMEOUT_COUNT) { | |
| 351 | + return; | |
| 352 | + } | |
| 353 | + } | |
| 354 | + if (openKeys.add(source.getKey())) { | |
| 355 | + log.error("[recordTimeout][{} 脚本 1 分钟内超时 {} 次,本节点熔断 product({}) hash({})]", name, | |
| 356 | + BREAKER_TIMEOUT_COUNT, source.getProductId(), source.getShortHash()); | |
| 357 | + } | |
| 358 | + } | |
| 359 | + | |
| 360 | + /** | |
| 361 | + * 每秒执行一次:运行超过 stuckNanos 的任务记为卡住 → 本节点熔断这个 (productId, hash),记 ERROR;不调用 RPC、不改数据库开关 | |
| 362 | + */ | |
| 363 | + private void scanStuck() { | |
| 364 | + try { | |
| 365 | + long now = System.nanoTime(); | |
| 366 | + int stuck = 0; | |
| 367 | + for (Map.Entry<IotScriptTask, Long> entry : running.entrySet()) { | |
| 368 | + if (now - entry.getValue() <= stuckNanos) { | |
| 369 | + continue; | |
| 370 | + } | |
| 371 | + stuck++; | |
| 372 | + IotScriptSource source = entry.getKey().getSource(); | |
| 373 | + if (openKeys.add(source.getKey())) { | |
| 374 | + log.error("[scanStuck][{} 脚本卡住一个线程,本节点熔断 product({}) hash({})]", name, | |
| 375 | + source.getProductId(), source.getShortHash()); | |
| 376 | + } | |
| 377 | + } | |
| 378 | + if (stuck >= maxStuckThreads && stuckThreads < maxStuckThreads) { | |
| 379 | + log.error("[scanStuck][{} 卡住的线程数({}) 达到上限,本节点全局熔断;线程恢复后自动解除]", name, stuck); | |
| 380 | + } | |
| 381 | + stuckThreads = stuck; | |
| 382 | + } catch (RuntimeException e) { | |
| 383 | + log.error("[scanStuck][{} 卡住扫描异常]", name, e); | |
| 384 | + } | |
| 385 | + } | |
| 386 | + | |
| 387 | + private static long elapsedMillis(long startNanos) { | |
| 388 | + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); | |
| 389 | + } | |
| 390 | + | |
| 391 | + /** | |
| 392 | + * 执行器参数 | |
| 393 | + */ | |
| 394 | + @Data | |
| 395 | + @Accessors(chain = true) | |
| 396 | + public static class Options { | |
| 397 | + | |
| 398 | + /** | |
| 399 | + * 线程名前缀,如 iot-script-js | |
| 400 | + */ | |
| 401 | + private String name = "iot-script-js"; | |
| 402 | + /** | |
| 403 | + * 线程数 | |
| 404 | + */ | |
| 405 | + private int poolSize = 4; | |
| 406 | + /** | |
| 407 | + * 队列容量 | |
| 408 | + */ | |
| 409 | + private int queueCapacity = 200; | |
| 410 | + /** | |
| 411 | + * 单次执行的时间预算(毫秒) | |
| 412 | + */ | |
| 413 | + private long execBudgetMillis = 500; | |
| 414 | + /** | |
| 415 | + * 单次执行的指令上限 | |
| 416 | + */ | |
| 417 | + private long maxInstructions = 1_000_000; | |
| 418 | + /** | |
| 419 | + * 运行超过该时长视为卡住(毫秒) | |
| 420 | + */ | |
| 421 | + private long stuckMillis = 5000; | |
| 422 | + /** | |
| 423 | + * 卡住的线程达到该数量时全局熔断 | |
| 424 | + */ | |
| 425 | + private int maxStuckThreads = 3; | |
| 426 | + | |
| 427 | + } | |
| 428 | + | |
| 429 | + /** | |
| 430 | + * JS 任务:一个 AtomicInteger 状态机;JS 线程的「开始」与超时、拒绝、完成的「终结」竞争同一个状态 | |
| 431 | + * <p> | |
| 432 | + * 只代表「这一次脚本调用」的终态。终结时取消的是本任务自己的预算计时器,不涉及调用方(协议)的响应计时器 | |
| 433 | + */ | |
| 434 | + static final class IotScriptTask { | |
| 435 | + | |
| 436 | + /** | |
| 437 | + * 状态:排队中,还没开始 | |
| 438 | + */ | |
| 439 | + static final int PENDING = 0; | |
| 440 | + /** | |
| 441 | + * 状态:JS 线程执行中 | |
| 442 | + */ | |
| 443 | + static final int RUNNING = 1; | |
| 444 | + /** | |
| 445 | + * 状态:已终结(成功、错误、拒绝或超时;具体原因见终结的结果) | |
| 446 | + */ | |
| 447 | + static final int DONE = 2; | |
| 448 | + | |
| 449 | + /** | |
| 450 | + * 当前状态:PENDING → RUNNING(markRunning);PENDING 或 RUNNING → DONE(finish),只能终结一次 | |
| 451 | + */ | |
| 452 | + private final AtomicInteger state = new AtomicInteger(PENDING); | |
| 453 | + /** | |
| 454 | + * 本次调用的脚本 | |
| 455 | + */ | |
| 456 | + @Getter | |
| 457 | + private final IotScriptSource source; | |
| 458 | + /** | |
| 459 | + * 终结的结果:调用方在 {@link #execute} 中等待 | |
| 460 | + */ | |
| 461 | + @Getter | |
| 462 | + private final CompletableFuture<IotScriptResult> result = new CompletableFuture<>(); | |
| 463 | + /** | |
| 464 | + * 执行中的时间兜底(原生长操作不经过观察器时) | |
| 465 | + */ | |
| 466 | + @Setter | |
| 467 | + private volatile ScheduledFuture<?> budgetTimer; | |
| 468 | + | |
| 469 | + IotScriptTask(IotScriptSource source) { | |
| 470 | + this.source = source; | |
| 471 | + } | |
| 472 | + | |
| 473 | + /** | |
| 474 | + * JS 线程:只有 PENDING 才能开始;已被超时或拒绝终结的任务返回 false,不再启动 | |
| 475 | + */ | |
| 476 | + boolean markRunning() { | |
| 477 | + return state.compareAndSet(PENDING, RUNNING); | |
| 478 | + } | |
| 479 | + | |
| 480 | + /** | |
| 481 | + * 任意线程:PENDING 或 RUNNING → DONE;并发调用时只有一个返回 true | |
| 482 | + */ | |
| 483 | + boolean finish() { | |
| 484 | + for (;;) { | |
| 485 | + int s = state.get(); | |
| 486 | + if (s == DONE) { | |
| 487 | + return false; | |
| 488 | + } | |
| 489 | + if (state.compareAndSet(s, DONE)) { | |
| 490 | + ScheduledFuture<?> timer = budgetTimer; | |
| 491 | + if (timer != null) { | |
| 492 | + timer.cancel(false); | |
| 493 | + } | |
| 494 | + return true; | |
| 495 | + } | |
| 496 | + } | |
| 497 | + } | |
| 498 | + | |
| 499 | + boolean isFinished() { | |
| 500 | + return state.get() == DONE; | |
| 501 | + } | |
| 502 | + | |
| 503 | + } | |
| 504 | + | |
| 505 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptBudget.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 一次脚本调用的执行预算:用户源码顶层、ctx 原生化、函数调用、结果序列化共用同一个预算对象 | |
| 5 | + * <p> | |
| 6 | + * 只在本次调用的 JS 线程上使用,不需要同步 | |
| 7 | + * | |
| 8 | + * @author 芋道源码 | |
| 9 | + */ | |
| 10 | +final class IotScriptBudget { | |
| 11 | + | |
| 12 | + /** | |
| 13 | + * 截止时间(System.nanoTime)= min(开始执行 + execBudget, 调用方传入的截止时间) | |
| 14 | + */ | |
| 15 | + private final long deadlineNanos; | |
| 16 | + /** | |
| 17 | + * 指令上限 | |
| 18 | + */ | |
| 19 | + private final long maxInstructions; | |
| 20 | + /** | |
| 21 | + * 已执行的指令数 | |
| 22 | + */ | |
| 23 | + private long usedInstructions; | |
| 24 | + | |
| 25 | + IotScriptBudget(long deadlineNanos, long maxInstructions) { | |
| 26 | + this.deadlineNanos = deadlineNanos; | |
| 27 | + this.maxInstructions = maxInstructions; | |
| 28 | + } | |
| 29 | + | |
| 30 | + void consume(int count) { | |
| 31 | + usedInstructions += count; | |
| 32 | + if (usedInstructions >= maxInstructions) { | |
| 33 | + throw new BudgetExceededError("超过指令预算"); | |
| 34 | + } | |
| 35 | + if (System.nanoTime() >= deadlineNanos) { | |
| 36 | + throw new BudgetExceededError("超过执行时间预算"); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 观察器抛出的宿主 Error:Rhino 原样向上抛,脚本的 catch/finally 拦不住 | |
| 42 | + * <p> | |
| 43 | + * 一旦超限,之后每个检查点都会再抛,所以 finally 中的死循环也会被中断 | |
| 44 | + */ | |
| 45 | + static final class BudgetExceededError extends Error { | |
| 46 | + | |
| 47 | + BudgetExceededError(String message) { | |
| 48 | + super(message, null, false, false); // 不收集栈:开销小 | |
| 49 | + } | |
| 50 | + | |
| 51 | + } | |
| 52 | + | |
| 53 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptContextFactory.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import org.mozilla.javascript.Context; | |
| 4 | +import org.mozilla.javascript.ContextFactory; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * IoT 脚本专用的 Rhino ContextFactory:不使用 ContextFactory.getGlobal(),不影响进程内其它 Rhino 使用方 | |
| 8 | + * | |
| 9 | + * @author 芋道源码 | |
| 10 | + */ | |
| 11 | +final class IotScriptContextFactory extends ContextFactory { | |
| 12 | + | |
| 13 | + /** | |
| 14 | + * 每执行这么多条解释器指令,回调一次 observeInstructionCount | |
| 15 | + */ | |
| 16 | + static final int INSTRUCTION_THRESHOLD = 1000; | |
| 17 | + /** | |
| 18 | + * 解释器最大栈深(递归深度) | |
| 19 | + */ | |
| 20 | + static final int MAX_STACK_DEPTH = 64; | |
| 21 | + | |
| 22 | + @Override | |
| 23 | + protected Context makeContext() { | |
| 24 | + Context cx = super.makeContext(); | |
| 25 | + cx.setOptimizationLevel(-1); // 解释模式:指令观察器只在解释模式下生效 | |
| 26 | + cx.setLanguageVersion(Context.VERSION_1_8); | |
| 27 | + cx.setInstructionObserverThreshold(INSTRUCTION_THRESHOLD); | |
| 28 | + cx.setMaximumInterpreterStackDepth(MAX_STACK_DEPTH); // 超出 → EvaluatorException「Exceeded maximum stack depth」 | |
| 29 | + cx.setClassShutter(className -> false); // 拒绝任何 Java 类 | |
| 30 | + cx.setGeneratingDebug(false); | |
| 31 | + return cx; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Override | |
| 35 | + protected void observeInstructionCount(Context cx, int instructionCount) { | |
| 36 | + IotScriptBudget budget = (IotScriptBudget) cx.getThreadLocal(IotScriptBudget.class); | |
| 37 | + if (budget == null) { | |
| 38 | + throw new IotScriptBudget.BudgetExceededError("缺少执行预算"); // fail-closed:没有预算的 Context 不允许继续执行 | |
| 39 | + } | |
| 40 | + budget.consume(instructionCount); // 超限时抛 BudgetExceededError | |
| 41 | + } | |
| 42 | + | |
| 43 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotScriptRuntime.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptException; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 7 | +import org.mozilla.javascript.Context; | |
| 8 | +import org.mozilla.javascript.Function; | |
| 9 | +import org.mozilla.javascript.RhinoException; | |
| 10 | +import org.mozilla.javascript.Script; | |
| 11 | +import org.mozilla.javascript.Scriptable; | |
| 12 | +import org.mozilla.javascript.ScriptableObject; | |
| 13 | +import org.mozilla.javascript.Undefined; | |
| 14 | + | |
| 15 | +import java.util.LinkedHashMap; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +// DONE @AI:字段注释; | |
| 19 | +/** | |
| 20 | + * 单个 JS worker 线程独占的 Rhino 运行时 | |
| 21 | + * <ul> | |
| 22 | + * <li>sealedStandard:initSafeStandardObjects(null, true) 创建的标准对象,不含 Packages / java / JavaAdapter / getClass,已封存;</li> | |
| 23 | + * <li>userScripts:按 productId:hash 的编译缓存,只在本线程使用;</li> | |
| 24 | + * <li>每次调用新建一个可写作用域,用完即丢,调用之间不保留任何状态。</li> | |
| 25 | + * </ul> | |
| 26 | + * 不跨线程共享 Script,也不跨线程共享作用域 | |
| 27 | + * | |
| 28 | + * @author 芋道源码 | |
| 29 | + */ | |
| 30 | +final class IotScriptRuntime { | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * 脚本输出(序列化后的 JSON)的最大字符数,超过按输出不合法处理 | |
| 34 | + */ | |
| 35 | + static final int MAX_RESULT_CHARS = 65536; | |
| 36 | + /** | |
| 37 | + * 编译缓存最多保留的脚本数:超过时淘汰最久未使用的 | |
| 38 | + */ | |
| 39 | + private static final int MAX_COMPILED_SCRIPTS = 64; | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * IoT 专用的 Rhino ContextFactory:每次编译、调用都由它进入 Context | |
| 43 | + */ | |
| 44 | + private final IotScriptContextFactory factory; | |
| 45 | + /** | |
| 46 | + * 创建本运行时的 JS 线程:只能在该线程上使用 | |
| 47 | + */ | |
| 48 | + private final Thread owner = Thread.currentThread(); | |
| 49 | + /** | |
| 50 | + * 封存的标准对象:每次调用的作用域都以它为原型(prototype),作用域自身没有父作用域 | |
| 51 | + */ | |
| 52 | + private final ScriptableObject sealedStandard; | |
| 53 | + /** | |
| 54 | + * 封存的标准对象里的 JSON 对象:宿主只用它做 parse/stringify,用户脚本里的同名变量影响不到 | |
| 55 | + */ | |
| 56 | + private final Scriptable json; | |
| 57 | + /** | |
| 58 | + * 封存的 JSON.parse:把平台构造的入参 JSON 转为脚本里的 ctx 对象 | |
| 59 | + */ | |
| 60 | + private final Function jsonParse; | |
| 61 | + /** | |
| 62 | + * 封存的 JSON.stringify:把脚本的返回值序列化为结果 JSON | |
| 63 | + */ | |
| 64 | + private final Function jsonStringify; | |
| 65 | + /** | |
| 66 | + * 编译缓存:key = productId:sha256(调试时为 debug:sha256);只在本线程访问 | |
| 67 | + */ | |
| 68 | + private final Map<String, Script> userScripts = new LinkedHashMap<String, Script>(16, 0.75f, true) { | |
| 69 | + | |
| 70 | + @Override | |
| 71 | + protected boolean removeEldestEntry(Map.Entry<String, Script> eldest) { | |
| 72 | + return size() > MAX_COMPILED_SCRIPTS; | |
| 73 | + } | |
| 74 | + | |
| 75 | + }; | |
| 76 | + | |
| 77 | + IotScriptRuntime(IotScriptContextFactory factory) { | |
| 78 | + this.factory = factory; | |
| 79 | + Context cx = factory.enterContext(); | |
| 80 | + try { | |
| 81 | + this.sealedStandard = cx.initSafeStandardObjects(null, true); // 第二个参数 true = 封存 | |
| 82 | + this.json = (Scriptable) ScriptableObject.getProperty(sealedStandard, "JSON"); | |
| 83 | + this.jsonParse = (Function) ScriptableObject.getProperty(json, "parse"); | |
| 84 | + this.jsonStringify = (Function) ScriptableObject.getProperty(json, "stringify"); | |
| 85 | + } finally { | |
| 86 | + Context.exit(); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** | |
| 91 | + * 只编译、不执行任何用户代码:保存前的语法检查(biz)和首次调用前的编译(gateway)都用它 | |
| 92 | + * | |
| 93 | + * @throws org.mozilla.javascript.EvaluatorException 语法错误,带行号 | |
| 94 | + */ | |
| 95 | + static Script compile(Context cx, String name, String source) { | |
| 96 | + return cx.compileString(source, name, 1, null); | |
| 97 | + } | |
| 98 | + | |
| 99 | + /** | |
| 100 | + * 只做语法检查:不执行任何用户代码,也不放入编译缓存 | |
| 101 | + * | |
| 102 | + * @param content 脚本内容 | |
| 103 | + * @throws IotScriptException 语法错误 | |
| 104 | + */ | |
| 105 | + void compileOnly(String content) throws IotScriptException { | |
| 106 | + checkOwner(); | |
| 107 | + Context cx = factory.enterContext(); | |
| 108 | + try { | |
| 109 | + compile(cx, "product", content); | |
| 110 | + } catch (RhinoException e) { | |
| 111 | + throw new IotScriptException(IotScriptResult.Status.ERROR, | |
| 112 | + StrUtil.maxLength(e.details(), 200), e.lineNumber()); | |
| 113 | + } finally { | |
| 114 | + Context.exit(); | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + /** | |
| 119 | + * 执行一次 | |
| 120 | + * | |
| 121 | + * @param source 脚本 | |
| 122 | + * @param function "decode" 或 "encode" | |
| 123 | + * @param ctxJson 平台构造的 JSON 字符串(不含 Java 对象、byte[]、Map、List 或任何秘密;字节是数字数组) | |
| 124 | + * @param budget 执行器传入的本次预算:下面的第 2~5 步都受它约束 | |
| 125 | + * @return 结果 JSON 字符串;null 表示脚本返回了 null/undefined,或没有定义该函数 | |
| 126 | + */ | |
| 127 | + String invoke(IotScriptSource source, String function, String ctxJson, IotScriptBudget budget) throws IotScriptException { | |
| 128 | + checkOwner(); | |
| 129 | + Context cx = factory.enterContext(); | |
| 130 | + cx.putThreadLocal(IotScriptBudget.class, budget); // 先挂预算,之后的一切解释执行都计数 | |
| 131 | + try { | |
| 132 | + // 1. 编译缓存:首次使用时在本线程编译(编译本身不经过指令观察器,只受 JS 任务的时间兜底约束;源码不超过 32KB) | |
| 133 | + Script user = userScripts.get(source.getKey()); | |
| 134 | + if (user == null) { | |
| 135 | + user = compile(cx, "product-" + source.getProductId(), source.getContent()); | |
| 136 | + userScripts.put(source.getKey(), user); | |
| 137 | + } | |
| 138 | + // 2. 新的可写作用域:原型是封存的标准对象、没有父作用域,脚本能定义自己的变量,但改不了 Array.prototype 等标准对象 | |
| 139 | + Scriptable scope = cx.newObject(sealedStandard); | |
| 140 | + scope.setPrototype(sealedStandard); | |
| 141 | + scope.setParentScope(null); | |
| 142 | + // 3. 用户源码顶层在这个作用域中执行(每次都执行),定义 decode / encode | |
| 143 | + user.exec(cx, scope); | |
| 144 | + Object fn = ScriptableObject.getProperty(scope, function); | |
| 145 | + if (!(fn instanceof Function)) { | |
| 146 | + return null; // 没有定义这个方向的函数:等同于返回 null | |
| 147 | + } | |
| 148 | + // 4. 入参原生化:JSON 字符串 → JS 原生对象(字节是原生数字数组);不传任何 Java 对象 | |
| 149 | + Object ctx = jsonParse.call(cx, scope, json, new Object[]{ctxJson}); | |
| 150 | + Object result = ((Function) fn).call(cx, scope, scope, new Object[]{ctx}); | |
| 151 | + if (result == null || result instanceof Undefined) { | |
| 152 | + return null; | |
| 153 | + } | |
| 154 | + // 5. 结果序列化:result 的 toJSON / getter 也在这里执行,同样受预算约束 | |
| 155 | + Object out = jsonStringify.call(cx, scope, json, new Object[]{result}); | |
| 156 | + if (!(out instanceof String)) { // 函数等不能序列化的值得到 undefined:按输出不合法处理 | |
| 157 | + throw new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, "脚本返回值不能序列化为 JSON", -1); | |
| 158 | + } | |
| 159 | + String text = (String) out; | |
| 160 | + if (text.length() > MAX_RESULT_CHARS) { | |
| 161 | + throw new IotScriptException(IotScriptResult.Status.OUTPUT_INVALID, "脚本输出超过 " + MAX_RESULT_CHARS + " 字符", -1); | |
| 162 | + } | |
| 163 | + return text; | |
| 164 | + } catch (RhinoException e) { // 语法错误、运行错误、throw、封存对象被修改、超出栈深 | |
| 165 | + throw new IotScriptException(IotScriptResult.Status.ERROR, | |
| 166 | + StrUtil.maxLength(e.details(), 200), e.lineNumber()); | |
| 167 | + } finally { | |
| 168 | + cx.removeThreadLocal(IotScriptBudget.class); // 清理预算 | |
| 169 | + Context.exit(); // 退出 Context;BudgetExceededError、OOM 等 Error 照常向上抛 | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 173 | + private void checkOwner() { | |
| 174 | + if (Thread.currentThread() != owner) { | |
| 175 | + throw new IllegalStateException("IotScriptRuntime 只能在创建它的 JS 线程上使用"); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | +} | ... | ... |
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; |
| 8 | 8 | import cn.hutool.system.SystemUtil; |
| 9 | 9 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 11 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 11 | 12 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 12 | 13 | |
| 14 | +import java.util.Base64; | |
| 13 | 15 | import java.util.LinkedHashSet; |
| 14 | 16 | import java.util.Map; |
| 15 | 17 | import java.util.Set; |
| ... | ... | @@ -52,6 +54,150 @@ public class IotDeviceMessageUtils { |
| 52 | 54 | } |
| 53 | 55 | |
| 54 | 56 | /** |
| 57 | + * 是否是下行指令的请求:property.set / service.invoke(数据解析脚本 encode 只处理这两种) | |
| 58 | + * | |
| 59 | + * @param message 消息 | |
| 60 | + * @return 是否 | |
| 61 | + */ | |
| 62 | + public static boolean isCommandRequest(IotDeviceMessage message) { | |
| 63 | + return !isReplyMessage(message) && isCommandMethod(message.getMethod()); | |
| 64 | + } | |
| 65 | + | |
| 66 | + /** | |
| 67 | + * 是否是下行指令的方法:property.set / service.invoke | |
| 68 | + * | |
| 69 | + * @param method 消息方法 | |
| 70 | + * @return 是否 | |
| 71 | + */ | |
| 72 | + public static boolean isCommandMethod(String method) { | |
| 73 | + return StrUtil.equalsAny(method, IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), | |
| 74 | + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // ========== 透传(raw)相关 ========== | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * 通用协议 raw 载荷的最大字节数(Modbus 另按 PDU 不超过 {@link #MODBUS_PDU_MAX_BYTES} 校验) | |
| 81 | + */ | |
| 82 | + public static final int RAW_PAYLOAD_MAX_BYTES = 4096; | |
| 83 | + | |
| 84 | + /** | |
| 85 | + * Modbus PDU 的最大字节数(不带 slaveId、CRC、MBAP) | |
| 86 | + */ | |
| 87 | + public static final int MODBUS_PDU_MAX_BYTES = 253; | |
| 88 | + | |
| 89 | + /** | |
| 90 | + * 是否是透传方法:thing.raw.post、thing.raw.push | |
| 91 | + * | |
| 92 | + * @param method 消息方法 | |
| 93 | + * @return 是否 | |
| 94 | + */ | |
| 95 | + public static boolean isRawMethod(String method) { | |
| 96 | + return StrUtil.equalsAny(method, IotDeviceMessageMethodEnum.RAW_POST.getMethod(), | |
| 97 | + IotDeviceMessageMethodEnum.RAW_PUSH.getMethod()); | |
| 98 | + } | |
| 99 | + | |
| 100 | + /** | |
| 101 | + * 编码原始字节为 {@link IotDeviceMessage#getRaw()}(Base64) | |
| 102 | + * | |
| 103 | + * @param bytes 原始字节 | |
| 104 | + * @return Base64 | |
| 105 | + */ | |
| 106 | + public static String encodeRaw(byte[] bytes) { | |
| 107 | + return Base64.getEncoder().encodeToString(bytes); | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** | |
| 111 | + * 解码 {@link IotDeviceMessage#getRaw()}(Base64);严格 Base64(必须与 {@link #encodeRaw} 的结果一致,带 padding), | |
| 112 | + * 非法或超长抛 {@link IllegalArgumentException} | |
| 113 | + * | |
| 114 | + * @param raw Base64 | |
| 115 | + * @param maxBytes 最大字节数 | |
| 116 | + * @return 原始字节 | |
| 117 | + */ | |
| 118 | + public static byte[] decodeRaw(String raw, int maxBytes) { | |
| 119 | + Assert.notBlank(raw, "raw 不能为空"); | |
| 120 | + Assert.isTrue(raw.length() <= (maxBytes + 2) / 3 * 4, "raw 长度必须在 1~{} 字节之间", maxBytes); // 先按长度拦截,不解码超长串 | |
| 121 | + byte[] bytes = Base64.getDecoder().decode(raw); | |
| 122 | + Assert.isTrue(bytes.length >= 1 && bytes.length <= maxBytes, "raw 长度必须在 1~{} 字节之间", maxBytes); | |
| 123 | + Assert.isTrue(raw.equals(encodeRaw(bytes)), "raw 必须是标准 Base64(带 padding)"); | |
| 124 | + return bytes; | |
| 125 | + } | |
| 126 | + | |
| 127 | + /** | |
| 128 | + * 从 params({"payload": Base64})解出透传载荷:管理后台下发 thing.raw.push 的入参格式; | |
| 129 | + * 标准 Base64(允许省略 padding,由调用方用 {@link #encodeRaw} 规范化),非法或超长抛 {@link IllegalArgumentException} | |
| 130 | + * | |
| 131 | + * @param params 消息参数 | |
| 132 | + * @param maxBytes 最大字节数 | |
| 133 | + * @return 载荷 | |
| 134 | + */ | |
| 135 | + public static byte[] decodeRawPayload(Object params, int maxBytes) { | |
| 136 | + String payload = StrUtil.toStringOrNull(readField(params, "payload")); | |
| 137 | + Assert.notBlank(payload, "payload 不能为空"); | |
| 138 | + byte[] bytes = Base64.getDecoder().decode(payload); | |
| 139 | + Assert.isTrue(bytes.length >= 1 && bytes.length <= maxBytes, "payload 长度必须在 1~{} 字节之间", maxBytes); | |
| 140 | + return bytes; | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * 校验透传下行的字节:按协议的规则,Modbus 必须是合法的请求 PDU({@link #validateModbusRequestPdu}),其它协议 1~4096 字节 | |
| 145 | + * <p> | |
| 146 | + * biz 的 /send、脚本调试的 encode 输出、gateway 的 encode 输出调用同一份规则;不合法抛 {@link IllegalArgumentException} | |
| 147 | + * | |
| 148 | + * @param protocol 协议类型 | |
| 149 | + * @param bytes 下行字节 | |
| 150 | + */ | |
| 151 | + public static void validateRawPayload(IotProtocolTypeEnum protocol, byte[] bytes) { | |
| 152 | + if (protocol != null && protocol.isModbus()) { | |
| 153 | + validateModbusRequestPdu(bytes); | |
| 154 | + return; | |
| 155 | + } | |
| 156 | + Assert.isTrue(bytes != null && bytes.length >= 1 && bytes.length <= RAW_PAYLOAD_MAX_BYTES, | |
| 157 | + "raw 长度必须在 1~{} 字节之间", RAW_PAYLOAD_MAX_BYTES); | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * 校验 Modbus 原始请求 PDU 的格式:功能码白名单、长度、数量范围、05 的取值、0F/10 的 byteCount | |
| 162 | + * <p> | |
| 163 | + * 透传下行的字节规则({@link #validateRawPayload})与 Modbus 协议写出前调用同一份规则;不合法抛 {@link IllegalArgumentException} | |
| 164 | + * | |
| 165 | + * @param pdu PDU(功能码 + 数据,不带 slaveId、CRC、MBAP) | |
| 166 | + */ | |
| 167 | + public static void validateModbusRequestPdu(byte[] pdu) { | |
| 168 | + Assert.isTrue(pdu != null && pdu.length >= 5 && pdu.length <= MODBUS_PDU_MAX_BYTES, | |
| 169 | + "PDU 长度必须在 5~{} 字节之间", MODBUS_PDU_MAX_BYTES); | |
| 170 | + int functionCode = pdu[0] & 0xFF; | |
| 171 | + int quantity = ((pdu[3] & 0xFF) << 8) | (pdu[4] & 0xFF); | |
| 172 | + switch (functionCode) { | |
| 173 | + case 0x01: | |
| 174 | + case 0x02: | |
| 175 | + Assert.isTrue(pdu.length == 5 && quantity >= 1 && quantity <= 2000, "PDU 不合法:读线圈数量必须在 1~2000 之间"); | |
| 176 | + break; | |
| 177 | + case 0x03: | |
| 178 | + case 0x04: | |
| 179 | + Assert.isTrue(pdu.length == 5 && quantity >= 1 && quantity <= 125, "PDU 不合法:读寄存器数量必须在 1~125 之间"); | |
| 180 | + break; | |
| 181 | + case 0x05: | |
| 182 | + Assert.isTrue(pdu.length == 5 && (quantity == 0xFF00 || quantity == 0x0000), "PDU 不合法:写单个线圈的值只能是 FF00 或 0000"); | |
| 183 | + break; | |
| 184 | + case 0x06: | |
| 185 | + Assert.isTrue(pdu.length == 5, "PDU 不合法:写单个寄存器必须是 5 字节"); | |
| 186 | + break; | |
| 187 | + case 0x0F: | |
| 188 | + Assert.isTrue(quantity >= 1 && quantity <= 1968 && pdu.length >= 6 && pdu.length == 6 + (pdu[5] & 0xFF) | |
| 189 | + && (pdu[5] & 0xFF) == (quantity + 7) / 8, "PDU 不合法:写多个线圈的数量或字节数不一致"); | |
| 190 | + break; | |
| 191 | + case 0x10: | |
| 192 | + Assert.isTrue(quantity >= 1 && quantity <= 123 && pdu.length >= 6 && pdu.length == 6 + (pdu[5] & 0xFF) | |
| 193 | + && (pdu[5] & 0xFF) == quantity * 2, "PDU 不合法:写多个寄存器的数量或字节数不一致"); | |
| 194 | + break; | |
| 195 | + default: | |
| 196 | + throw new IllegalArgumentException("PDU 不合法:不支持的功能码 " + functionCode); | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 55 | 201 | * 提取消息中的标识符 |
| 56 | 202 | * |
| 57 | 203 | * @param message 消息 | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/IotScriptUtilsTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import java.util.Map; | |
| 9 | + | |
| 10 | +import static org.junit.jupiter.api.Assertions.*; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * {@link IotScriptUtils} 的单元测试 | |
| 14 | + * | |
| 15 | + * @author 芋道源码 | |
| 16 | + */ | |
| 17 | +public class IotScriptUtilsTest { | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + public void testValidateDecode_success() throws IotScriptException { | |
| 21 | + // 调用:一个标准消息对象 | |
| 22 | + IotDeviceMessage message = IotScriptUtils.validateDecode( | |
| 23 | + "{\"method\":\"thing.property.post\",\"params\":{\"t\":25}}", null); | |
| 24 | + // 断言 | |
| 25 | + assertEquals("thing.property.post", message.getMethod()); | |
| 26 | + assertEquals(25, ((Map<?, ?>) message.getParams()).get("t")); | |
| 27 | + assertNull(message.getCode()); | |
| 28 | + assertNull(message.getRaw()); | |
| 29 | + // 调用 + 断言:事件 | |
| 30 | + IotDeviceMessage event = IotScriptUtils.validateDecode( | |
| 31 | + "{\"method\":\"thing.event.post\",\"params\":{\"identifier\":\"alarm\",\"value\":{}}}", null); | |
| 32 | + assertEquals("thing.event.post", event.getMethod()); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Test | |
| 36 | + public void testValidateDecode_null() throws IotScriptException { | |
| 37 | + // 调用 + 断言:decode 未定义或返回 null → 没有业务消息 | |
| 38 | + assertNull(IotScriptUtils.validateDecode(null, null)); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Test | |
| 42 | + public void testValidateDecode_invalid() { | |
| 43 | + // 不是对象、旧的 messages / ack 包装 | |
| 44 | + assertOutputInvalid("[]"); | |
| 45 | + assertOutputInvalid("{\"messages\":[{\"method\":\"thing.property.post\",\"params\":{}}]}"); | |
| 46 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"ack\":[1]}"); | |
| 47 | + // 身份、路由、可信字段和未知键不允许:脚本不能伪造 raw、设备、租户、编号 | |
| 48 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"deviceId\":1}"); | |
| 49 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"tenantId\":1}"); | |
| 50 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"id\":\"x\"}"); | |
| 51 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"raw\":\"AQI=\"}"); | |
| 52 | + assertOutputInvalid("{\"method\":\"thing.property.post\",\"params\":{},\"unknown\":true}"); | |
| 53 | + // 不支持的方法;事件缺 identifier;回执缺 requestId | |
| 54 | + assertOutputInvalid("{\"method\":\"thing.raw.post\",\"params\":{}}"); | |
| 55 | + assertOutputInvalid("{\"method\":\"thing.event.post\",\"params\":{}}"); | |
| 56 | + assertOutputInvalid("{\"method\":\"thing.property.set\",\"code\":0}"); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Test | |
| 60 | + public void testValidateDecode_transactionReply() throws IotScriptException { | |
| 61 | + // 准备参数 | |
| 62 | + IotDeviceMessage request = IotDeviceMessage.requestOf("req-1", "thing.property.set", null); | |
| 63 | + | |
| 64 | + // 调用 + 断言:方法、requestId 一致的回执 | |
| 65 | + IotDeviceMessage reply = IotScriptUtils.validateDecode( | |
| 66 | + "{\"method\":\"thing.property.set\",\"requestId\":\"req-1\",\"code\":0}", request); | |
| 67 | + assertEquals(0, reply.getCode()); | |
| 68 | + // 调用 + 断言:requestId 不一致、不是回执、没有输出 | |
| 69 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode( | |
| 70 | + "{\"method\":\"thing.property.set\",\"requestId\":\"req-2\",\"code\":0}", request)); | |
| 71 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode( | |
| 72 | + "{\"method\":\"thing.property.post\",\"params\":{}}", request)); | |
| 73 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateDecode(null, request)); | |
| 74 | + } | |
| 75 | + | |
| 76 | + @Test | |
| 77 | + public void testValidateEncode() throws IotScriptException { | |
| 78 | + // 未定义或返回 null → 不写设备 | |
| 79 | + assertNull(IotScriptUtils.validateEncode(null, IotProtocolTypeEnum.TCP)); | |
| 80 | + // 走标准路径 | |
| 81 | + assertTrue(IotScriptUtils.validateEncode("{\"standard\":true}", IotProtocolTypeEnum.TCP).isStandard()); | |
| 82 | + // 字节 | |
| 83 | + IotScriptUtils.EncodeOutput output = IotScriptUtils.validateEncode("{\"bytes\":[1,2,255]}", IotProtocolTypeEnum.TCP); | |
| 84 | + assertFalse(output.isStandard()); | |
| 85 | + assertArrayEquals(new byte[]{1, 2, (byte) 0xFF}, output.getBytes()); | |
| 86 | + assertEquals(4096, IotScriptUtils.validateEncode("{\"bytes\":" + buildArray(4096, 0) + "}", | |
| 87 | + IotProtocolTypeEnum.TCP).getBytes().length); | |
| 88 | + // 不合法:standard=false、两者都有、没有 bytes、不是数组、超过 255 | |
| 89 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"standard\":false}", IotProtocolTypeEnum.TCP)); | |
| 90 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"standard\":true,\"bytes\":[1]}", IotProtocolTypeEnum.TCP)); | |
| 91 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{}", IotProtocolTypeEnum.TCP)); | |
| 92 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"bytes\":\"AQI=\"}", IotProtocolTypeEnum.TCP)); | |
| 93 | + assertThrows(IotScriptException.class, () -> IotScriptUtils.validateEncode("{\"bytes\":[256]}", IotProtocolTypeEnum.TCP)); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Test | |
| 97 | + public void testValidateEncode_rawPayload() throws IotScriptException { | |
| 98 | + // 调用 + 断言:其它协议 1~4096 字节,不合法为 OUTPUT_INVALID,带原规则的错误信息 | |
| 99 | + assertEncodeOutputInvalid("{\"bytes\":[]}", IotProtocolTypeEnum.TCP, "raw 长度必须在 1~4096 字节之间"); | |
| 100 | + assertEncodeOutputInvalid("{\"bytes\":" + buildArray(4097, 0) + "}", IotProtocolTypeEnum.TCP, | |
| 101 | + "raw 长度必须在 1~4096 字节之间"); | |
| 102 | + | |
| 103 | + // 调用 + 断言:Modbus 必须是合法的请求 PDU | |
| 104 | + assertArrayEquals(new byte[]{3, 0, 0, 0, 10}, IotScriptUtils.validateEncode("{\"bytes\":[3,0,0,0,10]}", | |
| 105 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT).getBytes()); | |
| 106 | + assertEncodeOutputInvalid("{\"bytes\":[3,0,0,0,0]}", IotProtocolTypeEnum.MODBUS_TCP_SERVER, | |
| 107 | + "PDU 不合法:读寄存器数量必须在 1~125 之间"); | |
| 108 | + // 调用 + 断言:走标准路径时没有字节,不按协议的规则校验 | |
| 109 | + assertTrue(IotScriptUtils.validateEncode("{\"standard\":true}", IotProtocolTypeEnum.MODBUS_TCP_CLIENT).isStandard()); | |
| 110 | + } | |
| 111 | + | |
| 112 | + private static void assertOutputInvalid(String json) { | |
| 113 | + IotScriptException ex = assertThrows(IotScriptException.class, | |
| 114 | + () -> IotScriptUtils.validateDecode(json, null), json); | |
| 115 | + assertEquals(IotScriptResult.Status.OUTPUT_INVALID, ex.getStatus()); | |
| 116 | + } | |
| 117 | + | |
| 118 | + private static void assertEncodeOutputInvalid(String json, IotProtocolTypeEnum protocol, String message) { | |
| 119 | + IotScriptException ex = assertThrows(IotScriptException.class, | |
| 120 | + () -> IotScriptUtils.validateEncode(json, protocol)); | |
| 121 | + assertEquals(IotScriptResult.Status.OUTPUT_INVALID, ex.getStatus()); | |
| 122 | + assertEquals(message, ex.getMessage()); | |
| 123 | + } | |
| 124 | + | |
| 125 | + private static String buildArray(int size, int value) { | |
| 126 | + StringBuilder sb = new StringBuilder("["); | |
| 127 | + for (int i = 0; i < size; i++) { | |
| 128 | + sb.append(i > 0 ? "," : "").append(value); | |
| 129 | + } | |
| 130 | + return sb.append("]").toString(); | |
| 131 | + } | |
| 132 | + | |
| 133 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/script/rhino/IotRhinoScriptExecutorTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.core.script.rhino; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.ReflectUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 9 | +import org.junit.jupiter.api.AfterEach; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | + | |
| 13 | +import java.util.concurrent.CountDownLatch; | |
| 14 | +import java.util.concurrent.Executors; | |
| 15 | +import java.util.concurrent.Future; | |
| 16 | +import java.util.concurrent.ScheduledExecutorService; | |
| 17 | +import java.util.concurrent.ScheduledFuture; | |
| 18 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 19 | +import java.util.concurrent.TimeUnit; | |
| 20 | + | |
| 21 | +import static org.junit.jupiter.api.Assertions.*; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * {@link IotRhinoScriptExecutor} 的单元测试:真实的 Rhino 解释执行 | |
| 25 | + * | |
| 26 | + * @author 芋道源码 | |
| 27 | + */ | |
| 28 | +public class IotRhinoScriptExecutorTest { | |
| 29 | + | |
| 30 | + private static final String CTX = "{\"bytes\":[1,2,3],\"hex\":\"010203\"}"; | |
| 31 | + | |
| 32 | + private IotRhinoScriptExecutor executor; | |
| 33 | + | |
| 34 | + @BeforeEach | |
| 35 | + public void setUp() { | |
| 36 | + executor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options().setName("test-script-js").setPoolSize(1) | |
| 37 | + .setQueueCapacity(4).setExecBudgetMillis(500).setMaxInstructions(1_000_000)); | |
| 38 | + } | |
| 39 | + | |
| 40 | + @AfterEach | |
| 41 | + public void tearDown() { | |
| 42 | + executor.shutdown(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + private IotScriptResult invoke(String content, String function) { | |
| 46 | + return executor.execute(IotScriptSource.of(1L, content), function, CTX, | |
| 47 | + System.nanoTime() + TimeUnit.SECONDS.toNanos(3)); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + public void testExecute_decodeSuccess() { | |
| 52 | + // 准备参数:宿主侧 JSON.parse 入参,函数返回原生对象,宿主侧 JSON.stringify | |
| 53 | + String content = "function decode(ctx) { return {method: 'thing.property.post'," | |
| 54 | + + " params: {sum: ctx.bytes[0] + ctx.bytes[1] + ctx.bytes[2], hex: ctx.hex}}; }"; | |
| 55 | + | |
| 56 | + // 调用 | |
| 57 | + IotScriptResult result = invoke(content, IotScriptUtils.FUNCTION_DECODE); | |
| 58 | + // 断言 | |
| 59 | + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); | |
| 60 | + JsonNode root = JsonUtils.parseTree(result.getJson()); | |
| 61 | + assertEquals(6, root.get("params").get("sum").intValue()); | |
| 62 | + assertEquals("010203", root.get("params").get("hex").textValue()); | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Test | |
| 66 | + public void testExecute_functionMissing() { | |
| 67 | + // 调用:没有定义 encode | |
| 68 | + IotScriptResult result = invoke("function decode(ctx) { return null; }", IotScriptUtils.FUNCTION_ENCODE); | |
| 69 | + // 断言:成功但没有输出,由调用方处理 | |
| 70 | + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); | |
| 71 | + assertNull(result.getJson()); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test | |
| 75 | + public void testExecute_javaAccessDenied() { | |
| 76 | + // 调用:java、Packages、getClass 都不可用 | |
| 77 | + String content = "function decode(ctx) { return {method: 'thing.property.post', params: {" | |
| 78 | + + "java: typeof java === 'undefined' ? 1 : 0, packages: typeof Packages === 'undefined' ? 1 : 0}}; }"; | |
| 79 | + IotScriptResult result = invoke(content, IotScriptUtils.FUNCTION_DECODE); | |
| 80 | + // 断言 | |
| 81 | + assertEquals(IotScriptResult.Status.SUCCESS, result.getStatus()); | |
| 82 | + assertEquals("{\"java\":1,\"packages\":1}", JsonUtils.parseTree(result.getJson()).get("params").toString()); | |
| 83 | + | |
| 84 | + // 调用:显式访问 Java 类 | |
| 85 | + IotScriptResult result2 = invoke("function decode(ctx) { return {method: 'thing.property.post'," | |
| 86 | + + " params: {home: java.lang.System.getProperty('user.home').length}}; }", IotScriptUtils.FUNCTION_DECODE); | |
| 87 | + // 断言:脚本错误,不是成功 | |
| 88 | + assertEquals(IotScriptResult.Status.ERROR, result2.getStatus()); | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + public void testExecute_infiniteLoopTimeoutAndReuse() { | |
| 93 | + // 调用:死循环被预算中断 | |
| 94 | + IotScriptResult result = invoke("function decode(ctx) { while (true) {} }", IotScriptUtils.FUNCTION_DECODE); | |
| 95 | + // 断言 | |
| 96 | + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); | |
| 97 | + | |
| 98 | + // 调用:同一个线程继续可用(线程池只有 1 个线程) | |
| 99 | + IotScriptResult result2 = invoke("function decode(ctx) { return null; }", IotScriptUtils.FUNCTION_DECODE); | |
| 100 | + // 断言 | |
| 101 | + assertEquals(IotScriptResult.Status.SUCCESS, result2.getStatus()); | |
| 102 | + } | |
| 103 | + | |
| 104 | + @Test | |
| 105 | + public void testExecute_breakerOpenAfterTimeouts() { | |
| 106 | + // 准备参数:同一个脚本 1 分钟内超时 3 次 | |
| 107 | + String content = "function decode(ctx) { for (;;) {} }"; | |
| 108 | + for (int i = 0; i < 3; i++) { | |
| 109 | + assertEquals(IotScriptResult.Status.TIMEOUT, invoke(content, IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // 调用 + 断言:之后直接拒绝;其它脚本不受影响 | |
| 113 | + assertTrue(executor.isOpen(IotScriptSource.of(1L, content))); | |
| 114 | + assertEquals(IotScriptResult.Status.REJECTED, invoke(content, IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 115 | + assertEquals(IotScriptResult.Status.SUCCESS, invoke("function decode(ctx) { return null; }", | |
| 116 | + IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Test | |
| 120 | + public void testExecute_deadlinePassed() { | |
| 121 | + // 调用:截止时间已过 | |
| 122 | + IotScriptResult result = executor.execute(IotScriptSource.of(1L, "function decode(ctx) { return null; }"), | |
| 123 | + IotScriptUtils.FUNCTION_DECODE, CTX, System.nanoTime() - 1); | |
| 124 | + // 断言:不进入 JS 池 | |
| 125 | + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); | |
| 126 | + } | |
| 127 | + | |
| 128 | + @Test | |
| 129 | + public void testExecute_pendingTimeoutNeverStarts() throws Exception { | |
| 130 | + // 准备参数:用一个 Java 任务确定地占住唯一的 JS 线程(不依赖脚本预算与睡眠); | |
| 131 | + // 该线程还没有执行过脚本(没有预热),所以它的 runtime 为空:任何脚本一旦在它上面启动,都会创建 runtime | |
| 132 | + ThreadPoolExecutor jsPool = (ThreadPoolExecutor) ReflectUtil.getFieldValue(executor, "jsPool"); | |
| 133 | + ThreadLocal<?> runtimes = (ThreadLocal<?>) ReflectUtil.getFieldValue(executor, "runtimes"); | |
| 134 | + CountDownLatch entered = new CountDownLatch(1); | |
| 135 | + CountDownLatch release = new CountDownLatch(1); | |
| 136 | + jsPool.execute(() -> { | |
| 137 | + entered.countDown(); | |
| 138 | + try { | |
| 139 | + release.await(10, TimeUnit.SECONDS); | |
| 140 | + } catch (InterruptedException e) { | |
| 141 | + Thread.currentThread().interrupt(); | |
| 142 | + } | |
| 143 | + }); | |
| 144 | + assertTrue(entered.await(5, TimeUnit.SECONDS)); | |
| 145 | + | |
| 146 | + // 调用:排队中的任务 100ms 后到期 | |
| 147 | + IotScriptSource pending = IotScriptSource.of(2L, "function decode(ctx) { return {method: 'thing.property.post'}; }"); | |
| 148 | + IotScriptResult result = executor.execute(pending, IotScriptUtils.FUNCTION_DECODE, CTX, | |
| 149 | + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(100)); | |
| 150 | + // 断言:调用方按截止时间返回超时;任务仍在 JS 池的队列里,没有启动 | |
| 151 | + assertEquals(IotScriptResult.Status.TIMEOUT, result.getStatus()); | |
| 152 | + assertEquals(1, jsPool.getQueue().size()); | |
| 153 | + | |
| 154 | + // 调用:放开 JS 线程,再提交一个探测任务;单线程 FIFO,探测任务执行时,排队超时的任务已经出队 | |
| 155 | + release.countDown(); | |
| 156 | + Future<Boolean> runtimeCreated = jsPool.submit(() -> runtimes.get() != null); | |
| 157 | + // 断言:排队超时的任务出队后没有启动脚本(JS 线程上仍没有创建 runtime) | |
| 158 | + assertFalse(runtimeCreated.get(5, TimeUnit.SECONDS)); | |
| 159 | + // 断言:排队超时的任务不计入熔断;JS 线程可以继续使用,之后真正执行了脚本 | |
| 160 | + assertFalse(executor.isOpen(pending)); | |
| 161 | + assertEquals(IotScriptResult.Status.SUCCESS, invoke("function decode(ctx) { return null; }", | |
| 162 | + IotScriptUtils.FUNCTION_DECODE).getStatus()); | |
| 163 | + assertTrue(jsPool.submit(() -> runtimes.get() != null).get(5, TimeUnit.SECONDS)); | |
| 164 | + } | |
| 165 | + | |
| 166 | + @Test | |
| 167 | + public void testScriptTask_finishOnce() { | |
| 168 | + // 准备参数:执行中的任务,预算计时器 1 小时后才会触发 | |
| 169 | + ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); | |
| 170 | + try { | |
| 171 | + IotScriptSource source = IotScriptSource.of(1L, "function decode(ctx) { return null; }"); | |
| 172 | + IotRhinoScriptExecutor.IotScriptTask task = new IotRhinoScriptExecutor.IotScriptTask(source); | |
| 173 | + assertTrue(task.markRunning()); | |
| 174 | + ScheduledFuture<?> budgetTimer = timer.schedule(() -> { }, 1, TimeUnit.HOURS); | |
| 175 | + task.setBudgetTimer(budgetTimer); | |
| 176 | + | |
| 177 | + // 调用 + 断言:第一次终结成功,并取消预算计时器 | |
| 178 | + assertTrue(task.finish()); | |
| 179 | + assertTrue(task.isFinished()); | |
| 180 | + assertTrue(budgetTimer.isCancelled()); | |
| 181 | + // 调用 + 断言:重复终结失败(迟到的结果直接丢弃) | |
| 182 | + assertFalse(task.finish()); | |
| 183 | + | |
| 184 | + // 调用 + 断言:排队中已被终结的任务,之后不能再开始 | |
| 185 | + IotRhinoScriptExecutor.IotScriptTask pending = new IotRhinoScriptExecutor.IotScriptTask(source); | |
| 186 | + assertTrue(pending.finish()); | |
| 187 | + assertFalse(pending.markRunning()); | |
| 188 | + } finally { | |
| 189 | + timer.shutdownNow(); | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + @Test | |
| 194 | + public void testCompileOnly() { | |
| 195 | + // 调用 + 断言:语法正确,不执行顶层代码(死循环也不会卡住) | |
| 196 | + assertTrue(executor.compileOnly("while (true) {} function decode(ctx) {}", 2, TimeUnit.SECONDS).isSuccess()); | |
| 197 | + // 调用 + 断言:语法错误带行号(第 2 行只有一个不能作为语句开头的 ")",解析器在该 token 上立即报错,行号无歧义) | |
| 198 | + IotScriptResult result = executor.compileOnly("var a = 1;\n)\nfunction decode(ctx) {}", 2, TimeUnit.SECONDS); | |
| 199 | + assertEquals(IotScriptResult.Status.ERROR, result.getStatus()); | |
| 200 | + assertEquals(2, result.getLineNumber()); | |
| 201 | + } | |
| 202 | + | |
| 203 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/test/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtilsTest.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.core.util; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 4 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 4 | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | 6 | import cn.iocoder.yudao.module.iot.core.topic.event.IotDeviceEventPostReqDTO; |
| 6 | 7 | import org.junit.jupiter.api.Test; |
| ... | ... | @@ -348,4 +349,78 @@ public class IotDeviceMessageUtilsTest { |
| 348 | 349 | assertTrue(identifiers.contains("humidity")); |
| 349 | 350 | } |
| 350 | 351 | |
| 352 | + @Test | |
| 353 | + public void testEncodeRaw_decodeRaw() { | |
| 354 | + // 标准 Base64(带 padding)往返 | |
| 355 | + byte[] bytes = new byte[]{0x01, 0x02, (byte) 0xFF, 0x00}; | |
| 356 | + String raw = IotDeviceMessageUtils.encodeRaw(bytes); | |
| 357 | + assertEquals("AQL/AA==", raw); | |
| 358 | + assertArrayEquals(bytes, IotDeviceMessageUtils.decodeRaw(raw, 4)); | |
| 359 | + // 上限边界:4096 字节 | |
| 360 | + byte[] max = new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]; | |
| 361 | + assertArrayEquals(max, IotDeviceMessageUtils.decodeRaw(IotDeviceMessageUtils.encodeRaw(max), | |
| 362 | + IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES)); | |
| 363 | + } | |
| 364 | + | |
| 365 | + @Test | |
| 366 | + public void testDecodeRaw_invalid() { | |
| 367 | + // 为空、不是 Base64 | |
| 368 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw(null, 10)); | |
| 369 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("", 10)); | |
| 370 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("!!not-base64!!", 10)); | |
| 371 | + // 不规范:省略 padding、末尾多余的位不为 0(与 AQI= 解出相同字节) | |
| 372 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("AQI", 10)); | |
| 373 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.decodeRaw("AQJ=", 10)); | |
| 374 | + // 超过上限:Modbus PDU 253 字节 | |
| 375 | + String tooLong = IotDeviceMessageUtils.encodeRaw(new byte[IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES + 1]); | |
| 376 | + assertThrows(IllegalArgumentException.class, | |
| 377 | + () -> IotDeviceMessageUtils.decodeRaw(tooLong, IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES)); | |
| 378 | + } | |
| 379 | + | |
| 380 | + @Test | |
| 381 | + public void testDecodeRawPayload_withoutPadding() { | |
| 382 | + // 管理后台的 payload 允许省略 padding,由调用方规范化 | |
| 383 | + Map<String, Object> params = new HashMap<>(); | |
| 384 | + params.put("payload", "AQI"); | |
| 385 | + assertArrayEquals(new byte[]{0x01, 0x02}, IotDeviceMessageUtils.decodeRawPayload(params, 10)); | |
| 386 | + } | |
| 387 | + | |
| 388 | + @Test | |
| 389 | + public void testIsCommandRequest() { | |
| 390 | + // 下行指令的请求:property.set / service.invoke | |
| 391 | + assertTrue(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.property.set"))); | |
| 392 | + assertTrue(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.service.invoke"))); | |
| 393 | + // 其它方法、回执不是 | |
| 394 | + assertFalse(IotDeviceMessageUtils.isCommandRequest(IotDeviceMessage.requestOf("thing.ota.upgrade"))); | |
| 395 | + assertFalse(IotDeviceMessageUtils.isCommandRequest( | |
| 396 | + IotDeviceMessage.replyOf("1", "thing.property.set", null, 0, null))); | |
| 397 | + } | |
| 398 | + | |
| 399 | + @Test | |
| 400 | + public void testValidateRawPayload() { | |
| 401 | + // 通用协议:1~4096 字节 | |
| 402 | + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[]{0x01}); | |
| 403 | + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.MQTT, | |
| 404 | + new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES]); | |
| 405 | + assertThrows(IllegalArgumentException.class, | |
| 406 | + () -> IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.TCP, new byte[0])); | |
| 407 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 408 | + IotProtocolTypeEnum.TCP, new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES + 1])); | |
| 409 | + } | |
| 410 | + | |
| 411 | + @Test | |
| 412 | + public void testValidateRawPayload_modbus() { | |
| 413 | + // 合法的 03 读保持寄存器:地址 0、数量 10 | |
| 414 | + IotDeviceMessageUtils.validateRawPayload(IotProtocolTypeEnum.MODBUS_TCP_CLIENT, new byte[]{3, 0, 0, 0, 10}); | |
| 415 | + // 不合法:不支持的功能码、数量超限、超过 253 字节(通用协议允许的长度,Modbus 也拒绝) | |
| 416 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 417 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, new byte[]{8, 0, 0, 0, 1})); | |
| 418 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 419 | + IotProtocolTypeEnum.MODBUS_TCP_SERVER, new byte[]{3, 0, 0, 0, 126})); | |
| 420 | + byte[] tooLong = new byte[IotDeviceMessageUtils.MODBUS_PDU_MAX_BYTES + 1]; | |
| 421 | + tooLong[0] = 0x10; | |
| 422 | + assertThrows(IllegalArgumentException.class, () -> IotDeviceMessageUtils.validateRawPayload( | |
| 423 | + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, tooLong)); | |
| 424 | + } | |
| 425 | + | |
| 351 | 426 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/pom.xml
| ... | ... | @@ -12,8 +12,9 @@ |
| 12 | 12 | <name>${project.artifactId}</name> |
| 13 | 13 | <description> |
| 14 | 14 | iot 模块下,设备网关: |
| 15 | - ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息网关,提供给 iot-biz 进行处理 | |
| 16 | - ② 功能二:接收来自消息网关的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 | |
| 15 | + ① 功能一:接收来自设备的消息,并进行解码(decode)后,发送到消息总线,提供给 iot-biz 进行处理 | |
| 16 | + ② 功能二:接收来自消息总线的消息(由 iot-biz 发送),并进行编码(encode)后,发送给设备 | |
| 17 | + 目前已支持 HTTP、MQTT、EMQX、TCP、UDP、WebSocket、CoAP、Modbus 等协议 | |
| 17 | 18 | </description> |
| 18 | 19 | |
| 19 | 20 | <dependencies> | ... | ... |
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 |
| 13 | 13 | import jakarta.validation.Valid; |
| 14 | 14 | import jakarta.validation.constraints.NotEmpty; |
| 15 | 15 | import jakarta.validation.constraints.NotNull; |
| 16 | +import jakarta.validation.constraints.Positive; | |
| 16 | 17 | import lombok.Data; |
| 17 | 18 | import org.springframework.boot.context.properties.ConfigurationProperties; |
| 18 | 19 | import org.springframework.validation.annotation.Validated; |
| ... | ... | @@ -33,6 +34,11 @@ public class IotGatewayProperties { |
| 33 | 34 | * Token 配置 |
| 34 | 35 | */ |
| 35 | 36 | private TokenProperties token; |
| 37 | + /** | |
| 38 | + * 数据解析脚本配置 | |
| 39 | + */ | |
| 40 | + @Valid | |
| 41 | + private ScriptProperties script = new ScriptProperties(); | |
| 36 | 42 | |
| 37 | 43 | /** |
| 38 | 44 | * 协议实例列表 |
| ... | ... | @@ -77,6 +83,41 @@ public class IotGatewayProperties { |
| 77 | 83 | } |
| 78 | 84 | |
| 79 | 85 | /** |
| 86 | + * 数据解析脚本配置 | |
| 87 | + */ | |
| 88 | + @Data | |
| 89 | + public static class ScriptProperties { | |
| 90 | + | |
| 91 | + /** | |
| 92 | + * 是否开启数据解析脚本功能(运维开关,默认关闭) | |
| 93 | + * <p> | |
| 94 | + * 关闭时整体绕开脚本:不执行、不预热;raw 上行纯透传,标准下行与未开启时相同,raw.push 照常写出 | |
| 95 | + */ | |
| 96 | + private boolean enabled = false; | |
| 97 | + /** | |
| 98 | + * 透传处理的截止时间:raw 上行(含 HTTP / CoAP 同一请求内应答)、脚本下行、透传回复,从收到(入队)开始计算, | |
| 99 | + * 覆盖排队与执行脚本 | |
| 100 | + */ | |
| 101 | + @NotNull(message = "透传处理截止时间不能为空") | |
| 102 | + private Duration deadline = Duration.ofMillis(2000); | |
| 103 | + /** | |
| 104 | + * 单次脚本执行的时间预算:与截止时间取较早者 | |
| 105 | + */ | |
| 106 | + @NotNull(message = "脚本执行时间预算不能为空") | |
| 107 | + private Duration execBudget = Duration.ofMillis(500); | |
| 108 | + /** | |
| 109 | + * 单次脚本执行的指令上限 | |
| 110 | + */ | |
| 111 | + private long maxInstructions = 1_000_000; | |
| 112 | + /** | |
| 113 | + * 每个设备最多排队的脚本下行指令数(不含执行中的一条):超过时丢弃并记日志 | |
| 114 | + */ | |
| 115 | + @Positive(message = "每个设备最多排队的下行指令数必须大于 0") | |
| 116 | + private int maxPendingCommandsPerDevice = 256; | |
| 117 | + | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** | |
| 80 | 121 | * 协议实例配置 |
| 81 | 122 | */ |
| 82 | 123 | @Data | ... | ... |
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 { |
| 16 | 16 | // ========== 设备信息 1-050-002-000 ============ |
| 17 | 17 | ErrorCode DEVICE_NOT_EXISTS = new ErrorCode(1_051_002_001, "设备({}/{}) 不存在"); |
| 18 | 18 | |
| 19 | + // ========== 设备透传与数据解析脚本 1-051-003-000 ============ | |
| 20 | + ErrorCode DEVICE_MESSAGE_RAW_METHOD_RESERVED = new ErrorCode(1_051_003_000, "透传方法只能由网关 raw 入口使用"); | |
| 21 | + ErrorCode SCRIPT_EXECUTE_FAILED = new ErrorCode(1_051_003_100, "数据解析脚本执行失败:{}"); | |
| 22 | + ErrorCode SCRIPT_TIMEOUT = new ErrorCode(1_051_003_101, "处理超时:未在截止时间内完成(排队、加载配置或脚本执行)"); | |
| 23 | + ErrorCode SCRIPT_REJECTED = new ErrorCode(1_051_003_102, "数据解析脚本繁忙或已熔断"); | |
| 24 | + ErrorCode SCRIPT_OUTPUT_INVALID = new ErrorCode(1_051_003_103, "数据解析脚本输出不合法:{}"); | |
| 25 | + ErrorCode RAW_NOT_SUPPORTED = new ErrorCode(1_051_003_105, "当前连接不支持透传下发"); | |
| 26 | + ErrorCode DEVICE_OFFLINE_OR_WRITE_FAILED = new ErrorCode(1_051_003_106, "设备不在线或写入失败"); | |
| 27 | + ErrorCode MODBUS_TIMEOUT = new ErrorCode(1_051_003_110, "Modbus 响应超时"); | |
| 28 | + ErrorCode MODBUS_EXCEPTION = new ErrorCode(1_051_003_111, "Modbus 异常响应(异常码 {})"); | |
| 29 | + ErrorCode MODBUS_PDU_INVALID = new ErrorCode(1_051_003_112, "Modbus PDU 不合法:{}"); | |
| 30 | + ErrorCode MODBUS_QUEUE_FULL = new ErrorCode(1_051_003_113, "Modbus 事务队列已满"); | |
| 31 | + ErrorCode MODBUS_RESPONSE_INVALID = new ErrorCode(1_051_003_114, "Modbus 响应不合法"); | |
| 32 | + ErrorCode MODBUS_DISCONNECTED = new ErrorCode(1_051_003_115, "Modbus 连接已断开"); | |
| 33 | + | |
| 19 | 34 | } |
| 20 | 35 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.util.StrUtil; |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 4 | 8 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 5 | 9 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; |
| 6 | 10 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 11 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 7 | 12 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO; | |
| 16 | +import io.vertx.core.Future; | |
| 8 | 17 | import lombok.AllArgsConstructor; |
| 9 | 18 | import lombok.extern.slf4j.Slf4j; |
| 10 | 19 | |
| 20 | +import java.util.ArrayDeque; | |
| 21 | +import java.util.Collections; | |
| 22 | +import java.util.Deque; | |
| 23 | +import java.util.HashMap; | |
| 24 | +import java.util.Map; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; | |
| 28 | + | |
| 11 | 29 | /** |
| 12 | 30 | * IoT 协议下行消息订阅者抽象类 |
| 13 | 31 | * |
| ... | ... | @@ -23,6 +41,34 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 23 | 41 | |
| 24 | 42 | private final IotMessageBus messageBus; |
| 25 | 43 | |
| 44 | + /** | |
| 45 | + * 有排队 / 执行中脚本指令的设备:设备编号 → 待执行的指令(第一条已在 worker 上执行时,队列可为空) | |
| 46 | + * <p> | |
| 47 | + * 总线的消费线程与 worker 都会访问,用它自身加锁;设备的指令全部执行完后移除 | |
| 48 | + */ | |
| 49 | + private final Map<Long, Deque<Runnable>> deviceCommandQueues = new HashMap<>(); | |
| 50 | + | |
| 51 | + /** | |
| 52 | + * @return 协议类型:encode 的入参与字节规则按协议区分 | |
| 53 | + */ | |
| 54 | + public IotProtocolTypeEnum getProtocolType() { | |
| 55 | + return protocol.getType(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * @return 是否支持透传下发:不支持时不执行编码脚本,指令直接走标准路径 | |
| 60 | + */ | |
| 61 | + public boolean supportsRawDownstream() { | |
| 62 | + return false; | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * @return 本协议实例的 serverId(可信来源):回复设备响应时显式传入 | |
| 67 | + */ | |
| 68 | + public String getServerId() { | |
| 69 | + return protocol.getServerId(); | |
| 70 | + } | |
| 71 | + | |
| 26 | 72 | @Override |
| 27 | 73 | public String getTopic() { |
| 28 | 74 | return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId()); |
| ... | ... | @@ -45,6 +91,10 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 45 | 91 | @Override |
| 46 | 92 | public void stop() { |
| 47 | 93 | messageBus.unregister(this); |
| 94 | + // 丢弃排队中的指令:执行中的那一条照常结束,之后不再执行 | |
| 95 | + synchronized (deviceCommandQueues) { | |
| 96 | + deviceCommandQueues.clear(); | |
| 97 | + } | |
| 48 | 98 | log.info("[stop][{} 下行消息订阅已停止,Topic:{}]", protocol.getType().name(), getTopic()); |
| 49 | 99 | } |
| 50 | 100 | |
| ... | ... | @@ -61,7 +111,27 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 61 | 111 | return; |
| 62 | 112 | } |
| 63 | 113 | |
| 64 | - // 2. 处理下行消息 | |
| 114 | + // 2.1 透传下发(raw.push):字节在 raw 字段,不经过脚本,直接交给协议写出(功能开关关闭时也照常); | |
| 115 | + // 协议自己的字节规则(例如请求 PDU)由协议写出前校验 | |
| 116 | + if (IotDeviceMessageMethodEnum.RAW_PUSH.getMethod().equals(method) | |
| 117 | + && !IotDeviceMessageUtils.isReplyMessage(message)) { | |
| 118 | + writeRawMessage(message, IotDeviceMessageUtils.decodeRaw(message.getRaw(), | |
| 119 | + IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES), null); | |
| 120 | + return; | |
| 121 | + } | |
| 122 | + // 2.2 指令(property.set / service.invoke):功能开关开启、协议支持透传下发时,按设备排队,切到协议的 worker 上 | |
| 123 | + // 按产品的数据解析脚本 encode(加载设备信息、执行脚本会阻塞),再透传写出或走标准路径。 | |
| 124 | + // 同一设备的指令(包括 encode 后走标准路径的)按收到的顺序执行,不同设备之间并行 | |
| 125 | + IotGatewayProperties.ScriptProperties script = SpringUtil.getBean(IotGatewayProperties.class).getScript(); | |
| 126 | + if (script.isEnabled() && supportsRawDownstream() && IotDeviceMessageUtils.isCommandRequest(message)) { | |
| 127 | + // 截止时间从收到(按设备排队之前)开始计算 | |
| 128 | + IotDeviceMessageService deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); | |
| 129 | + long receivedNanos = System.nanoTime(); | |
| 130 | + enqueueDeviceCommand(message, () -> handleScriptCommand(deviceMessageService, message, receivedNanos), | |
| 131 | + script.getMaxPendingCommandsPerDevice()); | |
| 132 | + return; | |
| 133 | + } | |
| 134 | + // 2.3 其它(OTA、影子、配置、回复等,功能开关关闭,或协议不支持透传下发):标准路径 | |
| 65 | 135 | handleMessage(message); |
| 66 | 136 | } catch (Exception e) { |
| 67 | 137 | log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]", |
| ... | ... | @@ -70,10 +140,182 @@ public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMess |
| 70 | 140 | } |
| 71 | 141 | |
| 72 | 142 | /** |
| 143 | + * 按设备排队脚本指令:同一设备按提交顺序在 worker 上逐条执行,不同设备之间并行; | |
| 144 | + * 每个设备最多排队 maxPending 条(不含执行中的一条),超过时丢弃并记日志 | |
| 145 | + * | |
| 146 | + * @param message 下行指令 | |
| 147 | + * @param task 阻塞任务 | |
| 148 | + * @param maxPending 每个设备最多排队的指令数 | |
| 149 | + */ | |
| 150 | + private void enqueueDeviceCommand(IotDeviceMessage message, Runnable task, int maxPending) { | |
| 151 | + // 1. 没有设备编号(不应出现):无法保序,直接执行 | |
| 152 | + Long deviceId = message.getDeviceId(); | |
| 153 | + if (deviceId == null) { | |
| 154 | + executeCommands(null, new ArrayDeque<>(Collections.singletonList(task))); | |
| 155 | + return; | |
| 156 | + } | |
| 157 | + ArrayDeque<Runnable> tasks; | |
| 158 | + synchronized (deviceCommandQueues) { | |
| 159 | + // 2. 设备还有排队 / 执行中的指令:排在它们之后,由正在执行的 worker 依次执行;队列已满时丢弃 | |
| 160 | + Deque<Runnable> pending = deviceCommandQueues.get(deviceId); | |
| 161 | + if (pending != null) { | |
| 162 | + if (pending.size() >= maxPending) { | |
| 163 | + log.error("[enqueueDeviceCommand][设备({}) 待处理的下行指令已达上限 {},丢弃 messageId: {}, method: {}]", | |
| 164 | + deviceId, maxPending, message.getId(), message.getMethod()); | |
| 165 | + } else { | |
| 166 | + pending.add(task); | |
| 167 | + } | |
| 168 | + return; | |
| 169 | + } | |
| 170 | + // 3. 设备没有在途的指令:新建队列,交给一个 worker 执行 | |
| 171 | + tasks = new ArrayDeque<>(); | |
| 172 | + tasks.add(task); | |
| 173 | + deviceCommandQueues.put(deviceId, tasks); | |
| 174 | + } | |
| 175 | + executeCommands(deviceId, tasks); | |
| 176 | + } | |
| 177 | + | |
| 178 | + /** | |
| 179 | + * 在一个 worker 上依次执行设备的指令队列,直到队列为空(或已被 {@link #stop()} 清空);单条失败只记日志,不影响后续指令 | |
| 180 | + * | |
| 181 | + * @param deviceId 设备编号(为 null 时只执行给定的任务) | |
| 182 | + * @param tasks 设备的指令队列 | |
| 183 | + */ | |
| 184 | + private void executeCommands(Long deviceId, Deque<Runnable> tasks) { | |
| 185 | + try { | |
| 186 | + protocol.getVertx().executeBlocking(() -> { | |
| 187 | + while (true) { | |
| 188 | + // 1. 取下一条:队列空了就移除(之后的指令新建队列);队列已被 stop 清掉或换成新的一代,不再执行 | |
| 189 | + Runnable task; | |
| 190 | + synchronized (deviceCommandQueues) { | |
| 191 | + task = tasks.poll(); | |
| 192 | + if (task == null) { | |
| 193 | + if (deviceId != null) { | |
| 194 | + deviceCommandQueues.remove(deviceId, tasks); | |
| 195 | + } | |
| 196 | + return null; | |
| 197 | + } | |
| 198 | + // 已停止 | |
| 199 | + if (deviceId != null && deviceCommandQueues.get(deviceId) != tasks) { | |
| 200 | + return null; | |
| 201 | + } | |
| 202 | + } | |
| 203 | + // 2. 执行:单条失败只记日志,不影响后续指令 | |
| 204 | + try { | |
| 205 | + task.run(); | |
| 206 | + } catch (Exception e) { | |
| 207 | + log.error("[executeCommands][设备({}) 处理下行指令失败]", deviceId, e); | |
| 208 | + } | |
| 209 | + } | |
| 210 | + }, false).onFailure(e -> { | |
| 211 | + // 3. 提交到 worker 失败:丢弃排队的指令,移除队列 | |
| 212 | + log.error("[executeCommands][设备({}) 下行指令无法提交到 worker,丢弃排队的指令]", deviceId, e); | |
| 213 | + removeDeviceCommandQueue(deviceId, tasks); | |
| 214 | + }); | |
| 215 | + } catch (Exception e) { | |
| 216 | + // Vertx 已关闭时 worker 池会同步拒绝 | |
| 217 | + log.error("[executeCommands][设备({}) 下行指令无法提交到 worker,丢弃排队的指令]", deviceId, e); | |
| 218 | + removeDeviceCommandQueue(deviceId, tasks); | |
| 219 | + } | |
| 220 | + } | |
| 221 | + | |
| 222 | + /** | |
| 223 | + * 移除设备的指令队列:只移除给定的这一代(已被 stop 清掉或换成新一代时不影响新队列);排队中的指令随之丢弃 | |
| 224 | + * | |
| 225 | + * @param deviceId 设备编号(为 null 时没有队列,直接返回) | |
| 226 | + * @param tasks 设备的指令队列 | |
| 227 | + */ | |
| 228 | + private void removeDeviceCommandQueue(Long deviceId, Deque<Runnable> tasks) { | |
| 229 | + if (deviceId == null) { | |
| 230 | + return; | |
| 231 | + } | |
| 232 | + synchronized (deviceCommandQueues) { | |
| 233 | + deviceCommandQueues.remove(deviceId, tasks); | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * 在 worker 上处理脚本指令:按 encode 的结果走标准路径、透传写出,或者不下发(设备消息 Service 已记日志) | |
| 239 | + * | |
| 240 | + * @param deviceMessageService 设备消息 Service(在总线消费线程获取) | |
| 241 | + * @param message 下行指令 | |
| 242 | + * @param receivedNanos 收到指令的时刻(System.nanoTime):按设备排队之前取得;设备消息 Service 据此计算截止, | |
| 243 | + * 返回结果之前已核对,拿到结果立即下发 | |
| 244 | + */ | |
| 245 | + private void handleScriptCommand(IotDeviceMessageService deviceMessageService, IotDeviceMessage message, | |
| 246 | + long receivedNanos) { | |
| 247 | + // 1. encode:null 表示不下发(设备消息 Service 已记日志) | |
| 248 | + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(message, getProtocolType(), | |
| 249 | + receivedNanos); | |
| 250 | + if (result == null) { | |
| 251 | + return; | |
| 252 | + } | |
| 253 | + // 2.1 走标准路径:与未启用脚本一致,由子类按协议的标准格式下发 | |
| 254 | + if (result.isStandard()) { | |
| 255 | + handleMessage(message); | |
| 256 | + return; | |
| 257 | + } | |
| 258 | + // 2.2 透传写出 encode 的字节,带上 encode 时固定的脚本(设备响应用它解析) | |
| 259 | + writeRawMessage(message, result.getBytes(), result.getSource()); | |
| 260 | + } | |
| 261 | + | |
| 262 | + /** | |
| 73 | 263 | * 处理下行消息 |
| 74 | 264 | * |
| 75 | 265 | * @param message 下行消息 |
| 76 | 266 | */ |
| 77 | 267 | protected abstract void handleMessage(IotDeviceMessage message); |
| 78 | 268 | |
| 269 | + /** | |
| 270 | + * 透传写出:交给协议写出({@link #handleRawMessage}),写出失败只记日志 | |
| 271 | + * | |
| 272 | + * @param message 下行消息(raw.push,或 encode 的 property.set / service.invoke) | |
| 273 | + * @param bytes 要写出的字节 | |
| 274 | + * @param source encode 时使用的脚本(固定 hash);raw.push 为 null | |
| 275 | + */ | |
| 276 | + private void writeRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 277 | + Future<Void> write = handleRawMessage(message, bytes, source); | |
| 278 | + if (write == null) { | |
| 279 | + log.warn("[writeRawMessage][下行({}) 设备不在线或写入失败]", message.getRequestId()); | |
| 280 | + return; | |
| 281 | + } | |
| 282 | + write.onFailure(e -> log.warn("[writeRawMessage][下行({}) 写出失败:{}]", message.getRequestId(), e.getMessage())); | |
| 283 | + } | |
| 284 | + | |
| 285 | + /** | |
| 286 | + * 透传写出。约定:只返回 Future,自己不发消息给 biz;设备的实际响应由协议经 {@link #replyRawResult} 回复 | |
| 287 | + * <p> | |
| 288 | + * 失败 = ServiceException(ErrorCode),只记日志;协议自己的字节规则(例如请求 PDU)在写出之前校验,不合法返回失败。 | |
| 289 | + * 成功:一般为本地异步写出已完成(MQTT 为 publish 已提交,晚到的写出失败由协议记日志),不代表设备已确认;有请求-响应事务的协议,提交事务之前的失败返回失败 Future, | |
| 290 | + * 提交之后 Future 在事务终态(响应 / 超时 / 断线 / 写失败)成功完成,网关判定的失败只记日志 | |
| 291 | + * | |
| 292 | + * @param message 下行消息(raw.push,或 encode 的 property.set / service.invoke) | |
| 293 | + * @param bytes 要写出的字节 | |
| 294 | + * @param source encode 时使用的脚本(固定 hash);raw.push 为 null | |
| 295 | + * @return 写出结果 | |
| 296 | + */ | |
| 297 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 298 | + return Future.failedFuture(exception(RAW_NOT_SUPPORTED)); | |
| 299 | + } | |
| 300 | + | |
| 301 | + /** | |
| 302 | + * 回复透传下行的设备实际响应:切到协议的 worker,交给设备消息 Service 回复给 biz | |
| 303 | + * <p> | |
| 304 | + * 只用于设备的实际响应(包括协议的异常响应);网关判定的失败(超时、断线、写失败等)由协议只记日志, | |
| 305 | + * 不调用本方法,也不给 biz 构造回复 | |
| 306 | + * | |
| 307 | + * @param request 原请求 | |
| 308 | + * @param source encode 时固定的脚本;raw.push 为 null | |
| 309 | + * @param payload 设备的响应字节 | |
| 310 | + * @param deviceError 协议判定的设备错误(例如异常响应);正常响应为 null | |
| 311 | + */ | |
| 312 | + protected final void replyRawResult(IotDeviceMessage request, IotScriptSource source, | |
| 313 | + byte[] payload, ErrorCode deviceError) { | |
| 314 | + String serverId = getServerId(); | |
| 315 | + protocol.getVertx().executeBlocking(() -> { | |
| 316 | + SpringUtil.getBean(IotDeviceMessageService.class).sendRawReply(request, source, payload, deviceError, serverId); | |
| 317 | + return null; | |
| 318 | + }, false).onFailure(e -> log.error("[replyRawResult][下行({}) 回复失败]", request.getRequestId(), e)); | |
| 319 | + } | |
| 320 | + | |
| 79 | 321 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocol.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; |
| 4 | +import io.vertx.core.Vertx; | |
| 4 | 5 | |
| 5 | 6 | /** |
| 6 | 7 | * IoT 协议接口 |
| ... | ... | @@ -49,4 +50,13 @@ public interface IotProtocol { |
| 49 | 50 | */ |
| 50 | 51 | boolean isRunning(); |
| 51 | 52 | |
| 53 | + /** | |
| 54 | + * 获取协议的 Vert.x 实例:阻塞的处理(数据解析脚本、发送消息)切到它的 worker 上执行 | |
| 55 | + * | |
| 56 | + * @return Vert.x 实例;没有使用 Vert.x 的协议(CoAP)返回 null | |
| 57 | + */ | |
| 58 | + default Vertx getVertx() { | |
| 59 | + return null; | |
| 60 | + } | |
| 61 | + | |
| 52 | 62 | } | ... | ... |
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 { |
| 62 | 62 | private CoapServer coapServer; |
| 63 | 63 | |
| 64 | 64 | /** |
| 65 | + * 上行处理器:停止时关闭它的透传截止计时器 | |
| 66 | + */ | |
| 67 | + private IotCoapUpstreamHandler upstreamHandler; | |
| 68 | + | |
| 69 | + /** | |
| 65 | 70 | * 下行消息订阅者 |
| 66 | 71 | */ |
| 67 | 72 | private IotCoapDownstreamSubscriber downstreamSubscriber; |
| ... | ... | @@ -116,7 +121,7 @@ public class IotCoapProtocol implements IotProtocol { |
| 116 | 121 | add(registerSubResource); |
| 117 | 122 | }}); |
| 118 | 123 | // 2.4 添加 /topic 根资源(用于上行消息) |
| 119 | - IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler(serverId); | |
| 124 | + upstreamHandler = new IotCoapUpstreamHandler(serverId); | |
| 120 | 125 | IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(serverId, upstreamHandler); |
| 121 | 126 | coapServer.add(topicResource); |
| 122 | 127 | |
| ... | ... | @@ -168,6 +173,11 @@ public class IotCoapProtocol implements IotProtocol { |
| 168 | 173 | log.error("[stop][IoT CoAP 协议 {} 服务器停止失败]", getId(), e); |
| 169 | 174 | } |
| 170 | 175 | } |
| 176 | + // 3. 关闭上行处理器的透传截止计时器 | |
| 177 | + if (upstreamHandler != null) { | |
| 178 | + upstreamHandler.stop(); | |
| 179 | + upstreamHandler = null; | |
| 180 | + } | |
| 171 | 181 | running = false; |
| 172 | 182 | log.info("[stop][IoT CoAP 协议 {} 已停止]", getId()); |
| 173 | 183 | } | ... | ... |
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 { |
| 36 | 36 | */ |
| 37 | 37 | public static final int OPTION_TOKEN = 2088; |
| 38 | 38 | |
| 39 | + /** | |
| 40 | + * handle0 返回这个哨兵:表示响应由子类异步写出(透传上报:worker 处理结果与截止计时只响应一次),基类不再写响应 | |
| 41 | + */ | |
| 42 | + protected static final CommonResult<Object> ASYNC_RESPONSE = new CommonResult<>(); | |
| 43 | + | |
| 39 | 44 | private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); |
| 40 | 45 | |
| 41 | 46 | /** |
| ... | ... | @@ -50,6 +55,9 @@ public abstract class IotCoapAbstractHandler { |
| 50 | 55 | |
| 51 | 56 | // 2. 执行业务逻辑 |
| 52 | 57 | CommonResult<Object> result = handle0(exchange); |
| 58 | + if (result == ASYNC_RESPONSE) { // 响应由子类异步写出 | |
| 59 | + return; | |
| 60 | + } | |
| 53 | 61 | writeResponse(exchange, result); |
| 54 | 62 | } catch (ServiceException e) { |
| 55 | 63 | // 业务异常,返回对应的错误码和消息 | ... | ... |
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; |
| 4 | 4 | import cn.hutool.core.lang.Assert; |
| 5 | 5 | import cn.hutool.core.map.MapUtil; |
| 6 | 6 | import cn.hutool.core.text.StrPool; |
| 7 | +import cn.hutool.core.thread.NamedThreadFactory; | |
| 8 | +import cn.hutool.core.util.StrUtil; | |
| 7 | 9 | import cn.hutool.extra.spring.SpringUtil; |
| 10 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 8 | 11 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 9 | 14 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 10 | 16 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 11 | 17 | import lombok.extern.slf4j.Slf4j; |
| 18 | +import org.eclipse.californium.core.coap.MediaTypeRegistry; | |
| 12 | 19 | import org.eclipse.californium.core.server.resources.CoapExchange; |
| 13 | 20 | |
| 21 | +import java.time.Duration; | |
| 14 | 22 | import java.util.List; |
| 23 | +import java.util.concurrent.Executors; | |
| 24 | +import java.util.concurrent.LinkedBlockingQueue; | |
| 25 | +import java.util.concurrent.RejectedExecutionException; | |
| 26 | +import java.util.concurrent.ScheduledExecutorService; | |
| 27 | +import java.util.concurrent.ScheduledFuture; | |
| 28 | +import java.util.concurrent.ThreadPoolExecutor; | |
| 29 | +import java.util.concurrent.TimeUnit; | |
| 30 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 31 | + | |
| 32 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 33 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; | |
| 34 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS; | |
| 35 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 15 | 36 | |
| 16 | 37 | /** |
| 17 | 38 | * IoT 网关 CoAP 协议的【上行】处理器 |
| ... | ... | @@ -19,6 +40,7 @@ import java.util.List; |
| 19 | 40 | * 处理设备通过 CoAP 协议发送的上行消息,包括: |
| 20 | 41 | * 1. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post |
| 21 | 42 | * 2. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post |
| 43 | + * 3. 透传上报:POST /topic/sys/{productKey}/{deviceName}/thing/raw/post,content-format 为 42(octet-stream) | |
| 22 | 44 | * |
| 23 | 45 | * Token 通过自定义 CoAP Option 2088 携带 |
| 24 | 46 | * |
| ... | ... | @@ -27,13 +49,41 @@ import java.util.List; |
| 27 | 49 | @Slf4j |
| 28 | 50 | public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { |
| 29 | 51 | |
| 52 | + /** | |
| 53 | + * 透传 worker 的线程数:阻塞在 IO 上,取 CPU 核数的 2 倍 | |
| 54 | + */ | |
| 55 | + private static final int RAW_WORKER_THREADS = Runtime.getRuntime().availableProcessors() * 2; | |
| 56 | + /** | |
| 57 | + * 透传上报最多排队的请求数:超过时立即响应 429 | |
| 58 | + */ | |
| 59 | + private static final int RAW_MAX_PENDING = 1024; | |
| 60 | + | |
| 30 | 61 | private final String serverId; |
| 31 | 62 | |
| 63 | + /** | |
| 64 | + * 透传上报的应答计时时长:同一请求内应答(截止判断由设备消息 Service 负责) | |
| 65 | + */ | |
| 66 | + private final Duration deadline; | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * 透传上报的截止计时器:到期先返回超时,与处理结果谁先到谁响应 | |
| 70 | + */ | |
| 71 | + private final ScheduledExecutorService deadlineTimer = Executors.newSingleThreadScheduledExecutor( | |
| 72 | + new NamedThreadFactory("iot-coap-raw-deadline-", true)); | |
| 73 | + /** | |
| 74 | + * 透传上报的 worker(有界):解析与发送会阻塞,不能占着 Californium 处理请求的线程(Exchange 的响应排在它之后) | |
| 75 | + */ | |
| 76 | + private final ThreadPoolExecutor rawExecutor = new ThreadPoolExecutor(RAW_WORKER_THREADS, RAW_WORKER_THREADS, | |
| 77 | + 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(RAW_MAX_PENDING), | |
| 78 | + new NamedThreadFactory("iot-coap-raw-", true)); | |
| 79 | + | |
| 32 | 80 | private final IotDeviceMessageService deviceMessageService; |
| 33 | 81 | |
| 34 | 82 | public IotCoapUpstreamHandler(String serverId) { |
| 35 | 83 | this.serverId = serverId; |
| 84 | + this.deadline = SpringUtil.getBean(IotGatewayProperties.class).getScript().getDeadline(); | |
| 36 | 85 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 86 | + this.rawExecutor.allowCoreThreadTimeOut(true); | |
| 37 | 87 | } |
| 38 | 88 | |
| 39 | 89 | @Override |
| ... | ... | @@ -44,7 +94,11 @@ public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { |
| 44 | 94 | String productKey = getProductKey(uriPath); |
| 45 | 95 | String deviceName = getDeviceName(uriPath); |
| 46 | 96 | String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size())); |
| 47 | - // 1.2 解析消息 | |
| 97 | + // 1.2 透传上报 | |
| 98 | + if (isRawPost(exchange, method)) { | |
| 99 | + return handleRawPost(exchange, productKey, deviceName); | |
| 100 | + } | |
| 101 | + // 1.3 解析消息 | |
| 48 | 102 | IotDeviceMessage message = deserializeRequest(exchange, IotDeviceMessage.class); |
| 49 | 103 | Assert.notNull(message, "请求参数不能为空"); |
| 50 | 104 | Assert.equals(method, message.getMethod(), "method 不匹配"); |
| ... | ... | @@ -56,6 +110,95 @@ public class IotCoapUpstreamHandler extends IotCoapAbstractHandler { |
| 56 | 110 | return CommonResult.success(MapUtil.of("messageId", message.getId())); |
| 57 | 111 | } |
| 58 | 112 | |
| 113 | + /** | |
| 114 | + * 是否透传上报:路径为 thing/raw/post,并且 content-format 为 42(application/octet-stream) | |
| 115 | + * <p> | |
| 116 | + * 其它 content-format 走 JSON 分支,由标准路径拒绝透传方法 | |
| 117 | + */ | |
| 118 | + private boolean isRawPost(CoapExchange exchange, String method) { | |
| 119 | + return StrUtil.equals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), method) | |
| 120 | + && exchange.getRequestOptions().getContentFormat() == MediaTypeRegistry.APPLICATION_OCTET_STREAM; | |
| 121 | + } | |
| 122 | + | |
| 123 | + /** | |
| 124 | + * 透传上报:身份来自前置认证(token 与路径一致),与 JSON 分支相同 | |
| 125 | + * <p> | |
| 126 | + * Californium 在 Exchange 的串行执行器上处理请求,其它线程写的响应要排在本次处理之后才发出; | |
| 127 | + * 所以这里不阻塞:解析与发送交给透传 worker,立即返回。截止计时归协议:到期先返回超时(101), | |
| 128 | + * 之后完成的结果丢弃,只响应一次。已开始的投递无法撤销;其它情况由 service 的截止检查决定是否投递,超时响应不等待它 | |
| 129 | + */ | |
| 130 | + private CommonResult<Object> handleRawPost(CoapExchange exchange, String productKey, String deviceName) { | |
| 131 | + // 1. CON 请求:先回空 ACK,避免处理期间设备重传;之后的响应作为分离响应发送 | |
| 132 | + if (exchange.advanced().getRequest().isConfirmable()) { | |
| 133 | + exchange.accept(); | |
| 134 | + } | |
| 135 | + | |
| 136 | + // 2. 应答计时:在计时器线程上到期响应;截止时间从收到(交给 worker 之前)开始计算 | |
| 137 | + AtomicBoolean responded = new AtomicBoolean(); | |
| 138 | + long receivedNanos = System.nanoTime(); | |
| 139 | + ScheduledFuture<?> timer = deadlineTimer.schedule(() -> writeRawResponse(exchange, responded, | |
| 140 | + CommonResult.error(SCRIPT_TIMEOUT)), deadline.toNanos(), TimeUnit.NANOSECONDS); | |
| 141 | + | |
| 142 | + // 3. 交给透传 worker:按产品的数据解析脚本 decode 后发送;队列已满时立即响应 | |
| 143 | + byte[] payload = exchange.getRequestPayload(); | |
| 144 | + try { | |
| 145 | + rawExecutor.execute(() -> { | |
| 146 | + // 排队期间已到截止时,由 service 的截止分支处理:不执行脚本、不投递,只记错误日志; | |
| 147 | + // 超时响应已由计时器写出,这里的结果被门闩丢弃 | |
| 148 | + CommonResult<Object> result = sendRawMessage(payload, productKey, deviceName, receivedNanos); | |
| 149 | + timer.cancel(false); | |
| 150 | + writeRawResponse(exchange, responded, result); | |
| 151 | + }); | |
| 152 | + } catch (RejectedExecutionException e) { | |
| 153 | + log.warn("[handleRawPost][productKey({}) deviceName({}) 透传上报积压,拒绝处理]", productKey, deviceName); | |
| 154 | + timer.cancel(false); | |
| 155 | + writeRawResponse(exchange, responded, CommonResult.error(TOO_MANY_REQUESTS)); | |
| 156 | + } | |
| 157 | + return ASYNC_RESPONSE; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** | |
| 161 | + * 发送透传上报(透传 worker 上执行):结果(含失败)转为响应,与基类的异常处理一致 | |
| 162 | + */ | |
| 163 | + private CommonResult<Object> sendRawMessage(byte[] payload, String productKey, String deviceName, | |
| 164 | + long receivedNanos) { | |
| 165 | + try { | |
| 166 | + String messageId = deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, | |
| 167 | + IotProtocolTypeEnum.COAP, receivedNanos); | |
| 168 | + return CommonResult.success(MapUtil.of("messageId", messageId)); | |
| 169 | + } catch (ServiceException e) { | |
| 170 | + return CommonResult.error(e.getCode(), e.getMessage()); | |
| 171 | + } catch (IllegalArgumentException e) { | |
| 172 | + return CommonResult.error(BAD_REQUEST.getCode(), e.getMessage()); | |
| 173 | + } catch (Exception e) { | |
| 174 | + log.error("[sendRawMessage][productKey({}) deviceName({}) 处理异常]", productKey, deviceName, e); | |
| 175 | + return CommonResult.error(INTERNAL_SERVER_ERROR); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * 写透传上报的响应:截止计时器与处理线程竞争,只有第一个响应,迟到的结果丢弃 | |
| 181 | + */ | |
| 182 | + private void writeRawResponse(CoapExchange exchange, AtomicBoolean responded, CommonResult<Object> result) { | |
| 183 | + if (!responded.compareAndSet(false, true)) { | |
| 184 | + log.warn("[writeRawResponse][已响应(超时),丢弃结果({})]", result.getCode()); | |
| 185 | + return; | |
| 186 | + } | |
| 187 | + try { | |
| 188 | + writeResponse(exchange, result); | |
| 189 | + } catch (Exception e) { | |
| 190 | + log.error("[writeRawResponse][写响应异常]", e); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + | |
| 194 | + /** | |
| 195 | + * 停止:关闭透传 worker(排队中的不再执行)与截止计时器 | |
| 196 | + */ | |
| 197 | + public void stop() { | |
| 198 | + rawExecutor.shutdownNow(); | |
| 199 | + deadlineTimer.shutdownNow(); | |
| 200 | + } | |
| 201 | + | |
| 59 | 202 | @Override |
| 60 | 203 | protected boolean requiresAuthentication() { |
| 61 | 204 | return true; | ... | ... |
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 |
| 14 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler; |
| 15 | 15 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 16 | 16 | import io.netty.handler.codec.mqtt.MqttQoS; |
| 17 | +import io.vertx.core.Future; | |
| 17 | 18 | import io.vertx.core.Vertx; |
| 18 | 19 | import io.vertx.core.buffer.Buffer; |
| 19 | 20 | import io.vertx.core.http.HttpServer; |
| ... | ... | @@ -31,7 +32,9 @@ import java.util.List; |
| 31 | 32 | import java.util.Map; |
| 32 | 33 | import java.util.concurrent.TimeUnit; |
| 33 | 34 | |
| 35 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 34 | 36 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; |
| 37 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 35 | 38 | |
| 36 | 39 | /** |
| 37 | 40 | * IoT 网关 EMQX 协议实现: |
| ... | ... | @@ -67,6 +70,7 @@ public class IotEmqxProtocol implements IotProtocol { |
| 67 | 70 | /** |
| 68 | 71 | * Vert.x 实例 |
| 69 | 72 | */ |
| 73 | + @Getter | |
| 70 | 74 | private Vertx vertx; |
| 71 | 75 | /** |
| 72 | 76 | * HTTP Hook 服务器 |
| ... | ... | @@ -519,6 +523,35 @@ public class IotEmqxProtocol implements IotProtocol { |
| 519 | 523 | } |
| 520 | 524 | |
| 521 | 525 | /** |
| 526 | + * 异步发布消息到 MQTT Broker:返回 publish 真实的结果(透传下发使用) | |
| 527 | + * <p> | |
| 528 | + * 成功只表示已交给 Broker,不代表设备已收到;未连接或发布失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) | |
| 529 | + * | |
| 530 | + * @param topic 主题 | |
| 531 | + * @param payload 消息内容 | |
| 532 | + * @return 发布结果 | |
| 533 | + */ | |
| 534 | + public Future<Void> publishMessageAsync(String topic, byte[] payload) { | |
| 535 | + MqttClient client = this.mqttClient; | |
| 536 | + if (client == null || !client.isConnected()) { | |
| 537 | + log.warn("[publishMessageAsync][IoT EMQX 协议 {} MQTT Client 未连接, 无法发布消息]", getId()); | |
| 538 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 539 | + } | |
| 540 | + try { | |
| 541 | + MqttQoS qos = MqttQoS.valueOf(emqxConfig.getMqttQos()); | |
| 542 | + return client.publish(topic, Buffer.buffer(payload), qos, false, false) | |
| 543 | + .<Void>mapEmpty() | |
| 544 | + .recover(e -> { | |
| 545 | + log.warn("[publishMessageAsync][IoT EMQX 协议 {} 发布失败, topic: {}, 错误: {}]", getId(), topic, e.getMessage()); | |
| 546 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 547 | + }); | |
| 548 | + } catch (Exception e) { | |
| 549 | + log.error("[publishMessageAsync][IoT EMQX 协议 {} 发布异常, topic: {}]", getId(), topic, e); | |
| 550 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 551 | + } | |
| 552 | + } | |
| 553 | + | |
| 554 | + /** | |
| 522 | 555 | * 延迟发布消息到 MQTT Broker |
| 523 | 556 | * |
| 524 | 557 | * @param topic 主题 | ... | ... |
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; |
| 9 | 9 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 12 | +import io.vertx.core.Future; | |
| 12 | 13 | import lombok.extern.slf4j.Slf4j; |
| 13 | 14 | |
| 15 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 16 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 17 | + | |
| 14 | 18 | /** |
| 15 | 19 | * IoT 网关 EMQX 下行消息处理器 |
| 16 | 20 | * <p> |
| ... | ... | @@ -61,6 +65,26 @@ public class IotEmqxDownstreamHandler { |
| 61 | 65 | } |
| 62 | 66 | |
| 63 | 67 | /** |
| 68 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节发布到 raw/push 主题 | |
| 69 | + * | |
| 70 | + * @param message 下行消息 | |
| 71 | + * @param bytes 要写出的原始字节 | |
| 72 | + * @return 真实的发布结果 | |
| 73 | + */ | |
| 74 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 75 | + // 1. 获取设备信息 | |
| 76 | + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); | |
| 77 | + if (deviceInfo == null) { | |
| 78 | + log.error("[handleRawMessage][设备信息({})不存在]", message.getDeviceId()); | |
| 79 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // 2. 发布到 raw/push 主题 | |
| 83 | + String topic = IotMqttTopicUtils.buildRawPushTopic(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 84 | + return protocol.publishMessageAsync(topic, bytes); | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** | |
| 64 | 88 | * 根据消息方法和回复状态构建主题 |
| 65 | 89 | * |
| 66 | 90 | * @param message 设备消息 | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -26,4 +28,14 @@ public class IotEmqxDownstreamSubscriber extends AbstractIotProtocolDownstreamSu |
| 26 | 28 | downstreamHandler.handle(message); |
| 27 | 29 | } |
| 28 | 30 | |
| 31 | + @Override | |
| 32 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 33 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Override | |
| 37 | + public boolean supportsRawDownstream() { | |
| 38 | + return true; | |
| 39 | + } | |
| 40 | + | |
| 29 | 41 | } | ... | ... |
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 { |
| 229 | 229 | return; |
| 230 | 230 | } |
| 231 | 231 | |
| 232 | - // 2. 执行 ACL 校验 | |
| 232 | + // 2. 执行 ACL 校验(透传的 raw/post 发布、raw/push 订阅同样只放行设备自己的 /sys/{productKey}/{deviceName}/ 前缀) | |
| 233 | 233 | boolean allowed = subscribe |
| 234 | 234 | ? IotMqttTopicUtils.isTopicSubscribeAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()) |
| 235 | 235 | : IotMqttTopicUtils.isTopicPublishAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | ... | ... |
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; |
| 3 | 3 | import cn.hutool.core.util.ArrayUtil; |
| 4 | 4 | import cn.hutool.core.util.StrUtil; |
| 5 | 5 | import cn.hutool.extra.spring.SpringUtil; |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 6 | 7 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | 8 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 11 | +import io.vertx.core.Context; | |
| 12 | +import io.vertx.core.Vertx; | |
| 9 | 13 | import io.vertx.mqtt.messages.MqttPublishMessage; |
| 10 | 14 | import lombok.extern.slf4j.Slf4j; |
| 11 | 15 | |
| ... | ... | @@ -17,10 +21,24 @@ import lombok.extern.slf4j.Slf4j; |
| 17 | 21 | @Slf4j |
| 18 | 22 | public class IotEmqxUpstreamHandler { |
| 19 | 23 | |
| 24 | + /** | |
| 25 | + * 标准消息、透传上报最多排队的消息数:超过时丢弃 | |
| 26 | + */ | |
| 27 | + private static final int MAX_PENDING = 1024; | |
| 28 | + | |
| 20 | 29 | private final IotDeviceMessageService deviceMessageService; |
| 21 | 30 | |
| 22 | 31 | private final String serverId; |
| 23 | 32 | |
| 33 | + /** | |
| 34 | + * 标准消息的 worker 队列(有序、有界):所有设备共用一个 MQTT Client,与原来在 event loop 上一样按收到的顺序逐条处理 | |
| 35 | + */ | |
| 36 | + private IotWorkerTaskQueue queue; | |
| 37 | + /** | |
| 38 | + * 透传上报的 worker 队列(无序、有界):执行脚本耗时较长,消息之间并行 | |
| 39 | + */ | |
| 40 | + private IotWorkerTaskQueue rawQueue; | |
| 41 | + | |
| 24 | 42 | public IotEmqxUpstreamHandler(String serverId) { |
| 25 | 43 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 26 | 44 | this.serverId = serverId; |
| ... | ... | @@ -33,8 +51,12 @@ public class IotEmqxUpstreamHandler { |
| 33 | 51 | log.debug("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload()); |
| 34 | 52 | String topic = mqttMessage.topicName(); |
| 35 | 53 | byte[] payload = mqttMessage.payload().getBytes(); |
| 54 | + // 0. 网关自己发布的透传下发(raw/push)被自己订阅到:直接忽略(回环) | |
| 55 | + if (IotMqttTopicUtils.isRawPushTopic(topic)) { | |
| 56 | + return; | |
| 57 | + } | |
| 36 | 58 | try { |
| 37 | - // 1. 解析主题,一次性获取所有信息 | |
| 59 | + // 1.1 解析主题,一次性获取所有信息 | |
| 38 | 60 | String[] topicParts = topic.split("/"); |
| 39 | 61 | String productKey = ArrayUtil.get(topicParts, 2); |
| 40 | 62 | String deviceName = ArrayUtil.get(topicParts, 3); |
| ... | ... | @@ -42,21 +64,74 @@ public class IotEmqxUpstreamHandler { |
| 42 | 64 | log.warn("[handle][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic); |
| 43 | 65 | return; |
| 44 | 66 | } |
| 45 | - | |
| 46 | - // 2.1 反序列化消息 | |
| 47 | - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 48 | - if (message == null) { | |
| 49 | - log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); | |
| 67 | + // 1.2 透传上报:身份来自 Topic(由 EMQX ACL 保证设备只能发布自己的 Topic),跳过反序列化 | |
| 68 | + if (IotMqttTopicUtils.isRawPostTopic(topic)) { | |
| 69 | + handleRawRequest(topic, productKey, deviceName, payload); | |
| 50 | 70 | return; |
| 51 | 71 | } |
| 52 | - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 53 | - IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 54 | 72 | |
| 55 | - // 3. 发送消息到队列 | |
| 56 | - deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 73 | + // 2. 标准消息:反序列化(获取设备)、发送总线会阻塞,交给 worker 按收到的顺序执行 | |
| 74 | + getQueue().submit(() -> { | |
| 75 | + handleStandardRequest(topic, productKey, deviceName, payload); | |
| 76 | + return null; | |
| 77 | + }).onFailure(e -> log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e)); | |
| 57 | 78 | } catch (Exception e) { |
| 58 | 79 | log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e); |
| 59 | 80 | } |
| 60 | 81 | } |
| 61 | 82 | |
| 83 | + /** | |
| 84 | + * 处理标准消息(worker 线程):反序列化后发送 | |
| 85 | + * | |
| 86 | + * @param topic 主题 | |
| 87 | + * @param productKey 产品 Key | |
| 88 | + * @param deviceName 设备名称 | |
| 89 | + * @param payload 消息内容 | |
| 90 | + */ | |
| 91 | + private void handleStandardRequest(String topic, String productKey, String deviceName, byte[] payload) { | |
| 92 | + // 1.1 反序列化消息 | |
| 93 | + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 94 | + if (message == null) { | |
| 95 | + log.warn("[handleStandardRequest][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); | |
| 96 | + return; | |
| 97 | + } | |
| 98 | + // 1.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 99 | + IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 100 | + | |
| 101 | + // 2. 发送消息到队列 | |
| 102 | + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * 获取标准消息的 worker 队列:按当前(MQTT Client 所在的)context 创建;协议重启后 context 变化时重建 | |
| 107 | + */ | |
| 108 | + private IotWorkerTaskQueue getQueue() { | |
| 109 | + Context context = Vertx.currentContext(); | |
| 110 | + if (queue == null || queue.getContext() != context) { | |
| 111 | + queue = new IotWorkerTaskQueue(context, true, MAX_PENDING); | |
| 112 | + } | |
| 113 | + return queue; | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * 处理透传上报:原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给 worker 执行(不同消息之间并行) | |
| 118 | + * | |
| 119 | + * @param topic 主题 | |
| 120 | + * @param productKey 产品 Key | |
| 121 | + * @param deviceName 设备名称 | |
| 122 | + * @param payload 原始字节 | |
| 123 | + */ | |
| 124 | + private void handleRawRequest(String topic, String productKey, String deviceName, byte[] payload) { | |
| 125 | + Context context = Vertx.currentContext(); | |
| 126 | + if (rawQueue == null || rawQueue.getContext() != context) { // 协议重启后 MQTT Client 换了 Vertx:按新的 context 重建 | |
| 127 | + rawQueue = new IotWorkerTaskQueue(context, false, MAX_PENDING); | |
| 128 | + } | |
| 129 | + // 截止时间从收到(入队之前)开始计算 | |
| 130 | + long receivedNanos = System.nanoTime(); | |
| 131 | + rawQueue.submit(() -> deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, | |
| 132 | + IotProtocolTypeEnum.EMQX, receivedNanos)).onFailure(e -> | |
| 133 | + log.warn("[handleRawRequest][topic({}) 透传上报处理失败,数据长度: {} 字节,错误: {}]", | |
| 134 | + topic, payload.length, e.getMessage())); | |
| 135 | + } | |
| 136 | + | |
| 62 | 137 | } | ... | ... |
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 |
| 29 | 29 | @Slf4j |
| 30 | 30 | public abstract class IotHttpAbstractHandler implements Handler<RoutingContext> { |
| 31 | 31 | |
| 32 | + /** | |
| 33 | + * handle0 返回这个哨兵:表示响应已由子类异步接管(上报在 worker 上处理完成后写一次,透传上报到期先写超时),基类不再写响应 | |
| 34 | + */ | |
| 35 | + protected static final CommonResult<Object> ASYNC_RESPONSE = new CommonResult<>(); | |
| 36 | + | |
| 32 | 37 | private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); |
| 33 | 38 | |
| 34 | 39 | @Override |
| ... | ... | @@ -39,6 +44,9 @@ public abstract class IotHttpAbstractHandler implements Handler<RoutingContext> |
| 39 | 44 | |
| 40 | 45 | // 2. 执行逻辑 |
| 41 | 46 | CommonResult<Object> result = handle0(context); |
| 47 | + if (result == ASYNC_RESPONSE) { // 响应由子类异步写出 | |
| 48 | + return; | |
| 49 | + } | |
| 42 | 50 | writeResponse(context, result); |
| 43 | 51 | } catch (ServiceException e) { |
| 44 | 52 | // 已知异常,返回对应的错误码和错误信息 | ... | ... |
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; |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.map.MapUtil; |
| 5 | 5 | import cn.hutool.core.text.StrPool; |
| 6 | +import cn.hutool.core.util.StrUtil; | |
| 6 | 7 | import cn.hutool.extra.spring.SpringUtil; |
| 8 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 7 | 9 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 10 | +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 8 | 12 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 9 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; |
| 10 | 15 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 16 | +import io.vertx.core.AsyncResult; | |
| 17 | +import io.vertx.core.Vertx; | |
| 18 | +import io.vertx.core.http.HttpHeaders; | |
| 11 | 19 | import io.vertx.ext.web.RoutingContext; |
| 12 | 20 | import lombok.extern.slf4j.Slf4j; |
| 21 | +import org.springframework.http.MediaType; | |
| 22 | + | |
| 23 | +import java.time.Duration; | |
| 24 | + | |
| 25 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT; | |
| 13 | 28 | |
| 14 | 29 | /** |
| 15 | 30 | * IoT 网关 HTTP 协议的【上行】处理器 |
| 31 | + * <p> | |
| 32 | + * 透传上报:POST /topic/sys/{productKey}/{deviceName}/thing/raw/post,Content-Type 为 application/octet-stream, | |
| 33 | + * 请求体是原始字节;同一请求内应答 JSON 结果(与标准上报一致,data 为 messageId) | |
| 16 | 34 | * |
| 17 | 35 | * @author 芋道源码 |
| 18 | 36 | */ |
| ... | ... | @@ -23,10 +41,16 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { |
| 23 | 41 | |
| 24 | 42 | private final String serverId; |
| 25 | 43 | |
| 44 | + /** | |
| 45 | + * 透传上报的应答计时时长:同一请求内应答,到期先返回超时(截止判断由设备消息 Service 负责) | |
| 46 | + */ | |
| 47 | + private final Duration deadline; | |
| 48 | + | |
| 26 | 49 | private final IotDeviceMessageService deviceMessageService; |
| 27 | 50 | |
| 28 | 51 | public IotHttpUpstreamHandler(IotHttpProtocol protocol) { |
| 29 | 52 | this.serverId = protocol.getServerId(); |
| 53 | + this.deadline = SpringUtil.getBean(IotGatewayProperties.class).getScript().getDeadline(); | |
| 30 | 54 | this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class); |
| 31 | 55 | } |
| 32 | 56 | |
| ... | ... | @@ -36,17 +60,90 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { |
| 36 | 60 | String productKey = context.pathParam("productKey"); |
| 37 | 61 | String deviceName = context.pathParam("deviceName"); |
| 38 | 62 | String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT); |
| 39 | - // 1.2 根据 Content-Type 反序列化消息 | |
| 63 | + // 1.2 透传上报:在 worker 上处理,响应异步写一次 | |
| 64 | + if (isRawPost(context, method)) { | |
| 65 | + return handleRawPost(context, productKey, deviceName); | |
| 66 | + } | |
| 67 | + // 1.3 根据 Content-Type 反序列化消息 | |
| 40 | 68 | IotDeviceMessage message = deserializeRequest(context, IotDeviceMessage.class); |
| 41 | 69 | Assert.notNull(message, "请求参数不能为空"); |
| 42 | 70 | Assert.equals(method, message.getMethod(), "method 不匹配"); |
| 43 | 71 | |
| 44 | - // 2. 发送消息 | |
| 45 | - deviceMessageService.sendDeviceMessage(message, | |
| 46 | - productKey, deviceName, serverId); | |
| 72 | + // 2. 发送消息:获取设备、发送总线会阻塞,交给 worker 执行,完成后在 event loop 上响应(与原来一样等发送完成) | |
| 73 | + context.vertx().executeBlocking(() -> { | |
| 74 | + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 75 | + return message.getId(); | |
| 76 | + }, false).onComplete(ar -> writeAsyncResponse(context, buildResult(context, ar))); | |
| 77 | + return ASYNC_RESPONSE; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * 是否透传上报:路径为 thing/raw/post,并且 Content-Type 为 application/octet-stream | |
| 82 | + * <p> | |
| 83 | + * 其它 Content-Type 走 JSON 分支,由标准路径拒绝透传方法 | |
| 84 | + */ | |
| 85 | + private boolean isRawPost(RoutingContext context, String method) { | |
| 86 | + if (!StrUtil.equals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), method)) { | |
| 87 | + return false; | |
| 88 | + } | |
| 89 | + String contentType = context.request().getHeader(HttpHeaders.CONTENT_TYPE); | |
| 90 | + return StrUtil.equalsIgnoreCase(StrUtil.trim(StrUtil.subBefore(contentType, ';', false)), | |
| 91 | + MediaType.APPLICATION_OCTET_STREAM_VALUE); | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * 透传上报:身份来自前置认证(token 与路径一致),与 JSON 分支相同 | |
| 96 | + * <p> | |
| 97 | + * 解析与发送会阻塞,交给 worker 执行;截止时间到期先返回超时(101),之后完成的结果不再响应。 | |
| 98 | + * 计时器与 worker 的结果都回到请求所在的 event loop,以「响应是否已结束」保证只响应一次 | |
| 99 | + */ | |
| 100 | + private CommonResult<Object> handleRawPost(RoutingContext context, String productKey, String deviceName) { | |
| 101 | + byte[] body = context.body().buffer() != null ? context.body().buffer().getBytes() : null; | |
| 102 | + // 截止时间从收到(交给 worker 之前)开始计算 | |
| 103 | + long receivedNanos = System.nanoTime(); | |
| 104 | + Vertx vertx = context.vertx(); | |
| 105 | + long timerId = vertx.setTimer(Math.max(1L, deadline.toMillis()), | |
| 106 | + id -> writeAsyncResponse(context, CommonResult.error(SCRIPT_TIMEOUT))); | |
| 107 | + vertx.executeBlocking(() -> deviceMessageService.sendRawMessage(body, productKey, deviceName, serverId, | |
| 108 | + IotProtocolTypeEnum.HTTP, receivedNanos), false).onComplete(ar -> { | |
| 109 | + vertx.cancelTimer(timerId); | |
| 110 | + writeAsyncResponse(context, buildResult(context, ar)); | |
| 111 | + }); | |
| 112 | + return ASYNC_RESPONSE; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * worker 的处理结果转为响应:与基类的异常处理一致 | |
| 117 | + */ | |
| 118 | + private static CommonResult<Object> buildResult(RoutingContext context, AsyncResult<String> ar) { | |
| 119 | + if (ar.succeeded()) { | |
| 120 | + return CommonResult.success(MapUtil.of("messageId", ar.result())); | |
| 121 | + } | |
| 122 | + if (ar.cause() instanceof ServiceException) { | |
| 123 | + ServiceException e = (ServiceException) ar.cause(); | |
| 124 | + return CommonResult.error(e.getCode(), e.getMessage()); | |
| 125 | + } | |
| 126 | + if (ar.cause() instanceof IllegalArgumentException) { | |
| 127 | + return CommonResult.error(BAD_REQUEST.getCode(), ar.cause().getMessage()); | |
| 128 | + } | |
| 129 | + log.error("[buildResult][path({}) 处理异常]", context.request().path(), ar.cause()); | |
| 130 | + return CommonResult.error(INTERNAL_SERVER_ERROR); | |
| 131 | + } | |
| 47 | 132 | |
| 48 | - // 3. 返回结果 | |
| 49 | - return CommonResult.success(MapUtil.of("messageId", message.getId())); | |
| 133 | + /** | |
| 134 | + * 写 worker 处理结果的响应(event loop 上执行):响应已结束(透传已超时返回)或连接已关闭时丢弃 | |
| 135 | + */ | |
| 136 | + private static void writeAsyncResponse(RoutingContext context, CommonResult<Object> result) { | |
| 137 | + if (context.response().ended() || context.response().closed()) { | |
| 138 | + log.warn("[writeAsyncResponse][path({}) 响应已结束或连接已关闭,丢弃结果({})]", | |
| 139 | + context.request().path(), result.getCode()); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + try { | |
| 143 | + writeResponse(context, result); | |
| 144 | + } catch (Exception e) { | |
| 145 | + log.error("[writeAsyncResponse][path({}) 写响应异常]", context.request().path(), e); | |
| 146 | + } | |
| 50 | 147 | } |
| 51 | 148 | |
| 52 | 149 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/IotModbusTx.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 6 | +import lombok.AccessLevel; | |
| 7 | +import lombok.Getter; | |
| 8 | +import lombok.extern.slf4j.Slf4j; | |
| 9 | + | |
| 10 | +import java.util.concurrent.TimeUnit; | |
| 11 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 12 | +import java.util.function.BiConsumer; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * IoT Modbus 透传事务(thing.raw.push,或编码脚本输出的 PDU),server、client 共用 | |
| 16 | + * <p> | |
| 17 | + * 标准轮询、标准写不创建事务,保持原有的发送与匹配方式。 | |
| 18 | + * 只有一个终态:{@link #complete} / {@link #fail} 通过 CAS 只生效一次,之后的终态信号(迟到的响应、重复的超时、断线)直接忽略。 | |
| 19 | + * 终态回调在触发终态的线程上执行(server:连接的 owner;client:executeBlocking 的完成回调),只能做非阻塞动作 | |
| 20 | + * | |
| 21 | + * @author 芋道源码 | |
| 22 | + */ | |
| 23 | +@Getter | |
| 24 | +@Slf4j | |
| 25 | +public class IotModbusTx { | |
| 26 | + | |
| 27 | + /** | |
| 28 | + * 原下行请求 | |
| 29 | + */ | |
| 30 | + private final IotDeviceMessage request; | |
| 31 | + /** | |
| 32 | + * 请求 PDU(功能码 + 数据,不带 slaveId、CRC、MBAP) | |
| 33 | + */ | |
| 34 | + private final byte[] pdu; | |
| 35 | + /** | |
| 36 | + * 截止时间(System.nanoTime):排队超过它就不再发送 | |
| 37 | + */ | |
| 38 | + private final long deadlineNanos; | |
| 39 | + | |
| 40 | + /** | |
| 41 | + * 终态回调:responsePdu = 设备响应 PDU(含异常响应 fc|0x80 + 异常码),failure = 网关判定的失败,二者只有一个非空 | |
| 42 | + */ | |
| 43 | + @Getter(AccessLevel.NONE) | |
| 44 | + private final BiConsumer<byte[], ErrorCode> callback; | |
| 45 | + @Getter(AccessLevel.NONE) | |
| 46 | + private final AtomicBoolean done = new AtomicBoolean(); | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * 只通过 {@link #ofServer} / {@link #ofClient} 创建 | |
| 50 | + */ | |
| 51 | + private IotModbusTx(IotDeviceMessage request, byte[] pdu, long deadlineNanos, | |
| 52 | + BiConsumer<byte[], ErrorCode> callback) { | |
| 53 | + this.request = request; | |
| 54 | + this.pdu = pdu; | |
| 55 | + this.deadlineNanos = deadlineNanos; | |
| 56 | + this.callback = callback; | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * 创建 server 端的透传事务:截止时间 = 现在 + 通道的 requestTimeout | |
| 61 | + * | |
| 62 | + * @param channel 提交它的连接通道:只用于读取 requestTimeout | |
| 63 | + * @param pdu 请求 PDU | |
| 64 | + * @param request 原下行请求 | |
| 65 | + * @param callback 终态回调(可为 null) | |
| 66 | + * @return 事务 | |
| 67 | + */ | |
| 68 | + public static IotModbusTx ofServer(IotModbusTcpServerTransactionChannel channel, byte[] pdu, | |
| 69 | + IotDeviceMessage request, BiConsumer<byte[], ErrorCode> callback) { | |
| 70 | + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(channel.getRequestTimeout()); | |
| 71 | + return new IotModbusTx(request, pdu, deadlineNanos, callback); | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** | |
| 75 | + * 创建 client 端的透传事务 | |
| 76 | + * | |
| 77 | + * @param request 原下行请求 | |
| 78 | + * @param pdu 请求 PDU | |
| 79 | + * @param deadlineNanos 截止时间(System.nanoTime) | |
| 80 | + * @param callback 终态回调 | |
| 81 | + * @return 事务 | |
| 82 | + */ | |
| 83 | + public static IotModbusTx ofClient(IotDeviceMessage request, byte[] pdu, long deadlineNanos, | |
| 84 | + BiConsumer<byte[], ErrorCode> callback) { | |
| 85 | + return new IotModbusTx(request, pdu, deadlineNanos, callback); | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * 收到设备响应(包括异常响应):进入成功终态 | |
| 90 | + * | |
| 91 | + * @param responsePdu 响应 PDU | |
| 92 | + * @return 是否由本次调用进入终态 | |
| 93 | + */ | |
| 94 | + public boolean complete(byte[] responsePdu) { | |
| 95 | + return terminate(responsePdu, null); | |
| 96 | + } | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法):进入失败终态 | |
| 100 | + * | |
| 101 | + * @param failure 错误码 | |
| 102 | + * @return 是否由本次调用进入终态 | |
| 103 | + */ | |
| 104 | + public boolean fail(ErrorCode failure) { | |
| 105 | + return terminate(null, failure); | |
| 106 | + } | |
| 107 | + | |
| 108 | + /** | |
| 109 | + * @return 是否已经超过截止时间 | |
| 110 | + */ | |
| 111 | + public boolean isExpired() { | |
| 112 | + return System.nanoTime() - deadlineNanos >= 0; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** | |
| 116 | + * 进入终态:只有第一次调用生效,并执行终态回调 | |
| 117 | + * | |
| 118 | + * @param responsePdu 响应 PDU;失败时为 null | |
| 119 | + * @param failure 网关判定的失败;成功时为 null | |
| 120 | + * @return 是否由本次调用进入终态 | |
| 121 | + */ | |
| 122 | + private boolean terminate(byte[] responsePdu, ErrorCode failure) { | |
| 123 | + // 1. CAS:已进入终态时直接忽略(迟到的响应、重复的超时、断线) | |
| 124 | + if (!done.compareAndSet(false, true)) { | |
| 125 | + return false; | |
| 126 | + } | |
| 127 | + if (callback == null) { | |
| 128 | + return true; | |
| 129 | + } | |
| 130 | + // 2. 执行终态回调:异常只记日志,不影响调用方(owner 上的清理、executeBlocking 的完成回调) | |
| 131 | + try { | |
| 132 | + callback.accept(responsePdu, failure); | |
| 133 | + } catch (Exception e) { | |
| 134 | + log.error("[terminate][Modbus 透传事务终态回调异常, deviceId={}]", | |
| 135 | + request != null ? request.getDeviceId() : null, e); | |
| 136 | + } | |
| 137 | + return true; | |
| 138 | + } | |
| 139 | + | |
| 140 | +} | ... | ... |
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; |
| 4 | 4 | import cn.hutool.core.util.ArrayUtil; |
| 5 | 5 | import cn.hutool.core.util.ObjectUtil; |
| 6 | 6 | import cn.hutool.core.util.StrUtil; |
| 7 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 7 | 8 | import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; |
| 8 | 9 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; |
| 10 | 11 | import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum; |
| 11 | 12 | import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants; | |
| 12 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; |
| 13 | 15 | import lombok.experimental.UtilityClass; |
| 14 | 16 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -17,6 +19,7 @@ import java.math.BigDecimal; |
| 17 | 19 | import java.math.RoundingMode; |
| 18 | 20 | import java.nio.ByteBuffer; |
| 19 | 21 | import java.nio.ByteOrder; |
| 22 | +import java.util.Arrays; | |
| 20 | 23 | |
| 21 | 24 | /** |
| 22 | 25 | * IoT Modbus 协议工具类 |
| ... | ... | @@ -524,6 +527,113 @@ public class IotModbusCommonUtils { |
| 524 | 527 | return -1; |
| 525 | 528 | } |
| 526 | 529 | |
| 530 | + // ==================== 事务响应:PDU 还原与格式校验 ==================== | |
| 531 | + | |
| 532 | + /** | |
| 533 | + * 构建异常响应 PDU:[fc | 0x80, 异常码] | |
| 534 | + * | |
| 535 | + * @param functionCode 原功能码 | |
| 536 | + * @param exceptionCode 异常码 | |
| 537 | + * @return 异常响应 PDU | |
| 538 | + */ | |
| 539 | + public static byte[] buildExceptionPdu(int functionCode, int exceptionCode) { | |
| 540 | + return new byte[]{(byte) (functionCode | FC_EXCEPTION_MASK), (byte) exceptionCode}; | |
| 541 | + } | |
| 542 | + | |
| 543 | + /** | |
| 544 | + * 从解码后的响应帧还原响应 PDU(不带 slaveId、CRC、MBAP) | |
| 545 | + * <p> | |
| 546 | + * 解码器的 {@link IotModbusFrame#getPdu()} 是功能码之后的全部数据(读响应含 byteCount); | |
| 547 | + * 异常响应时解码器已把功能码还原为原功能码、异常码单独存放,这里重新加上 0x80 异常位 | |
| 548 | + * | |
| 549 | + * @param frame 响应帧 | |
| 550 | + * @return 异常 → [fc | 0x80, 异常码];正常 → [fc] + pdu | |
| 551 | + */ | |
| 552 | + public static byte[] buildResponsePdu(IotModbusFrame frame) { | |
| 553 | + if (frame.isException()) { | |
| 554 | + return buildExceptionPdu(frame.getFunctionCode(), frame.getExceptionCode()); | |
| 555 | + } | |
| 556 | + byte[] data = frame.getPdu() != null ? frame.getPdu() : new byte[0]; | |
| 557 | + byte[] responsePdu = new byte[1 + data.length]; | |
| 558 | + responsePdu[0] = (byte) frame.getFunctionCode(); | |
| 559 | + System.arraycopy(data, 0, responsePdu, 1, data.length); | |
| 560 | + return responsePdu; | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * 获取透传响应对应的设备错误:用于回复 biz 时的结果码 | |
| 565 | + * | |
| 566 | + * @param responsePdu 设备响应 PDU(非空) | |
| 567 | + * @return 异常响应 [fc | 0x80, 异常码] → {@link ErrorCodeConstants#MODBUS_EXCEPTION}(带异常码);正常响应 → null | |
| 568 | + */ | |
| 569 | + public static ErrorCode getRawResponseError(byte[] responsePdu) { | |
| 570 | + if (!isExceptionResponse(responsePdu[0] & 0xFF) || responsePdu.length < 2) { | |
| 571 | + return null; | |
| 572 | + } | |
| 573 | + ErrorCode exception = ErrorCodeConstants.MODBUS_EXCEPTION; | |
| 574 | + return new ErrorCode(exception.getCode(), StrUtil.format(exception.getMsg(), responsePdu[1] & 0xFF)); | |
| 575 | + } | |
| 576 | + | |
| 577 | + /** | |
| 578 | + * 获取请求 PDU 中的数量:FC01-04 / FC15 / FC16 取第 3-4 字节;FC05 / FC06 固定为 1 | |
| 579 | + * | |
| 580 | + * @param requestPdu 请求 PDU(已通过格式校验,至少 5 字节) | |
| 581 | + * @return 数量 | |
| 582 | + */ | |
| 583 | + public static int getRequestQuantity(byte[] requestPdu) { | |
| 584 | + int functionCode = requestPdu[0] & 0xFF; | |
| 585 | + if (functionCode == FC_WRITE_SINGLE_COIL || functionCode == FC_WRITE_SINGLE_REGISTER) { | |
| 586 | + return 1; | |
| 587 | + } | |
| 588 | + return ((requestPdu[3] & 0xFF) << 8) | (requestPdu[4] & 0xFF); | |
| 589 | + } | |
| 590 | + | |
| 591 | + /** | |
| 592 | + * 校验正常响应 PDU 的格式是否与请求一致(异常响应不经过这里) | |
| 593 | + * <ul> | |
| 594 | + * <li>FC01 / FC02:byteCount = ceil(数量 / 8),总长 = 2 + byteCount;</li> | |
| 595 | + * <li>FC03 / FC04:byteCount = 2 × 数量,总长 = 2 + byteCount;</li> | |
| 596 | + * <li>FC05 / FC06:原样回显请求;</li> | |
| 597 | + * <li>FC15 / FC16:回显地址和数量。</li> | |
| 598 | + * </ul> | |
| 599 | + * | |
| 600 | + * @param requestPdu 请求 PDU | |
| 601 | + * @param responsePdu 响应 PDU | |
| 602 | + * @return 是否合法 | |
| 603 | + */ | |
| 604 | + @SuppressWarnings("EnhancedSwitchMigration") | |
| 605 | + public static boolean isResponseShapeValid(byte[] requestPdu, byte[] responsePdu) { | |
| 606 | + if (requestPdu == null || requestPdu.length < 5 || responsePdu == null || responsePdu.length < 1) { | |
| 607 | + return false; | |
| 608 | + } | |
| 609 | + int functionCode = requestPdu[0] & 0xFF; | |
| 610 | + if ((responsePdu[0] & 0xFF) != functionCode) { | |
| 611 | + return false; | |
| 612 | + } | |
| 613 | + int quantity = getRequestQuantity(requestPdu); | |
| 614 | + switch (functionCode) { | |
| 615 | + case FC_READ_COILS: | |
| 616 | + case FC_READ_DISCRETE_INPUTS: | |
| 617 | + return isReadResponseShapeValid(responsePdu, (quantity + 7) / 8); | |
| 618 | + case FC_READ_HOLDING_REGISTERS: | |
| 619 | + case FC_READ_INPUT_REGISTERS: | |
| 620 | + return isReadResponseShapeValid(responsePdu, quantity * 2); | |
| 621 | + case FC_WRITE_SINGLE_COIL: | |
| 622 | + case FC_WRITE_SINGLE_REGISTER: | |
| 623 | + return responsePdu.length == 5 && Arrays.equals(Arrays.copyOf(requestPdu, 5), responsePdu); | |
| 624 | + case FC_WRITE_MULTIPLE_COILS: | |
| 625 | + case FC_WRITE_MULTIPLE_REGISTERS: | |
| 626 | + return responsePdu.length == 5 | |
| 627 | + && Arrays.equals(Arrays.copyOfRange(requestPdu, 1, 5), Arrays.copyOfRange(responsePdu, 1, 5)); | |
| 628 | + default: | |
| 629 | + return false; | |
| 630 | + } | |
| 631 | + } | |
| 632 | + | |
| 633 | + private static boolean isReadResponseShapeValid(byte[] responsePdu, int expectedByteCount) { | |
| 634 | + return responsePdu.length == 2 + expectedByteCount && (responsePdu[1] & 0xFF) == expectedByteCount; | |
| 635 | + } | |
| 636 | + | |
| 527 | 637 | // ==================== 点位查找 ==================== |
| 528 | 638 | |
| 529 | 639 | /** | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 3 | 6 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusRawRequest; | |
| 4 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; |
| 10 | +import com.ghgande.j2mod.modbus.ModbusException; | |
| 11 | +import com.ghgande.j2mod.modbus.ModbusIOException; | |
| 12 | +import com.ghgande.j2mod.modbus.ModbusSlaveException; | |
| 5 | 13 | import com.ghgande.j2mod.modbus.io.ModbusTCPTransaction; |
| 6 | 14 | import com.ghgande.j2mod.modbus.msg.*; |
| 7 | 15 | import com.ghgande.j2mod.modbus.procimg.InputRegister; |
| ... | ... | @@ -12,6 +20,13 @@ import io.vertx.core.Future; |
| 12 | 20 | import lombok.experimental.UtilityClass; |
| 13 | 21 | import lombok.extern.slf4j.Slf4j; |
| 14 | 22 | |
| 23 | +import java.io.IOException; | |
| 24 | +import java.net.SocketTimeoutException; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 27 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 28 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 29 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_TIMEOUT; | |
| 15 | 30 | import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.*; |
| 16 | 31 | |
| 17 | 32 | /** |
| ... | ... | @@ -124,6 +139,118 @@ public class IotModbusTcpClientUtils { |
| 124 | 139 | } |
| 125 | 140 | |
| 126 | 141 | /** |
| 142 | + * 执行透传请求(raw.push / encode 输出的 PDU) | |
| 143 | + * <p> | |
| 144 | + * 设备的正常、异常响应都从 transaction.getResponse() 取真实响应,先核对事务号、unit、功能码,再还原 PDU: | |
| 145 | + * 异常响应还原为 [fc | 0x80, 异常码] 作为成功结果;在有序队列里排队超过截止 → 110; | |
| 146 | + * 身份不一致 → 在本有序任务内关闭连接再 114(下一个任务开始时重连,不会读到错位的响应);格式与请求不一致 → 114; | |
| 147 | + * 传输失败由 executeRawBlocking 的守卫先断开连接再抛出(只作用于透传;标准读写超时不断开共享连接) | |
| 148 | + * | |
| 149 | + * @param connection Modbus 连接 | |
| 150 | + * @param slaveId 从站地址 | |
| 151 | + * @param tx 透传事务 | |
| 152 | + * @return 响应 PDU | |
| 153 | + */ | |
| 154 | + public static Future<byte[]> executeRaw(IotModbusTcpClientConnectionManager.ModbusConnection connection, | |
| 155 | + Integer slaveId, IotModbusTx tx) { | |
| 156 | + return connection.executeRawBlocking(tcpConnection -> { | |
| 157 | + // 1. 在有序队列里排得太久(超过本连接的 timeout):不再执行 | |
| 158 | + if (tx.isExpired()) { | |
| 159 | + throw exception(MODBUS_TIMEOUT); | |
| 160 | + } | |
| 161 | + // 2.1 构造事务:retries = 1 表示只尝试 1 次(0 会被 j2mod 当成默认的 5 次;写功能码重试会重复写出) | |
| 162 | + IotModbusRawRequest request = new IotModbusRawRequest(tx.getPdu()); | |
| 163 | + request.setUnitID(slaveId); | |
| 164 | + ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection); | |
| 165 | + transaction.setRetries(1); | |
| 166 | + transaction.setRequest(request); | |
| 167 | + // 2.2 保存发出的身份:setRequest 分配事务号,execute 结束后 j2mod 会把请求的事务号自增 | |
| 168 | + int transactionId = request.getTransactionID(); | |
| 169 | + int unitId = request.getUnitID(); | |
| 170 | + int functionCode = request.getFunctionCode(); | |
| 171 | + // 2.3 执行事务 | |
| 172 | + try { | |
| 173 | + transaction.execute(); | |
| 174 | + } catch (ModbusSlaveException e) { | |
| 175 | + // 设备异常响应:不在这里拼异常 PDU,下面用 transaction.getResponse() 的真实异常响应核对身份 | |
| 176 | + log.debug("[executeRaw][设备异常响应, slaveId={}, functionCode={}]", slaveId, functionCode); | |
| 177 | + } catch (ModbusException e) { | |
| 178 | + throw new RuntimeException(String.format("Modbus 透传失败 [slaveId=%d, functionCode=%d]", | |
| 179 | + slaveId, functionCode), e); | |
| 180 | + } | |
| 181 | + // 3.1 核对真实响应的身份:不一致说明读到的不是这次请求的响应,在本有序任务内关闭连接 | |
| 182 | + ModbusResponse response = transaction.getResponse(); | |
| 183 | + if (!IotModbusRawRequest.isResponseOf(response, transactionId, unitId, functionCode)) { | |
| 184 | + log.warn("[executeRaw][响应与请求不一致, 关闭连接, 请求(tid={}, unit={}, fc={}), 响应(tid={}, unit={}, fc={})]", | |
| 185 | + transactionId, unitId, functionCode, | |
| 186 | + response != null ? response.getTransactionID() : null, | |
| 187 | + response != null ? response.getUnitID() : null, | |
| 188 | + response != null ? response.getFunctionCode() : null); | |
| 189 | + tcpConnection.close(); | |
| 190 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 191 | + } | |
| 192 | + // 3.2 还原响应 PDU,并核对响应格式 | |
| 193 | + return request.buildResponsePdu(response); | |
| 194 | + }); | |
| 195 | + } | |
| 196 | + | |
| 197 | + /** | |
| 198 | + * 是否是传输失败(读超时、IO 异常):沿 cause 链查找 ModbusIOException / IOException; | |
| 199 | + * 遇到 ModbusSlaveException(设备异常响应,说明链路正常)返回 false | |
| 200 | + * | |
| 201 | + * @param e 异常 | |
| 202 | + * @return 是否传输失败 | |
| 203 | + */ | |
| 204 | + public static boolean isTransportFailure(Throwable e) { | |
| 205 | + int depth = 0; | |
| 206 | + for (Throwable cause = e; cause != null && depth < 16; cause = cause.getCause(), depth++) { | |
| 207 | + if (cause instanceof ModbusSlaveException) { | |
| 208 | + return false; | |
| 209 | + } | |
| 210 | + if (cause instanceof ModbusIOException || cause instanceof IOException) { | |
| 211 | + return true; | |
| 212 | + } | |
| 213 | + } | |
| 214 | + return false; | |
| 215 | + } | |
| 216 | + | |
| 217 | + /** | |
| 218 | + * 是否是读超时:j2mod 的 ModbusTCPTransport.readResponse 把 SocketTimeoutException 转为 | |
| 219 | + * ModbusIOException("Socket timeout reading response - ..."),不保留 cause,所以同时按消息判断 | |
| 220 | + * | |
| 221 | + * @param e 异常 | |
| 222 | + * @return 是否读超时 | |
| 223 | + */ | |
| 224 | + public static boolean isTimeoutFailure(Throwable e) { | |
| 225 | + int depth = 0; | |
| 226 | + for (Throwable cause = e; cause != null && depth < 16; cause = cause.getCause(), depth++) { | |
| 227 | + if (cause instanceof SocketTimeoutException) { | |
| 228 | + return true; | |
| 229 | + } | |
| 230 | + if (cause instanceof ModbusIOException && StrUtil.containsIgnoreCase(cause.getMessage(), "timeout")) { | |
| 231 | + return true; | |
| 232 | + } | |
| 233 | + } | |
| 234 | + return false; | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * 把透传任务的失败转换为网关判定的错误码:ServiceException 保留其错误码(110 / 114 / 115),读超时 → 110,其它 → 106 | |
| 239 | + * | |
| 240 | + * @param e 异常 | |
| 241 | + * @return 错误码 | |
| 242 | + */ | |
| 243 | + public static ErrorCode toRawFailure(Throwable e) { | |
| 244 | + if (e instanceof ServiceException) { | |
| 245 | + return new ErrorCode(((ServiceException) e).getCode(), e.getMessage()); | |
| 246 | + } | |
| 247 | + if (isTimeoutFailure(e)) { | |
| 248 | + return MODBUS_TIMEOUT; | |
| 249 | + } | |
| 250 | + return DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 127 | 254 | * 创建读取请求 |
| 128 | 255 | */ |
| 129 | 256 | @SuppressWarnings("EnhancedSwitchMigration") | ... | ... |
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 { |
| 52 | 52 | /** |
| 53 | 53 | * Vert.x 实例 |
| 54 | 54 | */ |
| 55 | + @Getter | |
| 55 | 56 | private final Vertx vertx; |
| 56 | 57 | /** |
| 57 | 58 | * 配置刷新定时器 ID |
| ... | ... | @@ -127,7 +128,8 @@ public class IotModbusTcpClientProtocol implements IotProtocol { |
| 127 | 128 | IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); |
| 128 | 129 | IotModbusTcpClientDownstreamHandler downstreamHandler = new IotModbusTcpClientDownstreamHandler(connectionManager, |
| 129 | 130 | configCacheService); |
| 130 | - this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, messageBus); | |
| 131 | + this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, | |
| 132 | + connectionManager, messageBus); | |
| 131 | 133 | this.downstreamSubscriber.start(); |
| 132 | 134 | } catch (Exception e) { |
| 133 | 135 | log.error("[start][IoT Modbus TCP Client 协议 {} 启动失败]", getId(), e); | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 4 | +import com.ghgande.j2mod.modbus.msg.ExceptionResponse; | |
| 5 | +import com.ghgande.j2mod.modbus.msg.ModbusRequest; | |
| 6 | +import com.ghgande.j2mod.modbus.msg.ModbusResponse; | |
| 7 | +import com.ghgande.j2mod.modbus.net.AbstractModbusListener; | |
| 8 | + | |
| 9 | +import java.io.DataInput; | |
| 10 | +import java.io.DataOutput; | |
| 11 | +import java.io.IOException; | |
| 12 | +import java.util.Arrays; | |
| 13 | + | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 15 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Modbus 原始 PDU 请求(只用于透传,主站发送) | |
| 19 | + * <p> | |
| 20 | + * j2mod 3.4.0 的 ModbusTCPTransport.writeMessage 按 [MBAP][unitId][getFunctionCode()][getMessage()] 写出, | |
| 21 | + * 所以 {@link #getMessage()} 返回 PDU 去掉功能码之后的数据部分(不带 unit、MBAP)。 | |
| 22 | + * 响应由 ModbusTCPTransport.readResponse 按功能码用 ModbusResponse.createModbusResponse 创建(不经过 {@link #getResponse()}), | |
| 23 | + * 白名单内的 8 个功能码(01-06、0F、10)都有对应的标准响应类,{@link #buildResponsePdu} 用它的 getMessage 还原数据部分; | |
| 24 | + * 异常响应由 ModbusTCPTransaction.execute 转为 ModbusSlaveException,调用方再用 transaction.getResponse() 拿到真实的 ExceptionResponse 交给这里。 | |
| 25 | + * 正常、异常响应都先经过 {@link #isResponseOf} 核对身份 | |
| 26 | + * | |
| 27 | + * @author 芋道源码 | |
| 28 | + */ | |
| 29 | +public class IotModbusRawRequest extends ModbusRequest { | |
| 30 | + | |
| 31 | + /** | |
| 32 | + * 完整的请求 PDU(功能码 + 数据) | |
| 33 | + */ | |
| 34 | + private final byte[] pdu; | |
| 35 | + /** | |
| 36 | + * PDU 去掉功能码之后的部分 | |
| 37 | + */ | |
| 38 | + private final byte[] data; | |
| 39 | + | |
| 40 | + public IotModbusRawRequest(byte[] pdu) { | |
| 41 | + this.pdu = pdu.clone(); | |
| 42 | + this.data = Arrays.copyOfRange(pdu, 1, pdu.length); | |
| 43 | + setFunctionCode(pdu[0] & 0xFF); | |
| 44 | + setDataLength(data.length); | |
| 45 | + } | |
| 46 | + | |
| 47 | + /** | |
| 48 | + * TCP 主站不经过这里;按 j2mod 的约定返回带请求头的空响应 | |
| 49 | + */ | |
| 50 | + @Override | |
| 51 | + public ModbusResponse getResponse() { | |
| 52 | + ModbusResponse response = ModbusResponse.createModbusResponse(getFunctionCode()); | |
| 53 | + response.setHeadless(isHeadless()); | |
| 54 | + response.setTransactionID(getTransactionID()); | |
| 55 | + response.setProtocolID(getProtocolID()); | |
| 56 | + response.setUnitID(getUnitID()); | |
| 57 | + return response; | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Override | |
| 61 | + public ModbusResponse createResponse(AbstractModbusListener listener) { | |
| 62 | + throw new UnsupportedOperationException("透传请求只用于主站发送"); | |
| 63 | + } | |
| 64 | + | |
| 65 | + @Override | |
| 66 | + public void writeData(DataOutput dout) throws IOException { | |
| 67 | + dout.write(data); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Override | |
| 71 | + public void readData(DataInput din) { | |
| 72 | + throw new UnsupportedOperationException("透传请求只用于主站发送"); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Override | |
| 76 | + public byte[] getMessage() { | |
| 77 | + return data; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** | |
| 81 | + * 响应是否属于这次发出的请求:事务号、unit、功能码(异常响应为 fc | 0x80)都一致 | |
| 82 | + * <p> | |
| 83 | + * j2mod 只在正常响应时核对事务号(异常响应在核对之前就抛出 ModbusSlaveException),unit 都不核对,所以这里逐项核对。 | |
| 84 | + * 发出的身份要在 setRequest 之后、execute 之前保存:execute 结束后 j2mod 会把请求的事务号自增,不能再用请求本身核对 | |
| 85 | + * | |
| 86 | + * @param response j2mod 响应(transaction.getResponse(),正常或异常响应) | |
| 87 | + * @param transactionId 发出的事务号 | |
| 88 | + * @param unitId 发出的 unit | |
| 89 | + * @param functionCode 发出的功能码 | |
| 90 | + * @return 是否属于这次请求 | |
| 91 | + */ | |
| 92 | + public static boolean isResponseOf(ModbusResponse response, int transactionId, int unitId, int functionCode) { | |
| 93 | + if (response == null) { | |
| 94 | + return false; | |
| 95 | + } | |
| 96 | + int expectedFunctionCode = response instanceof ExceptionResponse | |
| 97 | + ? functionCode | IotModbusCommonUtils.FC_EXCEPTION_MASK : functionCode; | |
| 98 | + return response.getTransactionID() == transactionId | |
| 99 | + && response.getUnitID() == unitId | |
| 100 | + && response.getFunctionCode() == expectedFunctionCode; | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** | |
| 104 | + * 从 j2mod 解析出的响应还原响应 PDU,并核对功能码和响应格式 | |
| 105 | + * | |
| 106 | + * @param response j2mod 响应(正常或异常响应) | |
| 107 | + * @return 响应 PDU:异常 → [fc | 0x80, 异常码];正常 → 功能码 + 数据 | |
| 108 | + */ | |
| 109 | + public byte[] buildResponsePdu(ModbusResponse response) { | |
| 110 | + // 1.1 异常响应:功能码必须是 fc | 0x80(ExceptionResponse.getMessage 是 1 字节异常码) | |
| 111 | + if (response instanceof ExceptionResponse) { | |
| 112 | + if (response.getFunctionCode() != (getFunctionCode() | IotModbusCommonUtils.FC_EXCEPTION_MASK)) { | |
| 113 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 114 | + } | |
| 115 | + return IotModbusCommonUtils.buildExceptionPdu(getFunctionCode(), | |
| 116 | + ((ExceptionResponse) response).getExceptionCode()); | |
| 117 | + } | |
| 118 | + // 1.2 正常响应:功能码必须与请求一致 | |
| 119 | + if (response == null || response.getFunctionCode() != getFunctionCode()) { | |
| 120 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 121 | + } | |
| 122 | + // 2. 还原 PDU:功能码 + 标准响应类重新序列化的数据部分 | |
| 123 | + byte[] message = response.getMessage(); | |
| 124 | + byte[] responsePdu = new byte[1 + message.length]; | |
| 125 | + responsePdu[0] = (byte) getFunctionCode(); | |
| 126 | + System.arraycopy(message, 0, responsePdu, 1, message.length); | |
| 127 | + // 3. 核对响应格式(数量、回显) | |
| 128 | + if (!IotModbusCommonUtils.isResponseShapeValid(pdu, responsePdu)) { | |
| 129 | + throw exception(MODBUS_RESPONSE_INVALID); | |
| 130 | + } | |
| 131 | + return responsePdu; | |
| 132 | + } | |
| 133 | + | |
| 134 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.ArrayUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 3 | 5 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 5 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 12 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; | |
| 6 | 13 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection; | |
| 16 | +import io.vertx.core.Future; | |
| 17 | +import io.vertx.core.Promise; | |
| 7 | 18 | import lombok.extern.slf4j.Slf4j; |
| 8 | 19 | |
| 20 | +import java.util.concurrent.TimeUnit; | |
| 21 | + | |
| 22 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 23 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 24 | + | |
| 9 | 25 | /** |
| 10 | 26 | * IoT Modbus TCP 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 |
| 11 | 27 | * |
| ... | ... | @@ -14,13 +30,21 @@ import lombok.extern.slf4j.Slf4j; |
| 14 | 30 | @Slf4j |
| 15 | 31 | public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { |
| 16 | 32 | |
| 33 | + /** | |
| 34 | + * 每个连接透传请求的在途 + 排队上限 | |
| 35 | + */ | |
| 36 | + public static final int MAX_RAW_OUTSTANDING = 32; | |
| 37 | + | |
| 17 | 38 | private final IotModbusTcpClientDownstreamHandler downstreamHandler; |
| 39 | + private final IotModbusTcpClientConnectionManager connectionManager; | |
| 18 | 40 | |
| 19 | 41 | public IotModbusTcpClientDownstreamSubscriber(IotModbusTcpClientProtocol protocol, |
| 20 | 42 | IotModbusTcpClientDownstreamHandler downstreamHandler, |
| 43 | + IotModbusTcpClientConnectionManager connectionManager, | |
| 21 | 44 | IotMessageBus messageBus) { |
| 22 | 45 | super(protocol, messageBus); |
| 23 | 46 | this.downstreamHandler = downstreamHandler; |
| 47 | + this.connectionManager = connectionManager; | |
| 24 | 48 | } |
| 25 | 49 | |
| 26 | 50 | @Override |
| ... | ... | @@ -28,4 +52,81 @@ public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolD |
| 28 | 52 | downstreamHandler.handle(message); |
| 29 | 53 | } |
| 30 | 54 | |
| 55 | + /** | |
| 56 | + * 透传写出:占用 raw 名额,提交到连接的有序 executeBlocking | |
| 57 | + * <p> | |
| 58 | + * 提交之前的失败(PDU 不合法、未连接、raw 名额已满、提交或注册完成回调被同步拒绝)返回失败 Future,只记日志; | |
| 59 | + * 提交之后,executeBlocking 结束(成功、异常、j2mod 超时)就是事务终态:返回的 Future 随即成功完成, | |
| 60 | + * 结果由事务经 {@link #handleRawResult} 处理。raw 名额在事务终态回调里释放,事务只进入一次终态,名额也只释放一次 | |
| 61 | + */ | |
| 62 | + @Override | |
| 63 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 64 | + // 1.1 校验 PDU:与 biz 的 /send、encode 输出校验是同一份规则 | |
| 65 | + try { | |
| 66 | + IotDeviceMessageUtils.validateModbusRequestPdu(bytes); | |
| 67 | + } catch (IllegalArgumentException e) { | |
| 68 | + return Future.failedFuture(exception(MODBUS_PDU_INVALID, e.getMessage())); | |
| 69 | + } | |
| 70 | + // 1.2 获取连接和 slave ID(一个连接可挂多个从站) | |
| 71 | + ModbusConnection connection = connectionManager.getConnection(message.getDeviceId()); | |
| 72 | + Integer slaveId = connection != null ? connection.getSlaveId(message.getDeviceId()) : null; | |
| 73 | + if (slaveId == null) { | |
| 74 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 75 | + } | |
| 76 | + // 1.3 占用 raw 名额:尚未进入事务,失败 Future 只记日志 | |
| 77 | + if (!connection.tryAcquireRawSlot(MAX_RAW_OUTSTANDING)) { | |
| 78 | + return Future.failedFuture(exception(MODBUS_QUEUE_FULL)); | |
| 79 | + } | |
| 80 | + | |
| 81 | + // 2.1 构造事务:截止时间取本连接现有的 timeout(也是 j2mod 的读超时),提交前算一次; | |
| 82 | + // 终态回调先释放名额:事务的终态只生效一次(CAS),名额也只释放一次 | |
| 83 | + Promise<Void> terminal = Promise.promise(); | |
| 84 | + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(connection.getTimeout()); | |
| 85 | + IotModbusTx tx = IotModbusTx.ofClient(message, bytes, deadlineNanos, (responsePdu, failure) -> { | |
| 86 | + connection.releaseRawSlot(); | |
| 87 | + handleRawResult(terminal, message, source, responsePdu, failure); | |
| 88 | + }); | |
| 89 | + // 2.2 提交到有序 executeBlocking:结束即终态(这里不重连,重连由下一个任务开始时的守卫完成) | |
| 90 | + try { | |
| 91 | + IotModbusTcpClientUtils.executeRaw(connection, slaveId, tx).onComplete(ar -> { | |
| 92 | + if (ar.succeeded()) { | |
| 93 | + tx.complete(ar.result()); | |
| 94 | + } else { | |
| 95 | + log.warn("[handleRawMessage][设备 {} 透传失败]", message.getDeviceId(), ar.cause()); | |
| 96 | + tx.fail(IotModbusTcpClientUtils.toRawFailure(ar.cause())); | |
| 97 | + } | |
| 98 | + }); | |
| 99 | + } catch (RuntimeException e) { | |
| 100 | + // 2.3 提交或注册完成回调被同步拒绝(例如 Context 已关闭):先让返回的 Future 失败,再结束事务释放名额; | |
| 101 | + // 完成回调已经执行过(事务已是终态)时,两步都不生效,不会重复释放 | |
| 102 | + log.warn("[handleRawMessage][设备 {} 透传无法提交到连接的 Context]", message.getDeviceId(), e); | |
| 103 | + terminal.tryFail(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 104 | + tx.fail(DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 105 | + } | |
| 106 | + return terminal.future(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * 透传事务终态:先完成 Future,再处理结果 | |
| 111 | + * <p> | |
| 112 | + * 设备的实际响应(包括异常响应)经 {@link #replyRawResult} 回复给 biz; | |
| 113 | + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法)只记日志,不给 biz 构造回复 | |
| 114 | + */ | |
| 115 | + private void handleRawResult(Promise<Void> terminal, IotDeviceMessage message, IotScriptSource source, | |
| 116 | + byte[] responsePdu, ErrorCode failure) { | |
| 117 | + terminal.tryComplete(); | |
| 118 | + if (failure != null || ArrayUtil.isEmpty(responsePdu)) { | |
| 119 | + ErrorCode code = failure != null ? failure : MODBUS_RESPONSE_INVALID; | |
| 120 | + log.warn("[handleRawResult][下行({}) 事务失败 code({}) msg({})]", message.getRequestId(), | |
| 121 | + code.getCode(), code.getMsg()); | |
| 122 | + return; | |
| 123 | + } | |
| 124 | + replyRawResult(message, source, responsePdu, IotModbusCommonUtils.getRawResponseError(responsePdu)); | |
| 125 | + } | |
| 126 | + | |
| 127 | + @Override | |
| 128 | + public boolean supportsRawDownstream() { | |
| 129 | + return true; | |
| 130 | + } | |
| 131 | + | |
| 31 | 132 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java
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; |
| 3 | 3 | import cn.hutool.core.util.ObjUtil; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 5 | 5 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; | |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 7 | 9 | import com.ghgande.j2mod.modbus.net.TCPMasterConnection; |
| 8 | 10 | import io.vertx.core.Context; |
| 9 | 11 | import io.vertx.core.Future; |
| 10 | 12 | import io.vertx.core.Vertx; |
| 13 | +import lombok.AccessLevel; | |
| 11 | 14 | import lombok.Data; |
| 15 | +import lombok.Setter; | |
| 12 | 16 | import lombok.extern.slf4j.Slf4j; |
| 13 | 17 | import org.redisson.api.RLock; |
| 14 | 18 | import org.redisson.api.RedissonClient; |
| ... | ... | @@ -18,6 +22,11 @@ import java.util.ArrayList; |
| 18 | 22 | import java.util.List; |
| 19 | 23 | import java.util.Map; |
| 20 | 24 | import java.util.concurrent.ConcurrentHashMap; |
| 25 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 26 | +import java.util.function.Function; | |
| 27 | + | |
| 28 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 29 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_DISCONNECTED; | |
| 21 | 30 | |
| 22 | 31 | /** |
| 23 | 32 | * IoT Modbus TCP 连接管理器 |
| ... | ... | @@ -232,6 +241,8 @@ public class IotModbusTcpClientConnectionManager { |
| 232 | 241 | if (connection == null) { |
| 233 | 242 | return; |
| 234 | 243 | } |
| 244 | + // 先标记失效:已经排进有序队列、尚未执行的任务直接 115,不会在被关闭的连接上重新 connect() | |
| 245 | + connection.setRetired(true); | |
| 235 | 246 | |
| 236 | 247 | try { |
| 237 | 248 | if (connection.getTcpConnection() != null) { |
| ... | ... | @@ -288,6 +299,35 @@ public class IotModbusTcpClientConnectionManager { |
| 288 | 299 | * Vert.x Context,用于 executeBlocking 执行 Modbus 操作,保证同一连接的操作串行执行 |
| 289 | 300 | */ |
| 290 | 301 | private Context context; |
| 302 | + /** | |
| 303 | + * 本连接读取结果的上报队列({@link #context} 上的有序有界 worker 队列,随 {@link #setContext} 创建): | |
| 304 | + * 点位翻译、发送到消息总线会阻塞,交给 worker 按读取完成的顺序执行,不占用 context 的 event loop; | |
| 305 | + * 只在 context 线程上提交(executeBlocking 的结果回调就在它上面) | |
| 306 | + */ | |
| 307 | + @Setter(AccessLevel.NONE) | |
| 308 | + private IotWorkerTaskQueue upstreamQueue; | |
| 309 | + | |
| 310 | + /** | |
| 311 | + * 是否已失效:closeConnection 移出连接池时置为 true;之后开始执行的任务直接 115 | |
| 312 | + */ | |
| 313 | + private volatile boolean retired; | |
| 314 | + | |
| 315 | + /** | |
| 316 | + * 透传请求的在途 + 排队计数(只计 raw;轮询和标准读写不计入) | |
| 317 | + */ | |
| 318 | + private final AtomicInteger rawOutstanding = new AtomicInteger(); | |
| 319 | + | |
| 320 | + /** | |
| 321 | + * 设置执行 Modbus 操作的 Context,同时创建它上面的上报队列 | |
| 322 | + * | |
| 323 | + * @param context Vert.x Context | |
| 324 | + * @return this | |
| 325 | + */ | |
| 326 | + public ModbusConnection setContext(Context context) { | |
| 327 | + this.context = context; | |
| 328 | + this.upstreamQueue = new IotWorkerTaskQueue(context); | |
| 329 | + return this; | |
| 330 | + } | |
| 291 | 331 | |
| 292 | 332 | public Integer addDevice(Long deviceId, Integer slaveId) { |
| 293 | 333 | return deviceSlaveMap.putIfAbsent(deviceId, slaveId); |
| ... | ... | @@ -306,12 +346,75 @@ public class IotModbusTcpClientConnectionManager { |
| 306 | 346 | } |
| 307 | 347 | |
| 308 | 348 | /** |
| 309 | - * 执行 Modbus 读取操作(阻塞方式,在 Vert.x worker 线程执行) | |
| 349 | + * 执行标准 Modbus 操作(轮询读取、标准写入;阻塞方式,在 Vert.x worker 线程执行) | |
| 350 | + * <p> | |
| 351 | + * 与原逻辑一致:操作失败(包括读超时)只抛出,不关闭这个可能被多个从站共用的物理连接, | |
| 352 | + * 未连接时由 j2mod 的 ModbusTCPTransaction.execute 自己 connect。 | |
| 353 | + * 只多一个检查:已被 closeConnection 移出连接池(retired)时,排队中的任务不再执行,直接 115,不会重新连上被关闭的连接 | |
| 310 | 354 | */ |
| 311 | - public <T> Future<T> executeBlocking(java.util.function.Function<TCPMasterConnection, T> operation) { | |
| 355 | + public <T> Future<T> executeBlocking(Function<TCPMasterConnection, T> operation) { | |
| 312 | 356 | // ordered=true 保证同一 Context 的操作串行执行,不同连接之间可并行 |
| 313 | - return context.executeBlocking(() -> operation.apply(tcpConnection), true); | |
| 357 | + return context.executeBlocking(() -> { | |
| 358 | + if (retired) { | |
| 359 | + throw exception(MODBUS_DISCONNECTED); | |
| 360 | + } | |
| 361 | + return operation.apply(tcpConnection); | |
| 362 | + }, true); | |
| 314 | 363 | } |
| 364 | + | |
| 365 | + /** | |
| 366 | + * 执行透传操作(阻塞方式,在 Vert.x worker 线程执行;与标准操作排在同一个有序队列里) | |
| 367 | + * <p> | |
| 368 | + * 只有透传使用的传输失效隔离,在有序边界内完成(不在结果投递或 onComplete 之后重连): | |
| 369 | + * 1. 已被 closeConnection 移出连接池(retired):排队中的任务不再执行,直接 115; | |
| 370 | + * 2. 之前的透传因传输失败关闭了连接:本任务开始时用现有的 connect() 恢复; | |
| 371 | + * 3. 传输失败(读超时、IO 异常):在同一个有序任务里先关闭连接,再抛出异常,迟到的响应不会被下一个事务读到; | |
| 372 | + * 设备异常响应(ModbusSlaveException)说明链路正常,不关闭、不重连 | |
| 373 | + */ | |
| 374 | + public <T> Future<T> executeRawBlocking(Function<TCPMasterConnection, T> operation) { | |
| 375 | + return context.executeBlocking(() -> { | |
| 376 | + // 1. 连接已被 closeConnection 移出连接池:排队中的任务不再执行 | |
| 377 | + if (retired) { | |
| 378 | + throw exception(MODBUS_DISCONNECTED); | |
| 379 | + } | |
| 380 | + // 2. 之前的透传因传输失败关闭了连接:在本任务里用现有的 connect() 恢复 | |
| 381 | + // (j2mod 的 ModbusTCPTransaction.execute 也会在未连接时 connect;这里先连上,它看到已连接就不会再连一次) | |
| 382 | + if (!tcpConnection.isConnected()) { | |
| 383 | + tcpConnection.connect(); | |
| 384 | + } | |
| 385 | + // 3. 执行操作:传输失败时先让连接失效,之后的任务开始时才会重连 | |
| 386 | + try { | |
| 387 | + return operation.apply(tcpConnection); | |
| 388 | + } catch (RuntimeException e) { | |
| 389 | + if (IotModbusTcpClientUtils.isTransportFailure(e)) { | |
| 390 | + tcpConnection.close(); | |
| 391 | + } | |
| 392 | + throw e; | |
| 393 | + } | |
| 394 | + }, true); | |
| 395 | + } | |
| 396 | + | |
| 397 | + /** | |
| 398 | + * 占用一个透传名额 | |
| 399 | + * | |
| 400 | + * @param max 每个连接的上限 | |
| 401 | + * @return 是否成功;false 时调用方直接返回 113 失败,不进入 executeBlocking | |
| 402 | + */ | |
| 403 | + public boolean tryAcquireRawSlot(int max) { | |
| 404 | + if (rawOutstanding.incrementAndGet() <= max) { | |
| 405 | + return true; | |
| 406 | + } | |
| 407 | + rawOutstanding.decrementAndGet(); | |
| 408 | + return false; | |
| 409 | + } | |
| 410 | + | |
| 411 | + /** | |
| 412 | + * 释放一个透传名额(透传任务到达终态时调用) | |
| 413 | + */ | |
| 414 | + public void releaseRawSlot() { | |
| 415 | + rawOutstanding.decrementAndGet(); | |
| 416 | + } | |
| 417 | + | |
| 315 | 418 | } |
| 316 | 419 | |
| 317 | 420 | } | ... | ... |
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; |
| 14 | 14 | import lombok.extern.slf4j.Slf4j; |
| 15 | 15 | |
| 16 | 16 | import java.util.*; |
| 17 | +import java.util.concurrent.RejectedExecutionException; | |
| 17 | 18 | |
| 18 | 19 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; |
| 19 | 20 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; |
| ... | ... | @@ -76,10 +77,11 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu |
| 76 | 77 | Integer slaveId = connectionManager.getSlaveId(deviceId); |
| 77 | 78 | Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); |
| 78 | 79 | |
| 79 | - // 3. 执行 Modbus 批量读取 | |
| 80 | + // 3. 执行 Modbus 批量读取:读取结果的上报交给连接的 worker 队列 | |
| 80 | 81 | IotModbusTcpClientUtils.read(connection, slaveId, segment.getFunctionCode(), |
| 81 | 82 | segment.getStartAddress(), segment.getRegisterCount()) |
| 82 | - .onSuccess(rawValues -> handleSegmentReadResult(config, segment, rawValues)) | |
| 83 | + .onSuccess(rawValues -> submitReadResult(connection, deviceId, | |
| 84 | + () -> handleSegmentReadResult(config, segment, rawValues))) | |
| 83 | 85 | .onFailure(e -> log.error("[pollTask][读取点位段失败, deviceId={}, segment={}]", |
| 84 | 86 | deviceId, segment.getKey(), e)); |
| 85 | 87 | } |
| ... | ... | @@ -112,13 +114,38 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu |
| 112 | 114 | Integer slaveId = connectionManager.getSlaveId(deviceId); |
| 113 | 115 | Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); |
| 114 | 116 | |
| 115 | - // 3. 执行 Modbus 读取 | |
| 117 | + // 3. 执行 Modbus 读取:读取结果的上报交给连接的 worker 队列 | |
| 116 | 118 | IotModbusTcpClientUtils.read(connection, slaveId, point) |
| 117 | - .onSuccess(rawValue -> upstreamHandler.handleReadResult(config, point, rawValue)) | |
| 119 | + .onSuccess(rawValue -> submitReadResult(connection, deviceId, | |
| 120 | + () -> upstreamHandler.handleReadResult(config, point, rawValue))) | |
| 118 | 121 | .onFailure(e -> log.error("[pollPoint][读取点位失败, deviceId={}, identifier={}]", |
| 119 | 122 | deviceId, point.getIdentifier(), e)); |
| 120 | 123 | } |
| 121 | 124 | |
| 125 | + /** | |
| 126 | + * 提交读取结果的上报(在连接的 context 上调用:读取结果的回调就在它上面) | |
| 127 | + * <p> | |
| 128 | + * 点位翻译、发送到消息总线会阻塞,交给连接的有序 worker 队列,按读取完成的顺序执行,不占用 context 的 event loop; | |
| 129 | + * 读取本身、透传仍在连接原有的有序 executeBlocking 里,不受上报阻塞影响。队列已满时丢弃这次上报,下一轮轮询会再次读取 | |
| 130 | + * | |
| 131 | + * @param connection Modbus 连接 | |
| 132 | + * @param deviceId 设备 ID | |
| 133 | + * @param task 上报任务 | |
| 134 | + */ | |
| 135 | + private void submitReadResult(IotModbusTcpClientConnectionManager.ModbusConnection connection, Long deviceId, | |
| 136 | + Runnable task) { | |
| 137 | + connection.getUpstreamQueue().submit(() -> { | |
| 138 | + task.run(); | |
| 139 | + return null; | |
| 140 | + }).onFailure(e -> { | |
| 141 | + if (e instanceof RejectedExecutionException) { | |
| 142 | + log.warn("[submitReadResult][设备 {} 读取结果上报积压, 丢弃本次上报: {}]", deviceId, e.getMessage()); | |
| 143 | + return; | |
| 144 | + } | |
| 145 | + log.error("[submitReadResult][设备 {} 读取结果上报失败]", deviceId, e); | |
| 146 | + }); | |
| 147 | + } | |
| 148 | + | |
| 122 | 149 | private void handleSegmentReadResult(IotModbusDeviceConfigRespDTO config, |
| 123 | 150 | ReadSegment segment, |
| 124 | 151 | int[] rawValues) { | ... | ... |
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; |
| 7 | 7 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; |
| 9 | 9 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 10 | -import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 11 | 10 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; |
| 12 | 11 | import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties; |
| 13 | 12 | 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 |
| 21 | 20 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; |
| 22 | 21 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; |
| 23 | 22 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 24 | 24 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 25 | 25 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 26 | 26 | import io.vertx.core.Vertx; |
| ... | ... | @@ -67,6 +67,7 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 67 | 67 | /** |
| 68 | 68 | * Vert.x 实例 |
| 69 | 69 | */ |
| 70 | + @Getter | |
| 70 | 71 | private final Vertx vertx; |
| 71 | 72 | /** |
| 72 | 73 | * TCP Server |
| ... | ... | @@ -98,7 +99,6 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 98 | 99 | private final IotModbusTcpServerPendingRequestManager pendingRequestManager; |
| 99 | 100 | private final IotModbusTcpServerUpstreamHandler upstreamHandler; |
| 100 | 101 | private final IotModbusTcpServerPollScheduler pollScheduler; |
| 101 | - private final IotDeviceMessageService messageService; | |
| 102 | 102 | |
| 103 | 103 | public IotModbusTcpServerProtocol(ProtocolProperties properties) { |
| 104 | 104 | IotModbusTcpServerConfig slaveConfig = properties.getModbusTcpServer(); |
| ... | ... | @@ -119,20 +119,21 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 119 | 119 | this.frameDecoder = new IotModbusFrameDecoder(slaveConfig.getCustomFunctionCode()); |
| 120 | 120 | this.frameEncoder = new IotModbusFrameEncoder(slaveConfig.getCustomFunctionCode()); |
| 121 | 121 | |
| 122 | - // 初始化共享事务 ID 自增器(PollScheduler 和 DownstreamHandler 共用,避免 transactionId 冲突) | |
| 122 | + // 初始化共享事务 ID 自增器(所有连接的事务通道共用:轮询、标准写、透传都从通道分配,避免 transactionId 冲突) | |
| 123 | 123 | AtomicInteger transactionIdCounter = new AtomicInteger(0); |
| 124 | 124 | // 初始化轮询调度器 |
| 125 | 125 | this.pollScheduler = new IotModbusTcpServerPollScheduler( |
| 126 | 126 | vertx, connectionManager, frameEncoder, pendingRequestManager, |
| 127 | - slaveConfig.getRequestTimeout(), transactionIdCounter, configCacheService); | |
| 127 | + slaveConfig.getRequestTimeout(), configCacheService); | |
| 128 | 128 | |
| 129 | 129 | // 初始化 Handler |
| 130 | - this.messageService = SpringUtil.getBean(IotDeviceMessageService.class); | |
| 130 | + IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class); | |
| 131 | 131 | IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class); |
| 132 | 132 | this.upstreamHandler = new IotModbusTcpServerUpstreamHandler( |
| 133 | - deviceApi, this.messageService, frameEncoder, | |
| 133 | + deviceApi, messageService, frameEncoder, | |
| 134 | 134 | connectionManager, configCacheService, pendingRequestManager, |
| 135 | - pollScheduler, deviceService, serverId); | |
| 135 | + pollScheduler, deviceService, serverId, | |
| 136 | + transactionIdCounter, slaveConfig.getRequestTimeout()); | |
| 136 | 137 | } |
| 137 | 138 | |
| 138 | 139 | @Override |
| ... | ... | @@ -165,7 +166,7 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 165 | 166 | // 2.2 启动 PendingRequest 清理定时器 |
| 166 | 167 | requestCleanupTimerId = vertx.setPeriodic( |
| 167 | 168 | slaveConfig.getRequestCleanupInterval(), |
| 168 | - id -> pendingRequestManager.cleanupExpired()); | |
| 169 | + id -> cleanupExpiredRequests()); | |
| 169 | 170 | running = true; |
| 170 | 171 | log.info("[start][IoT Modbus TCP Server 协议 {} 启动成功, serverId={}, port={}]", |
| 171 | 172 | getId(), serverId, properties.getPort()); |
| ... | ... | @@ -173,9 +174,9 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 173 | 174 | // 3. 启动下行消息订阅 |
| 174 | 175 | IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); |
| 175 | 176 | IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler( |
| 176 | - connectionManager, configCacheService, frameEncoder, this.pollScheduler.getTransactionIdCounter()); | |
| 177 | + connectionManager, configCacheService, frameEncoder); | |
| 177 | 178 | this.downstreamSubscriber = new IotModbusTcpServerDownstreamSubscriber( |
| 178 | - this, downstreamHandler, messageBus); | |
| 179 | + this, downstreamHandler, connectionManager, messageBus); | |
| 179 | 180 | downstreamSubscriber.start(); |
| 180 | 181 | } catch (Exception e) { |
| 181 | 182 | log.error("[start][IoT Modbus TCP Server 协议 {} 启动失败]", getId(), e); |
| ... | ... | @@ -281,15 +282,11 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 281 | 282 | return; |
| 282 | 283 | } |
| 283 | 284 | pollScheduler.stopPolling(info.getDeviceId()); |
| 284 | - pendingRequestManager.removeDevice(info.getDeviceId()); | |
| 285 | + // 设备自己断开:排队中、在途的透传全部以 115 收尾,并只清理这条连接登记的待响应请求(不影响同一设备的新连接) | |
| 286 | + info.getChannel().onClose(); | |
| 285 | 287 | configCacheService.removeConfig(info.getDeviceId()); |
| 286 | - // 发送设备下线消息 | |
| 287 | - try { | |
| 288 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 289 | - messageService.sendDeviceMessage(offlineMessage, info.getProductKey(), info.getDeviceName(), serverId); | |
| 290 | - } catch (Exception ex) { | |
| 291 | - log.error("[handleConnection][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), ex); | |
| 292 | - } | |
| 288 | + // 发送设备下线消息:交给本连接的 worker 队列,排在已收到的轮询上报之后 | |
| 289 | + upstreamHandler.sendOfflineMessage(info); | |
| 293 | 290 | log.info("[handleConnection][连接关闭, deviceId={}, remoteAddress={}]", |
| 294 | 291 | info.getDeviceId(), socket.remoteAddress()); |
| 295 | 292 | }); |
| ... | ... | @@ -301,6 +298,16 @@ public class IotModbusTcpServerProtocol implements IotProtocol { |
| 301 | 298 | } |
| 302 | 299 | |
| 303 | 300 | /** |
| 301 | + * 清理过期的待响应请求(定时调用) | |
| 302 | + */ | |
| 303 | + private void cleanupExpiredRequests() { | |
| 304 | + // 1. 待响应请求:保持原逻辑,到期移除、只记日志 | |
| 305 | + pendingRequestManager.cleanupExpired(); | |
| 306 | + // 2. 同一个清理周期里,各连接检查透传在途是否超时、排队是否超过截止时间(切回各自的 owner 收尾,不新增定时器) | |
| 307 | + connectionManager.getChannels().forEach(IotModbusTcpServerTransactionChannel::onCleanup); | |
| 308 | + } | |
| 309 | + | |
| 310 | + /** | |
| 304 | 311 | * 刷新已连接设备的配置(定时调用) |
| 305 | 312 | */ |
| 306 | 313 | private synchronized void refreshConfig() { | ... | ... |
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 { |
| 155 | 155 | // ==================== 帧封装 ==================== |
| 156 | 156 | |
| 157 | 157 | /** |
| 158 | - * 将 PDU 封装为完整帧 | |
| 158 | + * 将 PDU 封装为完整帧(透传请求直接封装已校验的 PDU) | |
| 159 | 159 | * |
| 160 | 160 | * @param slaveId 从站地址 |
| 161 | 161 | * @param pdu PDU 数据(含 functionCode) |
| ... | ... | @@ -163,7 +163,7 @@ public class IotModbusFrameEncoder { |
| 163 | 163 | * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式可为 null) |
| 164 | 164 | * @return 完整帧字节数组 |
| 165 | 165 | */ |
| 166 | - private byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { | |
| 166 | + public byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { | |
| 167 | 167 | if (format == IotModbusFrameFormatEnum.MODBUS_TCP) { |
| 168 | 168 | return wrapTcpFrame(slaveId, pdu, transactionId != null ? transactionId : 0); |
| 169 | 169 | } else { | ... | ... |
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 |
| 12 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; |
| 13 | 13 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; |
| 14 | 14 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 15 | 16 | import lombok.extern.slf4j.Slf4j; |
| 16 | 17 | |
| 17 | 18 | import java.util.Map; |
| 18 | -import java.util.concurrent.atomic.AtomicInteger; | |
| 19 | 19 | |
| 20 | 20 | /** |
| 21 | 21 | * IoT Modbus TCP Server 下行消息处理器 |
| ... | ... | @@ -33,19 +33,12 @@ public class IotModbusTcpServerDownstreamHandler { |
| 33 | 33 | private final IotModbusTcpServerConfigCacheService configCacheService; |
| 34 | 34 | private final IotModbusFrameEncoder frameEncoder; |
| 35 | 35 | |
| 36 | - /** | |
| 37 | - * TCP 事务 ID 自增器(与 PollScheduler 共享) | |
| 38 | - */ | |
| 39 | - private final AtomicInteger transactionIdCounter; | |
| 40 | - | |
| 41 | 36 | public IotModbusTcpServerDownstreamHandler(IotModbusTcpServerConnectionManager connectionManager, |
| 42 | 37 | IotModbusTcpServerConfigCacheService configCacheService, |
| 43 | - IotModbusFrameEncoder frameEncoder, | |
| 44 | - AtomicInteger transactionIdCounter) { | |
| 38 | + IotModbusFrameEncoder frameEncoder) { | |
| 45 | 39 | this.connectionManager = connectionManager; |
| 46 | 40 | this.configCacheService = configCacheService; |
| 47 | 41 | this.frameEncoder = frameEncoder; |
| 48 | - this.transactionIdCounter = transactionIdCounter; | |
| 49 | 42 | } |
| 50 | 43 | |
| 51 | 44 | /** |
| ... | ... | @@ -109,12 +102,11 @@ public class IotModbusTcpServerDownstreamHandler { |
| 109 | 102 | // 1.1 转换属性值为原始值 |
| 110 | 103 | int[] rawValues = IotModbusCommonUtils.convertToRawValues(value, point); |
| 111 | 104 | |
| 112 | - // 1.2 确定帧格式和事务 ID | |
| 105 | + // 1.2 确定帧格式和事务 ID(事务号由连接的事务通道分配:跳过本连接仍占用的编号) | |
| 113 | 106 | IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat(); |
| 114 | 107 | Assert.notNull(frameFormat, "连接帧格式不能为空"); |
| 115 | - Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP | |
| 116 | - ? (transactionIdCounter.incrementAndGet() & 0xFFFF) | |
| 117 | - : null; | |
| 108 | + IotModbusTcpServerTransactionChannel channel = connInfo.getChannel(); | |
| 109 | + Integer transactionId = channel.allocateTransactionId(); | |
| 118 | 110 | int slaveId = connInfo.getSlaveId() != null ? connInfo.getSlaveId() : 1; |
| 119 | 111 | // 1.3 编码写请求 |
| 120 | 112 | byte[] data; |
| ... | ... | @@ -139,8 +131,8 @@ public class IotModbusTcpServerDownstreamHandler { |
| 139 | 131 | return; |
| 140 | 132 | } |
| 141 | 133 | |
| 142 | - // 2. 发送消息 | |
| 143 | - connectionManager.sendToDevice(deviceId, data).onSuccess(v -> | |
| 134 | + // 2. 发送消息(用 connInfo 对应的连接写出;socket 写出成功即记成功,不等待设备回显) | |
| 135 | + channel.send(data).onSuccess(v -> | |
| 144 | 136 | log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]", |
| 145 | 137 | deviceId, point.getIdentifier(), value) |
| 146 | 138 | ).onFailure(e -> | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.util.ArrayUtil; | |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ErrorCode; | |
| 3 | 5 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 6 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 7 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 5 | 9 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 6 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; | |
| 15 | +import io.vertx.core.Future; | |
| 16 | +import io.vertx.core.Promise; | |
| 7 | 17 | import lombok.extern.slf4j.Slf4j; |
| 8 | 18 | |
| 19 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 20 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 21 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_PDU_INVALID; | |
| 22 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID; | |
| 23 | + | |
| 9 | 24 | /** |
| 10 | 25 | * IoT Modbus TCP Server 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 |
| 11 | 26 | * |
| ... | ... | @@ -15,12 +30,15 @@ import lombok.extern.slf4j.Slf4j; |
| 15 | 30 | public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { |
| 16 | 31 | |
| 17 | 32 | private final IotModbusTcpServerDownstreamHandler downstreamHandler; |
| 33 | + private final IotModbusTcpServerConnectionManager connectionManager; | |
| 18 | 34 | |
| 19 | 35 | public IotModbusTcpServerDownstreamSubscriber(IotModbusTcpServerProtocol protocol, |
| 20 | 36 | IotModbusTcpServerDownstreamHandler downstreamHandler, |
| 37 | + IotModbusTcpServerConnectionManager connectionManager, | |
| 21 | 38 | IotMessageBus messageBus) { |
| 22 | 39 | super(protocol, messageBus); |
| 23 | 40 | this.downstreamHandler = downstreamHandler; |
| 41 | + this.connectionManager = connectionManager; | |
| 24 | 42 | } |
| 25 | 43 | |
| 26 | 44 | @Override |
| ... | ... | @@ -28,4 +46,56 @@ public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolD |
| 28 | 46 | downstreamHandler.handle(message); |
| 29 | 47 | } |
| 30 | 48 | |
| 49 | + /** | |
| 50 | + * 透传写出:提交透传事务到连接的事务通道 | |
| 51 | + * <p> | |
| 52 | + * 提交之前的失败(PDU 不合法、未连接或未认证)返回失败 Future,只记日志; | |
| 53 | + * 提交之后,返回的 Future 在事务终态(响应 / 超时 / 断线 / 写失败 / 队列满)成功完成, | |
| 54 | + * 结果由事务经 {@link #handleRawResult} 处理 | |
| 55 | + */ | |
| 56 | + @Override | |
| 57 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 58 | + // 1.1 校验 PDU:与 biz 的 /send、encode 输出校验是同一份规则 | |
| 59 | + try { | |
| 60 | + IotDeviceMessageUtils.validateModbusRequestPdu(bytes); | |
| 61 | + } catch (IllegalArgumentException e) { | |
| 62 | + return Future.failedFuture(exception(MODBUS_PDU_INVALID, e.getMessage())); | |
| 63 | + } | |
| 64 | + // 1.2 获取连接(未连接、未认证的连接没有事务通道) | |
| 65 | + ConnectionInfo info = connectionManager.getConnectionInfoByDeviceId(message.getDeviceId()); | |
| 66 | + if (info == null || info.getChannel() == null) { | |
| 67 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 68 | + } | |
| 69 | + | |
| 70 | + // 2. 提交事务:终态时先完成 Future,再处理结果(设备响应含异常响应 → responsePdu;网关判定 → failure) | |
| 71 | + Promise<Void> terminal = Promise.promise(); | |
| 72 | + IotModbusTx tx = IotModbusTx.ofServer(info.getChannel(), bytes, message, | |
| 73 | + (responsePdu, failure) -> handleRawResult(terminal, message, source, responsePdu, failure)); | |
| 74 | + info.getChannel().submitRaw(tx); | |
| 75 | + return terminal.future(); | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** | |
| 79 | + * 透传事务终态:先完成 Future,再处理结果 | |
| 80 | + * <p> | |
| 81 | + * 设备的实际响应(包括异常响应)经 {@link #replyRawResult} 回复给 biz; | |
| 82 | + * 网关判定的失败(超时、断线、写失败、队列满、响应不合法)只记日志,不给 biz 构造回复 | |
| 83 | + */ | |
| 84 | + private void handleRawResult(Promise<Void> terminal, IotDeviceMessage message, IotScriptSource source, | |
| 85 | + byte[] responsePdu, ErrorCode failure) { | |
| 86 | + terminal.tryComplete(); | |
| 87 | + if (failure != null || ArrayUtil.isEmpty(responsePdu)) { | |
| 88 | + ErrorCode code = failure != null ? failure : MODBUS_RESPONSE_INVALID; | |
| 89 | + log.warn("[handleRawResult][下行({}) 事务失败 code({}) msg({})]", message.getRequestId(), | |
| 90 | + code.getCode(), code.getMsg()); | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + replyRawResult(message, source, responsePdu, IotModbusCommonUtils.getRawResponseError(responsePdu)); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Override | |
| 97 | + public boolean supportsRawDownstream() { | |
| 98 | + return true; | |
| 99 | + } | |
| 100 | + | |
| 31 | 101 | } | ... | ... |
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 |
| 28 | 28 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; |
| 29 | 29 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; |
| 30 | 30 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; |
| 31 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel; | |
| 31 | 32 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 32 | 33 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 34 | +import io.vertx.core.Context; | |
| 35 | +import io.vertx.core.Future; | |
| 36 | +import io.vertx.core.Vertx; | |
| 33 | 37 | import io.vertx.core.net.NetSocket; |
| 34 | 38 | import lombok.extern.slf4j.Slf4j; |
| 35 | 39 | |
| 36 | 40 | import java.util.Map; |
| 41 | +import java.util.concurrent.RejectedExecutionException; | |
| 42 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 37 | 43 | |
| 38 | 44 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; |
| 39 | 45 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; |
| ... | ... | @@ -64,6 +70,15 @@ public class IotModbusTcpServerUpstreamHandler { |
| 64 | 70 | |
| 65 | 71 | private final String serverId; |
| 66 | 72 | |
| 73 | + /** | |
| 74 | + * TCP 事务 ID 自增器(所有连接的事务通道共享) | |
| 75 | + */ | |
| 76 | + private final AtomicInteger transactionIdCounter; | |
| 77 | + /** | |
| 78 | + * 请求超时时间(毫秒) | |
| 79 | + */ | |
| 80 | + private final int requestTimeout; | |
| 81 | + | |
| 67 | 82 | public IotModbusTcpServerUpstreamHandler(IotDeviceCommonApi deviceApi, |
| 68 | 83 | IotDeviceMessageService messageService, |
| 69 | 84 | IotModbusFrameEncoder frameEncoder, |
| ... | ... | @@ -72,7 +87,9 @@ public class IotModbusTcpServerUpstreamHandler { |
| 72 | 87 | IotModbusTcpServerPendingRequestManager pendingRequestManager, |
| 73 | 88 | IotModbusTcpServerPollScheduler pollScheduler, |
| 74 | 89 | IotDeviceService deviceService, |
| 75 | - String serverId) { | |
| 90 | + String serverId, | |
| 91 | + AtomicInteger transactionIdCounter, | |
| 92 | + int requestTimeout) { | |
| 76 | 93 | this.deviceApi = deviceApi; |
| 77 | 94 | this.messageService = messageService; |
| 78 | 95 | this.frameEncoder = frameEncoder; |
| ... | ... | @@ -82,21 +99,25 @@ public class IotModbusTcpServerUpstreamHandler { |
| 82 | 99 | this.pollScheduler = pollScheduler; |
| 83 | 100 | this.deviceService = deviceService; |
| 84 | 101 | this.serverId = serverId; |
| 102 | + this.transactionIdCounter = transactionIdCounter; | |
| 103 | + this.requestTimeout = requestTimeout; | |
| 85 | 104 | } |
| 86 | 105 | |
| 87 | 106 | // ========== 帧处理入口 ========== |
| 88 | 107 | |
| 89 | 108 | /** |
| 90 | - * 处理帧 | |
| 109 | + * 处理帧(连接的 owner 上调用:RecordParser 回调) | |
| 91 | 110 | */ |
| 92 | 111 | public void handleFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) { |
| 93 | 112 | if (frame == null) { |
| 94 | 113 | return; |
| 95 | 114 | } |
| 96 | - // 1. 异常响应 | |
| 115 | + // 1. 异常响应:只尝试匹配透传请求,让它以设备的异常响应收尾(例如 83 02); | |
| 116 | + // 标准轮询的异常响应照旧丢弃,它的待响应请求等到期清理 | |
| 97 | 117 | if (frame.isException()) { |
| 98 | 118 | log.warn("[handleFrame][设备异常响应, slaveId={}, FC={}, exceptionCode={}]", |
| 99 | 119 | frame.getSlaveId(), frame.getFunctionCode(), frame.getExceptionCode()); |
| 120 | + handlePollingResponse(socket, frame, frameFormat); | |
| 100 | 121 | return; |
| 101 | 122 | } |
| 102 | 123 | |
| ... | ... | @@ -192,13 +213,19 @@ public class IotModbusTcpServerUpstreamHandler { |
| 192 | 213 | return; |
| 193 | 214 | } |
| 194 | 215 | |
| 195 | - // 3.1 注册连接 | |
| 216 | + // 3.1 注册连接:事务通道在本连接的 owner(当前 event loop Context)上创建 | |
| 217 | + Context owner = Vertx.currentContext(); | |
| 218 | + Assert.notNull(owner, "事务通道必须在连接的 event loop 上创建"); | |
| 219 | + IotModbusTcpServerTransactionChannel channel = new IotModbusTcpServerTransactionChannel(owner, socket, | |
| 220 | + device.getId(), frame.getSlaveId(), frameFormat, frameEncoder, pendingRequestManager, | |
| 221 | + transactionIdCounter, requestTimeout); | |
| 196 | 222 | ConnectionInfo connectionInfo = new ConnectionInfo() |
| 197 | 223 | .setDeviceId(device.getId()) |
| 198 | 224 | .setProductKey(deviceInfo.getProductKey()) |
| 199 | 225 | .setDeviceName(deviceInfo.getDeviceName()) |
| 200 | 226 | .setSlaveId(frame.getSlaveId()) |
| 201 | - .setFrameFormat(frameFormat); | |
| 227 | + .setFrameFormat(frameFormat) | |
| 228 | + .setChannel(channel); | |
| 202 | 229 | connectionManager.registerConnection(socket, connectionInfo); |
| 203 | 230 | // 3.2 发送上线消息 |
| 204 | 231 | IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); |
| ... | ... | @@ -232,6 +259,9 @@ public class IotModbusTcpServerUpstreamHandler { |
| 232 | 259 | |
| 233 | 260 | /** |
| 234 | 261 | * 处理轮询响应(云端轮询模式) |
| 262 | + * <p> | |
| 263 | + * 匹配待响应请求、透传收尾留在连接的 owner 上(与发送、清理同一个线程); | |
| 264 | + * 标准轮询的点位翻译、发送到消息总线会阻塞,交给本连接(事务通道)的 worker 队列按收到的顺序执行 | |
| 235 | 265 | */ |
| 236 | 266 | private void handlePollingResponse(NetSocket socket, IotModbusFrame frame, |
| 237 | 267 | IotModbusFrameFormatEnum frameFormat) { |
| ... | ... | @@ -242,39 +272,94 @@ public class IotModbusTcpServerUpstreamHandler { |
| 242 | 272 | return; |
| 243 | 273 | } |
| 244 | 274 | |
| 245 | - // 2.1 匹配 PendingRequest | |
| 275 | + // 2.1 匹配 PendingRequest:只匹配这条连接自己发出的请求 | |
| 246 | 276 | PendingRequest request = pendingRequestManager.matchResponse( |
| 247 | - info.getDeviceId(), frame, frameFormat); | |
| 277 | + info.getDeviceId(), frame, frameFormat, info.getChannel()); | |
| 248 | 278 | if (request == null) { |
| 249 | 279 | log.debug("[handlePollingResponse][未匹配到 PendingRequest, deviceId={}, FC={}]", |
| 250 | 280 | info.getDeviceId(), frame.getFunctionCode()); |
| 251 | 281 | return; |
| 252 | 282 | } |
| 253 | - // 2.2 提取寄存器值 | |
| 283 | + // 2.2 透传请求:交给事务通道进入唯一终态,不做点位处理 | |
| 284 | + if (request.getTx() != null) { | |
| 285 | + info.getChannel().onRawResponse(frame, request); | |
| 286 | + return; | |
| 287 | + } | |
| 288 | + | |
| 289 | + // 3. 标准轮询:交给本连接的 worker 队列;队列已满时丢弃这次上报(下一轮轮询会再次读取),不断开连接 | |
| 290 | + info.getChannel().getUpstreamQueue().submit(() -> { | |
| 291 | + handlePollingResult(info, frame, request); | |
| 292 | + return null; | |
| 293 | + }).onFailure(e -> { | |
| 294 | + if (e instanceof RejectedExecutionException) { | |
| 295 | + log.warn("[handlePollingResponse][轮询上报积压, 丢弃本次上报, deviceId={}, identifier={}, 错误: {}]", | |
| 296 | + info.getDeviceId(), request.getIdentifier(), e.getMessage()); | |
| 297 | + return; | |
| 298 | + } | |
| 299 | + log.error("[handlePollingResponse][轮询上报失败, deviceId={}, identifier={}]", | |
| 300 | + info.getDeviceId(), request.getIdentifier(), e); | |
| 301 | + }); | |
| 302 | + } | |
| 303 | + | |
| 304 | + /** | |
| 305 | + * 处理标准轮询的响应(worker 上执行):点位翻译 → thing.property.post 发送到消息总线 | |
| 306 | + * | |
| 307 | + * @param info 连接信息 | |
| 308 | + * @param frame 响应帧 | |
| 309 | + * @param request 匹配到的待响应请求 | |
| 310 | + */ | |
| 311 | + private void handlePollingResult(ConnectionInfo info, IotModbusFrame frame, PendingRequest request) { | |
| 312 | + // 1.1 提取寄存器值 | |
| 254 | 313 | int[] rawValues = IotModbusCommonUtils.extractValues(frame); |
| 255 | 314 | if (rawValues == null) { |
| 256 | - log.warn("[handlePollingResponse][提取寄存器值失败, deviceId={}, identifier={}]", | |
| 315 | + log.warn("[handlePollingResult][提取寄存器值失败, deviceId={}, identifier={}]", | |
| 257 | 316 | info.getDeviceId(), request.getIdentifier()); |
| 258 | 317 | return; |
| 259 | 318 | } |
| 260 | - // 2.3 查找点位配置 | |
| 319 | + // 1.2 查找点位配置 | |
| 261 | 320 | IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(info.getDeviceId()); |
| 262 | 321 | IotModbusPointRespDTO point = IotModbusCommonUtils.findPointById(config, request.getPointId()); |
| 263 | 322 | if (point == null) { |
| 264 | 323 | return; |
| 265 | 324 | } |
| 266 | 325 | |
| 267 | - // 3.1 转换原始值为物模型属性值(点位翻译) | |
| 326 | + // 2.1 转换原始值为物模型属性值(点位翻译) | |
| 268 | 327 | Object convertedValue = IotModbusCommonUtils.convertToPropertyValue(rawValues, point); |
| 269 | - // 3.2 构造属性上报消息 | |
| 328 | + // 2.2 构造属性上报消息 | |
| 270 | 329 | Map<String, Object> params = MapUtil.of(request.getIdentifier(), convertedValue); |
| 271 | 330 | IotDeviceMessage message = IotDeviceMessage.requestOf( |
| 272 | 331 | IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params); |
| 273 | 332 | |
| 274 | - // 4. 发送到消息总线 | |
| 333 | + // 3. 发送到消息总线 | |
| 275 | 334 | messageService.sendDeviceMessage(message, info.getProductKey(), info.getDeviceName(), serverId); |
| 276 | - log.debug("[handlePollingResponse][设备={}, 属性={}, 原始值={}, 转换值={}]", | |
| 335 | + log.debug("[handlePollingResult][设备={}, 属性={}, 原始值={}, 转换值={}]", | |
| 277 | 336 | info.getDeviceId(), request.getIdentifier(), rawValues, convertedValue); |
| 278 | 337 | } |
| 279 | 338 | |
| 339 | + // ========== 连接关闭 ========== | |
| 340 | + | |
| 341 | + /** | |
| 342 | + * 发送设备下线消息(连接的 owner 上调用:closeHandler) | |
| 343 | + * <p> | |
| 344 | + * 发送会阻塞,作为本连接的最后一个任务交给 worker 队列,排在已收到的轮询上报之后; | |
| 345 | + * 执行时设备已在新连接上线就不再发送,发送期间设备在新连接上线则补发上线消息(与 TCP 协议一致) | |
| 346 | + * | |
| 347 | + * @param info 已移除的连接信息 | |
| 348 | + * @return 发送结果(在 owner 上完成);失败只记日志 | |
| 349 | + */ | |
| 350 | + public Future<Void> sendOfflineMessage(ConnectionInfo info) { | |
| 351 | + return info.getChannel().getUpstreamQueue().<Void>submitLast(() -> { | |
| 352 | + if (connectionManager.getConnectionInfoByDeviceId(info.getDeviceId()) != null) { | |
| 353 | + return null; | |
| 354 | + } | |
| 355 | + messageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), | |
| 356 | + info.getProductKey(), info.getDeviceName(), serverId); | |
| 357 | + if (connectionManager.getConnectionInfoByDeviceId(info.getDeviceId()) != null) { | |
| 358 | + messageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 359 | + info.getProductKey(), info.getDeviceName(), serverId); | |
| 360 | + } | |
| 361 | + return null; | |
| 362 | + }).onFailure(e -> log.error("[sendOfflineMessage][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), e)); | |
| 363 | + } | |
| 364 | + | |
| 280 | 365 | } | ... | ... |
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 { |
| 64 | 64 | */ |
| 65 | 65 | private IotModbusFrameFormatEnum frameFormat; |
| 66 | 66 | |
| 67 | + /** | |
| 68 | + * 本连接的事务通道:认证注册时在连接的 owner 上创建;未认证连接没有 | |
| 69 | + */ | |
| 70 | + private IotModbusTcpServerTransactionChannel channel; | |
| 71 | + | |
| 67 | 72 | } |
| 68 | 73 | |
| 69 | 74 | /** |
| ... | ... | @@ -75,7 +80,8 @@ public class IotModbusTcpServerConnectionManager { |
| 75 | 80 | if (oldSocket != null && oldSocket != socket) { |
| 76 | 81 | log.info("[registerConnection][设备 {} 存在旧连接, 关闭旧 socket, oldRemote={}, newRemote={}]", |
| 77 | 82 | info.getDeviceId(), oldSocket.remoteAddress(), socket.remoteAddress()); |
| 78 | - connectionMap.remove(oldSocket); | |
| 83 | + // 旧 socket 的 closeHandler 之后拿不到 info(提前返回),所以在这里收尾旧通道 | |
| 84 | + notifyChannelClosed(connectionMap.remove(oldSocket)); | |
| 79 | 85 | try { |
| 80 | 86 | oldSocket.close(); |
| 81 | 87 | } catch (Exception e) { |
| ... | ... | @@ -84,8 +90,11 @@ public class IotModbusTcpServerConnectionManager { |
| 84 | 90 | } |
| 85 | 91 | } |
| 86 | 92 | |
| 87 | - // 注册新连接 | |
| 88 | - connectionMap.put(socket, info); | |
| 93 | + // 注册新连接(同一个 socket 重复认证时,被替换的旧 info 的通道也要收尾) | |
| 94 | + ConnectionInfo previous = connectionMap.put(socket, info); | |
| 95 | + if (previous != null && previous.getChannel() != info.getChannel()) { | |
| 96 | + notifyChannelClosed(previous); | |
| 97 | + } | |
| 89 | 98 | deviceSocketMap.put(info.getDeviceId(), socket); |
| 90 | 99 | log.info("[registerConnection][设备 {} 连接已注册, remoteAddress={}]", |
| 91 | 100 | info.getDeviceId(), socket.remoteAddress()); |
| ... | ... | @@ -114,6 +123,19 @@ public class IotModbusTcpServerConnectionManager { |
| 114 | 123 | } |
| 115 | 124 | |
| 116 | 125 | /** |
| 126 | + * 获取所有已认证连接的事务通道 | |
| 127 | + */ | |
| 128 | + public List<IotModbusTcpServerTransactionChannel> getChannels() { | |
| 129 | + List<IotModbusTcpServerTransactionChannel> channels = new ArrayList<>(); | |
| 130 | + for (ConnectionInfo info : connectionMap.values()) { | |
| 131 | + if (info.getChannel() != null) { | |
| 132 | + channels.add(info.getChannel()); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + return channels; | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** | |
| 117 | 139 | * 移除连接 |
| 118 | 140 | */ |
| 119 | 141 | public ConnectionInfo removeConnection(NetSocket socket) { |
| ... | ... | @@ -139,7 +161,9 @@ public class IotModbusTcpServerConnectionManager { |
| 139 | 161 | if (socket == null) { |
| 140 | 162 | return; |
| 141 | 163 | } |
| 142 | - connectionMap.remove(socket); | |
| 164 | + // 在 ConnectionInfo 还能取到时通知通道(切回 owner 收尾,幂等),然后照旧 close: | |
| 165 | + // 映射已移除,closeHandler 照旧提前返回,不发普通下线消息 | |
| 166 | + notifyChannelClosed(connectionMap.remove(socket)); | |
| 143 | 167 | try { |
| 144 | 168 | socket.close(); |
| 145 | 169 | log.info("[closeConnection][设备 {} 连接已关闭]", deviceId); |
| ... | ... | @@ -178,6 +202,8 @@ public class IotModbusTcpServerConnectionManager { |
| 178 | 202 | public void closeAll() { |
| 179 | 203 | // 1. 先复制再清空,避免 closeHandler 回调时并发修改 |
| 180 | 204 | List<NetSocket> sockets = new ArrayList<>(connectionMap.keySet()); |
| 205 | + // 清空前逐个通知通道(与 closeConnection 同理:closeHandler 之后拿不到 info) | |
| 206 | + connectionMap.values().forEach(this::notifyChannelClosed); | |
| 181 | 207 | connectionMap.clear(); |
| 182 | 208 | deviceSocketMap.clear(); |
| 183 | 209 | // 2. 关闭所有 socket(closeHandler 中 removeConnection 发现 map 为空会安全跳过) |
| ... | ... | @@ -190,4 +216,13 @@ public class IotModbusTcpServerConnectionManager { |
| 190 | 216 | } |
| 191 | 217 | } |
| 192 | 218 | |
| 219 | + /** | |
| 220 | + * 通知连接的事务通道已关闭(任意线程:通道内部切回自己的 owner;onClose 可以重复调用) | |
| 221 | + */ | |
| 222 | + private void notifyChannelClosed(ConnectionInfo info) { | |
| 223 | + if (info != null && info.getChannel() != null) { | |
| 224 | + info.getChannel().onClose(); | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 193 | 228 | } | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; |
| 7 | 8 | import lombok.AllArgsConstructor; |
| ... | ... | @@ -17,9 +18,12 @@ import java.util.concurrent.ConcurrentLinkedDeque; |
| 17 | 18 | /** |
| 18 | 19 | * IoT Modbus TCP Server 待响应请求管理器 |
| 19 | 20 | * <p> |
| 20 | - * 管理轮询下发的请求,用于匹配设备响应: | |
| 21 | + * 管理轮询、透传下发的请求,用于匹配设备响应: | |
| 21 | 22 | * - TCP 模式:按 transactionId 精确匹配 |
| 22 | 23 | * - RTU 模式:按 slaveId + functionCode FIFO 匹配 |
| 24 | + * <p> | |
| 25 | + * 只匹配收到响应的这条连接自己发出的请求({@link PendingRequest#getChannel()} 的引用身份): | |
| 26 | + * 同一设备的新连接替换旧连接后,旧连接的条目不能抢走新连接的响应 | |
| 23 | 27 | * |
| 24 | 28 | * @author 芋道源码 |
| 25 | 29 | */ |
| ... | ... | @@ -47,6 +51,14 @@ public class IotModbusTcpServerPendingRequestManager { |
| 47 | 51 | private int registerCount; |
| 48 | 52 | private Integer transactionId; |
| 49 | 53 | private long expireAt; |
| 54 | + /** | |
| 55 | + * 发出它的连接通道:登记、发送、匹配、清理都用这条连接的身份 | |
| 56 | + */ | |
| 57 | + private IotModbusTcpServerTransactionChannel channel; | |
| 58 | + /** | |
| 59 | + * 透传事务;标准轮询为 null | |
| 60 | + */ | |
| 61 | + private IotModbusTx tx; | |
| 50 | 62 | |
| 51 | 63 | } |
| 52 | 64 | |
| ... | ... | @@ -60,14 +72,19 @@ public class IotModbusTcpServerPendingRequestManager { |
| 60 | 72 | |
| 61 | 73 | /** |
| 62 | 74 | * 匹配响应(TCP 模式按 transactionId,RTU 模式按 FIFO) |
| 75 | + * <p> | |
| 76 | + * 异常响应只用来收尾透传请求:标准轮询的异常响应照旧丢弃,它的条目等到期清理。 | |
| 77 | + * RTU 模式的异常响应保持 FIFO,不会跳过排在前面的标准轮询去配给后面的透传 | |
| 63 | 78 | * |
| 64 | 79 | * @param deviceId 设备 ID |
| 65 | 80 | * @param frame 收到的响应帧 |
| 66 | 81 | * @param frameFormat 帧格式 |
| 82 | + * @param channel 收到响应的这条连接的事务通道 | |
| 67 | 83 | * @return 匹配到的 PendingRequest,没有匹配返回 null |
| 68 | 84 | */ |
| 69 | 85 | public PendingRequest matchResponse(Long deviceId, IotModbusFrame frame, |
| 70 | - IotModbusFrameFormatEnum frameFormat) { | |
| 86 | + IotModbusFrameFormatEnum frameFormat, | |
| 87 | + IotModbusTcpServerTransactionChannel channel) { | |
| 71 | 88 | Deque<PendingRequest> queue = pendingRequests.get(deviceId); |
| 72 | 89 | if (CollUtil.isEmpty(queue)) { |
| 73 | 90 | return null; |
| ... | ... | @@ -75,37 +92,61 @@ public class IotModbusTcpServerPendingRequestManager { |
| 75 | 92 | |
| 76 | 93 | // TCP 模式:按 transactionId 精确匹配 |
| 77 | 94 | if (frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP && frame.getTransactionId() != null) { |
| 78 | - return matchByTransactionId(queue, frame.getTransactionId()); | |
| 95 | + return matchByTransactionId(queue, channel, frame); | |
| 79 | 96 | } |
| 80 | 97 | // RTU 模式:FIFO,匹配 slaveId + functionCode + registerCount |
| 98 | + // 异常响应(解码器已还原原功能码)的 pdu[0] 是异常码,不是 byteCount,所以跳过数量校验,只按 unit + 原功能码找排在最前面的请求 | |
| 99 | + if (frame.isException()) { | |
| 100 | + return matchExceptionByFifo(queue, channel, frame.getSlaveId(), frame.getFunctionCode()); | |
| 101 | + } | |
| 81 | 102 | int responseRegisterCount = IotModbusCommonUtils.extractRegisterCountFromResponse(frame); |
| 82 | - return matchByFifo(queue, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); | |
| 103 | + return matchByFifo(queue, channel, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); | |
| 83 | 104 | } |
| 84 | 105 | |
| 85 | 106 | /** |
| 86 | 107 | * 按 transactionId 匹配 |
| 108 | + * <p> | |
| 109 | + * 透传请求另外核对 unit 与功能码(异常响应的 functionCode 已由解码器还原),不一致就不出队,等它到期;标准轮询保持只按 transactionId | |
| 87 | 110 | */ |
| 88 | - private PendingRequest matchByTransactionId(Deque<PendingRequest> queue, int transactionId) { | |
| 111 | + private PendingRequest matchByTransactionId(Deque<PendingRequest> queue, | |
| 112 | + IotModbusTcpServerTransactionChannel channel, | |
| 113 | + IotModbusFrame frame) { | |
| 114 | + int transactionId = frame.getTransactionId(); | |
| 89 | 115 | Iterator<PendingRequest> it = queue.iterator(); |
| 90 | 116 | while (it.hasNext()) { |
| 91 | 117 | PendingRequest req = it.next(); |
| 92 | - if (req.getTransactionId() != null && req.getTransactionId() == transactionId) { | |
| 93 | - it.remove(); | |
| 94 | - return req; | |
| 118 | + if (req.getChannel() != channel | |
| 119 | + || req.getTransactionId() == null || req.getTransactionId() != transactionId) { | |
| 120 | + continue; | |
| 121 | + } | |
| 122 | + if (req.getTx() == null) { | |
| 123 | + // 标准轮询:异常响应照旧丢弃,不出队 | |
| 124 | + if (frame.isException()) { | |
| 125 | + return null; | |
| 126 | + } | |
| 127 | + } else if (req.getSlaveId() != frame.getSlaveId() || req.getFunctionCode() != frame.getFunctionCode()) { | |
| 128 | + return null; | |
| 95 | 129 | } |
| 130 | + it.remove(); | |
| 131 | + return req; | |
| 96 | 132 | } |
| 97 | 133 | return null; |
| 98 | 134 | } |
| 99 | 135 | |
| 100 | 136 | /** |
| 101 | 137 | * 按 FIFO 匹配(slaveId + functionCode + registerCount) |
| 138 | + * <p> | |
| 139 | + * 这是原有的 RTU 匹配逻辑,透传请求只是加入同一个 FIFO,有原有的限制: | |
| 140 | + * RTU 帧没有事务号,同 unit、同功能码、同数量的迟到响应(例如到期清理后才到的轮询响应), | |
| 141 | + * 或者同功能码的标准写(不登记待响应请求)的回显 / 异常响应,都可能被配给排在前面的另一个请求 | |
| 102 | 142 | */ |
| 103 | - private PendingRequest matchByFifo(Deque<PendingRequest> queue, int slaveId, int functionCode, | |
| 104 | - int responseRegisterCount) { | |
| 143 | + private PendingRequest matchByFifo(Deque<PendingRequest> queue, IotModbusTcpServerTransactionChannel channel, | |
| 144 | + int slaveId, int functionCode, int responseRegisterCount) { | |
| 105 | 145 | Iterator<PendingRequest> it = queue.iterator(); |
| 106 | 146 | while (it.hasNext()) { |
| 107 | 147 | PendingRequest req = it.next(); |
| 108 | - if (req.getSlaveId() == slaveId | |
| 148 | + if (req.getChannel() == channel | |
| 149 | + && req.getSlaveId() == slaveId | |
| 109 | 150 | && req.getFunctionCode() == functionCode |
| 110 | 151 | && (responseRegisterCount <= 0 || req.getRegisterCount() == responseRegisterCount)) { |
| 111 | 152 | it.remove(); |
| ... | ... | @@ -116,6 +157,53 @@ public class IotModbusTcpServerPendingRequestManager { |
| 116 | 157 | } |
| 117 | 158 | |
| 118 | 159 | /** |
| 160 | + * 按 FIFO 匹配 RTU 异常响应(slaveId + 原功能码,异常响应没有数量可核对) | |
| 161 | + * <p> | |
| 162 | + * 只看本连接排在最前面的同 unit、同功能码的请求,不跳过它: | |
| 163 | + * 1. 它是透传请求:出队,由设备的异常响应收尾; | |
| 164 | + * 2. 它是标准轮询:照旧丢弃这个异常响应,轮询请求不出队、等到期清理;排在它后面的透传也不出队(按原有的 FIFO,异常响应属于更早的请求) | |
| 165 | + */ | |
| 166 | + private PendingRequest matchExceptionByFifo(Deque<PendingRequest> queue, IotModbusTcpServerTransactionChannel channel, | |
| 167 | + int slaveId, int functionCode) { | |
| 168 | + Iterator<PendingRequest> it = queue.iterator(); | |
| 169 | + while (it.hasNext()) { | |
| 170 | + PendingRequest req = it.next(); | |
| 171 | + if (req.getChannel() != channel || req.getSlaveId() != slaveId || req.getFunctionCode() != functionCode) { | |
| 172 | + continue; | |
| 173 | + } | |
| 174 | + if (req.getTx() == null) { | |
| 175 | + return null; | |
| 176 | + } | |
| 177 | + it.remove(); | |
| 178 | + return req; | |
| 179 | + } | |
| 180 | + return null; | |
| 181 | + } | |
| 182 | + | |
| 183 | + /** | |
| 184 | + * 本连接是否有占用该 transactionId 的待响应请求(分配事务号时跳过) | |
| 185 | + * | |
| 186 | + * @param deviceId 设备 ID | |
| 187 | + * @param channel 连接的事务通道 | |
| 188 | + * @param transactionId 事务号 | |
| 189 | + * @return 是否占用 | |
| 190 | + */ | |
| 191 | + public boolean containsTransactionId(Long deviceId, IotModbusTcpServerTransactionChannel channel, | |
| 192 | + int transactionId) { | |
| 193 | + Deque<PendingRequest> queue = pendingRequests.get(deviceId); | |
| 194 | + if (CollUtil.isEmpty(queue)) { | |
| 195 | + return false; | |
| 196 | + } | |
| 197 | + for (PendingRequest req : queue) { | |
| 198 | + if (req.getChannel() == channel | |
| 199 | + && req.getTransactionId() != null && req.getTransactionId() == transactionId) { | |
| 200 | + return true; | |
| 201 | + } | |
| 202 | + } | |
| 203 | + return false; | |
| 204 | + } | |
| 205 | + | |
| 206 | + /** | |
| 119 | 207 | * 清理过期请求 |
| 120 | 208 | */ |
| 121 | 209 | public void cleanupExpired() { |
| ... | ... | @@ -138,6 +226,26 @@ public class IotModbusTcpServerPendingRequestManager { |
| 138 | 226 | } |
| 139 | 227 | |
| 140 | 228 | /** |
| 229 | + * 移除指定的待响应请求:按引用(PendingRequest 是 @Data,equals 按字段比较),已被匹配或清理时无副作用 | |
| 230 | + */ | |
| 231 | + public void removeRequest(PendingRequest request) { | |
| 232 | + Deque<PendingRequest> queue = pendingRequests.get(request.getDeviceId()); | |
| 233 | + if (queue != null) { | |
| 234 | + queue.removeIf(req -> req == request); | |
| 235 | + } | |
| 236 | + } | |
| 237 | + | |
| 238 | + /** | |
| 239 | + * 清理指定连接的所有待响应请求:只删这条连接自己的条目,不影响同一设备新连接的条目 | |
| 240 | + */ | |
| 241 | + public void removeChannel(Long deviceId, IotModbusTcpServerTransactionChannel channel) { | |
| 242 | + Deque<PendingRequest> queue = pendingRequests.get(deviceId); | |
| 243 | + if (queue != null) { | |
| 244 | + queue.removeIf(req -> req.getChannel() == channel); | |
| 245 | + } | |
| 246 | + } | |
| 247 | + | |
| 248 | + /** | |
| 141 | 249 | * 清理指定设备的所有待响应请求 |
| 142 | 250 | */ |
| 143 | 251 | public void removeDevice(Long deviceId) { | ... | ... |
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 |
| 11 | 11 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; |
| 13 | 13 | import io.vertx.core.Vertx; |
| 14 | -import lombok.Getter; | |
| 15 | 14 | import lombok.extern.slf4j.Slf4j; |
| 16 | 15 | |
| 17 | -import java.util.concurrent.atomic.AtomicInteger; | |
| 18 | - | |
| 19 | 16 | /** |
| 20 | 17 | * IoT Modbus TCP Server 轮询调度器:编码读请求帧,通过 TCP 连接发送到设备,注册 PendingRequest 等待响应 |
| 21 | 18 | * |
| ... | ... | @@ -29,25 +26,18 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu |
| 29 | 26 | private final IotModbusTcpServerPendingRequestManager pendingRequestManager; |
| 30 | 27 | private final IotModbusTcpServerConfigCacheService configCacheService; |
| 31 | 28 | private final int requestTimeout; |
| 32 | - /** | |
| 33 | - * TCP 事务 ID 自增器(与 DownstreamHandler 共享) | |
| 34 | - */ | |
| 35 | - @Getter | |
| 36 | - private final AtomicInteger transactionIdCounter; | |
| 37 | 29 | |
| 38 | 30 | public IotModbusTcpServerPollScheduler(Vertx vertx, |
| 39 | 31 | IotModbusTcpServerConnectionManager connectionManager, |
| 40 | 32 | IotModbusFrameEncoder frameEncoder, |
| 41 | 33 | IotModbusTcpServerPendingRequestManager pendingRequestManager, |
| 42 | 34 | int requestTimeout, |
| 43 | - AtomicInteger transactionIdCounter, | |
| 44 | 35 | IotModbusTcpServerConfigCacheService configCacheService) { |
| 45 | 36 | super(vertx); |
| 46 | 37 | this.connectionManager = connectionManager; |
| 47 | 38 | this.frameEncoder = frameEncoder; |
| 48 | 39 | this.pendingRequestManager = pendingRequestManager; |
| 49 | 40 | this.requestTimeout = requestTimeout; |
| 50 | - this.transactionIdCounter = transactionIdCounter; | |
| 51 | 41 | this.configCacheService = configCacheService; |
| 52 | 42 | } |
| 53 | 43 | |
| ... | ... | @@ -84,10 +74,9 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu |
| 84 | 74 | Integer slaveId = connection.getSlaveId(); |
| 85 | 75 | Assert.notNull(connection.getSlaveId(), "设备 {} 的 slaveId 不能为空", deviceId); |
| 86 | 76 | |
| 87 | - // 3.1 编码读请求 | |
| 88 | - Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP | |
| 89 | - ? (transactionIdCounter.incrementAndGet() & 0xFFFF) | |
| 90 | - : null; | |
| 77 | + // 3.1 编码读请求(事务号由连接的事务通道分配:跳过本连接仍占用的编号) | |
| 78 | + IotModbusTcpServerTransactionChannel channel = connection.getChannel(); | |
| 79 | + Integer transactionId = channel.allocateTransactionId(); | |
| 91 | 80 | byte[] data = frameEncoder.encodeReadRequest(slaveId, point.getFunctionCode(), |
| 92 | 81 | point.getRegisterAddress(), point.getRegisterCount(), frameFormat, transactionId); |
| 93 | 82 | // 3.2 注册 PendingRequest |
| ... | ... | @@ -96,10 +85,11 @@ public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollSchedu |
| 96 | 85 | slaveId, point.getFunctionCode(), |
| 97 | 86 | point.getRegisterAddress(), point.getRegisterCount(), |
| 98 | 87 | transactionId, |
| 99 | - System.currentTimeMillis() + requestTimeout); | |
| 88 | + System.currentTimeMillis() + requestTimeout, | |
| 89 | + channel, null); | |
| 100 | 90 | pendingRequestManager.addRequest(pendingRequest); |
| 101 | - // 3.3 发送读请求 | |
| 102 | - connectionManager.sendToDevice(deviceId, data).onSuccess(v -> | |
| 91 | + // 3.3 发送读请求(与登记用同一条连接) | |
| 92 | + channel.send(data).onSuccess(v -> | |
| 103 | 93 | log.debug("[pollPoint][设备={}, 点位={}, FC={}, 地址={}, 数量={}]", |
| 104 | 94 | deviceId, point.getIdentifier(), point.getFunctionCode(), |
| 105 | 95 | point.getRegisterAddress(), point.getRegisterCount()) | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannel.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; | |
| 4 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 10 | +import io.vertx.core.Context; | |
| 11 | +import io.vertx.core.Future; | |
| 12 | +import io.vertx.core.buffer.Buffer; | |
| 13 | +import io.vertx.core.net.NetSocket; | |
| 14 | +import lombok.Getter; | |
| 15 | +import lombok.extern.slf4j.Slf4j; | |
| 16 | + | |
| 17 | +import java.util.ArrayDeque; | |
| 18 | +import java.util.ArrayList; | |
| 19 | +import java.util.Deque; | |
| 20 | +import java.util.HashMap; | |
| 21 | +import java.util.Iterator; | |
| 22 | +import java.util.List; | |
| 23 | +import java.util.Map; | |
| 24 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 25 | + | |
| 26 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*; | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * IoT Modbus TCP Server 单个连接的事务通道 | |
| 30 | + * <p> | |
| 31 | + * 认证注册时捕获这条连接的 socket 和 owner(event loop Context):标准轮询、标准写、透传的登记、发送、匹配、清理都用这一个连接身份, | |
| 32 | + * 不会在 A 连接上登记、从 B 连接发出。 | |
| 33 | + * <ul> | |
| 34 | + * <li>标准轮询、标准写:只通过 {@link #allocateTransactionId()} 取事务号、{@link #send} 写出,其余保持原逻辑: | |
| 35 | + * 不排队、不受透传名额限制,超时只由清理定时器移除、不断开连接,标准写不等待回显;</li> | |
| 36 | + * <li>透传:排队 + 在途不超过 {@link #MAX_RAW_OUTSTANDING};TCP 帧格式可以多个同时在途, | |
| 37 | + * RTU 帧格式同一时刻只有 1 个透传在途(轮询、标准写不受此限制);实际发送时才登记待响应请求, | |
| 38 | + * 排队过期、在途超时、写失败、连接关闭都只进入一次终态,同时释放名额和待响应请求。</li> | |
| 39 | + * </ul> | |
| 40 | + * 透传状态(queue / sent / closed)只在 owner 上读写,不加锁:submitRaw / onCleanup / onClose 可能来自总线消费线程、 | |
| 41 | + * 清理定时器、配置刷新定时器、stop 线程,一律先 runOnContext 切回 owner;onRawResponse(RecordParser 回调)、写出回调本来就在 owner 上 | |
| 42 | + * | |
| 43 | + * @author 芋道源码 | |
| 44 | + */ | |
| 45 | +@Slf4j | |
| 46 | +public class IotModbusTcpServerTransactionChannel { | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * 每个连接透传请求的排队 + 在途上限 | |
| 50 | + */ | |
| 51 | + public static final int MAX_RAW_OUTSTANDING = 32; | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * 本连接的 event loop Context:透传状态只在它上面读写 | |
| 55 | + */ | |
| 56 | + private final Context owner; | |
| 57 | + /** | |
| 58 | + * 认证注册时捕获的连接:标准轮询、标准写、透传都从它写出 | |
| 59 | + */ | |
| 60 | + private final NetSocket socket; | |
| 61 | + /** | |
| 62 | + * 设备编号 | |
| 63 | + */ | |
| 64 | + private final Long deviceId; | |
| 65 | + /** | |
| 66 | + * 从站地址 | |
| 67 | + */ | |
| 68 | + private final int slaveId; | |
| 69 | + /** | |
| 70 | + * 帧格式:TCP 可以多个透传同时在途(按事务号匹配),RTU 同一时刻只有 1 个透传在途(按 FIFO 匹配) | |
| 71 | + */ | |
| 72 | + private final IotModbusFrameFormatEnum frameFormat; | |
| 73 | + /** | |
| 74 | + * 帧编码器:把 PDU 封装成 TCP(MBAP)/ RTU(CRC)帧 | |
| 75 | + */ | |
| 76 | + private final IotModbusFrameEncoder frameEncoder; | |
| 77 | + /** | |
| 78 | + * 协议共享的待响应请求管理器:透传实际发送时登记,终态时按引用移除 | |
| 79 | + */ | |
| 80 | + private final IotModbusTcpServerPendingRequestManager pendingRequestManager; | |
| 81 | + /** | |
| 82 | + * 协议共享的事务号计数器(16 位) | |
| 83 | + */ | |
| 84 | + private final AtomicInteger transactionIdCounter; | |
| 85 | + /** | |
| 86 | + * 请求超时时间(毫秒):透传排队截止、待响应到期都按它计算 | |
| 87 | + */ | |
| 88 | + @Getter | |
| 89 | + private final int requestTimeout; | |
| 90 | + /** | |
| 91 | + * 本连接的 worker 队列(owner 上的有序有界队列,只在 owner 上提交): | |
| 92 | + * 标准轮询响应的点位翻译、发送到消息总线,以及断开后的下线消息会阻塞,按收到的顺序交给 worker 执行,不占用 owner | |
| 93 | + */ | |
| 94 | + @Getter | |
| 95 | + private final IotWorkerTaskQueue upstreamQueue; | |
| 96 | + | |
| 97 | + /** | |
| 98 | + * 排队中的透传 | |
| 99 | + */ | |
| 100 | + private final Deque<IotModbusTx> queue = new ArrayDeque<>(); | |
| 101 | + /** | |
| 102 | + * 在途的透传 → 它登记的待响应请求 | |
| 103 | + */ | |
| 104 | + private final Map<IotModbusTx, PendingRequest> sent = new HashMap<>(); | |
| 105 | + /** | |
| 106 | + * 连接已关闭:不再发送透传 | |
| 107 | + */ | |
| 108 | + private boolean closed; | |
| 109 | + | |
| 110 | + public IotModbusTcpServerTransactionChannel(Context owner, NetSocket socket, | |
| 111 | + Long deviceId, int slaveId, IotModbusFrameFormatEnum frameFormat, | |
| 112 | + IotModbusFrameEncoder frameEncoder, | |
| 113 | + IotModbusTcpServerPendingRequestManager pendingRequestManager, | |
| 114 | + AtomicInteger transactionIdCounter, int requestTimeout) { | |
| 115 | + this.owner = owner; | |
| 116 | + this.socket = socket; | |
| 117 | + this.deviceId = deviceId; | |
| 118 | + this.slaveId = slaveId; | |
| 119 | + this.frameFormat = frameFormat; | |
| 120 | + this.frameEncoder = frameEncoder; | |
| 121 | + this.pendingRequestManager = pendingRequestManager; | |
| 122 | + this.transactionIdCounter = transactionIdCounter; | |
| 123 | + this.requestTimeout = requestTimeout; | |
| 124 | + this.upstreamQueue = new IotWorkerTaskQueue(owner); | |
| 125 | + } | |
| 126 | + | |
| 127 | + // ========== 标准轮询、标准写的入口(任意线程) ========== | |
| 128 | + | |
| 129 | + /** | |
| 130 | + * 分配事务号:RTU 帧格式返回 null | |
| 131 | + * <p> | |
| 132 | + * 复用协议共享的计数器(16 位),跳过本连接仍有待响应请求(轮询、在途透传)占用的编号。 | |
| 133 | + * 标准写不登记待响应请求,它的编号不受保护:计数器回绕时,仍在路上的写回显可能与新编号相同 | |
| 134 | + * | |
| 135 | + * @return 事务号 | |
| 136 | + */ | |
| 137 | + public Integer allocateTransactionId() { | |
| 138 | + if (!isTcpFormat()) { | |
| 139 | + return null; | |
| 140 | + } | |
| 141 | + int id; | |
| 142 | + int attempts = 0; | |
| 143 | + do { | |
| 144 | + id = transactionIdCounter.incrementAndGet() & 0xFFFF; | |
| 145 | + } while (pendingRequestManager.containsTransactionId(deviceId, this, id) && ++attempts <= 0xFFFF); | |
| 146 | + return id; | |
| 147 | + } | |
| 148 | + | |
| 149 | + /** | |
| 150 | + * 写出到本连接捕获的 socket | |
| 151 | + * | |
| 152 | + * @param data 完整帧 | |
| 153 | + * @return 写出结果:只代表 socket 写出成功或失败,不代表设备已经响应 | |
| 154 | + */ | |
| 155 | + public Future<Void> send(byte[] data) { | |
| 156 | + return socket.write(Buffer.buffer(data)); | |
| 157 | + } | |
| 158 | + | |
| 159 | + // ========== 透传的入口(任意线程,先切回 owner) ========== | |
| 160 | + | |
| 161 | + /** | |
| 162 | + * 提交透传事务 | |
| 163 | + * | |
| 164 | + * @param tx 事务 | |
| 165 | + */ | |
| 166 | + public void submitRaw(IotModbusTx tx) { | |
| 167 | + owner.runOnContext(v -> submitRaw0(tx)); | |
| 168 | + } | |
| 169 | + | |
| 170 | + /** | |
| 171 | + * 清理周期(复用待响应请求的清理定时器):在途透传是否超时、排队透传是否超过截止时间 | |
| 172 | + */ | |
| 173 | + public void onCleanup() { | |
| 174 | + owner.runOnContext(v -> onCleanup0()); | |
| 175 | + } | |
| 176 | + | |
| 177 | + /** | |
| 178 | + * 连接关闭(closeHandler,或 ConnectionManager 的 closeConnection / closeAll / 旧连接替换);可以重复调用 | |
| 179 | + */ | |
| 180 | + public void onClose() { | |
| 181 | + owner.runOnContext(v -> onClose0()); | |
| 182 | + } | |
| 183 | + | |
| 184 | + /** | |
| 185 | + * 收到匹配到透传请求的响应(owner:handlePollingResponse 先 matchResponse 出队,再调用这里) | |
| 186 | + * | |
| 187 | + * @param frame 响应帧 | |
| 188 | + * @param request 匹配到的待响应请求 | |
| 189 | + */ | |
| 190 | + public void onRawResponse(IotModbusFrame frame, PendingRequest request) { | |
| 191 | + // 1. 已超时、写失败或已关闭:迟到的响应丢弃 | |
| 192 | + IotModbusTx tx = request.getTx(); | |
| 193 | + if (sent.remove(tx) == null) { | |
| 194 | + log.debug("[onRawResponse][设备 {} 迟到的透传响应, 丢弃, FC={}]", deviceId, frame.getFunctionCode()); | |
| 195 | + return; | |
| 196 | + } | |
| 197 | + // 2. 进入终态:异常响应原样回给透传请求;正常响应核对格式 | |
| 198 | + byte[] responsePdu = IotModbusCommonUtils.buildResponsePdu(frame); | |
| 199 | + if (!frame.isException() && !IotModbusCommonUtils.isResponseShapeValid(tx.getPdu(), responsePdu)) { | |
| 200 | + log.warn("[onRawResponse][设备 {} 透传响应格式与请求不一致, FC={}]", deviceId, frame.getFunctionCode()); | |
| 201 | + tx.fail(MODBUS_RESPONSE_INVALID); | |
| 202 | + } else { | |
| 203 | + tx.complete(responsePdu); | |
| 204 | + } | |
| 205 | + // 3. 释放了在途位置:继续发送排队中的透传 | |
| 206 | + pump(); | |
| 207 | + } | |
| 208 | + | |
| 209 | + // ========== owner 上的处理 ========== | |
| 210 | + | |
| 211 | + /** | |
| 212 | + * owner:透传入队;连接已关闭、排队 + 在途已满时直接失败 | |
| 213 | + */ | |
| 214 | + private void submitRaw0(IotModbusTx tx) { | |
| 215 | + // 1. 校验:连接已关闭、名额已满 | |
| 216 | + if (closed) { | |
| 217 | + tx.fail(MODBUS_DISCONNECTED); | |
| 218 | + return; | |
| 219 | + } | |
| 220 | + if (queue.size() + sent.size() >= MAX_RAW_OUTSTANDING) { | |
| 221 | + tx.fail(MODBUS_QUEUE_FULL); | |
| 222 | + return; | |
| 223 | + } | |
| 224 | + // 2. 入队,尝试发送 | |
| 225 | + queue.add(tx); | |
| 226 | + pump(); | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 230 | + * owner:按帧格式允许的在途数量,依次发送排队中的透传(TCP 不限,RTU 只在没有在途时发送 1 个) | |
| 231 | + */ | |
| 232 | + private void pump() { | |
| 233 | + while (!closed && !queue.isEmpty() && (isTcpFormat() || sent.isEmpty())) { | |
| 234 | + IotModbusTx tx = queue.poll(); | |
| 235 | + // 1. 排队超过截止时间:不再发送 | |
| 236 | + if (tx.isExpired()) { | |
| 237 | + tx.fail(MODBUS_TIMEOUT); | |
| 238 | + continue; | |
| 239 | + } | |
| 240 | + // 2.1 分配事务号、封装帧 | |
| 241 | + Integer transactionId = allocateTransactionId(); | |
| 242 | + byte[] data = frameEncoder.wrapFrame(slaveId, tx.getPdu(), frameFormat, transactionId); | |
| 243 | + // 2.2 只在实际发送时登记待响应请求,并占用在途位置 | |
| 244 | + PendingRequest request = buildPendingRequest(tx, transactionId); | |
| 245 | + pendingRequestManager.addRequest(request); | |
| 246 | + sent.put(tx, request); | |
| 247 | + // 3. 写出:回调在 owner 上 | |
| 248 | + send(data).onFailure(e -> onRawWriteFailed(tx, e)); | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + /** | |
| 253 | + * owner:透传写出失败,结束这一笔并继续发送排队中的透传 | |
| 254 | + */ | |
| 255 | + private void onRawWriteFailed(IotModbusTx tx, Throwable e) { | |
| 256 | + log.warn("[onRawWriteFailed][设备 {} 透传写出失败]", deviceId, e); | |
| 257 | + // 只结束这一笔透传,不主动关闭连接:连接真的异常时由 closeHandler 收尾 | |
| 258 | + if (release(tx)) { | |
| 259 | + tx.fail(DEVICE_OFFLINE_OR_WRITE_FAILED); | |
| 260 | + pump(); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + | |
| 264 | + /** | |
| 265 | + * owner:清理周期,结束超时的在途透传、过期的排队透传 | |
| 266 | + */ | |
| 267 | + private void onCleanup0() { | |
| 268 | + if (closed) { | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + // 1. 在途透传:登记的待响应请求已到期(与 cleanupExpired 的判断一致),不断开连接 | |
| 272 | + long now = System.currentTimeMillis(); | |
| 273 | + List<IotModbusTx> timeoutTxs = new ArrayList<>(); | |
| 274 | + for (Map.Entry<IotModbusTx, PendingRequest> entry : sent.entrySet()) { | |
| 275 | + if (entry.getValue().getExpireAt() < now) { | |
| 276 | + timeoutTxs.add(entry.getKey()); | |
| 277 | + } | |
| 278 | + } | |
| 279 | + for (IotModbusTx tx : timeoutTxs) { | |
| 280 | + if (release(tx)) { | |
| 281 | + tx.fail(MODBUS_TIMEOUT); | |
| 282 | + } | |
| 283 | + } | |
| 284 | + // 2. 排队中的透传:超过截止时间的不再发送 | |
| 285 | + List<IotModbusTx> expiredTxs = new ArrayList<>(); | |
| 286 | + for (Iterator<IotModbusTx> it = queue.iterator(); it.hasNext(); ) { | |
| 287 | + IotModbusTx tx = it.next(); | |
| 288 | + if (tx.isExpired()) { | |
| 289 | + it.remove(); | |
| 290 | + expiredTxs.add(tx); | |
| 291 | + } | |
| 292 | + } | |
| 293 | + for (IotModbusTx tx : expiredTxs) { | |
| 294 | + tx.fail(MODBUS_TIMEOUT); | |
| 295 | + } | |
| 296 | + // 3. 释放了在途位置:继续发送排队中的透传 | |
| 297 | + pump(); | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** | |
| 301 | + * owner:连接关闭,排队中、在途的透传全部以断线结束;可能重复到达,照常清理;清理后各集合为空,重复执行无副作用 | |
| 302 | + */ | |
| 303 | + private void onClose0() { | |
| 304 | + // 1. 标记关闭,取出排队中、在途的透传 | |
| 305 | + closed = true; | |
| 306 | + List<IotModbusTx> txs = new ArrayList<>(queue); | |
| 307 | + txs.addAll(sent.keySet()); | |
| 308 | + queue.clear(); | |
| 309 | + sent.clear(); | |
| 310 | + // 2. 只删这条连接自己登记的待响应请求(轮询 + 透传),不影响同一设备新连接的条目 | |
| 311 | + pendingRequestManager.removeChannel(deviceId, this); | |
| 312 | + // 3. 全部以断线结束 | |
| 313 | + for (IotModbusTx tx : txs) { | |
| 314 | + tx.fail(MODBUS_DISCONNECTED); | |
| 315 | + } | |
| 316 | + } | |
| 317 | + | |
| 318 | + /** | |
| 319 | + * owner:把透传移出在途位置,并按引用移除它登记的待响应请求 | |
| 320 | + * | |
| 321 | + * @return false 表示它已经不在途(重复的终态信号直接忽略) | |
| 322 | + */ | |
| 323 | + private boolean release(IotModbusTx tx) { | |
| 324 | + PendingRequest request = sent.remove(tx); | |
| 325 | + if (request == null) { | |
| 326 | + return false; | |
| 327 | + } | |
| 328 | + pendingRequestManager.removeRequest(request); | |
| 329 | + return true; | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** | |
| 333 | + * 构造透传登记的待响应请求:到期时间 = 现在 + requestTimeout | |
| 334 | + * | |
| 335 | + * @param tx 事务 | |
| 336 | + * @param transactionId 事务号;RTU 帧格式为 null | |
| 337 | + * @return 待响应请求 | |
| 338 | + */ | |
| 339 | + private PendingRequest buildPendingRequest(IotModbusTx tx, Integer transactionId) { | |
| 340 | + byte[] pdu = tx.getPdu(); | |
| 341 | + int functionCode = pdu[0] & 0xFF; | |
| 342 | + int registerAddress = ((pdu[1] & 0xFF) << 8) | (pdu[2] & 0xFF); | |
| 343 | + // registerCount 用于 RTU FIFO 按原逻辑核对 03 / 04 的正常响应 | |
| 344 | + return new PendingRequest(deviceId, null, null, slaveId, functionCode, registerAddress, | |
| 345 | + IotModbusCommonUtils.getRequestQuantity(pdu), transactionId, | |
| 346 | + System.currentTimeMillis() + requestTimeout, this, tx); | |
| 347 | + } | |
| 348 | + | |
| 349 | + /** | |
| 350 | + * @return 是否是 TCP 帧格式(MBAP,按事务号匹配响应) | |
| 351 | + */ | |
| 352 | + private boolean isTcpFormat() { | |
| 353 | + return frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP; | |
| 354 | + } | |
| 355 | + | |
| 356 | +} | ... | ... |
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 |
| 19 | 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 20 | 20 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 21 | 21 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 22 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 22 | 23 | import io.netty.handler.codec.mqtt.MqttConnectReturnCode; |
| 23 | 24 | import io.netty.handler.codec.mqtt.MqttQoS; |
| 24 | 25 | import io.vertx.core.Vertx; |
| ... | ... | @@ -68,6 +69,7 @@ public class IotMqttProtocol implements IotProtocol { |
| 68 | 69 | /** |
| 69 | 70 | * Vert.x 实例 |
| 70 | 71 | */ |
| 72 | + @Getter | |
| 71 | 73 | private Vertx vertx; |
| 72 | 74 | /** |
| 73 | 75 | * MQTT 服务器 |
| ... | ... | @@ -232,21 +234,22 @@ public class IotMqttProtocol implements IotProtocol { |
| 232 | 234 | } |
| 233 | 235 | |
| 234 | 236 | // 2.1 设置异常和关闭处理器 |
| 237 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(vertx.getOrCreateContext()); // 本连接的 worker 队列:阻塞的消息处理按收到的顺序执行,不同连接之间并行 | |
| 235 | 238 | endpoint.exceptionHandler(ex -> { |
| 236 | 239 | log.warn("[handleEndpoint][连接异常,客户端 ID: {},地址: {},异常: {}]", |
| 237 | 240 | clientId, connectionManager.getEndpointAddress(endpoint), ex.getMessage()); |
| 238 | 241 | endpoint.close(); |
| 239 | 242 | }); |
| 240 | - endpoint.closeHandler(v -> cleanupConnection(endpoint)); // 处理底层连接关闭(网络中断、异常等) | |
| 243 | + endpoint.closeHandler(v -> cleanupConnection(endpoint, queue)); // 处理底层连接关闭(网络中断、异常等) | |
| 241 | 244 | endpoint.disconnectHandler(v -> { // 处理 MQTT DISCONNECT 报文 |
| 242 | 245 | log.debug("[handleEndpoint][设备断开连接,客户端 ID: {}]", clientId); |
| 243 | - cleanupConnection(endpoint); | |
| 246 | + cleanupConnection(endpoint, queue); | |
| 244 | 247 | }); |
| 245 | 248 | // 2.2 设置心跳处理器 |
| 246 | 249 | endpoint.pingHandler(v -> log.debug("[handleEndpoint][收到客户端心跳,客户端 ID: {}]", clientId)); |
| 247 | 250 | |
| 248 | 251 | // 3.1 设置消息处理器 |
| 249 | - endpoint.publishHandler(message -> processMessage(endpoint, message)); | |
| 252 | + endpoint.publishHandler(message -> processMessage(endpoint, queue, message)); | |
| 250 | 253 | // 3.2 设置 QoS 2 消息的 PUBREL 处理器 |
| 251 | 254 | endpoint.publishReleaseHandler(endpoint::publishComplete); |
| 252 | 255 | |
| ... | ... | @@ -282,23 +285,24 @@ public class IotMqttProtocol implements IotProtocol { |
| 282 | 285 | * 处理消息(发布) |
| 283 | 286 | * |
| 284 | 287 | * @param endpoint MQTT 连接端点 |
| 288 | + * @param queue 本连接的 worker 队列 | |
| 285 | 289 | * @param message 发布消息 |
| 286 | 290 | */ |
| 287 | - private void processMessage(MqttEndpoint endpoint, MqttPublishMessage message) { | |
| 291 | + private void processMessage(MqttEndpoint endpoint, IotWorkerTaskQueue queue, MqttPublishMessage message) { | |
| 288 | 292 | String clientId = endpoint.clientIdentifier(); |
| 289 | - try { | |
| 290 | - // 1. 处理业务消息 | |
| 291 | - String topic = message.topicName(); | |
| 292 | - byte[] payload = message.payload().getBytes(); | |
| 293 | - upstreamHandler.handleBusinessRequest(endpoint, topic, payload); | |
| 294 | - | |
| 295 | - // 2. 根据 QoS 级别发送相应的确认消息 | |
| 293 | + // 1. 处理业务消息:阻塞的部分在本连接的 worker 队列上按顺序执行,完成后回到 event loop | |
| 294 | + String topic = message.topicName(); | |
| 295 | + byte[] payload = message.payload().getBytes(); | |
| 296 | + upstreamHandler.handleBusinessRequest(endpoint, queue, topic, payload).onComplete(ar -> { | |
| 297 | + if (ar.failed()) { // 队列已满或已关闭:不确认,断开连接,由设备重连后重发 | |
| 298 | + log.error("[processMessage][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", | |
| 299 | + clientId, connectionManager.getEndpointAddress(endpoint), ar.cause().getMessage()); | |
| 300 | + endpoint.close(); | |
| 301 | + return; | |
| 302 | + } | |
| 303 | + // 2. 处理完成后,根据 QoS 级别发送相应的确认消息:同一连接按收到的顺序完成,确认也按顺序 | |
| 296 | 304 | handleQoSAck(endpoint, message); |
| 297 | - } catch (Exception e) { | |
| 298 | - log.error("[processMessage][消息处理失败,断开连接,客户端 ID: {},地址: {},错误: {}]", | |
| 299 | - clientId, connectionManager.getEndpointAddress(endpoint), e.getMessage()); | |
| 300 | - endpoint.close(); | |
| 301 | - } | |
| 305 | + }); | |
| 302 | 306 | } |
| 303 | 307 | |
| 304 | 308 | /** |
| ... | ... | @@ -308,6 +312,10 @@ public class IotMqttProtocol implements IotProtocol { |
| 308 | 312 | * @param message 发布消息 |
| 309 | 313 | */ |
| 310 | 314 | private void handleQoSAck(MqttEndpoint endpoint, MqttPublishMessage message) { |
| 315 | + // 处理期间连接已断开:无法确认,设备重连后重发 | |
| 316 | + if (!endpoint.isConnected()) { | |
| 317 | + return; | |
| 318 | + } | |
| 311 | 319 | if (message.qosLevel() == MqttQoS.AT_LEAST_ONCE) { |
| 312 | 320 | // QoS 1: 发送 PUBACK 确认 |
| 313 | 321 | endpoint.publishAcknowledge(message.messageId()); |
| ... | ... | @@ -322,23 +330,33 @@ public class IotMqttProtocol implements IotProtocol { |
| 322 | 330 | * 清理连接 |
| 323 | 331 | * |
| 324 | 332 | * @param endpoint MQTT 连接端点 |
| 333 | + * @param queue 本连接的 worker 队列 | |
| 325 | 334 | */ |
| 326 | - private void cleanupConnection(MqttEndpoint endpoint) { | |
| 327 | - try { | |
| 328 | - // 1. 发送设备离线消息 | |
| 329 | - IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); | |
| 330 | - if (connectionInfo != null) { | |
| 331 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 332 | - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 333 | - connectionInfo.getDeviceName(), serverId); | |
| 334 | - } | |
| 335 | - } catch (Exception e) { | |
| 336 | - log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", | |
| 337 | - endpoint.clientIdentifier(), e.getMessage()); | |
| 338 | - } finally { | |
| 339 | - // 2. 注销连接,避免发送离线消息失败时残留连接 | |
| 340 | - connectionManager.unregisterConnection(endpoint); | |
| 335 | + private void cleanupConnection(MqttEndpoint endpoint, IotWorkerTaskQueue queue) { | |
| 336 | + // 1. 注销连接:closeHandler、disconnectHandler 都会调用,第二次取不到连接信息,不再处理 | |
| 337 | + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(endpoint); | |
| 338 | + connectionManager.unregisterConnection(endpoint); | |
| 339 | + if (connectionInfo == null) { | |
| 340 | + return; | |
| 341 | 341 | } |
| 342 | + | |
| 343 | + // 2. 发送设备离线消息:排在本连接已收到的消息之后;执行时设备已在新连接上线,则不再发送 | |
| 344 | + queue.submitLast(() -> { | |
| 345 | + if (connectionManager.getDeviceEndpoint(connectionInfo.getDeviceId()) != null) { | |
| 346 | + log.info("[cleanupConnection][设备已在新连接上线,不发送离线消息,设备 ID: {}]", connectionInfo.getDeviceId()); | |
| 347 | + return null; | |
| 348 | + } | |
| 349 | + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 350 | + deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 351 | + connectionInfo.getDeviceName(), serverId); | |
| 352 | + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 | |
| 353 | + if (connectionManager.getDeviceEndpoint(connectionInfo.getDeviceId()) != null) { | |
| 354 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 355 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 356 | + } | |
| 357 | + return null; | |
| 358 | + }).onFailure(e -> log.error("[cleanupConnection][清理连接失败,客户端 ID: {},错误: {}]", | |
| 359 | + endpoint.clientIdentifier(), e.getMessage())); | |
| 342 | 360 | } |
| 343 | 361 | |
| 344 | 362 | } | ... | ... |
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 |
| 7 | 7 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 9 | 9 | import io.netty.handler.codec.mqtt.MqttQoS; |
| 10 | +import io.vertx.core.Future; | |
| 10 | 11 | import lombok.RequiredArgsConstructor; |
| 11 | 12 | import lombok.extern.slf4j.Slf4j; |
| 12 | 13 | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 15 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 16 | + | |
| 13 | 17 | /** |
| 14 | 18 | * IoT 网关 MQTT 协议:下行消息处理器 |
| 15 | 19 | * |
| ... | ... | @@ -67,4 +71,28 @@ public class IotMqttDownstreamHandler { |
| 67 | 71 | } |
| 68 | 72 | } |
| 69 | 73 | |
| 74 | + /** | |
| 75 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节发布到 raw/push 主题 | |
| 76 | + * | |
| 77 | + * @param message 下行消息 | |
| 78 | + * @param bytes 要写出的原始字节 | |
| 79 | + * @return 提交结果:设备离线或发送失败时失败;成功只表示 publish 已提交,晚到的写出失败由连接管理器记日志 | |
| 80 | + */ | |
| 81 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 82 | + // 1. 检查设备连接 | |
| 83 | + IotMqttConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId( | |
| 84 | + message.getDeviceId()); | |
| 85 | + if (connectionInfo == null) { | |
| 86 | + log.warn("[handleRawMessage][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", | |
| 87 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 88 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 89 | + } | |
| 90 | + | |
| 91 | + // 2. 发布到 raw/push 主题 | |
| 92 | + String topic = IotMqttTopicUtils.buildRawPushTopic(connectionInfo.getProductKey(), connectionInfo.getDeviceName()); | |
| 93 | + boolean success = connectionManager.sendToDevice(message.getDeviceId(), topic, bytes, | |
| 94 | + MqttQoS.AT_LEAST_ONCE.value(), false); | |
| 95 | + return success ? Future.succeededFuture() : Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 96 | + } | |
| 97 | + | |
| 70 | 98 | } | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotMqttDownstreamSubscriber extends AbstractIotProtocolDownstreamSu |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | 4 | import cn.hutool.core.util.ArrayUtil; |
| 5 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 5 | 7 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 6 | 8 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 7 | 9 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 8 | 10 | import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; |
| 11 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 12 | +import io.vertx.core.Future; | |
| 9 | 13 | import io.vertx.mqtt.MqttEndpoint; |
| 10 | 14 | import lombok.extern.slf4j.Slf4j; |
| 11 | 15 | |
| 12 | - | |
| 13 | 16 | /** |
| 14 | 17 | * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等) |
| 15 | 18 | * |
| ... | ... | @@ -29,22 +32,30 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { |
| 29 | 32 | |
| 30 | 33 | /** |
| 31 | 34 | * 处理业务消息 |
| 35 | + * <p> | |
| 36 | + * 在 event loop 上校验主题与身份;反序列化、透传解析、发送消息会阻塞,交给本连接的 worker 队列按顺序执行。 | |
| 37 | + * 处理失败、校验不通过只记录日志(与原来一致,照常确认);只有队列已满或已关闭时返回失败的 Future | |
| 32 | 38 | * |
| 33 | 39 | * @param endpoint MQTT 连接端点 |
| 40 | + * @param queue 本连接的 worker 队列 | |
| 34 | 41 | * @param topic 主题 |
| 35 | 42 | * @param payload 消息内容 |
| 43 | + * @return 处理完成的结果,在 event loop 上完成:调用方在它完成后再确认(PUBACK / PUBREC) | |
| 36 | 44 | */ |
| 37 | - public void handleBusinessRequest(MqttEndpoint endpoint, String topic, byte[] payload) { | |
| 45 | + public Future<Void> handleBusinessRequest(MqttEndpoint endpoint, IotWorkerTaskQueue queue, | |
| 46 | + String topic, byte[] payload) { | |
| 38 | 47 | String clientId = endpoint.clientIdentifier(); |
| 48 | + String productKey; | |
| 49 | + String deviceName; | |
| 39 | 50 | try { |
| 40 | 51 | // 1.1 基础检查 |
| 41 | 52 | if (ArrayUtil.isEmpty(payload)) { |
| 42 | - return; | |
| 53 | + return skip(queue); | |
| 43 | 54 | } |
| 44 | 55 | // 1.2 解析主题,获取 productKey 和 deviceName |
| 45 | 56 | String[] topicParts = topic.split("/"); |
| 46 | - String productKey = ArrayUtil.get(topicParts, 2); | |
| 47 | - String deviceName = ArrayUtil.get(topicParts, 3); | |
| 57 | + productKey = ArrayUtil.get(topicParts, 2); | |
| 58 | + deviceName = ArrayUtil.get(topicParts, 3); | |
| 48 | 59 | Assert.notBlank(productKey, "产品 Key 不能为空"); |
| 49 | 60 | Assert.notBlank(deviceName, "设备名称不能为空"); |
| 50 | 61 | // 1.3 校验设备信息,防止伪造设备消息 |
| ... | ... | @@ -55,24 +66,70 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { |
| 55 | 66 | // 1.4 校验 topic 是否允许发布 |
| 56 | 67 | if (!IotMqttTopicUtils.isTopicPublishAllowed(topic, productKey, deviceName)) { |
| 57 | 68 | log.warn("[handleBusinessRequest][topic 不允许发布,客户端 ID: {},主题: {}]", clientId, topic); |
| 58 | - return; | |
| 69 | + return skip(queue); | |
| 59 | 70 | } |
| 71 | + } catch (Exception e) { | |
| 72 | + log.error("[handleBusinessRequest][消息校验失败,客户端 ID: {},主题: {},错误: {}]", | |
| 73 | + clientId, topic, e.getMessage()); | |
| 74 | + return skip(queue); | |
| 75 | + } | |
| 60 | 76 | |
| 61 | - // 2.1 反序列化消息 | |
| 62 | - IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 63 | - if (message == null) { | |
| 64 | - log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); | |
| 65 | - return; | |
| 77 | + // 2. 交给本连接的 worker 队列:透传上报的截止时间从收到(入队之前)开始计算 | |
| 78 | + boolean raw = IotMqttTopicUtils.isRawPostTopic(topic); | |
| 79 | + long receivedNanos = System.nanoTime(); | |
| 80 | + return queue.submit(() -> { | |
| 81 | + try { | |
| 82 | + if (raw) { | |
| 83 | + handleRawRequest(productKey, deviceName, payload, receivedNanos); | |
| 84 | + } else { | |
| 85 | + handleStandardRequest(clientId, topic, productKey, deviceName, payload); | |
| 86 | + } | |
| 87 | + } catch (Exception e) { | |
| 88 | + log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", | |
| 89 | + clientId, topic, e.getMessage(), e); | |
| 66 | 90 | } |
| 67 | - // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 68 | - IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 91 | + return null; | |
| 92 | + }); | |
| 93 | + } | |
| 69 | 94 | |
| 70 | - // 3. 处理业务消息 | |
| 71 | - deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 72 | - log.debug("[handleBusinessRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); | |
| 73 | - } catch (Exception e) { | |
| 74 | - log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", | |
| 75 | - clientId, topic, e.getMessage(), e); | |
| 95 | + /** | |
| 96 | + * 不处理的消息:也排在本连接之前的消息之后完成,保证确认(PUBACK / PUBREC)仍按收到的顺序 | |
| 97 | + */ | |
| 98 | + private static Future<Void> skip(IotWorkerTaskQueue queue) { | |
| 99 | + return queue.submit(() -> null); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 处理标准消息(worker 线程):反序列化后发送 | |
| 104 | + */ | |
| 105 | + private void handleStandardRequest(String clientId, String topic, String productKey, String deviceName, | |
| 106 | + byte[] payload) { | |
| 107 | + // 1.1 反序列化消息 | |
| 108 | + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); | |
| 109 | + if (message == null) { | |
| 110 | + log.warn("[handleStandardRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + // 1.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) | |
| 114 | + IotMqttTopicUtils.normalizeReplyMethod(message); | |
| 115 | + | |
| 116 | + // 2. 处理业务消息 | |
| 117 | + deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); | |
| 118 | + log.debug("[handleStandardRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); | |
| 119 | + } | |
| 120 | + | |
| 121 | + /** | |
| 122 | + * 处理透传上报(worker 线程):原始字节按产品的数据解析脚本 decode 后发送;身份已在 event loop 上与连接比对 | |
| 123 | + * | |
| 124 | + * @param receivedNanos 收到的时刻(System.nanoTime):入队之前取得,截止时间从此刻开始计算 | |
| 125 | + */ | |
| 126 | + private void handleRawRequest(String productKey, String deviceName, byte[] payload, long receivedNanos) { | |
| 127 | + try { | |
| 128 | + deviceMessageService.sendRawMessage(payload, productKey, deviceName, serverId, IotProtocolTypeEnum.MQTT, | |
| 129 | + receivedNanos); | |
| 130 | + } catch (ServiceException e) { | |
| 131 | + log.warn("[handleRawRequest][透传上报处理失败,productKey: {},deviceName: {},数据长度: {} 字节,错误: {}]", | |
| 132 | + productKey, deviceName, payload.length, e.getMessage()); | |
| 76 | 133 | } |
| 77 | 134 | } |
| 78 | 135 | ... | ... |
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 { |
| 142 | 142 | } |
| 143 | 143 | |
| 144 | 144 | /** |
| 145 | - * 发送消息到设备 | |
| 145 | + * 发送消息到设备(标准下行、透传下行共用) | |
| 146 | + * <p> | |
| 147 | + * 不等待写出完成:返回 true 只表示 publish 已提交(设备在线、没有同步异常),不代表已写出或设备已收到; | |
| 148 | + * 之后的写出失败(例如连接已关闭)只记日志 | |
| 146 | 149 | * |
| 147 | 150 | * @param deviceId 设备 ID |
| 148 | 151 | * @param topic 主题 |
| 149 | 152 | * @param payload 消息内容 |
| 150 | 153 | * @param qos 服务质量 |
| 151 | 154 | * @param retain 是否保留消息 |
| 152 | - * @return 是否发送成功 | |
| 155 | + * @return 是否已提交发送 | |
| 153 | 156 | */ |
| 154 | 157 | public boolean sendToDevice(Long deviceId, String topic, byte[] payload, int qos, boolean retain) { |
| 155 | 158 | MqttEndpoint endpoint = deviceEndpointMap.get(deviceId); |
| ... | ... | @@ -159,7 +162,9 @@ public class IotMqttConnectionManager { |
| 159 | 162 | } |
| 160 | 163 | |
| 161 | 164 | try { |
| 162 | - endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain); | |
| 165 | + endpoint.publish(topic, Buffer.buffer(payload), MqttQoS.valueOf(qos), false, retain) | |
| 166 | + .onFailure(e -> log.warn("[sendToDevice][发送消息失败,设备 ID: {},主题: {},错误: {}]", | |
| 167 | + deviceId, topic, e.getMessage())); | |
| 163 | 168 | log.debug("[sendToDevice][发送消息成功,设备 ID: {},主题: {},QoS: {}]", deviceId, topic, qos); |
| 164 | 169 | return true; |
| 165 | 170 | } catch (Exception e) { | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java
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; |
| 6 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; |
| 7 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; |
| 8 | 8 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 9 | +import io.vertx.core.Future; | |
| 9 | 10 | import io.vertx.core.buffer.Buffer; |
| 11 | +import io.vertx.core.net.NetSocket; | |
| 10 | 12 | import lombok.RequiredArgsConstructor; |
| 11 | 13 | import lombok.extern.slf4j.Slf4j; |
| 12 | 14 | |
| 15 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 16 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 17 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED; | |
| 18 | + | |
| 13 | 19 | /** |
| 14 | 20 | * IoT 网关 TCP 下行消息处理器 |
| 15 | 21 | * |
| ... | ... | @@ -71,4 +77,40 @@ public class IotTcpDownstreamHandler { |
| 71 | 77 | } |
| 72 | 78 | } |
| 73 | 79 | |
| 80 | + /** | |
| 81 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节按 codec 加帧后写出 | |
| 82 | + * <p> | |
| 83 | + * 只有认证时声明了 mode=raw 的透传连接才支持;普通 JSON / 二进制连接返回 RAW_NOT_SUPPORTED | |
| 84 | + * | |
| 85 | + * @param message 下行消息 | |
| 86 | + * @param bytes 要写出的原始字节 | |
| 87 | + * @return 真实的写出结果 | |
| 88 | + */ | |
| 89 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 90 | + // 1. 检查设备连接:之后只写这个检查过的连接(期间设备换成新连接时写出失败,不写到未校验的新连接上) | |
| 91 | + NetSocket socket = connectionManager.getSocketByDeviceId(message.getDeviceId()); | |
| 92 | + IotTcpConnectionManager.ConnectionInfo connectionInfo = socket != null ? connectionManager.getConnectionInfo(socket) : null; | |
| 93 | + if (connectionInfo == null) { | |
| 94 | + log.warn("[handleRawMessage][连接信息不存在,设备 ID: {},方法: {},消息 ID: {}]", | |
| 95 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 96 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 97 | + } | |
| 98 | + if (!Boolean.TRUE.equals(connectionInfo.getRawMode())) { | |
| 99 | + log.warn("[handleRawMessage][非透传连接,不支持透传下发,设备 ID: {},方法: {},消息 ID: {}]", | |
| 100 | + message.getDeviceId(), message.getMethod(), message.getId()); | |
| 101 | + return Future.failedFuture(exception(RAW_NOT_SUPPORTED)); | |
| 102 | + } | |
| 103 | + | |
| 104 | + // 2. 帧编码 + 发送到设备 | |
| 105 | + Buffer frameData; | |
| 106 | + try { | |
| 107 | + frameData = codec.encode(bytes); | |
| 108 | + } catch (Exception e) { | |
| 109 | + log.warn("[handleRawMessage][帧编码失败,设备 ID: {},数据长度: {} 字节,错误: {}]", | |
| 110 | + message.getDeviceId(), bytes.length, e.getMessage()); | |
| 111 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 112 | + } | |
| 113 | + return connectionManager.sendToConnectionAsync(message.getDeviceId(), socket, frameData.getBytes()); | |
| 114 | + } | |
| 115 | + | |
| 74 | 116 | } |
| 75 | 117 | \ No newline at end of file | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotTcpDownstreamSubscriber extends AbstractIotProtocolDownstreamSub |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
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; |
| 9 | 9 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; |
| 10 | 10 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 12 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 12 | 13 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 13 | 14 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| 14 | 15 | 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 |
| 19 | 20 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 20 | 21 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 21 | 22 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 23 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 22 | 24 | import io.vertx.core.Handler; |
| 25 | +import io.vertx.core.Vertx; | |
| 23 | 26 | import io.vertx.core.buffer.Buffer; |
| 24 | 27 | import io.vertx.core.net.NetSocket; |
| 25 | 28 | import io.vertx.core.parsetools.RecordParser; |
| 26 | 29 | import lombok.extern.slf4j.Slf4j; |
| 27 | 30 | import cn.hutool.core.lang.Assert; |
| 28 | 31 | |
| 32 | +import java.util.Map; | |
| 33 | +import java.util.concurrent.RejectedExecutionException; | |
| 34 | + | |
| 29 | 35 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; |
| 30 | 36 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 31 | 37 | import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; |
| ... | ... | @@ -40,6 +46,15 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 40 | 46 | |
| 41 | 47 | private static final String AUTH_METHOD = "auth"; |
| 42 | 48 | |
| 49 | + /** | |
| 50 | + * 认证参数中的连接模式 Key | |
| 51 | + */ | |
| 52 | + private static final String PARAM_KEY_MODE = "mode"; | |
| 53 | + /** | |
| 54 | + * 连接模式:透传(认证之后的每一帧都是原始字节) | |
| 55 | + */ | |
| 56 | + private static final String MODE_RAW = "raw"; | |
| 57 | + | |
| 43 | 58 | private final String serverId; |
| 44 | 59 | |
| 45 | 60 | /** |
| ... | ... | @@ -82,19 +97,21 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 82 | 97 | log.debug("[handle][设备连接,地址: {}]", remoteAddress); |
| 83 | 98 | |
| 84 | 99 | // 1. 设置异常和关闭处理器 |
| 100 | + // 本连接的 worker 队列:业务消息、透传帧的发送会阻塞,按收到的顺序在 worker 上执行;离线消息排在它们之后 | |
| 101 | + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(Vertx.currentContext()); | |
| 85 | 102 | socket.exceptionHandler(ex -> { |
| 86 | 103 | log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex); |
| 87 | 104 | socket.close(); |
| 88 | 105 | }); |
| 89 | 106 | socket.closeHandler(v -> { |
| 90 | 107 | log.debug("[handle][连接关闭,地址: {}]", remoteAddress); |
| 91 | - cleanupConnection(socket); | |
| 108 | + cleanupConnection(socket, queue); | |
| 92 | 109 | }); |
| 93 | 110 | |
| 94 | 111 | // 2.1 设置消息处理器 |
| 95 | 112 | Handler<Buffer> messageHandler = buffer -> { |
| 96 | 113 | try { |
| 97 | - processMessage(buffer, socket); | |
| 114 | + processMessage(buffer, socket, queue); | |
| 98 | 115 | } catch (Exception e) { |
| 99 | 116 | log.error("[handle][消息处理失败,地址: {}]", remoteAddress, e); |
| 100 | 117 | socket.close(); |
| ... | ... | @@ -111,8 +128,16 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 111 | 128 | * |
| 112 | 129 | * @param buffer 消息 |
| 113 | 130 | * @param socket 网络连接 |
| 131 | + * @param queue 本连接的 worker 队列 | |
| 114 | 132 | */ |
| 115 | - private void processMessage(Buffer buffer, NetSocket socket) { | |
| 133 | + private void processMessage(Buffer buffer, NetSocket socket, IotWorkerTaskQueue queue) { | |
| 134 | + // 0. 已认证、且认证时声明了 mode=raw 的连接:每一帧都是原始字节(帧仍由 codec 切分),不再反序列化 | |
| 135 | + IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); | |
| 136 | + if (connectionInfo != null && Boolean.TRUE.equals(connectionInfo.getRawMode())) { | |
| 137 | + handleRawRequest(buffer.getBytes(), connectionInfo, socket, queue); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + | |
| 116 | 141 | IotDeviceMessage message = null; |
| 117 | 142 | try { |
| 118 | 143 | // 1. 反序列化消息 |
| ... | ... | @@ -131,7 +156,7 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 131 | 156 | handleRegisterRequest(message, socket); |
| 132 | 157 | } else { |
| 133 | 158 | // 业务消息 |
| 134 | - handleBusinessRequest(message, socket); | |
| 159 | + handleBusinessRequest(message, socket, queue); | |
| 135 | 160 | } |
| 136 | 161 | } catch (ServiceException e) { |
| 137 | 162 | // 业务异常,返回对应的错误码和错误信息 |
| ... | ... | @@ -183,8 +208,8 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 183 | 208 | IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); |
| 184 | 209 | Assert.notNull(device, "设备不存在"); |
| 185 | 210 | |
| 186 | - // 3.1 注册连接 | |
| 187 | - registerConnection(socket, device); | |
| 211 | + // 3.1 注册连接(params.mode = raw 时为透传连接) | |
| 212 | + registerConnection(socket, device, isRawMode(message.getParams())); | |
| 188 | 213 | // 3.2 发送上线消息 |
| 189 | 214 | sendOnlineMessage(device); |
| 190 | 215 | // 3.3 发送成功响应 |
| ... | ... | @@ -220,12 +245,13 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 220 | 245 | } |
| 221 | 246 | |
| 222 | 247 | /** |
| 223 | - * 处理业务请求 | |
| 248 | + * 处理业务请求:认证检查在 event loop 上;获取设备、发送总线会阻塞,交给本连接的 worker 队列按收到的顺序执行 | |
| 224 | 249 | * |
| 225 | 250 | * @param message 消息信息 |
| 226 | 251 | * @param socket 网络连接 |
| 252 | + * @param queue 本连接的 worker 队列 | |
| 227 | 253 | */ |
| 228 | - private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket) { | |
| 254 | + private void handleBusinessRequest(IotDeviceMessage message, NetSocket socket, IotWorkerTaskQueue queue) { | |
| 229 | 255 | // 1. 获取认证信息并处理业务消息 |
| 230 | 256 | IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); |
| 231 | 257 | if (connectionInfo == null) { |
| ... | ... | @@ -235,10 +261,73 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 235 | 261 | return; |
| 236 | 262 | } |
| 237 | 263 | |
| 238 | - // 2. 发送消息到消息总线 | |
| 239 | - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 240 | - connectionInfo.getDeviceName(), serverId); | |
| 241 | - log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); | |
| 264 | + // 2. 发送消息到消息总线(worker):成功不应答(与原来一致);失败回到 event loop 应答错误 | |
| 265 | + queue.submit(() -> { | |
| 266 | + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(), | |
| 267 | + connectionInfo.getDeviceName(), serverId); | |
| 268 | + return null; | |
| 269 | + }).onComplete(ar -> { | |
| 270 | + if (ar.succeeded()) { | |
| 271 | + log.info("[handleBusinessRequest][发送消息到消息总线,地址: {},消息: {}]", socket.remoteAddress(), message); | |
| 272 | + return; | |
| 273 | + } | |
| 274 | + handleBusinessFailure(message, socket, ar.cause()); | |
| 275 | + }); | |
| 276 | + } | |
| 277 | + | |
| 278 | + /** | |
| 279 | + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 | |
| 280 | + * | |
| 281 | + * @param message 消息信息 | |
| 282 | + * @param socket 网络连接 | |
| 283 | + * @param e 异常 | |
| 284 | + */ | |
| 285 | + private void handleBusinessFailure(IotDeviceMessage message, NetSocket socket, Throwable e) { | |
| 286 | + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 | |
| 287 | + log.error("[handleBusinessFailure][业务消息积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 288 | + socket.close(); | |
| 289 | + return; | |
| 290 | + } | |
| 291 | + if (e instanceof ServiceException) { | |
| 292 | + log.warn("[handleBusinessFailure][业务异常,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 293 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), | |
| 294 | + ((ServiceException) e).getCode(), e.getMessage()); | |
| 295 | + return; | |
| 296 | + } | |
| 297 | + if (e instanceof IllegalArgumentException) { | |
| 298 | + log.warn("[handleBusinessFailure][参数校验失败,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 299 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), BAD_REQUEST.getCode(), e.getMessage()); | |
| 300 | + return; | |
| 301 | + } | |
| 302 | + // 其他异常:返回 500,并断开连接 | |
| 303 | + log.error("[handleBusinessFailure][处理消息失败,地址: {}]", socket.remoteAddress(), e); | |
| 304 | + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), | |
| 305 | + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); | |
| 306 | + socket.close(); | |
| 307 | + } | |
| 308 | + | |
| 309 | + /** | |
| 310 | + * 处理透传上报:原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给本连接的 worker 队列按顺序执行 | |
| 311 | + * | |
| 312 | + * @param payload 原始字节(一帧) | |
| 313 | + * @param connectionInfo 已认证的连接信息 | |
| 314 | + * @param socket 网络连接 | |
| 315 | + * @param queue 本连接的 worker 队列 | |
| 316 | + */ | |
| 317 | + private void handleRawRequest(byte[] payload, IotTcpConnectionManager.ConnectionInfo connectionInfo, | |
| 318 | + NetSocket socket, IotWorkerTaskQueue queue) { | |
| 319 | + // 截止时间从收到(入队之前)开始计算 | |
| 320 | + long receivedNanos = System.nanoTime(); | |
| 321 | + queue.submit(() -> deviceMessageService.sendRawMessage(payload, connectionInfo.getProductKey(), | |
| 322 | + connectionInfo.getDeviceName(), serverId, IotProtocolTypeEnum.TCP, receivedNanos)).onFailure(e -> { | |
| 323 | + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连 | |
| 324 | + log.error("[handleRawRequest][透传上报积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage()); | |
| 325 | + socket.close(); | |
| 326 | + return; | |
| 327 | + } | |
| 328 | + log.warn("[handleRawRequest][透传上报处理失败,地址: {},数据长度: {} 字节,错误: {}]", | |
| 329 | + socket.remoteAddress(), payload.length, e.getMessage()); | |
| 330 | + }); | |
| 242 | 331 | } |
| 243 | 332 | |
| 244 | 333 | /** |
| ... | ... | @@ -246,16 +335,28 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 246 | 335 | * |
| 247 | 336 | * @param socket 网络连接 |
| 248 | 337 | * @param device 设备 |
| 338 | + * @param rawMode 是否为透传连接 | |
| 249 | 339 | */ |
| 250 | - private void registerConnection(NetSocket socket, IotDeviceRespDTO device) { | |
| 340 | + private void registerConnection(NetSocket socket, IotDeviceRespDTO device, boolean rawMode) { | |
| 251 | 341 | IotTcpConnectionManager.ConnectionInfo connectionInfo = new IotTcpConnectionManager.ConnectionInfo() |
| 252 | 342 | .setDeviceId(device.getId()) |
| 253 | 343 | .setProductKey(device.getProductKey()) |
| 254 | - .setDeviceName(device.getDeviceName()); | |
| 344 | + .setDeviceName(device.getDeviceName()) | |
| 345 | + .setRawMode(rawMode); | |
| 255 | 346 | connectionManager.registerConnection(socket, device.getId(), connectionInfo); |
| 256 | 347 | } |
| 257 | 348 | |
| 258 | 349 | /** |
| 350 | + * 认证参数是否声明了透传模式(params.mode = raw) | |
| 351 | + * | |
| 352 | + * @param params 认证参数 | |
| 353 | + * @return 是否为透传模式 | |
| 354 | + */ | |
| 355 | + private static boolean isRawMode(Object params) { | |
| 356 | + return params instanceof Map && MODE_RAW.equals(((Map<?, ?>) params).get(PARAM_KEY_MODE)); | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 259 | 360 | * 发送设备上线消息 |
| 260 | 361 | * |
| 261 | 362 | * @param device 设备信息 |
| ... | ... | @@ -270,18 +371,31 @@ public class IotTcpUpstreamHandler implements Handler<NetSocket> { |
| 270 | 371 | * 清理连接 |
| 271 | 372 | * |
| 272 | 373 | * @param socket 网络连接 |
| 374 | + * @param queue 本连接的 worker 队列 | |
| 273 | 375 | */ |
| 274 | - private void cleanupConnection(NetSocket socket) { | |
| 275 | - // 1. 发送离线消息 | |
| 376 | + private void cleanupConnection(NetSocket socket, IotWorkerTaskQueue queue) { | |
| 377 | + // 1. 注销连接 | |
| 276 | 378 | IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket); |
| 277 | - if (connectionInfo != null) { | |
| 278 | - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 279 | - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(), | |
| 280 | - connectionInfo.getDeviceName(), serverId); | |
| 379 | + connectionManager.unregisterConnection(socket); | |
| 380 | + if (connectionInfo == null) { | |
| 381 | + return; | |
| 281 | 382 | } |
| 282 | 383 | |
| 283 | - // 2. 注销连接 | |
| 284 | - connectionManager.unregisterConnection(socket); | |
| 384 | + // 2. 发送离线消息:排在本连接已收到的业务消息、透传帧之后;执行时设备已在新连接上线,则不再发送 | |
| 385 | + queue.submitLast(() -> { | |
| 386 | + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { | |
| 387 | + return null; | |
| 388 | + } | |
| 389 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(), | |
| 390 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 391 | + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线 | |
| 392 | + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) { | |
| 393 | + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(), | |
| 394 | + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId); | |
| 395 | + } | |
| 396 | + return null; | |
| 397 | + }).onFailure(e -> log.error("[cleanupConnection][发送离线消息失败,地址: {},错误: {}]", | |
| 398 | + socket.remoteAddress(), e.getMessage())); | |
| 285 | 399 | } |
| 286 | 400 | |
| 287 | 401 | // ===================== 发送响应消息 ===================== | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager; |
| 2 | 2 | |
| 3 | +import io.vertx.core.Future; | |
| 3 | 4 | import io.vertx.core.buffer.Buffer; |
| 4 | 5 | import io.vertx.core.net.NetSocket; |
| 5 | 6 | import lombok.Data; |
| ... | ... | @@ -10,6 +11,9 @@ import java.util.List; |
| 10 | 11 | import java.util.Map; |
| 11 | 12 | import java.util.concurrent.ConcurrentHashMap; |
| 12 | 13 | |
| 14 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 15 | +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED; | |
| 16 | + | |
| 13 | 17 | /** |
| 14 | 18 | * IoT 网关 TCP 连接管理器 |
| 15 | 19 | * <p> |
| ... | ... | @@ -129,6 +133,42 @@ public class IotTcpConnectionManager { |
| 129 | 133 | } |
| 130 | 134 | |
| 131 | 135 | /** |
| 136 | + * 根据设备 ID 获取当前的连接 | |
| 137 | + */ | |
| 138 | + public NetSocket getSocketByDeviceId(Long deviceId) { | |
| 139 | + return deviceSocketMap.get(deviceId); | |
| 140 | + } | |
| 141 | + | |
| 142 | + /** | |
| 143 | + * 异步发送消息到设备的指定连接:返回 socket.write 真实的写出结果(透传下发使用) | |
| 144 | + * <p> | |
| 145 | + * 只写调用方已检查过的连接(例如已确认是透传连接):设备已换成其它连接时直接失败,不改写新连接,避免把原始字节写到未校验的连接上。 | |
| 146 | + * 成功只表示本地写出完成,不代表设备已收到或已执行;设备未连接、连接已替换或写出失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED) | |
| 147 | + * | |
| 148 | + * @param deviceId 设备 ID | |
| 149 | + * @param socket 已检查过的连接 | |
| 150 | + * @param data 数据(已按帧编码) | |
| 151 | + * @return 写出结果 | |
| 152 | + */ | |
| 153 | + public Future<Void> sendToConnectionAsync(Long deviceId, NetSocket socket, byte[] data) { | |
| 154 | + if (socket == null || deviceSocketMap.get(deviceId) != socket) { | |
| 155 | + log.warn("[sendToConnectionAsync][设备未连接或连接已替换,设备 ID: {}]", deviceId); | |
| 156 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 157 | + } | |
| 158 | + | |
| 159 | + try { | |
| 160 | + return socket.write(Buffer.buffer(data)) | |
| 161 | + .recover(e -> { | |
| 162 | + log.warn("[sendToConnectionAsync][发送消息失败,设备 ID: {},错误: {}]", deviceId, e.getMessage()); | |
| 163 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 164 | + }); | |
| 165 | + } catch (Exception e) { | |
| 166 | + log.error("[sendToConnectionAsync][发送消息异常,设备 ID: {}]", deviceId, e); | |
| 167 | + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED)); | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** | |
| 132 | 172 | * 关闭所有连接 |
| 133 | 173 | */ |
| 134 | 174 | public void closeAll() { |
| ... | ... | @@ -164,6 +204,12 @@ public class IotTcpConnectionManager { |
| 164 | 204 | * 设备名称 |
| 165 | 205 | */ |
| 166 | 206 | private String deviceName; |
| 207 | + /** | |
| 208 | + * 是否为透传连接(认证时 params.mode = raw) | |
| 209 | + * <p> | |
| 210 | + * 透传连接认证之后的每一帧都是原始字节,按数据解析脚本解析;只有透传连接才支持透传下发 | |
| 211 | + */ | |
| 212 | + private Boolean rawMode; | |
| 167 | 213 | |
| 168 | 214 | } |
| 169 | 215 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java
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; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol; |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream.IotUdpUpstreamHandler; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import io.vertx.core.datagram.DatagramSocket; |
| 8 | 10 | import lombok.RequiredArgsConstructor; |
| 9 | 11 | import lombok.extern.slf4j.Slf4j; |
| ... | ... | @@ -62,4 +64,19 @@ public class IotUdpDownstreamHandler { |
| 62 | 64 | } |
| 63 | 65 | } |
| 64 | 66 | |
| 67 | + /** | |
| 68 | + * 处理透传下行(raw.push 或编码脚本的输出):原始字节按透传下行格式包装后发送,不等待 | |
| 69 | + * <p> | |
| 70 | + * 会话不存在(或已过期)、UDP Socket 不可用时,由 {@link IotUdpSessionManager#sendToDeviceAsync} 返回失败 | |
| 71 | + * | |
| 72 | + * @param message 下行消息 | |
| 73 | + * @param bytes 要写出的原始字节 | |
| 74 | + * @return 真实的发送结果 | |
| 75 | + */ | |
| 76 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) { | |
| 77 | + DatagramSocket socket = protocol.getUdpSocket(); | |
| 78 | + return sessionManager.sendToDeviceAsync(message.getDeviceId(), | |
| 79 | + IotUdpUpstreamHandler.encodeRawDownstream(bytes), socket); | |
| 80 | + } | |
| 81 | + | |
| 65 | 82 | } | ... | ... |
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; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource; | |
| 5 | 6 | import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; |
| 8 | +import io.vertx.core.Future; | |
| 7 | 9 | import lombok.extern.slf4j.Slf4j; |
| 8 | 10 | |
| 9 | 11 | /** |
| ... | ... | @@ -28,4 +30,14 @@ public class IotUdpDownstreamSubscriber extends AbstractIotProtocolDownstreamSub |
| 28 | 30 | downstreamHandler.handle(message); |
| 29 | 31 | } |
| 30 | 32 | |
| 33 | + @Override | |
| 34 | + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) { | |
| 35 | + return downstreamHandler.handleRawMessage(message, bytes); | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + public boolean supportsRawDownstream() { | |
| 40 | + return true; | |
| 41 | + } | |
| 42 | + | |
| 31 | 43 | } | ... | ... |
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; |
| 13 | 13 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; |
| 14 | 14 | import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; |
| 15 | 15 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 16 | +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; | |
| 16 | 17 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 17 | 18 | import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; |
| 18 | 19 | import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; |
| 19 | 20 | import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; |
| 20 | 21 | import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; |
| 22 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 21 | 23 | import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager; |
| 22 | 24 | import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; |
| 23 | 25 | import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService; |
| 24 | 26 | import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 25 | 27 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 28 | +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue; | |
| 29 | +import io.vertx.core.Context; | |
| 30 | +import io.vertx.core.Vertx; | |
| 26 | 31 | import io.vertx.core.buffer.Buffer; |
| 27 | 32 | import io.vertx.core.datagram.DatagramPacket; |
| 28 | 33 | import io.vertx.core.datagram.DatagramSocket; |
| 34 | +import lombok.AllArgsConstructor; | |
| 35 | +import lombok.Getter; | |
| 29 | 36 | import lombok.extern.slf4j.Slf4j; |
| 30 | 37 | import cn.hutool.core.lang.Assert; |
| 31 | 38 | |
| 32 | 39 | import java.net.InetSocketAddress; |
| 40 | +import java.nio.charset.StandardCharsets; | |
| 41 | +import java.util.Arrays; | |
| 33 | 42 | import java.util.Map; |
| 43 | +import java.util.concurrent.RejectedExecutionException; | |
| 34 | 44 | |
| 35 | 45 | import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*; |
| 36 | 46 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 47 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; | |
| 37 | 48 | import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL; |
| 38 | 49 | |
| 39 | 50 | /** |
| ... | ... | @@ -44,6 +55,12 @@ import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVIC |
| 44 | 55 | * 2. 返回 Token:服务端验证后返回 JWT token |
| 45 | 56 | * 3. 后续请求:每次请求在 params 中携带 token |
| 46 | 57 | * 4. 服务端验证:每次请求通过 IotDeviceTokenService.verifyToken() 验证 |
| 58 | + * <p> | |
| 59 | + * 透传(raw)报文按首字节区分:'{' 是 JSON,0x7E 是二进制序列化,0x49 0x52("IR")是透传: | |
| 60 | + * <ul> | |
| 61 | + * <li>上行:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload(1..4096),每个包都携带 token,不凭地址识别设备;</li> | |
| 62 | + * <li>下行:0x49 0x52 | 0x01 | 0x0000 | payload。</li> | |
| 63 | + * </ul> | |
| 47 | 64 | * |
| 48 | 65 | * @author 芋道源码 |
| 49 | 66 | */ |
| ... | ... | @@ -53,6 +70,24 @@ public class IotUdpUpstreamHandler { |
| 53 | 70 | private static final String AUTH_METHOD = "auth"; |
| 54 | 71 | |
| 55 | 72 | /** |
| 73 | + * 透传报文魔术字:'I'、'R' | |
| 74 | + */ | |
| 75 | + private static final byte RAW_MAGIC_0 = 0x49; | |
| 76 | + private static final byte RAW_MAGIC_1 = 0x52; | |
| 77 | + /** | |
| 78 | + * 透传报文版本 | |
| 79 | + */ | |
| 80 | + private static final byte RAW_VERSION = 0x01; | |
| 81 | + /** | |
| 82 | + * 透传报文头长度:魔术字(2) + 版本(1) + tokenLen(2) | |
| 83 | + */ | |
| 84 | + private static final int RAW_HEADER_LENGTH = 5; | |
| 85 | + /** | |
| 86 | + * 业务消息、透传上报最多排队的包数:超过时丢弃 | |
| 87 | + */ | |
| 88 | + private static final int MAX_PENDING = 1024; | |
| 89 | + | |
| 90 | + /** | |
| 56 | 91 | * Token 参数 Key |
| 57 | 92 | */ |
| 58 | 93 | private static final String PARAM_KEY_TOKEN = "token"; |
| ... | ... | @@ -77,6 +112,11 @@ public class IotUdpUpstreamHandler { |
| 77 | 112 | private final IotDeviceTokenService deviceTokenService; |
| 78 | 113 | private final IotDeviceCommonApi deviceApi; |
| 79 | 114 | |
| 115 | + /** | |
| 116 | + * 业务消息、透传上报的 worker 队列(无序、有界):在包到达时,按 socket 所在的 context 创建 | |
| 117 | + */ | |
| 118 | + private IotWorkerTaskQueue queue; | |
| 119 | + | |
| 80 | 120 | public IotUdpUpstreamHandler(String serverId, |
| 81 | 121 | IotUdpSessionManager sessionManager, |
| 82 | 122 | IotMessageSerializer serializer) { |
| ... | ... | @@ -118,8 +158,14 @@ public class IotUdpUpstreamHandler { |
| 118 | 158 | if (ArrayUtil.isEmpty(buffer)) { |
| 119 | 159 | return; |
| 120 | 160 | } |
| 121 | - // 1.2 反序列化消息 | |
| 122 | - IotDeviceMessage message = serializer.deserialize(buffer.getBytes()); | |
| 161 | + // 1.2 透传报文("IR" 开头):每个包都携带 token,跳过反序列化,按数据解析脚本解析 | |
| 162 | + byte[] data = buffer.getBytes(); | |
| 163 | + if (isRawPacket(data)) { | |
| 164 | + handleRawRequest(data, senderAddress, socket); | |
| 165 | + return; | |
| 166 | + } | |
| 167 | + // 1.3 反序列化消息 | |
| 168 | + IotDeviceMessage message = serializer.deserialize(data); | |
| 123 | 169 | if (message == null) { |
| 124 | 170 | sendErrorResponse(socket, senderAddress, null, null, BAD_REQUEST.getCode(), "消息反序列化失败"); |
| 125 | 171 | return; |
| ... | ... | @@ -269,26 +315,130 @@ public class IotUdpUpstreamHandler { |
| 269 | 315 | UNAUTHORIZED.getCode(), "token 无效或已过期"); |
| 270 | 316 | return; |
| 271 | 317 | } |
| 272 | - // 1.3 获取设备详细信息 | |
| 273 | - IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | |
| 274 | - deviceInfo.getDeviceName()); | |
| 275 | - if (device == null) { | |
| 276 | - log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | |
| 277 | - addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 318 | + // 2. 获取设备、更新会话地址、发送消息到消息总线:会阻塞,交给 worker 执行(UDP 本身不保证顺序,包之间并行); | |
| 319 | + // 成功不应答(与原来一致),失败回到 event loop 应答错误 | |
| 320 | + Object params = body; | |
| 321 | + getQueue().submit(() -> { | |
| 322 | + // 2.1 获取设备详细信息 | |
| 323 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), | |
| 324 | + deviceInfo.getDeviceName()); | |
| 325 | + if (device == null) { | |
| 326 | + log.warn("[handleBusinessRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | |
| 327 | + addressKey, deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 328 | + throw invalidParamException("设备不存在"); | |
| 329 | + } | |
| 330 | + // 2.2 更新会话地址(如有变化) | |
| 331 | + sessionManager.updateSessionAddress(device.getId(), senderAddress); | |
| 332 | + // 2.3 将 body 设置为实际的 params,发送消息到消息总线 | |
| 333 | + message.setParams(params); | |
| 334 | + deviceMessageService.sendDeviceMessage(message, device.getProductKey(), | |
| 335 | + device.getDeviceName(), serverId); | |
| 336 | + log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", | |
| 337 | + device.getId(), message.getMethod(), addressKey); | |
| 338 | + return null; | |
| 339 | + }).onFailure(e -> handleBusinessFailure(message, senderAddress, socket, e)); | |
| 340 | + } | |
| 341 | + | |
| 342 | + /** | |
| 343 | + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致 | |
| 344 | + * | |
| 345 | + * @param message 消息信息 | |
| 346 | + * @param senderAddress 发送者地址 | |
| 347 | + * @param socket UDP Socket | |
| 348 | + * @param e 异常 | |
| 349 | + */ | |
| 350 | + private void handleBusinessFailure(IotDeviceMessage message, InetSocketAddress senderAddress, | |
| 351 | + DatagramSocket socket, Throwable e) { | |
| 352 | + String addressKey = sessionManager.buildAddressKey(senderAddress); | |
| 353 | + if (e instanceof RejectedExecutionException) { // 队列已满:丢弃,应答稍后重试 | |
| 354 | + log.warn("[handleBusinessFailure][业务消息积压,丢弃,来源: {},错误: {}]", addressKey, e.getMessage()); | |
| 278 | 355 | sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), |
| 279 | - BAD_REQUEST.getCode(), "设备不存在"); | |
| 356 | + TOO_MANY_REQUESTS.getCode(), TOO_MANY_REQUESTS.getMsg()); | |
| 280 | 357 | return; |
| 281 | 358 | } |
| 359 | + if (e instanceof ServiceException) { | |
| 360 | + log.warn("[handleBusinessFailure][业务异常,来源: {},requestId: {},method: {},错误: {}]", | |
| 361 | + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); | |
| 362 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), | |
| 363 | + ((ServiceException) e).getCode(), e.getMessage()); | |
| 364 | + return; | |
| 365 | + } | |
| 366 | + if (e instanceof IllegalArgumentException) { | |
| 367 | + log.warn("[handleBusinessFailure][参数校验失败,来源: {},requestId: {},method: {},错误: {}]", | |
| 368 | + addressKey, message.getRequestId(), message.getMethod(), e.getMessage()); | |
| 369 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), | |
| 370 | + BAD_REQUEST.getCode(), e.getMessage()); | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + log.error("[handleBusinessFailure][处理消息失败,来源: {},requestId: {},method: {}]", | |
| 374 | + addressKey, message.getRequestId(), message.getMethod(), e); | |
| 375 | + sendErrorResponse(socket, senderAddress, message.getRequestId(), message.getMethod(), | |
| 376 | + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); | |
| 377 | + } | |
| 282 | 378 | |
| 283 | - // 2. 更新会话地址(如有变化) | |
| 284 | - sessionManager.updateSessionAddress(device.getId(), senderAddress); | |
| 379 | + /** | |
| 380 | + * 处理透传上报:校验报文和 token 后,原始字节按产品的数据解析脚本 decode 后发送 | |
| 381 | + * <p> | |
| 382 | + * 报文格式不正确、token 无效或已过期、设备不存在:只记日志并丢弃(不回复 JSON 错误响应) | |
| 383 | + * | |
| 384 | + * @param data 透传报文 | |
| 385 | + * @param senderAddress 发送者地址 | |
| 386 | + * @param socket UDP Socket | |
| 387 | + */ | |
| 388 | + private void handleRawRequest(byte[] data, InetSocketAddress senderAddress, DatagramSocket socket) { | |
| 389 | + String addressKey = sessionManager.buildAddressKey(senderAddress); | |
| 390 | + // 1.1 解析透传报文 | |
| 391 | + RawPacket packet; | |
| 392 | + try { | |
| 393 | + packet = decodeRawPacket(data); | |
| 394 | + } catch (IllegalArgumentException e) { | |
| 395 | + log.warn("[handleRawRequest][透传报文格式不正确,来源: {},错误: {}]", addressKey, e.getMessage()); | |
| 396 | + return; | |
| 397 | + } | |
| 398 | + // 1.2 验证 token,获取设备信息 | |
| 399 | + IotDeviceIdentity deviceInfo; | |
| 400 | + try { | |
| 401 | + deviceInfo = deviceTokenService.verifyToken(packet.getToken()); | |
| 402 | + } catch (Exception e) { | |
| 403 | + log.warn("[handleRawRequest][token 无效或已过期,来源: {},错误: {}]", addressKey, e.getMessage()); | |
| 404 | + return; | |
| 405 | + } | |
| 406 | + if (deviceInfo == null) { | |
| 407 | + log.warn("[handleRawRequest][token 无效或已过期,来源: {}]", addressKey); | |
| 408 | + return; | |
| 409 | + } | |
| 410 | + // 2. 获取设备、更新会话地址、按产品的数据解析脚本 decode 后发送:会阻塞,交给 worker 执行(UDP 本身不保证顺序,包之间并行) | |
| 411 | + IotDeviceIdentity identity = deviceInfo; | |
| 412 | + byte[] payload = packet.getPayload(); | |
| 413 | + // 截止时间从收到(入队之前)开始计算 | |
| 414 | + long receivedNanos = System.nanoTime(); | |
| 415 | + getQueue().submit(() -> { | |
| 416 | + // 2.1 获取设备详细信息:不存在时只记日志并丢弃 | |
| 417 | + IotDeviceRespDTO device = deviceService.getDeviceFromCache(identity.getProductKey(), | |
| 418 | + identity.getDeviceName()); | |
| 419 | + if (device == null) { | |
| 420 | + log.warn("[handleRawRequest][设备不存在,来源: {},productKey: {},deviceName: {}]", | |
| 421 | + addressKey, identity.getProductKey(), identity.getDeviceName()); | |
| 422 | + return null; | |
| 423 | + } | |
| 424 | + // 2.2 更新会话地址(如有变化) | |
| 425 | + sessionManager.updateSessionAddress(device.getId(), senderAddress); | |
| 426 | + // 2.3 decode 后发送 | |
| 427 | + return deviceMessageService.sendRawMessage(payload, device.getProductKey(), | |
| 428 | + device.getDeviceName(), serverId, IotProtocolTypeEnum.UDP, receivedNanos); | |
| 429 | + }).onFailure(e -> log.warn("[handleRawRequest][透传上报处理失败,来源: {},数据长度: {} 字节,错误: {}]", | |
| 430 | + addressKey, payload.length, e.getMessage())); | |
| 431 | + } | |
| 285 | 432 | |
| 286 | - // 3. 将 body 设置为实际的 params,发送消息到消息总线 | |
| 287 | - message.setParams(body); | |
| 288 | - deviceMessageService.sendDeviceMessage(message, device.getProductKey(), | |
| 289 | - device.getDeviceName(), serverId); | |
| 290 | - log.debug("[handleBusinessRequest][业务消息处理成功,设备 ID: {},方法: {},来源: {}]", | |
| 291 | - device.getId(), message.getMethod(), addressKey); | |
| 433 | + /** | |
| 434 | + * 获取 worker 队列:按当前(socket 所在的)context 创建;协议重启后 context 变化时重建 | |
| 435 | + */ | |
| 436 | + private IotWorkerTaskQueue getQueue() { | |
| 437 | + Context context = Vertx.currentContext(); | |
| 438 | + if (queue == null || queue.getContext() != context) { | |
| 439 | + queue = new IotWorkerTaskQueue(context, false, MAX_PENDING); | |
| 440 | + } | |
| 441 | + return queue; | |
| 292 | 442 | } |
| 293 | 443 | |
| 294 | 444 | /** |
| ... | ... | @@ -373,4 +523,75 @@ public class IotUdpUpstreamHandler { |
| 373 | 523 | } |
| 374 | 524 | } |
| 375 | 525 | |
| 526 | + // ===================== 透传报文编解码 ===================== | |
| 527 | + | |
| 528 | + /** | |
| 529 | + * 是否为透传报文:以 0x49 0x52("IR")开头 | |
| 530 | + * | |
| 531 | + * @param data 数据包 | |
| 532 | + * @return 是否为透传报文 | |
| 533 | + */ | |
| 534 | + public static boolean isRawPacket(byte[] data) { | |
| 535 | + return data != null && data.length >= 2 && data[0] == RAW_MAGIC_0 && data[1] == RAW_MAGIC_1; | |
| 536 | + } | |
| 537 | + | |
| 538 | + /** | |
| 539 | + * 解码透传上行报文:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload(1..4096) | |
| 540 | + * | |
| 541 | + * @param data 数据包 | |
| 542 | + * @return 透传报文 | |
| 543 | + * @throws IllegalArgumentException 格式或长度不正确 | |
| 544 | + */ | |
| 545 | + public static RawPacket decodeRawPacket(byte[] data) { | |
| 546 | + // 1. 校验报文头 | |
| 547 | + Assert.isTrue(isRawPacket(data), "透传报文魔术字不正确"); | |
| 548 | + Assert.isTrue(data.length >= RAW_HEADER_LENGTH, "透传报文长度不足"); | |
| 549 | + Assert.isTrue(data[2] == RAW_VERSION, "透传报文版本不支持: {}", data[2]); | |
| 550 | + // 2.1 解析 token | |
| 551 | + int tokenLength = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); | |
| 552 | + Assert.isTrue(tokenLength > 0, "透传报文缺少 token"); | |
| 553 | + Assert.isTrue(data.length > RAW_HEADER_LENGTH + tokenLength, "透传报文缺少 payload"); | |
| 554 | + String token = new String(data, RAW_HEADER_LENGTH, tokenLength, StandardCharsets.UTF_8); | |
| 555 | + // 2.2 解析 payload | |
| 556 | + int payloadLength = data.length - RAW_HEADER_LENGTH - tokenLength; | |
| 557 | + Assert.isTrue(payloadLength <= IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES, | |
| 558 | + "透传报文 payload 超过 {} 字节", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES); | |
| 559 | + byte[] payload = Arrays.copyOfRange(data, RAW_HEADER_LENGTH + tokenLength, data.length); | |
| 560 | + return new RawPacket(token, payload); | |
| 561 | + } | |
| 562 | + | |
| 563 | + /** | |
| 564 | + * 编码透传下行报文:0x49 0x52 | 0x01 | 0x0000 | payload(下行不携带 token) | |
| 565 | + * | |
| 566 | + * @param payload 原始字节 | |
| 567 | + * @return 透传报文 | |
| 568 | + */ | |
| 569 | + public static byte[] encodeRawDownstream(byte[] payload) { | |
| 570 | + byte[] data = new byte[RAW_HEADER_LENGTH + payload.length]; | |
| 571 | + data[0] = RAW_MAGIC_0; | |
| 572 | + data[1] = RAW_MAGIC_1; | |
| 573 | + data[2] = RAW_VERSION; | |
| 574 | + // data[3]、data[4]:tokenLen = 0 | |
| 575 | + System.arraycopy(payload, 0, data, RAW_HEADER_LENGTH, payload.length); | |
| 576 | + return data; | |
| 577 | + } | |
| 578 | + | |
| 579 | + /** | |
| 580 | + * 透传上行报文 | |
| 581 | + */ | |
| 582 | + @Getter | |
| 583 | + @AllArgsConstructor | |
| 584 | + public static class RawPacket { | |
| 585 | + | |
| 586 | + /** | |
| 587 | + * 设备 token | |
| 588 | + */ | |
| 589 | + private final String token; | |
| 590 | + /** | |
| 591 | + * 原始字节 | |
| 592 | + */ | |
| 593 | + private final byte[] payload; | |
| 594 | + | |
| 595 | + } | |
| 596 | + | |
| 376 | 597 | } | ... | ... |