Commit 971c5a651511e244187cc6b3b0eadf9d9f29c573

Authored by YunaiV
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
... ... @@ -11,7 +11,6 @@ import org.springframework.stereotype.Component;
11 11  
12 12 import javax.sql.DataSource;
13 13  
14   -// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除独立单测 IotStorageInitRunnerTest,关键的缺数据源防回落 master 断言并入实库验收
15 14 /**
16 15 * IoT 时序存储的初始化 Runner:校验数据源、前提条件,并创建消息表
17 16 *
... ...
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 &quot;iot_product&quot; (
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&lt;String&gt; {
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&lt;String&gt; {
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&lt;String&gt; {
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&lt;RoutingContext&gt;
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
... ... @@ -30,7 +30,7 @@ public class IotModbusTcpClientUpstreamHandler {
30 30 }
31 31  
32 32 /**
33   - * 处理 Modbus 读取结果
  33 + * 处理 Modbus 读取结果:发送到消息总线会阻塞,由轮询调度器提交到连接的 worker 队列上执行
34 34 *
35 35 * @param config 设备配置
36 36 * @param point 点位配置
... ...
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
... ... @@ -53,6 +53,7 @@ public class IotTcpProtocol implements IotProtocol {
53 53 /**
54 54 * Vert.x 实例
55 55 */
  56 + @Getter
56 57 private Vertx vertx;
57 58 /**
58 59 * TCP 服务器
... ...
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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&lt;NetSocket&gt; {
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
... ... @@ -49,6 +49,7 @@ public class IotUdpProtocol implements IotProtocol {
49 49 /**
50 50 * Vert.x 实例
51 51 */
  52 + @Getter
52 53 private Vertx vertx;
53 54 /**
54 55 * UDP 服务器
... ...
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 }
... ...