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
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 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManager.java
... ... @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager;
3 3 import cn.hutool.core.util.ObjUtil;
4 4 import com.google.common.cache.Cache;
5 5 import com.google.common.cache.CacheBuilder;
  6 +import io.vertx.core.Future;
6 7 import io.vertx.core.buffer.Buffer;
7 8 import io.vertx.core.datagram.DatagramSocket;
8 9 import lombok.Data;
... ... @@ -12,6 +13,9 @@ import java.net.InetSocketAddress;
12 13 import java.util.concurrent.CompletableFuture;
13 14 import java.util.concurrent.TimeUnit;
14 15  
  16 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  17 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED;
  18 +
15 19 /**
16 20 * IoT 网关 UDP 会话管理器
17 21 * <p>
... ... @@ -136,6 +140,41 @@ public class IotUdpSessionManager {
136 140 }
137 141  
138 142 /**
  143 + * 异步发送消息到设备:直接返回 socket.send 的结果,不等待(透传下发使用)
  144 + * <p>
  145 + * 与 {@link #sendToDevice} 不同,不会同步等待发送结果,可以在 event loop 上调用。
  146 + * 成功只表示数据报已交给本机发送,UDP 不保证送达;会话不存在(或已过期)、发送失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED)
  147 + *
  148 + * @param deviceId 设备 ID
  149 + * @param data 数据
  150 + * @param socket UDP Socket
  151 + * @return 发送结果
  152 + */
  153 + public Future<Void> sendToDeviceAsync(Long deviceId, byte[] data, DatagramSocket socket) {
  154 + SessionInfo sessionInfo = deviceSessionCache.getIfPresent(deviceId);
  155 + if (sessionInfo == null || sessionInfo.getAddress() == null) {
  156 + log.warn("[sendToDeviceAsync][设备会话不存在,设备 ID: {}]", deviceId);
  157 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  158 + }
  159 + if (socket == null) {
  160 + log.warn("[sendToDeviceAsync][UDP Socket 不可用,设备 ID: {}]", deviceId);
  161 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  162 + }
  163 + InetSocketAddress address = sessionInfo.getAddress();
  164 + try {
  165 + return socket.send(Buffer.buffer(data), address.getPort(), address.getHostString())
  166 + .recover(e -> {
  167 + log.warn("[sendToDeviceAsync][发送消息失败,设备 ID: {},地址: {},错误: {}]",
  168 + deviceId, buildAddressKey(address), e.getMessage());
  169 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  170 + });
  171 + } catch (Exception e) {
  172 + log.error("[sendToDeviceAsync][发送消息异常,设备 ID: {}]", deviceId, e);
  173 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  174 + }
  175 + }
  176 +
  177 + /**
139 178 * 构建地址 Key(用于日志输出)
140 179 *
141 180 * @param address 地址
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java
... ... @@ -52,6 +52,7 @@ public class IotWebSocketProtocol implements IotProtocol {
52 52 /**
53 53 * Vert.x 实例
54 54 */
  55 + @Getter
55 56 private Vertx vertx;
56 57 /**
57 58 * WebSocket 服务器
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamHandler.java
... ... @@ -4,6 +4,7 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
4 4 import cn.hutool.core.util.StrUtil;
5 5 import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager;
6 6 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer;
  7 +import io.vertx.core.Future;
7 8 import lombok.RequiredArgsConstructor;
8 9 import lombok.extern.slf4j.Slf4j;
9 10  
... ... @@ -53,4 +54,17 @@ public class IotWebSocketDownstreamHandler {
53 54 }
54 55 }
55 56  
  57 + /**
  58 + * 处理透传下行(raw.push 或编码脚本的输出):原始字节以 binary 帧写出
  59 + * <p>
  60 + * 设备未连接时,由 {@link IotWebSocketConnectionManager#sendBinaryToDeviceAsync} 返回失败
  61 + *
  62 + * @param message 下行消息
  63 + * @param bytes 要写出的原始字节
  64 + * @return 真实的写出结果
  65 + */
  66 + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes) {
  67 + return connectionManager.sendBinaryToDeviceAsync(message.getDeviceId(), bytes);
  68 + }
  69 +
56 70 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java
... ... @@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstrea
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.websocket.IotWebSocketProtocol;
  8 +import io.vertx.core.Future;
7 9 import lombok.extern.slf4j.Slf4j;
8 10  
9 11 /**
... ... @@ -28,4 +30,14 @@ public class IotWebSocketDownstreamSubscriber extends AbstractIotProtocolDownstr
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/websocket/handler/upstream/IotWebSocketUpstreamHandler.java
... ... @@ -11,6 +11,7 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
11 11 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
12 12 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
13 13 import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  14 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
14 15 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
15 16 import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
16 17 import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
... ... @@ -20,11 +21,15 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSock
20 21 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer;
21 22 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
22 23 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  24 +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue;
23 25 import io.vertx.core.Handler;
  26 +import io.vertx.core.Vertx;
24 27 import io.vertx.core.http.ServerWebSocket;
25 28 import lombok.extern.slf4j.Slf4j;
26 29 import cn.hutool.core.lang.Assert;
27 30  
  31 +import java.util.concurrent.RejectedExecutionException;
  32 +
28 33 import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
29 34 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
30 35 import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL;
... ... @@ -77,20 +82,31 @@ public class IotWebSocketUpstreamHandler implements Handler&lt;ServerWebSocket&gt; {
77 82 log.warn("[handle][连接异常,地址: {}]", remoteAddress, ex);
78 83 socket.close();
79 84 });
  85 + // 本连接的 worker 队列:业务消息(text 帧)、透传帧(binary 帧)的发送会阻塞,按收到的顺序在 worker 上执行;离线消息排在它们之后
  86 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(Vertx.currentContext());
80 87 socket.closeHandler(v -> {
81 88 log.debug("[handle][连接关闭,地址: {}]", remoteAddress);
82   - cleanupConnection(socket);
  89 + cleanupConnection(socket, queue);
83 90 });
84 91  
85   - // 2. 设置消息处理器(仅支持文本帧)
  92 + // 2. 设置 text 帧处理器:认证、注册、标准业务消息
86 93 socket.textMessageHandler(message -> {
87 94 try {
88   - processMessage(StrUtil.utf8Bytes(message), socket);
  95 + processMessage(StrUtil.utf8Bytes(message), socket, queue);
89 96 } catch (Exception e) {
90 97 log.error("[handle][消息解码失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage());
91 98 socket.close();
92 99 }
93 100 });
  101 + // 3. 设置 binary 帧处理器:透传上报,必须先通过 text 帧认证
  102 + socket.binaryMessageHandler(buffer -> {
  103 + try {
  104 + processRawMessage(buffer.getBytes(), socket, remoteAddress, queue);
  105 + } catch (Exception e) {
  106 + log.error("[handle][透传消息处理失败,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage());
  107 + socket.close();
  108 + }
  109 + });
94 110 }
95 111  
96 112 /**
... ... @@ -98,8 +114,9 @@ public class IotWebSocketUpstreamHandler implements Handler&lt;ServerWebSocket&gt; {
98 114 *
99 115 * @param payload 消息负载
100 116 * @param socket WebSocket 连接
  117 + * @param queue 本连接的 worker 队列
101 118 */
102   - private void processMessage(byte[] payload, ServerWebSocket socket) {
  119 + private void processMessage(byte[] payload, ServerWebSocket socket, IotWorkerTaskQueue queue) {
103 120 IotDeviceMessage message = null;
104 121 try {
105 122 // 1.1 基础检查
... ... @@ -117,7 +134,7 @@ public class IotWebSocketUpstreamHandler implements Handler&lt;ServerWebSocket&gt; {
117 134 } else if (IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod().equals(message.getMethod())) {
118 135 handleRegisterRequest(message, socket);
119 136 } else {
120   - handleBusinessRequest(message, socket);
  137 + handleBusinessRequest(message, socket, queue);
121 138 }
122 139 } catch (ServiceException e) {
123 140 log.warn("[processMessage][业务异常,错误: {}]", e.getMessage());
... ... @@ -202,12 +219,13 @@ public class IotWebSocketUpstreamHandler implements Handler&lt;ServerWebSocket&gt; {
202 219 }
203 220  
204 221 /**
205   - * 处理业务请求
  222 + * 处理业务请求:认证检查在 event loop 上;获取设备、发送总线会阻塞,交给本连接的 worker 队列按收到的顺序执行
206 223 *
207 224 * @param message 消息信息
208 225 * @param socket WebSocket 连接
  226 + * @param queue 本连接的 worker 队列
209 227 */
210   - private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket) {
  228 + private void handleBusinessRequest(IotDeviceMessage message, ServerWebSocket socket, IotWorkerTaskQueue queue) {
211 229 // 1. 获取认证信息并处理业务消息
212 230 IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
213 231 if (connectionInfo == null) {
... ... @@ -217,10 +235,81 @@ public class IotWebSocketUpstreamHandler implements Handler&lt;ServerWebSocket&gt; {
217 235 return;
218 236 }
219 237  
220   - // 2. 发送消息到消息总线
221   - deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(),
222   - connectionInfo.getDeviceName(), serverId);
223   - log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message);
  238 + // 2. 发送消息到消息总线(worker):成功不应答(与原来一致);失败回到 event loop 应答错误
  239 + queue.submit(() -> {
  240 + deviceMessageService.sendDeviceMessage(message, connectionInfo.getProductKey(),
  241 + connectionInfo.getDeviceName(), serverId);
  242 + return null;
  243 + }).onComplete(ar -> {
  244 + if (ar.succeeded()) {
  245 + log.info("[handleBusinessRequest][发送消息到消息总线,消息: {}]", message);
  246 + return;
  247 + }
  248 + handleBusinessFailure(message, socket, ar.cause());
  249 + });
  250 + }
  251 +
  252 + /**
  253 + * 业务消息在 worker 上处理失败(event loop 上执行):与同步处理时的异常应答一致
  254 + *
  255 + * @param message 消息信息
  256 + * @param socket WebSocket 连接
  257 + * @param e 异常
  258 + */
  259 + private void handleBusinessFailure(IotDeviceMessage message, ServerWebSocket socket, Throwable e) {
  260 + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连
  261 + log.error("[handleBusinessFailure][业务消息积压,断开连接,地址: {},错误: {}]", socket.remoteAddress(), e.getMessage());
  262 + socket.close();
  263 + return;
  264 + }
  265 + if (e instanceof ServiceException) {
  266 + log.warn("[handleBusinessFailure][业务异常,错误: {}]", e.getMessage());
  267 + sendErrorResponse(socket, message.getRequestId(), message.getMethod(),
  268 + ((ServiceException) e).getCode(), e.getMessage());
  269 + return;
  270 + }
  271 + if (e instanceof IllegalArgumentException) {
  272 + log.warn("[handleBusinessFailure][参数校验失败,错误: {}]", e.getMessage());
  273 + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), BAD_REQUEST.getCode(), e.getMessage());
  274 + return;
  275 + }
  276 + // 其他异常:返回 500,并断开连接
  277 + log.error("[handleBusinessFailure][处理消息失败]", e);
  278 + sendErrorResponse(socket, message.getRequestId(), message.getMethod(), INTERNAL_SERVER_ERROR.getCode(),
  279 + INTERNAL_SERVER_ERROR.getMsg());
  280 + socket.close();
  281 + }
  282 +
  283 + /**
  284 + * 处理透传上报(binary 帧):原始字节按产品的数据解析脚本 decode 后发送;会阻塞,交给本连接的 worker 队列按顺序执行
  285 + *
  286 + * @param payload 原始字节
  287 + * @param socket WebSocket 连接
  288 + * @param remoteAddress 远程地址
  289 + * @param queue 本连接的 worker 队列
  290 + */
  291 + private void processRawMessage(byte[] payload, ServerWebSocket socket, String remoteAddress,
  292 + IotWorkerTaskQueue queue) {
  293 + // 1. 未认证的 binary 帧:断开连接
  294 + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
  295 + if (connectionInfo == null) {
  296 + log.warn("[processRawMessage][未认证的 binary 帧,断开连接,地址: {}]", remoteAddress);
  297 + socket.close();
  298 + return;
  299 + }
  300 +
  301 + // 2. 交给本连接的 worker 队列:截止时间从收到(入队之前)开始计算
  302 + long receivedNanos = System.nanoTime();
  303 + queue.submit(() -> deviceMessageService.sendRawMessage(payload, connectionInfo.getProductKey(),
  304 + connectionInfo.getDeviceName(), serverId, IotProtocolTypeEnum.WEBSOCKET, receivedNanos)).onFailure(e -> {
  305 + if (e instanceof RejectedExecutionException) { // 队列已满:断开连接,由设备重连
  306 + log.error("[processRawMessage][透传上报积压,断开连接,地址: {},错误: {}]", remoteAddress, e.getMessage());
  307 + socket.close();
  308 + return;
  309 + }
  310 + log.warn("[processRawMessage][透传上报处理失败,地址: {},数据长度: {} 字节,错误: {}]",
  311 + remoteAddress, payload.length, e.getMessage());
  312 + });
224 313 }
225 314  
226 315 /**
... ... @@ -256,22 +345,30 @@ public class IotWebSocketUpstreamHandler implements Handler&lt;ServerWebSocket&gt; {
256 345 * 清理连接
257 346 *
258 347 * @param socket WebSocket 连接
  348 + * @param queue 本连接的 worker 队列
259 349 */
260   - private void cleanupConnection(ServerWebSocket socket) {
261   - try {
262   - // 1. 发送离线消息(如果已认证)
263   - IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
264   - if (connectionInfo != null) {
265   - IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline();
266   - deviceMessageService.sendDeviceMessage(offlineMessage, connectionInfo.getProductKey(),
267   - connectionInfo.getDeviceName(), serverId);
268   - }
269   -
270   - // 2. 注销连接
271   - connectionManager.unregisterConnection(socket);
272   - } catch (Exception e) {
273   - log.error("[cleanupConnection][清理连接失败]", e);
  350 + private void cleanupConnection(ServerWebSocket socket, IotWorkerTaskQueue queue) {
  351 + // 1. 注销连接
  352 + IotWebSocketConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfo(socket);
  353 + connectionManager.unregisterConnection(socket);
  354 + if (connectionInfo == null) {
  355 + return;
274 356 }
  357 +
  358 + // 2. 发送离线消息(如果已认证):排在本连接已收到的业务消息、透传帧之后;执行时设备已在新连接上线,则不再发送
  359 + queue.submitLast(() -> {
  360 + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) {
  361 + return null;
  362 + }
  363 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateOffline(),
  364 + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId);
  365 + // 发送期间设备在新连接上线(它的上线消息可能先于离线消息):补发上线消息,避免在线设备被标记为离线
  366 + if (connectionManager.getConnectionInfoByDeviceId(connectionInfo.getDeviceId()) != null) {
  367 + deviceMessageService.sendDeviceMessage(IotDeviceMessage.buildStateUpdateOnline(),
  368 + connectionInfo.getProductKey(), connectionInfo.getDeviceName(), serverId);
  369 + }
  370 + return null;
  371 + }).onFailure(e -> log.error("[cleanupConnection][清理连接失败]", e));
275 372 }
276 373  
277 374 // ===================== 发送响应消息 =====================
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager;
2 2  
  3 +import io.vertx.core.Future;
  4 +import io.vertx.core.buffer.Buffer;
3 5 import io.vertx.core.http.ServerWebSocket;
4 6 import lombok.Data;
5 7 import lombok.experimental.Accessors;
... ... @@ -10,6 +12,9 @@ import java.util.List;
10 12 import java.util.Map;
11 13 import java.util.concurrent.ConcurrentHashMap;
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 +
13 18 /**
14 19 * IoT 网关 WebSocket 连接管理器
15 20 * <p>
... ... @@ -121,6 +126,34 @@ public class IotWebSocketConnectionManager {
121 126 }
122 127  
123 128 /**
  129 + * 异步发送 binary 消息到设备:返回 writeBinaryMessage 真实的写出结果(透传下发使用)
  130 + * <p>
  131 + * 成功只表示本地写出完成,不代表设备已收到或已执行;设备未连接或写出失败时,返回 ServiceException(DEVICE_OFFLINE_OR_WRITE_FAILED)
  132 + *
  133 + * @param deviceId 设备 ID
  134 + * @param data 原始字节
  135 + * @return 写出结果
  136 + */
  137 + public Future<Void> sendBinaryToDeviceAsync(Long deviceId, byte[] data) {
  138 + ServerWebSocket socket = deviceSocketMap.get(deviceId);
  139 + if (socket == null) {
  140 + log.warn("[sendBinaryToDeviceAsync][设备未连接,设备 ID: {}]", deviceId);
  141 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  142 + }
  143 +
  144 + try {
  145 + return socket.writeBinaryMessage(Buffer.buffer(data))
  146 + .recover(e -> {
  147 + log.warn("[sendBinaryToDeviceAsync][发送消息失败,设备 ID: {},错误: {}]", deviceId, e.getMessage());
  148 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  149 + });
  150 + } catch (Exception e) {
  151 + log.error("[sendBinaryToDeviceAsync][发送消息异常,设备 ID: {}]", deviceId, e);
  152 + return Future.failedFuture(exception(DEVICE_OFFLINE_OR_WRITE_FAILED));
  153 + }
  154 + }
  155 +
  156 + /**
124 157 * 关闭所有连接
125 158 */
126 159 public void closeAll() {
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageService.java
1 1 package cn.iocoder.yudao.module.iot.gateway.service.device.message;
2 2  
  3 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
3 5 import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum;
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.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO;
5 9  
6 10 /**
7 11 * IoT 设备消息 Service 接口
... ... @@ -53,6 +57,8 @@ public interface IotDeviceMessageService {
53 57  
54 58 /**
55 59 * 发送消息
  60 + * <p>
  61 + * 设备经标准路径上报的消息:不接受透传方法;编号、时间由服务端生成,raw 由服务端清空(只有透传上行才可信赋值)
56 62 *
57 63 * @param message 消息
58 64 * @param productKey 产品 Key
... ... @@ -62,4 +68,68 @@ public interface IotDeviceMessageService {
62 68 void sendDeviceMessage(IotDeviceMessage message,
63 69 String productKey, String deviceName, String serverId);
64 70  
  71 + // DONE @AI:deadlineNanos 是不是收敛掉到 service 里?已收进来:协议只传入收到的时刻(入队之前取得),
  72 + // 截止 = 收到时刻 + gateway.script.deadline,由本方法计算;协议不再读取截止配置
  73 + /**
  74 + * 发送透传(raw)上行:与阿里云一致,按产品的数据解析脚本 decode 为一条标准消息;只投递一条消息
  75 + * <ul>
  76 + * <li>解析成功:投递解析出的标准消息,raw 字段带上原始字节;</li>
  77 + * <li>正常未解析(功能开关关闭、脚本停用 / 未配置、decode 返回 null):
  78 + * 投递一条 thing.raw.post,raw 字段带上原始字节;</li>
  79 + * <li>解析失败(已启用但内容为空、执行失败、超时、繁忙、输出不合法、超过截止):不投递,只记错误日志,抛出对应的错误码。</li>
  80 + * </ul>
  81 + * 截止时间 = receivedNanos + gateway.script.deadline,覆盖排队、执行脚本,直到投递。
  82 + * 会阻塞(加载设备信息、执行脚本、发送总线):只能在 worker 线程调用,不能在 event loop 上调用
  83 + *
  84 + * @param payload 原始字节
  85 + * @param productKey 产品 Key
  86 + * @param deviceName 设备名称
  87 + * @param serverId 设备连接的 serverId
  88 + * @param protocol 协议类型
  89 + * @param receivedNanos 协议收到透传上报的时刻(System.nanoTime):在交给 worker 排队之前取得,排队的时间也计入截止
  90 + * @return 投递的消息编号
  91 + * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 载荷长度不合法(不投递);
  92 + * 已启用但内容为空、脚本失败、超时、繁忙、输出不合法、超过截止(都不投递)
  93 + */
  94 + String sendRawMessage(byte[] payload, String productKey, String deviceName, String serverId,
  95 + IotProtocolTypeEnum protocol, long receivedNanos);
  96 +
  97 + // DONE @AI:deadlineNanos 是不是收敛掉到 service 里?已收进来:下行订阅者只传入收到指令的时刻(按设备排队之前取得),
  98 + // 截止 = 收到时刻 + gateway.script.deadline,由本方法计算;订阅者不再读取截止配置
  99 + /**
  100 + * 下行指令(property.set / service.invoke)按产品的数据解析脚本 encode:决定走标准路径,还是透传写出 encode 的字节;
  101 + * 由协议的下行订阅者按结果下发(本方法不写设备)
  102 + * <ul>
  103 + * <li>设备不存在(或查询失败)、产品脚本停用 / 未配置:走标准路径;</li>
  104 + * <li>不能下发(脚本已启用但内容为空、执行失败、输出不合法、没有输出、超过截止):只记日志,返回 null,不写设备,也不改走标准路径。</li>
  105 + * </ul>
  106 + * 只在功能开关开启、协议支持透传下发时调用。截止时间 = receivedNanos + gateway.script.deadline,覆盖排队、执行脚本,
  107 + * 直到开始写设备:encode 之后、返回之前再核对一次,调用方拿到结果立即下发。
  108 + * 会阻塞(加载设备信息、执行脚本):只能在 worker 线程调用
  109 + *
  110 + * @param message 下行指令
  111 + * @param protocol 协议类型:encode 的入参与字节规则按它区分
  112 + * @param receivedNanos 下行订阅者收到指令的时刻(System.nanoTime):在按设备排队之前取得,排队的时间也计入截止
  113 + * @return encode 的结果;null 表示不下发
  114 + */
  115 + IotDownstreamEncodeRespDTO encodeDownstreamCommand(IotDeviceMessage message, IotProtocolTypeEnum protocol,
  116 + long receivedNanos);
  117 +
  118 + /**
  119 + * 回复透传下行(raw.push,或 encode 的指令)的设备实际响应:只投递一条回复,raw 字段带上响应字节
  120 + * <ul>
  121 + * <li>raw.push:直接回复,code / msg 取协议判定的设备错误(没有为成功);</li>
  122 + * <li>encode 的指令:用 encode 时固定的脚本执行 decode,得到与原请求一致的一条回复;期间脚本被停用、解析失败只记日志。</li>
  123 + * </ul>
  124 + * 只用于设备的实际响应:网关判定的失败(超时、断线、写失败等)由协议只记日志,不调用本方法。
  125 + * 会阻塞(执行脚本、发送总线):只能在 worker 线程调用
  126 + *
  127 + * @param request 原请求
  128 + * @param source encode 时固定的脚本;raw.push 为 null
  129 + * @param payload 设备的响应字节
  130 + * @param deviceError 协议判定的设备错误(例如 Modbus 异常响应);正常响应为 null。只用于 raw.push 的回复
  131 + * @param serverId 所属协议实例的 serverId
  132 + */
  133 + void sendRawReply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError, String serverId);
  134 +
65 135 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImpl.java
... ... @@ -2,25 +2,44 @@ package cn.iocoder.yudao.module.iot.gateway.service.device.message;
2 2  
3 3 import cn.hutool.core.lang.Assert;
4 4 import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  6 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
5 7 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  8 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  9 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
6 10 import cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum;
7 11 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
8 12 import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
  13 +import cn.iocoder.yudao.module.iot.core.script.IotScriptException;
  14 +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor;
  15 +import cn.iocoder.yudao.module.iot.core.script.IotScriptUtils;
  16 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult;
  17 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource;
  18 +import cn.iocoder.yudao.module.iot.core.script.rhino.IotRhinoScriptExecutor;
9 19 import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  20 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
10 21 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer;
11 22 import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager;
12 23 import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
  24 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO;
  25 +import jakarta.annotation.PostConstruct;
  26 +import jakarta.annotation.PreDestroy;
13 27 import jakarta.annotation.Resource;
14 28 import lombok.extern.slf4j.Slf4j;
15 29 import org.springframework.stereotype.Service;
16 30  
17 31 import java.time.LocalDateTime;
18 32  
  33 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS;
19 34 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
20   -import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_NOT_EXISTS;
  35 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
  36 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*;
21 37  
22 38 /**
23 39 * IoT 设备消息 Service 实现类
  40 + * <p>
  41 + * 透传(raw)上行、下行指令的 encode、透传回复会阻塞(加载设备信息、执行脚本、发送总线),
  42 + * 由各协议在自己的 Vert.x worker 上调用;本类只做编排,不切线程,也不写设备(下行由协议的订阅者按 encode 结果下发)
24 43 *
25 44 * @author 芋道源码
26 45 */
... ... @@ -37,6 +56,31 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
37 56 @Resource
38 57 private IotMessageSerializerManager messageSerializerManager;
39 58  
  59 + @Resource
  60 + private IotGatewayProperties gatewayProperties;
  61 +
  62 + /**
  63 + * 数据解析脚本执行器:JS 线程池与计时器(执行预算兜底、卡住扫描)由它自己创建和关闭
  64 + */
  65 + private IotScriptExecutor scriptExecutor;
  66 +
  67 + @PostConstruct
  68 + public void init() {
  69 + IotGatewayProperties.ScriptProperties script = gatewayProperties.getScript();
  70 + scriptExecutor = new IotRhinoScriptExecutor(new IotRhinoScriptExecutor.Options()
  71 + .setExecBudgetMillis(script.getExecBudget().toMillis())
  72 + .setMaxInstructions(script.getMaxInstructions()));
  73 + // 功能开关关闭:不预热 JS 线程
  74 + if (script.isEnabled()) {
  75 + scriptExecutor.warmUp();
  76 + }
  77 + }
  78 +
  79 + @PreDestroy
  80 + public void destroy() {
  81 + scriptExecutor.shutdown();
  82 + }
  83 +
40 84 @Override
41 85 public byte[] serializeDeviceMessage(IotDeviceMessage message,
42 86 String productKey, String deviceName) {
... ... @@ -97,32 +141,258 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService {
97 141 @Override
98 142 public void sendDeviceMessage(IotDeviceMessage message,
99 143 String productKey, String deviceName, String serverId) {
100   - // 1. 获取设备信息
101   - IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName);
102   - if (device == null) {
103   - throw exception(DEVICE_NOT_EXISTS, productKey, deviceName);
  144 + // 0. 标准 JSON 路径不接受透传方法:thing.raw.* 只能由网关的透传入口创建
  145 + if (IotDeviceMessageUtils.isRawMethod(message.getMethod())) {
  146 + throw exception(DEVICE_MESSAGE_RAW_METHOD_RESERVED);
104 147 }
  148 + // 1. 获取设备信息
  149 + IotDeviceRespDTO device = getDevice(productKey, deviceName);
105 150  
106   - // 2. 发送消息
107   - appendDeviceMessage(message, device, serverId);
108   - deviceMessageProducer.sendDeviceMessage(message);
  151 + // 2. 发送消息:raw 只能由服务端可信赋值,设备自己带上的一律清空
  152 + message.setRaw(null);
  153 + sendDeviceMessage(message, device, serverId);
109 154 }
110 155  
111 156 /**
112   - * 补充消息的后端字段
  157 + * 发送消息:编号、时间由服务端生成,身份由可信的 device 注入;不清空 raw(调用方是网关的可信路径)
113 158 *
114 159 * @param message 消息
115 160 * @param device 设备信息
116 161 * @param serverId 设备连接的 serverId
117 162 */
118   - private void appendDeviceMessage(IotDeviceMessage message,
119   - IotDeviceRespDTO device, String serverId) {
  163 + private void sendDeviceMessage(IotDeviceMessage message, IotDeviceRespDTO device, String serverId) {
120 164 message.setId(IotDeviceMessageUtils.generateMessageId()).setReportTime(LocalDateTime.now())
121 165 .setDeviceId(device.getId()).setTenantId(device.getTenantId()).setServerId(serverId);
122 166 // 特殊:如果设备没有指定 requestId,则使用 messageId
123 167 if (StrUtil.isEmpty(message.getRequestId())) {
124 168 message.setRequestId(message.getId());
125 169 }
  170 + deviceMessageProducer.sendDeviceMessage(message);
  171 + }
  172 +
  173 + // ========== 透传上行 ==========
  174 +
  175 + @Override
  176 + public String sendRawMessage(byte[] payload, String productKey, String deviceName, String serverId,
  177 + IotProtocolTypeEnum protocol, long receivedNanos) {
  178 + // 截止从协议收到时开始计算:排队的时间也计入
  179 + long deadlineNanos = buildDeadlineNanos(receivedNanos);
  180 + // 1.1 校验载荷长度
  181 + if (payload == null || payload.length < 1 || payload.length > IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES) {
  182 + throw invalidParamException("raw 载荷长度必须在 1~{} 字节之间", IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES);
  183 + }
  184 + // 1.2 获取设备信息
  185 + IotDeviceRespDTO device = getDevice(productKey, deviceName);
  186 + String raw = IotDeviceMessageUtils.encodeRaw(payload);
  187 + // 1.3 排队已超过截止:不执行脚本,不投递,只记错误日志
  188 + if (System.nanoTime() >= deadlineNanos) {
  189 + log.error("[sendRawMessage][设备({}/{}) 排队超过截止时间,不投递]", productKey, deviceName);
  190 + throw exception(SCRIPT_TIMEOUT);
  191 + }
  192 +
  193 + // 2.1 功能开关关闭:不执行脚本,只记录原始消息
  194 + if (!gatewayProperties.getScript().isEnabled()) {
  195 + return sendRawPostMessage(device, serverId, raw);
  196 + }
  197 + // 2.2 产品脚本(随设备信息缓存):停用或从未配置时只记录原始消息
  198 + if (!Boolean.TRUE.equals(device.getScriptEnabled())) {
  199 + return sendRawPostMessage(device, serverId, raw);
  200 + }
  201 + // 2.3 已启用但内容为空(配置不合法):不投递,只记错误日志
  202 + if (StrUtil.isBlank(device.getScriptContent())) {
  203 + log.error("[sendRawMessage][设备({}/{}) 数据解析脚本已启用但内容为空,不投递]", productKey, deviceName);
  204 + throw exception(SCRIPT_EXECUTE_FAILED, "已启用但脚本内容为空");
  205 + }
  206 +
  207 + // 3.1 执行 decode:失败时不投递,只记错误日志
  208 + IotScriptSource source = IotScriptSource.of(device.getProductId(), device.getScriptContent());
  209 + String ctx = IotScriptUtils.buildDecodeContext(protocol.getType(), device.getProductKey(),
  210 + device.getDeviceName(), System.currentTimeMillis(), payload, null);
  211 + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_DECODE, ctx, deadlineNanos);
  212 + if (!result.isSuccess()) {
  213 + log.error("[sendRawMessage][设备({}/{}) 脚本执行失败({}):{},不投递]", productKey, deviceName,
  214 + result.getStatus(), result.getMessage());
  215 + throw scriptException(result.getStatus(), result.getMessage());
  216 + }
  217 + // 3.2 校验输出:不合法时不投递,只记错误日志
  218 + IotDeviceMessage message;
  219 + try {
  220 + message = IotScriptUtils.validateDecode(result.getJson(), null);
  221 + } catch (IotScriptException e) {
  222 + log.error("[sendRawMessage][设备({}/{}) 脚本输出不合法:{},不投递]", productKey, deviceName, e.getMessage());
  223 + throw exception(SCRIPT_OUTPUT_INVALID, StrUtil.maxLength(e.getMessage(), 200));
  224 + }
  225 + // 3.3 投递之前核对截止:迟到的结果(包括 decode 返回 null)不投递,只记错误日志
  226 + if (System.nanoTime() >= deadlineNanos) {
  227 + log.error("[sendRawMessage][设备({}/{}) 脚本完成时已超过截止时间,不投递]", productKey, deviceName);
  228 + throw exception(SCRIPT_TIMEOUT);
  229 + }
  230 + // 3.4 decode 返回 null:没有业务消息,只记录原始消息
  231 + if (message == null) {
  232 + return sendRawPostMessage(device, serverId, raw);
  233 + }
  234 +
  235 + // 4. 投递解析出的业务消息,带上原始字节
  236 + message.setRaw(raw);
  237 + sendDeviceMessage(message, device, serverId);
  238 + return message.getId();
  239 + }
  240 +
  241 + /**
  242 + * 投递一条没有解析为业务消息的原始消息 thing.raw.post:只带原始帧
  243 + *
  244 + * @return 消息编号
  245 + */
  246 + private String sendRawPostMessage(IotDeviceRespDTO device, String serverId, String raw) {
  247 + IotDeviceMessage message = IotDeviceMessage.of(null, IotDeviceMessageMethodEnum.RAW_POST.getMethod(),
  248 + null, null, null, null).setRaw(raw);
  249 + sendDeviceMessage(message, device, serverId);
  250 + return message.getId();
  251 + }
  252 +
  253 + // ========== 下行指令 ==========
  254 +
  255 + @Override
  256 + public IotDownstreamEncodeRespDTO encodeDownstreamCommand(IotDeviceMessage message, IotProtocolTypeEnum protocol,
  257 + long receivedNanos) {
  258 + // 截止从订阅者收到指令时开始计算:排队的时间也计入
  259 + long deadlineNanos = buildDeadlineNanos(receivedNanos);
  260 + // 1.1 获取设备:不存在(或查询失败)时与未启用脚本一致,走标准路径
  261 + IotDeviceRespDTO device = getDeviceQuietly(message.getDeviceId());
  262 + if (device == null) {
  263 + return IotDownstreamEncodeRespDTO.standard();
  264 + }
  265 + // 1.2 产品脚本(随设备信息缓存):停用或从未配置,与未启用脚本一致;已启用但内容为空,不写设备,也不改发 JSON
  266 + if (!Boolean.TRUE.equals(device.getScriptEnabled())) {
  267 + return IotDownstreamEncodeRespDTO.standard();
  268 + }
  269 + if (StrUtil.isBlank(device.getScriptContent())) {
  270 + log.warn("[encodeDownstreamCommand][下行({}) 数据解析脚本已启用但内容为空,不写设备]", message.getRequestId());
  271 + return null;
  272 + }
  273 +
  274 + // 2.1 执行 encode
  275 + IotScriptSource source = IotScriptSource.of(device.getProductId(), device.getScriptContent());
  276 + String ctx = IotScriptUtils.buildEncodeContext(protocol.getType(), device.getProductKey(),
  277 + device.getDeviceName(), message);
  278 + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_ENCODE, ctx, deadlineNanos);
  279 + if (!result.isSuccess()) {
  280 + log.warn("[encodeDownstreamCommand][下行({}) 脚本执行失败({}):{}]", message.getRequestId(),
  281 + result.getStatus(), result.getMessage());
  282 + return null;
  283 + }
  284 + // 2.2 校验输出:结构,以及字节是否符合协议的规则(Modbus 必须是合法的请求 PDU)
  285 + IotScriptUtils.EncodeOutput output;
  286 + try {
  287 + output = IotScriptUtils.validateEncode(result.getJson(), protocol);
  288 + } catch (IotScriptException e) {
  289 + log.warn("[encodeDownstreamCommand][下行({}) 脚本输出不合法:{}]", message.getRequestId(), e.getMessage());
  290 + return null;
  291 + }
  292 + if (output == null) {
  293 + log.warn("[encodeDownstreamCommand][下行({}) 脚本未生成下行数据,不写设备]", message.getRequestId());
  294 + return null;
  295 + }
  296 +
  297 + // 3. 下发之前核对截止:迟到的结果不写设备,也不改走标准路径
  298 + if (System.nanoTime() >= deadlineNanos) {
  299 + log.warn("[encodeDownstreamCommand][下行({}) 超过截止,不写设备]", message.getRequestId());
  300 + return null;
  301 + }
  302 + return output.isStandard() ? IotDownstreamEncodeRespDTO.standard()
  303 + : IotDownstreamEncodeRespDTO.raw(output.getBytes(), source);
  304 + }
  305 +
  306 + // ========== 透传回复 ==========
  307 +
  308 + @Override
  309 + public void sendRawReply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError, String serverId) {
  310 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(request.getDeviceId());
  311 + if (device == null) {
  312 + log.warn("[sendRawReply][设备({}) 不存在]", request.getDeviceId());
  313 + return;
  314 + }
  315 + // 情况一:raw.push,直接回复设备的实际响应;结果码由协议判定(例如 Modbus 异常响应)
  316 + if (source == null) {
  317 + ErrorCode code = deviceError != null ? deviceError : SUCCESS;
  318 + IotDeviceMessage reply = IotDeviceMessage.replyOf(request.getRequestId(), request.getMethod(), null,
  319 + code.getCode(), code.getMsg()).setRaw(IotDeviceMessageUtils.encodeRaw(payload));
  320 + sendDeviceMessage(reply, device, serverId);
  321 + return;
  322 + }
  323 +
  324 + // 情况二:encode 的指令,用 encode 时固定的脚本执行 decode;功能开关已关闭,或设备缓存里已看到脚本停用时,不解析响应
  325 + if (!gatewayProperties.getScript().isEnabled()) {
  326 + log.warn("[sendRawReply][下行({}) 期间数据解析脚本功能已关闭,不解析响应]", request.getRequestId());
  327 + return;
  328 + }
  329 + if (!Boolean.TRUE.equals(device.getScriptEnabled())) {
  330 + log.warn("[sendRawReply][下行({}) 期间数据解析脚本已停用,不解析响应]", request.getRequestId());
  331 + return;
  332 + }
  333 + long deadlineNanos = buildDeadlineNanos(System.nanoTime());
  334 + String ctx = IotScriptUtils.buildDecodeContext(device.getProtocolType(), device.getProductKey(),
  335 + device.getDeviceName(), System.currentTimeMillis(), payload, request);
  336 + IotScriptResult result = scriptExecutor.execute(source, IotScriptUtils.FUNCTION_DECODE, ctx, deadlineNanos);
  337 + if (!result.isSuccess()) {
  338 + log.warn("[sendRawReply][下行({}) 脚本执行失败({}):{}]", request.getRequestId(),
  339 + result.getStatus(), result.getMessage());
  340 + return;
  341 + }
  342 + IotDeviceMessage reply;
  343 + try {
  344 + reply = IotScriptUtils.validateDecode(result.getJson(), request);
  345 + } catch (IotScriptException e) {
  346 + log.warn("[sendRawReply][下行({}) 脚本输出不合法:{}]", request.getRequestId(), e.getMessage());
  347 + return;
  348 + }
  349 + reply.setRaw(IotDeviceMessageUtils.encodeRaw(payload));
  350 + sendDeviceMessage(reply, device, serverId);
  351 + }
  352 +
  353 + // ========== 工具方法 ==========
  354 +
  355 + /**
  356 + * 计算截止时间(System.nanoTime):开始时刻 + gateway.script.deadline
  357 + *
  358 + * @param startNanos 开始计时的时刻(System.nanoTime)
  359 + */
  360 + private long buildDeadlineNanos(long startNanos) {
  361 + return startNanos + gatewayProperties.getScript().getDeadline().toNanos();
  362 + }
  363 +
  364 + private IotDeviceRespDTO getDevice(String productKey, String deviceName) {
  365 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName);
  366 + if (device == null) {
  367 + throw exception(DEVICE_NOT_EXISTS, productKey, deviceName);
  368 + }
  369 + return device;
  370 + }
  371 +
  372 + /**
  373 + * 下行获取设备:查询失败时按设备不存在处理(走标准路径,由标准路径自己处理),不丢弃指令
  374 + */
  375 + private IotDeviceRespDTO getDeviceQuietly(Long deviceId) {
  376 + try {
  377 + return deviceService.getDeviceFromCache(deviceId);
  378 + } catch (RuntimeException e) {
  379 + log.warn("[getDeviceQuietly][设备({}) 查询失败,按标准路径下发]", deviceId, e);
  380 + return null;
  381 + }
  382 + }
  383 +
  384 + private static ServiceException scriptException(IotScriptResult.Status status, String detail) {
  385 + detail = StrUtil.maxLength(StrUtil.nullToEmpty(detail), 200);
  386 + switch (status) {
  387 + case TIMEOUT:
  388 + return exception(SCRIPT_TIMEOUT);
  389 + case REJECTED:
  390 + return exception(SCRIPT_REJECTED);
  391 + case OUTPUT_INVALID:
  392 + return exception(SCRIPT_OUTPUT_INVALID, detail);
  393 + default:
  394 + return exception(SCRIPT_EXECUTE_FAILED, detail);
  395 + }
126 396 }
127 397  
128 398 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/dto/IotDownstreamEncodeRespDTO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.service.device.message.dto;
  2 +
  3 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource;
  4 +import lombok.AccessLevel;
  5 +import lombok.AllArgsConstructor;
  6 +import lombok.Getter;
  7 +
  8 +/**
  9 + * 下行指令按数据解析脚本 encode 的结果:走标准路径,或者透传写出 encode 的字节;由协议的下行订阅者执行
  10 + *
  11 + * @author 芋道源码
  12 + */
  13 +@Getter
  14 +@AllArgsConstructor(access = AccessLevel.PRIVATE)
  15 +public class IotDownstreamEncodeRespDTO {
  16 +
  17 + private static final IotDownstreamEncodeRespDTO STANDARD = new IotDownstreamEncodeRespDTO(true, null, null);
  18 +
  19 + /**
  20 + * 是否走标准路径:设备不存在、产品脚本停用 / 未配置,或 encode 返回 standard
  21 + */
  22 + private final boolean standard;
  23 + /**
  24 + * 透传写出的字节;走标准路径时为 null
  25 + */
  26 + private final byte[] bytes;
  27 + /**
  28 + * encode 时使用的脚本(固定 hash):协议回复设备的实际响应时用它 decode;走标准路径时为 null
  29 + */
  30 + private final IotScriptSource source;
  31 +
  32 + public static IotDownstreamEncodeRespDTO standard() {
  33 + return STANDARD;
  34 + }
  35 +
  36 + public static IotDownstreamEncodeRespDTO raw(byte[] bytes, IotScriptSource source) {
  37 + return new IotDownstreamEncodeRespDTO(false, bytes, source);
  38 + }
  39 +
  40 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java
... ... @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.util;
2 2  
3 3 import cn.hutool.core.util.StrUtil;
4 4 import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
  5 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
5 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 7  
7 8 /**
... ... @@ -141,4 +142,53 @@ public final class IotMqttTopicUtils {
141 142 return topic.startsWith(deviceTopicPrefix);
142 143 }
143 144  
  145 + // ========== 透传主题 ==========
  146 +
  147 + /**
  148 + * 是否为透传上报主题:/sys/{productKey}/{deviceName}/thing/raw/post
  149 + * <p>
  150 + * 只判断主题格式;设备身份由调用方与已认证的连接(或 EMQX ACL)比对
  151 + *
  152 + * @param topic 主题
  153 + * @return 是否为透传上报主题
  154 + */
  155 + public static boolean isRawPostTopic(String topic) {
  156 + return isRawTopic(topic, IotDeviceMessageMethodEnum.RAW_POST);
  157 + }
  158 +
  159 + /**
  160 + * 是否为透传下发主题:/sys/{productKey}/{deviceName}/thing/raw/push
  161 + * <p>
  162 + * 网关发布 raw 下行使用;EMQX 网关订阅到自己发布的该主题时需要忽略(回环)
  163 + *
  164 + * @param topic 主题
  165 + * @return 是否为透传下发主题
  166 + */
  167 + public static boolean isRawPushTopic(String topic) {
  168 + return isRawTopic(topic, IotDeviceMessageMethodEnum.RAW_PUSH);
  169 + }
  170 +
  171 + /**
  172 + * 构建透传下发主题:/sys/{productKey}/{deviceName}/thing/raw/push
  173 + *
  174 + * @param productKey 产品 Key
  175 + * @param deviceName 设备名称
  176 + * @return 透传下发主题
  177 + */
  178 + public static String buildRawPushTopic(String productKey, String deviceName) {
  179 + return buildTopicByMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), productKey, deviceName, false);
  180 + }
  181 +
  182 + private static boolean isRawTopic(String topic, IotDeviceMessageMethodEnum method) {
  183 + if (StrUtil.isBlank(topic)) {
  184 + return false;
  185 + }
  186 + // 格式必须是 /sys/{productKey}/{deviceName}/thing/raw/{post|push}:productKey、deviceName 各占一段且非空
  187 + String[] parts = topic.split("/", -1);
  188 + if (parts.length != 7 || StrUtil.hasBlank(parts[2], parts[3])) {
  189 + return false;
  190 + }
  191 + return topic.equals(buildTopicByMethod(method.getMethod(), parts[2], parts[3], false));
  192 + }
  193 +
144 194 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueue.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.util;
  2 +
  3 +import io.vertx.core.Context;
  4 +import io.vertx.core.Future;
  5 +import lombok.Getter;
  6 +
  7 +import java.util.concurrent.Callable;
  8 +import java.util.concurrent.RejectedExecutionException;
  9 +
  10 +/**
  11 + * 阻塞任务队列:任务在 Vert.x worker 上执行,结果回到所在的 context(event loop)
  12 + * <ul>
  13 + * <li>有序(一个连接一个):按提交顺序逐个执行、逐个完成(MQTT 的 PUBACK 顺序、TCP / WebSocket 的帧顺序);
  14 + * 不同连接各自一个队列,互不等待,并行使用 worker 池;</li>
  15 + * <li>无序(UDP、EMQX 这类没有连接的入口):只限制排队数量,任务之间并行;</li>
  16 + * <li>有界:排队中的任务达到上限时拒绝,由调用方决定(例如关闭连接、丢弃)。</li>
  17 + * </ul>
  18 + * 只能在所在的 context 线程上调用(Vert.x 的处理器都在这个线程上),所以不需要加锁
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +public class IotWorkerTaskQueue {
  23 +
  24 + /**
  25 + * 默认每个连接最多排队的任务数
  26 + */
  27 + public static final int DEFAULT_MAX_PENDING = 256;
  28 +
  29 + @Getter
  30 + private final Context context;
  31 + private final boolean ordered;
  32 + private final int maxPending;
  33 +
  34 + /**
  35 + * 最后一个任务的结果:新任务排在它之后
  36 + */
  37 + private Future<?> tail = Future.succeededFuture();
  38 + /**
  39 + * 排队中(含执行中)的任务数
  40 + */
  41 + private int pending;
  42 + /**
  43 + * 是否已关闭:连接关闭后不再接收新任务
  44 + */
  45 + private boolean closed;
  46 +
  47 + /**
  48 + * 创建一个连接的有序队列
  49 + *
  50 + * @param context 连接所在的 context
  51 + */
  52 + public IotWorkerTaskQueue(Context context) {
  53 + this(context, true, DEFAULT_MAX_PENDING);
  54 + }
  55 +
  56 + public IotWorkerTaskQueue(Context context, boolean ordered, int maxPending) {
  57 + this.context = context;
  58 + this.ordered = ordered;
  59 + this.maxPending = maxPending;
  60 + }
  61 +
  62 + /**
  63 + * 提交一个阻塞任务:有序时排在之前的任务之后执行(前一个任务失败也照常执行)
  64 + *
  65 + * @param task 阻塞任务
  66 + * @return 任务结果,在连接所在的 context 上完成;队列已满或已关闭时立即失败({@link RejectedExecutionException})
  67 + */
  68 + public <T> Future<T> submit(Callable<T> task) {
  69 + if (closed) {
  70 + return Future.failedFuture(new RejectedExecutionException("连接已关闭"));
  71 + }
  72 + if (pending >= maxPending) {
  73 + return Future.failedFuture(new RejectedExecutionException("连接的待处理消息已达上限 " + maxPending));
  74 + }
  75 + return enqueue(task);
  76 + }
  77 +
  78 + /**
  79 + * 提交连接的最后一个任务(例如离线通知):不受上限限制,之后不再接收新任务
  80 + *
  81 + * @param task 阻塞任务
  82 + * @return 任务结果
  83 + */
  84 + public <T> Future<T> submitLast(Callable<T> task) {
  85 + if (closed) {
  86 + return Future.failedFuture(new RejectedExecutionException("连接已关闭"));
  87 + }
  88 + closed = true;
  89 + return enqueue(task);
  90 + }
  91 +
  92 + /**
  93 + * 入队:计入排队数,交给 worker 执行;任务结束(成功或失败)时减去
  94 + *
  95 + * @param task 阻塞任务
  96 + * @return 任务结果
  97 + */
  98 + private <T> Future<T> enqueue(Callable<T> task) {
  99 + pending++;
  100 + // 1. 无序:直接提交,任务之间并行
  101 + if (!ordered) {
  102 + Future<T> result;
  103 + try {
  104 + result = context.executeBlocking(task, false);
  105 + } catch (RuntimeException e) { // Vertx 关闭时 worker 池会同步拒绝
  106 + pending--;
  107 + return Future.failedFuture(e);
  108 + }
  109 + return result.andThen(ar -> pending--);
  110 + }
  111 + // 2. 有序:排在上一个任务(tail)之后,上一个结束(无论成败)再提交;
  112 + // executeBlocking 的 ordered = false:同一个 context 上的其它连接不受影响;本连接的顺序由 tail 串起来
  113 + Future<T> result = tail.transform(ignored -> context.<T>executeBlocking(task, false))
  114 + .andThen(ar -> pending--);
  115 + tail = result;
  116 + return result;
  117 + }
  118 +
  119 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriberTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol;
  2 +
  3 +import cn.hutool.extra.spring.SpringUtil;
  4 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  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.messagebus.core.IotMessageBus;
  9 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  10 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource;
  11 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  12 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  13 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO;
  14 +import io.vertx.core.Future;
  15 +import io.vertx.core.Vertx;
  16 +import org.junit.jupiter.api.AfterEach;
  17 +import org.junit.jupiter.api.BeforeEach;
  18 +import org.junit.jupiter.api.Test;
  19 +import org.mockito.ArgumentCaptor;
  20 +import org.mockito.InOrder;
  21 +import org.mockito.Mock;
  22 +import org.mockito.MockedStatic;
  23 +
  24 +import java.util.ArrayList;
  25 +import java.util.Arrays;
  26 +import java.util.Base64;
  27 +import java.util.Collections;
  28 +import java.util.List;
  29 +import java.util.concurrent.Callable;
  30 +import java.util.concurrent.CopyOnWriteArrayList;
  31 +import java.util.concurrent.CountDownLatch;
  32 +import java.util.concurrent.TimeUnit;
  33 +import java.util.function.BooleanSupplier;
  34 +
  35 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION;
  36 +import static org.junit.jupiter.api.Assertions.*;
  37 +import static org.mockito.AdditionalMatchers.aryEq;
  38 +import static org.mockito.ArgumentMatchers.any;
  39 +import static org.mockito.ArgumentMatchers.eq;
  40 +import static org.mockito.Mockito.*;
  41 +
  42 +/**
  43 + * {@link AbstractIotProtocolDownstreamSubscriber} 的单元测试
  44 + *
  45 + * @author 芋道源码
  46 + */
  47 +public class AbstractIotProtocolDownstreamSubscriberTest extends BaseMockitoUnitTest {
  48 +
  49 + private static final Long DEVICE_ID = 1L;
  50 + private static final String SERVER_ID = "server-1";
  51 +
  52 + @Mock
  53 + private IotProtocol protocol;
  54 + @Mock
  55 + private IotMessageBus messageBus;
  56 + @Mock
  57 + private Vertx vertx;
  58 + @Mock
  59 + private IotDeviceMessageService deviceMessageService;
  60 +
  61 + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties();
  62 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  63 + private TestSubscriber subscriber;
  64 +
  65 + @BeforeEach
  66 + public void setUp() {
  67 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  68 + lenient().when(protocol.getType()).thenReturn(IotProtocolTypeEnum.TCP);
  69 + subscriber = new TestSubscriber(protocol, messageBus);
  70 + }
  71 +
  72 + @AfterEach
  73 + public void tearDown() {
  74 + springUtilMockedStatic.close();
  75 + }
  76 +
  77 + // ========== onMessage:raw.push ==========
  78 +
  79 + @Test
  80 + public void testOnMessage_rawPush() {
  81 + // 准备参数:字节在 raw 字段;功能开关关闭时也照常写出
  82 + byte[] bytes = new byte[]{0x01, 0x02, (byte) 0xFF};
  83 + IotDeviceMessage message = buildRawPush(Base64.getEncoder().encodeToString(bytes));
  84 +
  85 + // 调用
  86 + subscriber.onMessage(message);
  87 +
  88 + // 断言:从 raw 解码后直接交给协议写出,不经过脚本、不读开关
  89 + assertEquals(1, subscriber.rawMessages.size());
  90 + assertSame(message, subscriber.rawMessages.get(0));
  91 + assertArrayEquals(bytes, subscriber.rawBytes.get(0));
  92 + assertNull(subscriber.rawSources.get(0));
  93 + assertTrue(subscriber.handledMessages.isEmpty());
  94 + springUtilMockedStatic.verifyNoInteractions();
  95 + verify(protocol, never()).getVertx();
  96 + }
  97 +
  98 + @Test
  99 + public void testOnMessage_rawPushPayloadLimit() {
  100 + // 调用:4097 字节、4096 字节(公共层只按通用上限解码,协议自己的字节规则由协议写出前校验)
  101 + subscriber.onMessage(buildRawPush(Base64.getEncoder().encodeToString(new byte[4097])));
  102 + subscriber.onMessage(buildRawPush(Base64.getEncoder().encodeToString(new byte[4096])));
  103 +
  104 + // 断言:超过上限的不写出(只记日志),不改走标准路径
  105 + assertEquals(1, subscriber.rawBytes.size());
  106 + assertEquals(4096, subscriber.rawBytes.get(0).length);
  107 + assertTrue(subscriber.handledMessages.isEmpty());
  108 + }
  109 +
  110 + @Test
  111 + public void testOnMessage_rawPushInvalidRaw() {
  112 + // 调用:raw 为空、不是合法的 Base64
  113 + subscriber.onMessage(buildRawPush(null));
  114 + subscriber.onMessage(buildRawPush("!!not-base64!!"));
  115 +
  116 + // 断言:不写出,也不改走标准路径
  117 + assertTrue(subscriber.rawMessages.isEmpty());
  118 + assertTrue(subscriber.handledMessages.isEmpty());
  119 + }
  120 +
  121 + @Test
  122 + public void testOnMessage_rawPushWriteFailureOnlyLog() {
  123 + // 准备参数:协议写出失败(设备离线返回 null、异步失败)
  124 + IotDeviceMessage message = buildRawPush(Base64.getEncoder().encodeToString(new byte[]{0x01}));
  125 +
  126 + // 调用,并断言:只记日志,不抛出
  127 + subscriber.rawResult = null;
  128 + assertDoesNotThrow(() -> subscriber.onMessage(message));
  129 + subscriber.rawResult = Future.failedFuture(new IllegalStateException("closed"));
  130 + assertDoesNotThrow(() -> subscriber.onMessage(message));
  131 + assertEquals(2, subscriber.rawMessages.size());
  132 + assertTrue(subscriber.handledMessages.isEmpty());
  133 + }
  134 +
  135 + @Test
  136 + public void testOnMessage_rawPushReply() {
  137 + // 准备参数:raw.push 的回复(带 code)不是下发
  138 + mockScriptProperties();
  139 + IotDeviceMessage reply = IotDeviceMessage.replyOf("r1", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(),
  140 + null, 0, null).setDeviceId(DEVICE_ID);
  141 +
  142 + // 调用
  143 + subscriber.onMessage(reply);
  144 +
  145 + // 断言:直接走标准路径
  146 + assertEquals(Collections.singletonList(reply), subscriber.handledMessages);
  147 + assertTrue(subscriber.rawMessages.isEmpty());
  148 + }
  149 +
  150 + // ========== onMessage:指令与标准路径 ==========
  151 +
  152 + @Test
  153 + public void testOnMessage_commandSwitchOff() {
  154 + // 准备参数:功能开关默认关闭
  155 + mockScriptProperties();
  156 + IotDeviceMessage message = buildPropertySet();
  157 +
  158 + // 调用
  159 + subscriber.onMessage(message);
  160 +
  161 + // 断言:与原来一致,直接走标准路径,不切 worker、不调用设备消息 Service
  162 + assertEquals(Collections.singletonList(message), subscriber.handledMessages);
  163 + verify(protocol, never()).getVertx();
  164 + verifyNoInteractions(deviceMessageService);
  165 + }
  166 +
  167 + @Test
  168 + public void testOnMessage_commandSwitchOn() {
  169 + // 准备参数
  170 + gatewayProperties.getScript().setEnabled(true);
  171 + mockScriptProperties();
  172 + mockDeviceMessageService();
  173 + mockExecuteBlocking();
  174 + IotDeviceMessage message = buildPropertySet();
  175 +
  176 + // 调用
  177 + long beginNanos = System.nanoTime();
  178 + subscriber.onMessage(message);
  179 + long endNanos = System.nanoTime();
  180 +
  181 + // 断言:切到协议的 worker,交给设备消息 Service 按协议 encode,带上收到指令的时刻
  182 + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class);
  183 + verify(deviceMessageService).encodeDownstreamCommand(same(message), eq(IotProtocolTypeEnum.TCP),
  184 + receivedCaptor.capture());
  185 + assertTrue(receivedCaptor.getValue() >= beginNanos);
  186 + assertTrue(receivedCaptor.getValue() <= endNanos);
  187 + assertTrue(subscriber.handledMessages.isEmpty());
  188 + assertTrue(subscriber.rawMessages.isEmpty());
  189 + }
  190 +
  191 + @Test
  192 + public void testOnMessage_commandEncodeResult() {
  193 + // 准备参数:encode 结果分别为标准路径、透传字节、不下发
  194 + gatewayProperties.getScript().setEnabled(true);
  195 + mockScriptProperties();
  196 + mockDeviceMessageService();
  197 + mockExecuteBlocking();
  198 + IotDeviceMessage standard = buildPropertySet("c1", DEVICE_ID);
  199 + IotDeviceMessage raw = buildPropertySet("c2", DEVICE_ID);
  200 + IotDeviceMessage notSend = buildPropertySet("c3", DEVICE_ID);
  201 + byte[] bytes = new byte[]{0x01, 0x02};
  202 + IotScriptSource source = IotScriptSource.of(100L, "function encode(ctx) {}");
  203 + when(deviceMessageService.encodeDownstreamCommand(any(), any(), anyLong())).thenAnswer(invocation -> {
  204 + IotDeviceMessage message = invocation.getArgument(0);
  205 + return message == standard ? IotDownstreamEncodeRespDTO.standard()
  206 + : message == raw ? IotDownstreamEncodeRespDTO.raw(bytes, source) : null;
  207 + });
  208 +
  209 + // 调用
  210 + subscriber.onMessage(standard);
  211 + subscriber.onMessage(raw);
  212 + subscriber.onMessage(notSend);
  213 +
  214 + // 断言:标准路径 → handleMessage;透传 → handleRawMessage,带上 encode 时固定的脚本;不下发 → 都不调用
  215 + assertEquals(Collections.singletonList(standard), subscriber.handledMessages);
  216 + assertEquals(Collections.singletonList(raw), subscriber.rawMessages);
  217 + assertSame(bytes, subscriber.rawBytes.get(0));
  218 + assertSame(source, subscriber.rawSources.get(0));
  219 + verify(deviceMessageService).encodeDownstreamCommand(same(notSend), any(), anyLong());
  220 + }
  221 +
  222 + @Test
  223 + public void testOnMessage_commandNotSupportRaw() {
  224 + // 准备参数:协议不支持透传下发(HTTP / CoAP)
  225 + gatewayProperties.getScript().setEnabled(true);
  226 + mockScriptProperties();
  227 + subscriber.supportsRaw = false;
  228 + IotDeviceMessage message = buildPropertySet();
  229 +
  230 + // 调用
  231 + subscriber.onMessage(message);
  232 +
  233 + // 断言:直接走标准路径
  234 + assertEquals(Collections.singletonList(message), subscriber.handledMessages);
  235 + verify(protocol, never()).getVertx();
  236 + verifyNoInteractions(deviceMessageService);
  237 + }
  238 +
  239 + @Test
  240 + public void testOnMessage_notCommand() {
  241 + // 准备参数:功能开关开启,但不是指令(配置推送、OTA、属性设置的回复)
  242 + gatewayProperties.getScript().setEnabled(true);
  243 + mockScriptProperties();
  244 + List<IotDeviceMessage> messages = new ArrayList<>();
  245 + messages.add(IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.CONFIG_PUSH.getMethod(),
  246 + Collections.emptyMap()).setDeviceId(DEVICE_ID));
  247 + messages.add(IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(),
  248 + Collections.emptyMap()).setDeviceId(DEVICE_ID));
  249 + messages.add(IotDeviceMessage.replyOf("r1", IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(),
  250 + null, 0, null).setDeviceId(DEVICE_ID));
  251 +
  252 + // 调用
  253 + messages.forEach(subscriber::onMessage);
  254 +
  255 + // 断言:都走标准路径
  256 + assertEquals(messages, subscriber.handledMessages);
  257 + verify(protocol, never()).getVertx();
  258 + verifyNoInteractions(deviceMessageService);
  259 + }
  260 +
  261 + @Test
  262 + public void testOnMessage_blankMethod() {
  263 + // 调用
  264 + subscriber.onMessage(new IotDeviceMessage().setDeviceId(DEVICE_ID));
  265 +
  266 + // 断言:直接忽略
  267 + assertTrue(subscriber.handledMessages.isEmpty());
  268 + assertTrue(subscriber.rawMessages.isEmpty());
  269 + springUtilMockedStatic.verifyNoInteractions();
  270 + }
  271 +
  272 + // ========== onMessage:同一设备的指令保序 ==========
  273 +
  274 + @Test
  275 + public void testOnMessage_commandsOrderedPerDevice() throws Exception {
  276 + // 准备参数:设备 1 的指令 c1、c2,encode 后走标准路径的 c3;设备 2 的指令 d1
  277 + gatewayProperties.getScript().setEnabled(true);
  278 + mockScriptProperties();
  279 + mockDeviceMessageService();
  280 + Vertx realVertx = Vertx.vertx();
  281 + when(protocol.getVertx()).thenReturn(realVertx);
  282 + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID);
  283 + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID);
  284 + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID);
  285 + IotDeviceMessage d1 = buildPropertySet("d1", 2L);
  286 + // mock 方法:c1 的 worker 阻塞,直到放行;记录执行顺序
  287 + List<String> order = new CopyOnWriteArrayList<>();
  288 + CountDownLatch entered = new CountDownLatch(1);
  289 + CountDownLatch release = new CountDownLatch(1);
  290 + CountDownLatch otherDevice = new CountDownLatch(1);
  291 + doAnswer(invocation -> {
  292 + IotDeviceMessage message = invocation.getArgument(0);
  293 + if (message == c1) {
  294 + entered.countDown();
  295 + assertTrue(release.await(10, TimeUnit.SECONDS));
  296 + }
  297 + if (message == d1) {
  298 + otherDevice.countDown();
  299 + }
  300 + order.add(message.getRequestId());
  301 + return message == c3 ? IotDownstreamEncodeRespDTO.standard() : null;
  302 + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong());
  303 +
  304 + try {
  305 + // 调用:c1 阻塞在 worker 上时,依次收到 c2、c3、d1
  306 + subscriber.onMessage(c1);
  307 + assertTrue(entered.await(5, TimeUnit.SECONDS));
  308 + long c2BeginNanos = System.nanoTime();
  309 + subscriber.onMessage(c2);
  310 + long c2QueuedNanos = System.nanoTime();
  311 + subscriber.onMessage(c3);
  312 + subscriber.onMessage(d1);
  313 +
  314 + // 断言:其它设备不受影响;同一设备的 c2、c3(含标准路径)排在 c1 之后,不越过它
  315 + assertTrue(otherDevice.await(5, TimeUnit.SECONDS));
  316 + assertFalse(order.contains("c2"));
  317 + assertFalse(order.contains("c3"));
  318 + assertTrue(subscriber.handledMessages.isEmpty());
  319 +
  320 + // 放行:按收到的顺序执行
  321 + release.countDown();
  322 + await(() -> subscriber.handledMessages.size() == 1);
  323 + List<String> deviceOrder = new ArrayList<>(order);
  324 + deviceOrder.remove("d1");
  325 + assertEquals(Arrays.asList("c1", "c2", "c3"), deviceOrder);
  326 + assertEquals(Collections.singletonList(c3), subscriber.handledMessages);
  327 + // 断言:c2 排在 c1 之后才执行,传给 service 的仍是收到时(排队之前)的时刻,排队的时间计入截止
  328 + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class);
  329 + verify(deviceMessageService).encodeDownstreamCommand(same(c2), eq(IotProtocolTypeEnum.TCP),
  330 + receivedCaptor.capture());
  331 + assertTrue(receivedCaptor.getValue() >= c2BeginNanos);
  332 + assertTrue(receivedCaptor.getValue() <= c2QueuedNanos);
  333 + } finally {
  334 + release.countDown();
  335 + realVertx.close();
  336 + }
  337 + }
  338 +
  339 + @Test
  340 + public void testOnMessage_commandFailureNotBlockNext() {
  341 + // 准备参数
  342 + gatewayProperties.getScript().setEnabled(true);
  343 + mockScriptProperties();
  344 + mockDeviceMessageService();
  345 + mockExecuteBlocking();
  346 + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID);
  347 + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID);
  348 + // mock 方法:c1 处理失败
  349 + doAnswer(invocation -> {
  350 + if (invocation.getArgument(0) == c1) {
  351 + throw new IllegalStateException("boom");
  352 + }
  353 + return IotDownstreamEncodeRespDTO.standard();
  354 + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong());
  355 +
  356 + // 调用
  357 + subscriber.onMessage(c1);
  358 + subscriber.onMessage(c2);
  359 +
  360 + // 断言:c1 失败只记日志,c2 照常执行
  361 + InOrder inOrder = inOrder(deviceMessageService);
  362 + inOrder.verify(deviceMessageService).encodeDownstreamCommand(same(c1), any(), anyLong());
  363 + inOrder.verify(deviceMessageService).encodeDownstreamCommand(same(c2), any(), anyLong());
  364 + assertEquals(Collections.singletonList(c2), subscriber.handledMessages);
  365 + // 断言:队列执行完后已移除,之后的指令新建队列照常执行
  366 + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID);
  367 + subscriber.onMessage(c3);
  368 + assertEquals(Arrays.asList(c2, c3), subscriber.handledMessages);
  369 + }
  370 +
  371 + @Test
  372 + public void testOnMessage_commandsCapacity() throws Exception {
  373 + // 准备参数
  374 + gatewayProperties.getScript().setEnabled(true);
  375 + mockScriptProperties();
  376 + mockDeviceMessageService();
  377 + Vertx realVertx = Vertx.vertx();
  378 + when(protocol.getVertx()).thenReturn(realVertx);
  379 + IotDeviceMessage first = buildPropertySet("first", DEVICE_ID);
  380 + IotDeviceMessage overflow = buildPropertySet("overflow", DEVICE_ID);
  381 + // mock 方法:第一条阻塞
  382 + CountDownLatch entered = new CountDownLatch(1);
  383 + CountDownLatch release = new CountDownLatch(1);
  384 + doAnswer(invocation -> {
  385 + if (invocation.getArgument(0) == first) {
  386 + entered.countDown();
  387 + assertTrue(release.await(10, TimeUnit.SECONDS));
  388 + }
  389 + return null;
  390 + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong());
  391 +
  392 + try {
  393 + // 调用:第一条执行中,再排 256 条(上限),第 257 条超出
  394 + subscriber.onMessage(first);
  395 + assertTrue(entered.await(5, TimeUnit.SECONDS));
  396 + for (int i = 0; i < 256; i++) {
  397 + subscriber.onMessage(buildPropertySet("q" + i, DEVICE_ID));
  398 + }
  399 + subscriber.onMessage(overflow);
  400 + release.countDown();
  401 +
  402 + // 断言:上限内的全部执行,超出的丢弃
  403 + verify(deviceMessageService, timeout(5000).times(257)).encodeDownstreamCommand(any(), any(), anyLong());
  404 + verify(deviceMessageService, never()).encodeDownstreamCommand(same(overflow), any(), anyLong());
  405 + } finally {
  406 + release.countDown();
  407 + realVertx.close();
  408 + }
  409 + }
  410 +
  411 + @Test
  412 + public void testOnMessage_commandsCapacityConfigured() throws Exception {
  413 + // 准备参数:每个设备最多排队 2 条
  414 + gatewayProperties.getScript().setEnabled(true).setMaxPendingCommandsPerDevice(2);
  415 + mockScriptProperties();
  416 + mockDeviceMessageService();
  417 + Vertx realVertx = Vertx.vertx();
  418 + when(protocol.getVertx()).thenReturn(realVertx);
  419 + IotDeviceMessage first = buildPropertySet("first", DEVICE_ID);
  420 + IotDeviceMessage overflow = buildPropertySet("overflow", DEVICE_ID);
  421 + // mock 方法:第一条阻塞
  422 + CountDownLatch entered = new CountDownLatch(1);
  423 + CountDownLatch release = new CountDownLatch(1);
  424 + doAnswer(invocation -> {
  425 + if (invocation.getArgument(0) == first) {
  426 + entered.countDown();
  427 + assertTrue(release.await(10, TimeUnit.SECONDS));
  428 + }
  429 + return null;
  430 + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong());
  431 +
  432 + try {
  433 + // 调用:第一条执行中,再排 2 条(上限),第 3 条超出
  434 + subscriber.onMessage(first);
  435 + assertTrue(entered.await(5, TimeUnit.SECONDS));
  436 + subscriber.onMessage(buildPropertySet("q0", DEVICE_ID));
  437 + subscriber.onMessage(buildPropertySet("q1", DEVICE_ID));
  438 + subscriber.onMessage(overflow);
  439 + release.countDown();
  440 +
  441 + // 断言:按配置的上限,执行中 1 条 + 排队 2 条全部执行,超出的丢弃
  442 + verify(deviceMessageService, timeout(5000).times(3)).encodeDownstreamCommand(any(), any(), anyLong());
  443 + verify(deviceMessageService, never()).encodeDownstreamCommand(same(overflow), any(), anyLong());
  444 + } finally {
  445 + release.countDown();
  446 + realVertx.close();
  447 + }
  448 + }
  449 +
  450 + @Test
  451 + public void testStop_dropPendingCommands() throws Exception {
  452 + // 准备参数
  453 + gatewayProperties.getScript().setEnabled(true);
  454 + mockScriptProperties();
  455 + mockDeviceMessageService();
  456 + Vertx realVertx = Vertx.vertx();
  457 + when(protocol.getVertx()).thenReturn(realVertx);
  458 + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID);
  459 + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID);
  460 + // mock 方法:c1 阻塞
  461 + CountDownLatch entered = new CountDownLatch(1);
  462 + CountDownLatch release = new CountDownLatch(1);
  463 + doAnswer(invocation -> {
  464 + entered.countDown();
  465 + assertTrue(release.await(10, TimeUnit.SECONDS));
  466 + return null;
  467 + }).when(deviceMessageService).encodeDownstreamCommand(same(c1), any(), anyLong());
  468 +
  469 + try {
  470 + // 调用:c1 执行中、c2 排队时停止
  471 + subscriber.onMessage(c1);
  472 + assertTrue(entered.await(5, TimeUnit.SECONDS));
  473 + subscriber.onMessage(c2);
  474 + subscriber.stop();
  475 + release.countDown();
  476 +
  477 + // 断言:执行中的 c1 照常结束,排队的 c2 不再执行
  478 + verify(deviceMessageService, after(500).never()).encodeDownstreamCommand(same(c2), any(), anyLong());
  479 + verify(messageBus).unregister(same(subscriber));
  480 + } finally {
  481 + release.countDown();
  482 + realVertx.close();
  483 + }
  484 + }
  485 +
  486 + @Test
  487 + public void testStop_restartNotReviveOldCommands() throws Exception {
  488 + // 准备参数
  489 + gatewayProperties.getScript().setEnabled(true);
  490 + mockScriptProperties();
  491 + mockDeviceMessageService();
  492 + Vertx realVertx = Vertx.vertx();
  493 + when(protocol.getVertx()).thenReturn(realVertx);
  494 + IotDeviceMessage c1 = buildPropertySet("c1", DEVICE_ID);
  495 + IotDeviceMessage c2 = buildPropertySet("c2", DEVICE_ID);
  496 + IotDeviceMessage c3 = buildPropertySet("c3", DEVICE_ID);
  497 + // mock 方法:c1 阻塞
  498 + CountDownLatch entered = new CountDownLatch(1);
  499 + CountDownLatch release = new CountDownLatch(1);
  500 + doAnswer(invocation -> {
  501 + if (invocation.getArgument(0) == c1) {
  502 + entered.countDown();
  503 + assertTrue(release.await(10, TimeUnit.SECONDS));
  504 + }
  505 + return null;
  506 + }).when(deviceMessageService).encodeDownstreamCommand(any(), any(), anyLong());
  507 +
  508 + try {
  509 + // 调用:c1 执行中、c2 排队时停止,再启动后收到 c3
  510 + subscriber.onMessage(c1);
  511 + assertTrue(entered.await(5, TimeUnit.SECONDS));
  512 + subscriber.onMessage(c2);
  513 + subscriber.stop();
  514 + subscriber.start();
  515 + subscriber.onMessage(c3);
  516 +
  517 + // 断言:c3 新建队列照常执行,不等待旧的 c1
  518 + verify(deviceMessageService, timeout(5000)).encodeDownstreamCommand(same(c3), any(), anyLong());
  519 + // 断言:c1 结束后,旧队列里的 c2 不会因为新队列而复活
  520 + release.countDown();
  521 + verify(deviceMessageService, after(500).never()).encodeDownstreamCommand(same(c2), any(), anyLong());
  522 + } finally {
  523 + release.countDown();
  524 + realVertx.close();
  525 + }
  526 + }
  527 +
  528 + // ========== replyRawResult ==========
  529 +
  530 + @Test
  531 + public void testReplyRawResult() {
  532 + // 准备参数:设备的异常响应(协议判定的设备错误)
  533 + IotDeviceMessage request = buildRawPush(Base64.getEncoder().encodeToString(new byte[]{0x03, 0x00, 0x10, 0x00, 0x02}));
  534 + IotScriptSource source = IotScriptSource.of(100L, "function decode(ctx) {}");
  535 + byte[] payload = new byte[]{(byte) 0x83, 0x02};
  536 + // mock 方法
  537 + when(protocol.getServerId()).thenReturn(SERVER_ID);
  538 + mockDeviceMessageService();
  539 + mockExecuteBlocking();
  540 +
  541 + // 调用
  542 + subscriber.reply(request, source, payload, MODBUS_EXCEPTION);
  543 +
  544 + // 断言:切到协议的 worker 回复给 biz,serverId 为本协议实例,设备错误原样传入
  545 + verify(vertx).executeBlocking(any(Callable.class), eq(false));
  546 + verify(deviceMessageService).sendRawReply(same(request), same(source), aryEq(payload), same(MODBUS_EXCEPTION),
  547 + eq(SERVER_ID));
  548 + }
  549 +
  550 + // ========== 辅助方法 ==========
  551 +
  552 + private void mockScriptProperties() {
  553 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties);
  554 + }
  555 +
  556 + private void mockDeviceMessageService() {
  557 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService);
  558 + }
  559 +
  560 + /**
  561 + * executeBlocking 在当前线程同步执行(SpringUtil 的静态 mock 只在当前线程生效)
  562 + */
  563 + @SuppressWarnings("unchecked")
  564 + private void mockExecuteBlocking() {
  565 + when(protocol.getVertx()).thenReturn(vertx);
  566 + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenAnswer(invocation -> {
  567 + Callable<Object> callable = invocation.getArgument(0);
  568 + return Future.succeededFuture(callable.call());
  569 + });
  570 + }
  571 +
  572 + private static IotDeviceMessage buildRawPush(String raw) {
  573 + return IotDeviceMessage.requestOf("r1", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), null)
  574 + .setDeviceId(DEVICE_ID).setRaw(raw);
  575 + }
  576 +
  577 + private static IotDeviceMessage buildPropertySet() {
  578 + return buildPropertySet("r2", DEVICE_ID);
  579 + }
  580 +
  581 + private static IotDeviceMessage buildPropertySet(String requestId, Long deviceId) {
  582 + return IotDeviceMessage.requestOf(requestId, IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(),
  583 + Collections.singletonMap("power", 1)).setDeviceId(deviceId);
  584 + }
  585 +
  586 + /**
  587 + * 等待条件成立(最长 5 秒)
  588 + */
  589 + private static void await(BooleanSupplier condition) throws InterruptedException {
  590 + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
  591 + while (!condition.getAsBoolean()) {
  592 + assertTrue(System.nanoTime() < deadline, "等待超时");
  593 + Thread.sleep(10);
  594 + }
  595 + }
  596 +
  597 + /**
  598 + * 记录调用的测试订阅者
  599 + */
  600 + private static class TestSubscriber extends AbstractIotProtocolDownstreamSubscriber {
  601 +
  602 + // worker 上也会调用(指令保序的用例),所以使用线程安全的 List
  603 + private final List<IotDeviceMessage> handledMessages = new CopyOnWriteArrayList<>();
  604 + private final List<IotDeviceMessage> rawMessages = new CopyOnWriteArrayList<>();
  605 + private final List<byte[]> rawBytes = new CopyOnWriteArrayList<>();
  606 + private final List<IotScriptSource> rawSources = new CopyOnWriteArrayList<>();
  607 + private boolean supportsRaw = true;
  608 + private Future<Void> rawResult = Future.succeededFuture();
  609 +
  610 + TestSubscriber(IotProtocol protocol, IotMessageBus messageBus) {
  611 + super(protocol, messageBus);
  612 + }
  613 +
  614 + @Override
  615 + protected void handleMessage(IotDeviceMessage message) {
  616 + handledMessages.add(message);
  617 + }
  618 +
  619 + @Override
  620 + public Future<Void> handleRawMessage(IotDeviceMessage message, byte[] bytes, IotScriptSource source) {
  621 + rawMessages.add(message);
  622 + rawBytes.add(bytes);
  623 + rawSources.add(source);
  624 + return rawResult;
  625 + }
  626 +
  627 + @Override
  628 + public boolean supportsRawDownstream() {
  629 + return supportsRaw;
  630 + }
  631 +
  632 + void reply(IotDeviceMessage request, IotScriptSource source, byte[] payload, ErrorCode deviceError) {
  633 + replyRawResult(request, source, payload, deviceError);
  634 + }
  635 +
  636 + }
  637 +
  638 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/upstream/IotCoapUpstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
  2 +
  3 +import cn.hutool.core.util.ReflectUtil;
  4 +import cn.hutool.extra.spring.SpringUtil;
  5 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  6 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  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.topic.IotDeviceIdentity;
  10 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  11 +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
  12 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  13 +import org.eclipse.californium.core.coap.CoAP;
  14 +import org.eclipse.californium.core.coap.MediaTypeRegistry;
  15 +import org.eclipse.californium.core.coap.Option;
  16 +import org.eclipse.californium.core.coap.OptionSet;
  17 +import org.eclipse.californium.core.coap.Request;
  18 +import org.eclipse.californium.core.network.Exchange;
  19 +import org.eclipse.californium.core.server.resources.CoapExchange;
  20 +import org.junit.jupiter.api.AfterEach;
  21 +import org.junit.jupiter.api.BeforeEach;
  22 +import org.junit.jupiter.api.Test;
  23 +import org.mockito.ArgumentCaptor;
  24 +import org.mockito.InOrder;
  25 +import org.mockito.Mock;
  26 +import org.mockito.MockedStatic;
  27 +
  28 +import java.nio.charset.StandardCharsets;
  29 +import java.time.Duration;
  30 +import java.util.Arrays;
  31 +import java.util.Collections;
  32 +import java.util.List;
  33 +import java.util.Map;
  34 +import java.util.concurrent.CopyOnWriteArrayList;
  35 +import java.util.concurrent.CountDownLatch;
  36 +import java.util.concurrent.ExecutorService;
  37 +import java.util.concurrent.Executors;
  38 +import java.util.concurrent.ThreadPoolExecutor;
  39 +import java.util.concurrent.TimeUnit;
  40 +
  41 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
  42 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
  43 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
  44 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS;
  45 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
  46 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  47 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
  48 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT;
  49 +import static org.junit.jupiter.api.Assertions.*;
  50 +import static org.mockito.AdditionalMatchers.aryEq;
  51 +import static org.mockito.ArgumentMatchers.*;
  52 +import static org.mockito.Mockito.*;
  53 +
  54 +/**
  55 + * {@link IotCoapUpstreamHandler} 的单元测试
  56 + *
  57 + * @author 芋道源码
  58 + */
  59 +public class IotCoapUpstreamHandlerTest extends BaseMockitoUnitTest {
  60 +
  61 + private static final String PRODUCT_KEY = "pk";
  62 + private static final String DEVICE_NAME = "dn";
  63 + private static final String TOKEN = "token";
  64 + private static final String SERVER_ID = "coap-server";
  65 + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03};
  66 + private static final List<String> RAW_POST_PATH = Arrays.asList("topic", "sys", PRODUCT_KEY, DEVICE_NAME,
  67 + "thing", "raw", "post");
  68 + private static final List<String> PROPERTY_POST_PATH = Arrays.asList("topic", "sys", PRODUCT_KEY, DEVICE_NAME,
  69 + "thing", "property", "post");
  70 +
  71 + @Mock
  72 + private IotDeviceTokenService deviceTokenService;
  73 + @Mock
  74 + private IotDeviceMessageService deviceMessageService;
  75 + @Mock
  76 + private CoapExchange exchange;
  77 + @Mock
  78 + private Exchange advancedExchange;
  79 + @Mock
  80 + private OptionSet options;
  81 + @Mock
  82 + private Option tokenOption;
  83 +
  84 + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties();
  85 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  86 + private IotCoapUpstreamHandler handler;
  87 +
  88 + @BeforeEach
  89 + public void setUp() {
  90 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  91 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceTokenService.class)).thenReturn(deviceTokenService);
  92 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService);
  93 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties);
  94 + handler = new IotCoapUpstreamHandler(SERVER_ID);
  95 + }
  96 +
  97 + @AfterEach
  98 + public void tearDown() {
  99 + handler.stop();
  100 + springUtilMockedStatic.close();
  101 + }
  102 +
  103 + @Test
  104 + public void testHandle_rawPostConfirmable() {
  105 + // 准备参数
  106 + mockRawPostRequest(CoAP.Type.CON);
  107 + // mock 方法
  108 + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID),
  109 + eq(IotProtocolTypeEnum.COAP), anyLong())).thenReturn("M1");
  110 +
  111 + // 调用
  112 + handler.handle(exchange);
  113 +
  114 + // 断言:CON 先回空 ACK,再(在透传 worker 上)处理,最后以分离响应返回一次 JSON 结果,data = {messageId}
  115 + Map<String, Object> json = captureResponse();
  116 + InOrder inOrder = inOrder(exchange, deviceMessageService);
  117 + inOrder.verify(exchange).accept();
  118 + inOrder.verify(deviceMessageService).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  119 + inOrder.verify(exchange).respond(any(CoAP.ResponseCode.class), anyString(), anyInt());
  120 + assertEquals(0, json.get("code"));
  121 + @SuppressWarnings("unchecked")
  122 + Map<String, Object> data = (Map<String, Object>) json.get("data");
  123 + assertEquals("M1", data.get("messageId"));
  124 + assertEquals(1, data.size());
  125 + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt());
  126 + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt());
  127 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  128 + }
  129 +
  130 + @Test
  131 + public void testHandle_rawPostNonConfirmable() {
  132 + // 准备参数
  133 + mockRawPostRequest(CoAP.Type.NON);
  134 + // mock 方法
  135 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M2");
  136 +
  137 + // 调用
  138 + handler.handle(exchange);
  139 +
  140 + // 断言:NON 不回空 ACK,直接响应一次 JSON 结果
  141 + verify(exchange, never()).accept();
  142 + Map<String, Object> json = captureResponse();
  143 + assertEquals(0, json.get("code"));
  144 + @SuppressWarnings("unchecked")
  145 + Map<String, Object> data = (Map<String, Object>) json.get("data");
  146 + assertEquals("M2", data.get("messageId"));
  147 + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt());
  148 + }
  149 +
  150 + @Test
  151 + public void testHandle_rawPostServiceException() {
  152 + // 准备参数
  153 + mockRawPostRequest(CoAP.Type.CON);
  154 + // mock 方法:处理超时(service 不投递,只记错误日志)
  155 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  156 + .thenThrow(exception(SCRIPT_TIMEOUT));
  157 +
  158 + // 调用
  159 + handler.handle(exchange);
  160 +
  161 + // 断言:CON 已先回空 ACK;截止前失败,响应一次错误 JSON
  162 + verify(exchange).accept();
  163 + Map<String, Object> json = captureResponse();
  164 + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code"));
  165 + assertEquals(SCRIPT_TIMEOUT.getMsg(), json.get("msg"));
  166 + verify(exchange, never()).respond(any(CoAP.ResponseCode.class), any(byte[].class), anyInt());
  167 + }
  168 +
  169 + @Test
  170 + public void testHandle_rawPostDeadline() throws Exception {
  171 + // 准备参数:截止时长 100ms
  172 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100));
  173 + IotCoapUpstreamHandler deadlineHandler = new IotCoapUpstreamHandler(SERVER_ID);
  174 + mockRawPostRequest(CoAP.Type.CON);
  175 + // mock 方法:处理阻塞,直到放行后才返回(迟到的结果)
  176 + CountDownLatch entered = new CountDownLatch(1);
  177 + CountDownLatch release = new CountDownLatch(1);
  178 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> {
  179 + entered.countDown();
  180 + assertTrue(release.await(10, TimeUnit.SECONDS));
  181 + return "LATE";
  182 + });
  183 +
  184 + // mock 方法:与 Californium 一致,非处理线程写的响应排到 Exchange 的串行执行器(owner)上才发出
  185 + ExecutorService owner = Executors.newSingleThreadExecutor();
  186 + List<String> sent = new CopyOnWriteArrayList<>();
  187 + CountDownLatch sentLatch = new CountDownLatch(1);
  188 + doAnswer(invocation -> {
  189 + String json = invocation.getArgument(1);
  190 + owner.execute(() -> {
  191 + sent.add(json);
  192 + sentLatch.countDown();
  193 + });
  194 + return null;
  195 + }).when(exchange).respond(any(CoAP.ResponseCode.class), anyString(), anyInt());
  196 +
  197 + try {
  198 + // 调用:在 owner 上处理请求;处理入口立即返回,不等待阻塞中的发送
  199 + owner.submit(() -> deadlineHandler.handle(exchange)).get(5, TimeUnit.SECONDS);
  200 + assertTrue(entered.await(5, TimeUnit.SECONDS));
  201 +
  202 + // 断言:发送仍阻塞时,截止计时器的超时响应已经在 owner 上发出
  203 + assertTrue(sentLatch.await(5, TimeUnit.SECONDS));
  204 + assertEquals(1, release.getCount());
  205 + Map<String, Object> json = JsonUtils.parseMap(sent.get(0));
  206 + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code"));
  207 + assertEquals(SCRIPT_TIMEOUT.getMsg(), json.get("msg"));
  208 +
  209 + // 放行:等透传 worker 处理完,迟到的结果丢弃,不再响应
  210 + release.countDown();
  211 + ThreadPoolExecutor rawExecutor = getRawExecutor(deadlineHandler);
  212 + rawExecutor.shutdown();
  213 + assertTrue(rawExecutor.awaitTermination(5, TimeUnit.SECONDS));
  214 + owner.submit(() -> null).get(5, TimeUnit.SECONDS);
  215 + assertEquals(1, sent.size());
  216 + verify(exchange).accept();
  217 + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt());
  218 + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  219 + } finally {
  220 + release.countDown();
  221 + deadlineHandler.stop();
  222 + owner.shutdownNow();
  223 + }
  224 + }
  225 +
  226 + @Test
  227 + public void testHandle_rawPostQueueExpired() throws Exception {
  228 + // 准备参数:截止时长 100ms;透传 worker 只留 1 个线程,并被占住
  229 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100));
  230 + IotCoapUpstreamHandler deadlineHandler = new IotCoapUpstreamHandler(SERVER_ID);
  231 + mockRawPostRequest(CoAP.Type.NON);
  232 + ThreadPoolExecutor rawExecutor = getRawExecutor(deadlineHandler);
  233 + rawExecutor.setCorePoolSize(1);
  234 + rawExecutor.setMaximumPoolSize(1);
  235 + CountDownLatch blocked = new CountDownLatch(1);
  236 + CountDownLatch release = new CountDownLatch(1);
  237 + rawExecutor.execute(() -> {
  238 + blocked.countDown();
  239 + try {
  240 + release.await(10, TimeUnit.SECONDS);
  241 + } catch (InterruptedException e) {
  242 + Thread.currentThread().interrupt();
  243 + }
  244 + });
  245 + assertTrue(blocked.await(5, TimeUnit.SECONDS));
  246 + // mock 方法:与 service 的截止分支一致(已过截止:不执行脚本、不投递,只记错误日志,并返回超时)
  247 + List<Long> nowAtCall = new CopyOnWriteArrayList<>();
  248 + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID),
  249 + eq(IotProtocolTypeEnum.COAP), anyLong())).thenAnswer(invocation -> {
  250 + nowAtCall.add(System.nanoTime());
  251 + throw exception(SCRIPT_TIMEOUT);
  252 + });
  253 +
  254 + try {
  255 + // 调用:请求排在 worker 队列中
  256 + long beginNanos = System.nanoTime();
  257 + deadlineHandler.handle(exchange);
  258 + long queuedNanos = System.nanoTime();
  259 +
  260 + // 断言:排队期间到期,计时器响应一次超时
  261 + Map<String, Object> json = captureResponse();
  262 + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code"));
  263 +
  264 + // 放行:排队的请求带着入队之前的收到时刻交给 service(已过截止:不执行脚本、不投递,只记错误日志并抛出超时);
  265 + // 协议不再响应第二次
  266 + release.countDown();
  267 + rawExecutor.shutdown();
  268 + assertTrue(rawExecutor.awaitTermination(5, TimeUnit.SECONDS));
  269 + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class);
  270 + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(),
  271 + receivedCaptor.capture());
  272 + assertTrue(receivedCaptor.getValue() >= beginNanos);
  273 + assertTrue(receivedCaptor.getValue() <= queuedNanos);
  274 + assertTrue(nowAtCall.get(0) >= receivedCaptor.getValue() + TimeUnit.MILLISECONDS.toNanos(100));
  275 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  276 + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt());
  277 + } finally {
  278 + release.countDown();
  279 + deadlineHandler.stop();
  280 + }
  281 + }
  282 +
  283 + @Test
  284 + public void testHandle_rawPostRejected() {
  285 + // 准备参数:透传 worker 拒绝(已关闭)
  286 + mockRawPostRequest(CoAP.Type.NON);
  287 + getRawExecutor(handler).shutdown();
  288 +
  289 + // 调用
  290 + handler.handle(exchange);
  291 +
  292 + // 断言:立即响应一次 429,不调用发送
  293 + assertEquals(TOO_MANY_REQUESTS.getCode(), captureResponse().get("code"));
  294 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  295 + }
  296 +
  297 + @Test
  298 + public void testHandle_rawPostUnknownException() {
  299 + // 准备参数
  300 + mockRawPostRequest(CoAP.Type.NON);
  301 + // mock 方法:未知异常
  302 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  303 + .thenThrow(new IllegalStateException("boom"));
  304 +
  305 + // 调用
  306 + handler.handle(exchange);
  307 +
  308 + // 断言:响应一次 500
  309 + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code"));
  310 + }
  311 +
  312 + @Test
  313 + public void testHandle_rawPostPayloadInvalid() {
  314 + // 准备参数
  315 + mockRawPostRequest(CoAP.Type.NON);
  316 + // mock 方法:载荷长度不合法(不投递)
  317 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  318 + .thenThrow(invalidParamException("raw 载荷长度必须在 1~{} 字节之间", 4096));
  319 +
  320 + // 调用
  321 + handler.handle(exchange);
  322 +
  323 + // 断言
  324 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code"));
  325 + }
  326 +
  327 + @Test
  328 + public void testHandle_rawPathNotOctetStream() {
  329 + // 准备参数:raw/post 路径,但 content-format 不是 42:走 JSON 分支,交给标准路径(由它拒绝透传方法)
  330 + mockRequest(RAW_POST_PATH, "{\"method\":\"thing.raw.post\"}".getBytes(StandardCharsets.UTF_8));
  331 + when(options.getContentFormat()).thenReturn(MediaTypeRegistry.APPLICATION_JSON);
  332 +
  333 + // 调用
  334 + handler.handle(exchange);
  335 +
  336 + // 断言:不走透传
  337 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  338 + verify(deviceMessageService).sendDeviceMessage(any(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  339 + verify(exchange, never()).accept();
  340 + }
  341 +
  342 + // ========== 标准上报、认证 ==========
  343 +
  344 + @Test
  345 + public void testHandle_propertyPost() {
  346 + // 准备参数:JSON 属性上报
  347 + mockRequest(PROPERTY_POST_PATH, "{\"method\":\"thing.property.post\",\"params\":{\"t\":25}}"
  348 + .getBytes(StandardCharsets.UTF_8));
  349 +
  350 + // 调用
  351 + handler.handle(exchange);
  352 +
  353 + // 断言:同步响应成功;按路径的设备身份交给标准路径,不走透传
  354 + assertEquals(0, captureResponse().get("code"));
  355 + ArgumentCaptor<IotDeviceMessage> messageCaptor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  356 + verify(deviceMessageService).sendDeviceMessage(messageCaptor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME),
  357 + eq(SERVER_ID));
  358 + assertEquals("thing.property.post", messageCaptor.getValue().getMethod());
  359 + assertEquals(25, ((Map<?, ?>) messageCaptor.getValue().getParams()).get("t"));
  360 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  361 + verify(exchange, never()).accept();
  362 + }
  363 +
  364 + @Test
  365 + public void testHandle_propertyPostEmptyPayload() {
  366 + // 准备参数:没有请求体
  367 + mockRequest(PROPERTY_POST_PATH, new byte[0]);
  368 +
  369 + // 调用
  370 + handler.handle(exchange);
  371 +
  372 + // 断言:400,不投递
  373 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code"));
  374 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  375 + }
  376 +
  377 + @Test
  378 + public void testHandle_methodMismatch() {
  379 + // 准备参数:路径是属性上报,请求体是事件上报
  380 + mockRequest(PROPERTY_POST_PATH, "{\"method\":\"thing.event.post\",\"params\":{}}"
  381 + .getBytes(StandardCharsets.UTF_8));
  382 +
  383 + // 调用
  384 + handler.handle(exchange);
  385 +
  386 + // 断言:400,不投递
  387 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code"));
  388 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  389 + }
  390 +
  391 + @Test
  392 + public void testHandle_tokenMissing() {
  393 + // 准备参数:没有 token
  394 + when(exchange.getRequestOptions()).thenReturn(options);
  395 + when(options.getOthers()).thenReturn(Collections.emptyList());
  396 +
  397 + // 调用
  398 + handler.handle(exchange);
  399 +
  400 + // 断言:401,不投递
  401 + assertEquals(UNAUTHORIZED.getCode(), captureResponse().get("code"));
  402 + verifyNoInteractions(deviceMessageService);
  403 + }
  404 +
  405 + @Test
  406 + public void testHandle_tokenInvalid() {
  407 + // 准备参数:token 校验不通过
  408 + mockToken();
  409 + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(null);
  410 +
  411 + // 调用
  412 + handler.handle(exchange);
  413 +
  414 + // 断言:401,不投递
  415 + assertEquals(UNAUTHORIZED.getCode(), captureResponse().get("code"));
  416 + verifyNoInteractions(deviceMessageService);
  417 + }
  418 +
  419 + @Test
  420 + public void testHandle_deviceMismatch() {
  421 + // 准备参数:token 属于其它设备
  422 + mockToken();
  423 + when(options.getUriPath()).thenReturn(PROPERTY_POST_PATH);
  424 + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, "other"));
  425 +
  426 + // 调用
  427 + handler.handle(exchange);
  428 +
  429 + // 断言:403,不投递
  430 + assertEquals(FORBIDDEN.getCode(), captureResponse().get("code"));
  431 + verifyNoInteractions(deviceMessageService);
  432 + }
  433 +
  434 + // ========== 辅助方法 ==========
  435 +
  436 + private void mockRawPostRequest(CoAP.Type type) {
  437 + // 请求:thing/raw/post、content-format 42
  438 + mockRequest(RAW_POST_PATH, PAYLOAD);
  439 + when(options.getContentFormat()).thenReturn(MediaTypeRegistry.APPLICATION_OCTET_STREAM);
  440 + when(exchange.advanced()).thenReturn(advancedExchange);
  441 + when(advancedExchange.getRequest()).thenReturn(new Request(CoAP.Code.POST, type));
  442 + }
  443 +
  444 + private void mockRequest(List<String> uriPath, byte[] payload) {
  445 + // 请求:已认证(token 与路径一致)
  446 + mockToken();
  447 + when(options.getUriPath()).thenReturn(uriPath);
  448 + when(exchange.getRequestPayload()).thenReturn(payload);
  449 + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, DEVICE_NAME));
  450 + }
  451 +
  452 + private void mockToken() {
  453 + // 请求:带 token 的自定义 Option
  454 + when(exchange.getRequestOptions()).thenReturn(options);
  455 + when(options.getOthers()).thenReturn(Collections.singletonList(tokenOption));
  456 + when(tokenOption.getNumber()).thenReturn(IotCoapAbstractHandler.OPTION_TOKEN);
  457 + when(tokenOption.getValue()).thenReturn(TOKEN.getBytes(StandardCharsets.UTF_8));
  458 + }
  459 +
  460 + /**
  461 + * 等待(透传在 worker 上异步响应)并断言只响应一次 JSON,返回它
  462 + */
  463 + private Map<String, Object> captureResponse() {
  464 + ArgumentCaptor<String> jsonCaptor = ArgumentCaptor.forClass(String.class);
  465 + verify(exchange, timeout(5000)).respond(eq(CoAP.ResponseCode.CONTENT), jsonCaptor.capture(),
  466 + eq(MediaTypeRegistry.APPLICATION_JSON));
  467 + verify(exchange, times(1)).respond(any(CoAP.ResponseCode.class), anyString(), anyInt());
  468 + return JsonUtils.parseMap(jsonCaptor.getValue());
  469 + }
  470 +
  471 + /**
  472 + * 获取处理器的透传 worker
  473 + */
  474 + private static ThreadPoolExecutor getRawExecutor(IotCoapUpstreamHandler handler) {
  475 + return (ThreadPoolExecutor) ReflectUtil.getFieldValue(handler, "rawExecutor");
  476 + }
  477 +
  478 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocolTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
  2 +
  3 +import cn.hutool.core.util.ReflectUtil;
  4 +import cn.hutool.extra.spring.SpringUtil;
  5 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  6 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  7 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  8 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  9 +import io.netty.handler.codec.mqtt.MqttQoS;
  10 +import io.vertx.core.Future;
  11 +import io.vertx.core.buffer.Buffer;
  12 +import io.vertx.mqtt.MqttClient;
  13 +import org.junit.jupiter.api.AfterEach;
  14 +import org.junit.jupiter.api.BeforeEach;
  15 +import org.junit.jupiter.api.Test;
  16 +import org.mockito.ArgumentCaptor;
  17 +import org.mockito.Mock;
  18 +import org.mockito.MockedStatic;
  19 +
  20 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED;
  21 +import static org.junit.jupiter.api.Assertions.*;
  22 +import static org.mockito.ArgumentMatchers.any;
  23 +import static org.mockito.ArgumentMatchers.eq;
  24 +import static org.mockito.Mockito.*;
  25 +
  26 +/**
  27 + * {@link IotEmqxProtocol} 的单元测试
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +public class IotEmqxProtocolTest extends BaseMockitoUnitTest {
  32 +
  33 + private static final String RAW_PUSH_TOPIC = "/sys/pk/dn/thing/raw/push";
  34 + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03};
  35 +
  36 + @Mock
  37 + private MqttClient mqttClient;
  38 +
  39 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  40 + private IotEmqxProtocol protocol;
  41 +
  42 + @BeforeEach
  43 + public void setUp() {
  44 + // 构造方法里的 handler 通过 SpringUtil 获取 Bean:这里不需要真实的 Bean
  45 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  46 + IotGatewayProperties.ProtocolProperties properties = new IotGatewayProperties.ProtocolProperties()
  47 + .setId("emqx-test").setPort(18083).setEmqx(new IotEmqxConfig());
  48 + protocol = new IotEmqxProtocol(properties);
  49 + }
  50 +
  51 + @AfterEach
  52 + public void tearDown() {
  53 + springUtilMockedStatic.close();
  54 + }
  55 +
  56 + @Test
  57 + public void testPublishMessageAsync_success() {
  58 + // mock 方法:已连接,publish 成功
  59 + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient);
  60 + when(mqttClient.isConnected()).thenReturn(true);
  61 + when(mqttClient.publish(eq(RAW_PUSH_TOPIC), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)))
  62 + .thenReturn(Future.succeededFuture(1));
  63 +
  64 + // 调用
  65 + Future<Void> future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES);
  66 + // 断言
  67 + assertTrue(future.succeeded());
  68 + ArgumentCaptor<Buffer> payloadCaptor = ArgumentCaptor.forClass(Buffer.class);
  69 + verify(mqttClient).publish(eq(RAW_PUSH_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false));
  70 + assertArrayEquals(BYTES, payloadCaptor.getValue().getBytes());
  71 + }
  72 +
  73 + @Test
  74 + public void testPublishMessageAsync_notConnected() {
  75 + // 调用:MQTT Client 未创建
  76 + Future<Void> future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES);
  77 + // 断言
  78 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  79 +
  80 + // mock 方法:MQTT Client 已断开
  81 + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient);
  82 + when(mqttClient.isConnected()).thenReturn(false);
  83 + // 调用,并断言
  84 + assertFailed(protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES), DEVICE_OFFLINE_OR_WRITE_FAILED);
  85 + verify(mqttClient, never()).publish(any(), any(Buffer.class), any(), anyBoolean(), anyBoolean());
  86 + }
  87 +
  88 + @Test
  89 + public void testPublishMessageAsync_publishFailed() {
  90 + // mock 方法:已连接,但 publish 异步失败
  91 + ReflectUtil.setFieldValue(protocol, "mqttClient", mqttClient);
  92 + when(mqttClient.isConnected()).thenReturn(true);
  93 + when(mqttClient.publish(eq(RAW_PUSH_TOPIC), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)))
  94 + .thenReturn(Future.failedFuture(new IllegalStateException("Connection is closed")));
  95 +
  96 + // 调用
  97 + Future<Void> future = protocol.publishMessageAsync(RAW_PUSH_TOPIC, BYTES);
  98 + // 断言:异步失败转成 106,而不是成功
  99 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  100 + }
  101 +
  102 + private static void assertFailed(Future<Void> future, ErrorCode errorCode) {
  103 + assertTrue(future.failed());
  104 + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause());
  105 + assertEquals(errorCode.getCode(), exception.getCode());
  106 + }
  107 +
  108 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/upstream/IotHttpUpstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream;
  2 +
  3 +import cn.hutool.extra.spring.SpringUtil;
  4 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  7 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  8 +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
  9 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  10 +import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
  11 +import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
  12 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  13 +import io.vertx.core.Context;
  14 +import io.vertx.core.Vertx;
  15 +import io.vertx.core.VertxOptions;
  16 +import io.vertx.core.buffer.Buffer;
  17 +import io.vertx.core.http.HttpHeaders;
  18 +import io.vertx.core.http.HttpServerRequest;
  19 +import io.vertx.core.http.HttpServerResponse;
  20 +import io.vertx.ext.web.RequestBody;
  21 +import io.vertx.ext.web.RoutingContext;
  22 +import org.junit.jupiter.api.AfterEach;
  23 +import org.junit.jupiter.api.BeforeEach;
  24 +import org.junit.jupiter.api.Test;
  25 +import org.mockito.ArgumentCaptor;
  26 +import org.mockito.Mock;
  27 +import org.mockito.MockedStatic;
  28 +
  29 +import java.time.Duration;
  30 +import java.util.Map;
  31 +import java.util.concurrent.CountDownLatch;
  32 +import java.util.concurrent.TimeUnit;
  33 +import java.util.concurrent.atomic.AtomicBoolean;
  34 +
  35 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
  36 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
  37 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  38 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
  39 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_OUTPUT_INVALID;
  40 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_REJECTED;
  41 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT;
  42 +import static org.junit.jupiter.api.Assertions.*;
  43 +import static org.mockito.AdditionalMatchers.aryEq;
  44 +import static org.mockito.ArgumentMatchers.any;
  45 +import static org.mockito.ArgumentMatchers.anyLong;
  46 +import static org.mockito.ArgumentMatchers.anyString;
  47 +import static org.mockito.ArgumentMatchers.eq;
  48 +import static org.mockito.Mockito.*;
  49 +
  50 +/**
  51 + * {@link IotHttpUpstreamHandler} 的单元测试
  52 + *
  53 + * @author 芋道源码
  54 + */
  55 +public class IotHttpUpstreamHandlerTest extends BaseMockitoUnitTest {
  56 +
  57 + private static final String PRODUCT_KEY = "pk";
  58 + private static final String DEVICE_NAME = "dn";
  59 + private static final String TOKEN = "token";
  60 + private static final String SERVER_ID = "http-server";
  61 + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03};
  62 +
  63 + @Mock
  64 + private IotDeviceTokenService deviceTokenService;
  65 + @Mock
  66 + private IotDeviceMessageService deviceMessageService;
  67 + @Mock
  68 + private IotHttpProtocol protocol;
  69 + @Mock
  70 + private RoutingContext context;
  71 + @Mock
  72 + private HttpServerRequest request;
  73 + @Mock
  74 + private RequestBody requestBody;
  75 +
  76 + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties();
  77 + /**
  78 + * 响应是否已结束:end 之后 ended() 返回 true,与真实的 HttpServerResponse 一致
  79 + */
  80 + private final AtomicBoolean responseEnded = new AtomicBoolean();
  81 + private HttpServerResponse response;
  82 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  83 + private Vertx vertx;
  84 +
  85 + @BeforeEach
  86 + public void setUp() {
  87 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  88 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceTokenService.class)).thenReturn(deviceTokenService);
  89 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService);
  90 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotGatewayProperties.class)).thenReturn(gatewayProperties);
  91 + when(protocol.getServerId()).thenReturn(SERVER_ID);
  92 + // 链式调用(setStatusCode、putHeader)返回自身
  93 + response = mock(HttpServerResponse.class, RETURNS_SELF);
  94 + lenient().when(response.ended()).thenAnswer(invocation -> responseEnded.get());
  95 + lenient().doAnswer(invocation -> {
  96 + responseEnded.set(true);
  97 + return null;
  98 + }).when(response).end(anyString());
  99 + vertx = Vertx.vertx();
  100 + }
  101 +
  102 + @AfterEach
  103 + public void tearDown() {
  104 + springUtilMockedStatic.close();
  105 + vertx.close();
  106 + }
  107 +
  108 + @Test
  109 + public void testHandle_rawPostSuccess() throws Exception {
  110 + // 准备参数
  111 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  112 + mockRawPostRequest();
  113 + // mock 方法:记录执行线程
  114 + AtomicBoolean onWorker = new AtomicBoolean();
  115 + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID),
  116 + eq(IotProtocolTypeEnum.HTTP), anyLong())).thenAnswer(invocation -> {
  117 + onWorker.set(Context.isOnWorkerThread());
  118 + return "M1";
  119 + });
  120 +
  121 + // 调用
  122 + handleOnEventLoop(handler);
  123 +
  124 + // 断言:只写一次 JSON 结果,data = {messageId}
  125 + Map<String, Object> json = captureResponse();
  126 + assertEquals(0, json.get("code"));
  127 + @SuppressWarnings("unchecked")
  128 + Map<String, Object> data = (Map<String, Object>) json.get("data");
  129 + assertEquals("M1", data.get("messageId"));
  130 + assertEquals(1, data.size());
  131 + verify(response, never()).end(any(Buffer.class));
  132 + // 断言:在 worker 上处理
  133 + assertTrue(onWorker.get());
  134 + verify(deviceMessageService, never()).sendDeviceMessage(any(), any(), any(), any());
  135 + }
  136 +
  137 + @Test
  138 + public void testHandle_rawPostReceivedBeforeQueue() throws Exception {
  139 + // 准备参数:worker 池只有 1 个线程,并被占住,请求只能排队
  140 + vertx.close();
  141 + vertx = Vertx.vertx(new VertxOptions().setWorkerPoolSize(1));
  142 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  143 + mockRawPostRequest();
  144 + CountDownLatch blocked = new CountDownLatch(1);
  145 + CountDownLatch release = new CountDownLatch(1);
  146 + vertx.executeBlocking(() -> {
  147 + blocked.countDown();
  148 + return release.await(10, TimeUnit.SECONDS);
  149 + }, false);
  150 + assertTrue(blocked.await(5, TimeUnit.SECONDS));
  151 + // mock 方法
  152 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M5");
  153 +
  154 + long beginNanos;
  155 + long queuedNanos;
  156 + try {
  157 + // 调用:请求排在 worker 队列中
  158 + beginNanos = System.nanoTime();
  159 + handleOnEventLoop(handler);
  160 + queuedNanos = System.nanoTime();
  161 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  162 + } finally {
  163 + release.countDown();
  164 + }
  165 +
  166 + // 断言:放行后照常响应;传给 service 的收到时刻在交给 worker 之前取得(排队的时间计入截止),不是 worker 开始执行时
  167 + assertEquals(0, captureResponse().get("code"));
  168 + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class);
  169 + verify(deviceMessageService).sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID),
  170 + eq(IotProtocolTypeEnum.HTTP), receivedCaptor.capture());
  171 + assertTrue(receivedCaptor.getValue() >= beginNanos);
  172 + assertTrue(receivedCaptor.getValue() <= queuedNanos);
  173 + }
  174 +
  175 + @Test
  176 + public void testHandle_rawPostTimerCancelled() throws Exception {
  177 + // 准备参数:同步截止 100ms,处理很快完成
  178 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100));
  179 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  180 + mockRawPostRequest();
  181 + // mock 方法
  182 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M2");
  183 +
  184 + // 调用
  185 + handleOnEventLoop(handler);
  186 +
  187 + // 断言:先返回成功;过了截止时间也不再写超时
  188 + assertEquals(0, captureResponse().get("code"));
  189 + Thread.sleep(300);
  190 + verify(response, times(1)).end(anyString());
  191 + }
  192 +
  193 + @Test
  194 + public void testHandle_rawPostServiceException() throws Exception {
  195 + // 准备参数
  196 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  197 + mockRawPostRequest();
  198 + // mock 方法:脚本繁忙(service 不投递,只记错误日志)
  199 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  200 + .thenThrow(exception(SCRIPT_REJECTED));
  201 +
  202 + // 调用
  203 + handleOnEventLoop(handler);
  204 +
  205 + // 断言:返回对应的错误码
  206 + Map<String, Object> json = captureResponse();
  207 + assertEquals(SCRIPT_REJECTED.getCode(), json.get("code"));
  208 + assertEquals(SCRIPT_REJECTED.getMsg(), json.get("msg"));
  209 + }
  210 +
  211 + @Test
  212 + public void testHandle_rawPostOutputInvalid() throws Exception {
  213 + // 准备参数
  214 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  215 + mockRawPostRequest();
  216 + // mock 方法
  217 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  218 + .thenThrow(exception(SCRIPT_OUTPUT_INVALID, "脚本输出 不允许包含字段 raw"));
  219 +
  220 + // 调用
  221 + handleOnEventLoop(handler);
  222 +
  223 + // 断言
  224 + Map<String, Object> json = captureResponse();
  225 + assertEquals(SCRIPT_OUTPUT_INVALID.getCode(), json.get("code"));
  226 + assertEquals("数据解析脚本输出不合法:脚本输出 不允许包含字段 raw", json.get("msg"));
  227 + }
  228 +
  229 + @Test
  230 + public void testHandle_rawPostPayloadInvalid() throws Exception {
  231 + // 准备参数
  232 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  233 + mockRawPostRequest();
  234 + // mock 方法:载荷长度不合法(不投递)
  235 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  236 + .thenThrow(invalidParamException("raw 载荷长度必须在 1~{} 字节之间", 4096));
  237 +
  238 + // 调用
  239 + handleOnEventLoop(handler);
  240 +
  241 + // 断言
  242 + assertEquals(BAD_REQUEST.getCode(), captureResponse().get("code"));
  243 + }
  244 +
  245 + @Test
  246 + public void testHandle_rawPostUnknownException() throws Exception {
  247 + // 准备参数
  248 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  249 + mockRawPostRequest();
  250 + // mock 方法
  251 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  252 + .thenThrow(new IllegalStateException("boom"));
  253 +
  254 + // 调用
  255 + handleOnEventLoop(handler);
  256 +
  257 + // 断言
  258 + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code"));
  259 + }
  260 +
  261 + @Test
  262 + public void testHandle_rawPostSyncDeadline() throws Exception {
  263 + // 准备参数:同步截止 100ms,处理阻塞到截止之后
  264 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(100));
  265 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  266 + mockRawPostRequest();
  267 + CountDownLatch release = new CountDownLatch(1);
  268 + // mock 方法
  269 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> {
  270 + assertTrue(release.await(10, TimeUnit.SECONDS));
  271 + return "M-late";
  272 + });
  273 +
  274 + try {
  275 + // 调用
  276 + handleOnEventLoop(handler);
  277 +
  278 + // 断言:到期先返回 SCRIPT_TIMEOUT
  279 + Map<String, Object> json = captureResponse();
  280 + assertEquals(SCRIPT_TIMEOUT.getCode(), json.get("code"));
  281 + } finally {
  282 + release.countDown();
  283 + }
  284 + // 断言:之后完成的结果不再写(第二次检查 ended() 时丢弃)
  285 + verify(response, timeout(5000).times(2)).ended();
  286 + verify(response, times(1)).end(anyString());
  287 + verify(response, never()).end(any(Buffer.class));
  288 + }
  289 +
  290 + @Test
  291 + public void testHandle_rawPostClientClosed() throws Exception {
  292 + // 准备参数
  293 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  294 + mockRawPostRequest();
  295 + // mock 方法:客户端在处理期间断开
  296 + when(response.closed()).thenReturn(true);
  297 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenReturn("M3");
  298 +
  299 + // 调用
  300 + handleOnEventLoop(handler);
  301 +
  302 + // 断言:丢弃结果,不写响应
  303 + verify(response, timeout(5000)).closed();
  304 + verify(response, never()).end(anyString());
  305 + verify(response, never()).end(any(Buffer.class));
  306 + }
  307 +
  308 + @Test
  309 + public void testHandle_standardPost() throws Exception {
  310 + // 准备参数:标准 JSON 上报
  311 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  312 + mockStandardPostRequest();
  313 + // mock 方法:记录执行线程
  314 + AtomicBoolean onWorker = new AtomicBoolean();
  315 + doAnswer(invocation -> {
  316 + onWorker.set(Context.isOnWorkerThread());
  317 + ((IotDeviceMessage) invocation.getArgument(0)).setId("M4");
  318 + return null;
  319 + }).when(deviceMessageService).sendDeviceMessage(any(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  320 +
  321 + // 调用
  322 + handleOnEventLoop(handler);
  323 +
  324 + // 断言:发送完成后才响应,data = {messageId}
  325 + Map<String, Object> json = captureResponse();
  326 + assertEquals(0, json.get("code"));
  327 + @SuppressWarnings("unchecked")
  328 + Map<String, Object> data = (Map<String, Object>) json.get("data");
  329 + assertEquals("M4", data.get("messageId"));
  330 + verify(response, times(1)).end(anyString());
  331 + // 断言:获取设备、发送总线在 worker 上执行,不阻塞 event loop
  332 + assertTrue(onWorker.get());
  333 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  334 + }
  335 +
  336 + @Test
  337 + public void testHandle_standardPostServiceException() throws Exception {
  338 + // 准备参数
  339 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  340 + mockStandardPostRequest();
  341 + // mock 方法:设备不存在
  342 + doThrow(invalidParamException("设备不存在")).when(deviceMessageService)
  343 + .sendDeviceMessage(any(), any(), any(), any());
  344 +
  345 + // 调用
  346 + handleOnEventLoop(handler);
  347 +
  348 + // 断言:与原来一致,返回 CommonResult 的错误码(HTTP 状态码不变)
  349 + Map<String, Object> json = captureResponse();
  350 + assertEquals(BAD_REQUEST.getCode(), json.get("code"));
  351 + assertEquals("设备不存在", json.get("msg"));
  352 + }
  353 +
  354 + @Test
  355 + public void testHandle_standardPostUnknownException() throws Exception {
  356 + // 准备参数
  357 + IotHttpUpstreamHandler handler = new IotHttpUpstreamHandler(protocol);
  358 + mockStandardPostRequest();
  359 + // mock 方法
  360 + doThrow(new IllegalStateException("boom")).when(deviceMessageService)
  361 + .sendDeviceMessage(any(), any(), any(), any());
  362 +
  363 + // 调用
  364 + handleOnEventLoop(handler);
  365 +
  366 + // 断言
  367 + assertEquals(INTERNAL_SERVER_ERROR.getCode(), captureResponse().get("code"));
  368 + }
  369 +
  370 + // ========== 辅助方法 ==========
  371 +
  372 + private void mockRawPostRequest() {
  373 + mockRequest("thing/raw/post", "application/octet-stream", PAYLOAD);
  374 + when(context.vertx()).thenReturn(vertx);
  375 + }
  376 +
  377 + private void mockStandardPostRequest() {
  378 + mockRequest("thing/property/post", "application/json",
  379 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}".getBytes());
  380 + when(context.vertx()).thenReturn(vertx);
  381 + }
  382 +
  383 + private void mockRequest(String method, String contentType, byte[] body) {
  384 + // 请求:已认证(token 与路径一致)
  385 + when(context.request()).thenReturn(request);
  386 + when(context.response()).thenReturn(response);
  387 + lenient().when(request.path()).thenReturn("/topic/sys/" + PRODUCT_KEY + "/" + DEVICE_NAME + "/" + method);
  388 + when(request.getHeader(HttpHeaders.AUTHORIZATION)).thenReturn(TOKEN);
  389 + lenient().when(request.getHeader(HttpHeaders.CONTENT_TYPE)).thenReturn(contentType);
  390 + when(context.pathParam("productKey")).thenReturn(PRODUCT_KEY);
  391 + when(context.pathParam("deviceName")).thenReturn(DEVICE_NAME);
  392 + when(context.pathParam("*")).thenReturn(method);
  393 + when(context.body()).thenReturn(requestBody);
  394 + when(requestBody.buffer()).thenReturn(Buffer.buffer(body));
  395 + when(deviceTokenService.verifyToken(TOKEN)).thenReturn(new IotDeviceIdentity(PRODUCT_KEY, DEVICE_NAME));
  396 + }
  397 +
  398 + /**
  399 + * 在 Vert.x event loop 上执行 handle,与真实的 HTTP 请求一致
  400 + */
  401 + private void handleOnEventLoop(IotHttpUpstreamHandler handler) throws InterruptedException {
  402 + CountDownLatch latch = new CountDownLatch(1);
  403 + vertx.runOnContext(v -> {
  404 + try {
  405 + handler.handle(context);
  406 + } finally {
  407 + latch.countDown();
  408 + }
  409 + });
  410 + assertTrue(latch.await(5, TimeUnit.SECONDS));
  411 + }
  412 +
  413 + /**
  414 + * 等待并返回写出的 JSON 响应(只写一次)
  415 + */
  416 + private Map<String, Object> captureResponse() {
  417 + ArgumentCaptor<String> jsonCaptor = ArgumentCaptor.forClass(String.class);
  418 + verify(response, timeout(5000)).end(jsonCaptor.capture());
  419 + return JsonUtils.parseMap(jsonCaptor.getValue());
  420 + }
  421 +
  422 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtilsTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils;
  2 +
  3 +import cn.hutool.core.util.HexUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  6 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame;
  7 +import org.junit.jupiter.api.Test;
  8 +
  9 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION;
  10 +import static org.junit.jupiter.api.Assertions.*;
  11 +
  12 +/**
  13 + * {@link IotModbusCommonUtils} 的单元测试
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public class IotModbusCommonUtilsTest {
  18 +
  19 + @Test
  20 + public void testBuildResponsePdu_exception() {
  21 + // 准备参数:解码器已把 0x83 还原为原功能码 3,异常码单独存放
  22 + IotModbusFrame frame = new IotModbusFrame().setSlaveId(1).setFunctionCode(3)
  23 + .setPdu(new byte[]{0x02}).setExceptionCode(2);
  24 +
  25 + // 调用 + 断言:还原 0x80 异常位
  26 + assertArrayEquals(HexUtil.decodeHex("8302"), IotModbusCommonUtils.buildResponsePdu(frame));
  27 + }
  28 +
  29 + @Test
  30 + public void testBuildResponsePdu_normal() {
  31 + // 准备参数:解码器的 pdu 是功能码之后的数据(含 byteCount)
  32 + IotModbusFrame frame = new IotModbusFrame().setSlaveId(1).setFunctionCode(3)
  33 + .setPdu(HexUtil.decodeHex("0400FA0258"));
  34 +
  35 + // 调用 + 断言
  36 + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), IotModbusCommonUtils.buildResponsePdu(frame));
  37 + }
  38 +
  39 + @Test
  40 + public void testIsResponseShapeValid() {
  41 + // FC03 读 2 个寄存器:byteCount = 4
  42 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("0300100002"),
  43 + HexUtil.decodeHex("030400FA0258")));
  44 + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("0300100002"),
  45 + HexUtil.decodeHex("030200FA")));
  46 + // FC01 读 10 个线圈:byteCount = 2
  47 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("010000000A"),
  48 + HexUtil.decodeHex("01020103")));
  49 + // FC06:原样回显
  50 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("06001000DC"),
  51 + HexUtil.decodeHex("06001000DC")));
  52 + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("06001000DC"),
  53 + HexUtil.decodeHex("06001000DD")));
  54 + // FC16:回显地址和数量
  55 + assertTrue(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("100010000204000A0102"),
  56 + HexUtil.decodeHex("1000100002")));
  57 + assertFalse(IotModbusCommonUtils.isResponseShapeValid(HexUtil.decodeHex("100010000204000A0102"),
  58 + HexUtil.decodeHex("1000100001")));
  59 + }
  60 +
  61 + @Test
  62 + public void testGetRawResponseError() {
  63 + // 异常响应 83 02:MODBUS_EXCEPTION,文案带异常码
  64 + ErrorCode error = IotModbusCommonUtils.getRawResponseError(HexUtil.decodeHex("8302"));
  65 + assertNotNull(error);
  66 + assertEquals(MODBUS_EXCEPTION.getCode(), error.getCode());
  67 + assertEquals(StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2), error.getMsg());
  68 + // 正常响应:没有设备错误
  69 + assertNull(IotModbusCommonUtils.getRawResponseError(HexUtil.decodeHex("030400FA0258")));
  70 + }
  71 +
  72 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtilsTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils;
  2 +
  3 +import cn.hutool.core.util.HexUtil;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx;
  6 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection;
  7 +import com.ghgande.j2mod.modbus.ModbusIOException;
  8 +import com.ghgande.j2mod.modbus.ModbusSlaveException;
  9 +import com.ghgande.j2mod.modbus.io.AbstractModbusTransport;
  10 +import com.ghgande.j2mod.modbus.msg.ModbusRequest;
  11 +import com.ghgande.j2mod.modbus.msg.ModbusResponse;
  12 +import com.ghgande.j2mod.modbus.net.TCPMasterConnection;
  13 +import io.vertx.core.Future;
  14 +import io.vertx.core.Vertx;
  15 +import org.junit.jupiter.api.AfterEach;
  16 +import org.junit.jupiter.api.BeforeEach;
  17 +import org.junit.jupiter.api.Test;
  18 +
  19 +import java.io.ByteArrayInputStream;
  20 +import java.io.DataInputStream;
  21 +import java.io.IOException;
  22 +import java.net.ConnectException;
  23 +import java.net.SocketTimeoutException;
  24 +import java.util.concurrent.CountDownLatch;
  25 +import java.util.concurrent.TimeUnit;
  26 +import java.util.concurrent.atomic.AtomicReference;
  27 +
  28 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  29 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*;
  30 +import static org.junit.jupiter.api.Assertions.*;
  31 +import static org.mockito.ArgumentMatchers.any;
  32 +import static org.mockito.Mockito.*;
  33 +
  34 +/**
  35 + * {@link IotModbusTcpClientUtils} 的单元测试
  36 + *
  37 + * @author 芋道源码
  38 + */
  39 +public class IotModbusTcpClientUtilsTest {
  40 +
  41 + private static final int SLAVE_ID = 1;
  42 + /**
  43 + * 03 00 10 00 02:读 2 个保持寄存器
  44 + */
  45 + private static final String READ_PDU = "0300100002";
  46 +
  47 + private Vertx vertx;
  48 + private TCPMasterConnection tcpConnection;
  49 + private AbstractModbusTransport transport;
  50 + private ModbusConnection connection;
  51 +
  52 + /**
  53 + * 写出时请求上的事务号(setRequest 分配)
  54 + */
  55 + private final AtomicReference<Integer> sentTransactionId = new AtomicReference<>();
  56 + /**
  57 + * 写出的请求:execute 结束后它的事务号会被 j2mod 自增
  58 + */
  59 + private final AtomicReference<ModbusRequest> sentRequest = new AtomicReference<>();
  60 +
  61 + @BeforeEach
  62 + public void setUp() throws Exception {
  63 + vertx = Vertx.vertx();
  64 + tcpConnection = mock(TCPMasterConnection.class);
  65 + transport = mock(AbstractModbusTransport.class);
  66 + when(tcpConnection.isConnected()).thenReturn(true);
  67 + when(tcpConnection.getModbusTransport()).thenReturn(transport);
  68 + when(tcpConnection.getTimeout()).thenReturn(1000);
  69 + doAnswer(invocation -> {
  70 + ModbusRequest request = invocation.getArgument(0);
  71 + sentRequest.set(request);
  72 + sentTransactionId.set(request.getTransactionID());
  73 + return null;
  74 + }).when(transport).writeRequest(any(ModbusRequest.class));
  75 + connection = new ModbusConnection();
  76 + connection.setTcpConnection(tcpConnection);
  77 + connection.setContext(vertx.getOrCreateContext());
  78 + }
  79 +
  80 + @AfterEach
  81 + public void tearDown() throws Exception {
  82 + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
  83 + }
  84 +
  85 + @Test
  86 + public void testIsTransportFailure() {
  87 + // 传输失败:沿 cause 链找到 ModbusIOException / IOException(read / write 会包一层 RuntimeException)
  88 + assertTrue(IotModbusTcpClientUtils.isTransportFailure(
  89 + new RuntimeException("Modbus 读取失败", new ModbusIOException("Socket timeout reading response - Read timed out"))));
  90 + assertTrue(IotModbusTcpClientUtils.isTransportFailure(new RuntimeException(new SocketTimeoutException())));
  91 + assertTrue(IotModbusTcpClientUtils.isTransportFailure(new ConnectException("Connection refused")));
  92 + // 设备异常响应:链路正常
  93 + assertFalse(IotModbusTcpClientUtils.isTransportFailure(new RuntimeException(new ModbusSlaveException(2))));
  94 + // 网关自己判定的失败(排队过期、响应不合法、连接已失效)
  95 + assertFalse(IotModbusTcpClientUtils.isTransportFailure(exception(MODBUS_RESPONSE_INVALID)));
  96 + assertFalse(IotModbusTcpClientUtils.isTransportFailure(null));
  97 + }
  98 +
  99 + @Test
  100 + public void testToRawFailure() {
  101 + // ServiceException:保留错误码
  102 + assertEquals(MODBUS_DISCONNECTED.getCode(),
  103 + IotModbusTcpClientUtils.toRawFailure(exception(MODBUS_DISCONNECTED)).getCode());
  104 + // j2mod 读超时(cause 已丢失,只能按消息判断)→ 110
  105 + assertEquals(MODBUS_TIMEOUT.getCode(), IotModbusTcpClientUtils.toRawFailure(new RuntimeException(
  106 + new ModbusIOException("Executing transaction 00 failed (tried 1 times) Socket timeout reading response - Read timed out")))
  107 + .getCode());
  108 + // 其它 IO 失败 → 106
  109 + assertEquals(DEVICE_OFFLINE_OR_WRITE_FAILED.getCode(),
  110 + IotModbusTcpClientUtils.toRawFailure(new ConnectException("Connection refused")).getCode());
  111 + }
  112 +
  113 + // ========== 标准读写:保持原逻辑,超时不关闭共享连接 ==========
  114 +
  115 + @Test
  116 + public void testExecuteBlocking_standardTimeoutKeepSharedConnection() throws Exception {
  117 + // mock 方法:同一个物理连接上的从站 2 照常回正常响应
  118 + mockResponse(0, SLAVE_ID + 1, "0304" + "00FA0258");
  119 +
  120 + // 调用:从站 1 的标准读取超时(与 read 一样,把 j2mod 的 ModbusIOException 包一层 RuntimeException 抛出)
  121 + Future<int[]> timeout = await(connection.<int[]>executeBlocking(wire -> {
  122 + throw new RuntimeException("Modbus 读取失败 [slaveId=1]",
  123 + new ModbusIOException("Socket timeout reading response - Read timed out"));
  124 + }));
  125 + // 调用:之后从站 2 的标准读取
  126 + Future<int[]> next = await(IotModbusTcpClientUtils.read(connection, SLAVE_ID + 1, 3, 0x10, 2));
  127 +
  128 + // 断言:超时照常失败,但不关闭、不重连共享的物理连接;从站 2 的读取照常完成
  129 + assertTrue(timeout.failed());
  130 + assertTrue(IotModbusTcpClientUtils.isTransportFailure(timeout.cause()));
  131 + assertTrue(next.succeeded());
  132 + assertArrayEquals(new int[]{0x00FA, 0x0258}, next.result());
  133 + verify(tcpConnection, never()).close();
  134 + verify(tcpConnection, never()).connect();
  135 + }
  136 +
  137 + @Test
  138 + public void testExecuteBlocking_retired() throws Exception {
  139 + // 准备参数:连接已被 closeConnection 移出连接池
  140 + connection.setRetired(true);
  141 +
  142 + // 调用
  143 + Future<int[]> result = await(IotModbusTcpClientUtils.read(connection, SLAVE_ID, 3, 0x10, 2));
  144 +
  145 + // 断言:排队中的标准任务不再执行,直接 115,不会重新连上被关闭的连接
  146 + assertTrue(result.failed());
  147 + assertInstanceOf(ServiceException.class, result.cause());
  148 + assertEquals(MODBUS_DISCONNECTED.getCode(), ((ServiceException) result.cause()).getCode());
  149 + verify(transport, never()).writeRequest(any(ModbusRequest.class));
  150 + verify(tcpConnection, never()).connect();
  151 + }
  152 +
  153 + // ========== executeRaw:传输失败 ==========
  154 +
  155 + @Test
  156 + public void testExecuteRaw_timeoutCloseInOrderedTask() throws Exception {
  157 + // mock 方法:读响应超时(j2mod 的 ModbusTCPTransport 把 SocketTimeoutException 转为 ModbusIOException)
  158 + when(transport.readResponse()).thenThrow(new ModbusIOException("Socket timeout reading response - Read timed out"));
  159 +
  160 + // 调用
  161 + Future<byte[]> result = executeRaw();
  162 +
  163 + // 断言:只尝试 1 次;透传的守卫在本有序任务内关闭连接(迟到的响应不会被下一个事务读到),转换为 110
  164 + assertTrue(result.failed());
  165 + assertEquals(MODBUS_TIMEOUT.getCode(), IotModbusTcpClientUtils.toRawFailure(result.cause()).getCode());
  166 + verify(transport, times(1)).writeRequest(any(ModbusRequest.class));
  167 + verify(tcpConnection, times(1)).close();
  168 + }
  169 +
  170 + // ========== executeRaw:正常响应 ==========
  171 +
  172 + @Test
  173 + public void testExecuteRaw_normalUseSavedIdentity() throws Exception {
  174 + // mock 方法:设备按发出的事务号、unit 回正常响应
  175 + mockResponse(0, SLAVE_ID, "0304" + "00FA0258");
  176 +
  177 + // 调用
  178 + Future<byte[]> result = executeRaw();
  179 +
  180 + // 断言:用 execute 之前保存的身份核对通过;j2mod 已在 execute 之后把请求的事务号自增
  181 + assertTrue(result.succeeded());
  182 + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), result.result());
  183 + assertNotEquals(sentTransactionId.get().intValue(), sentRequest.get().getTransactionID());
  184 + verify(tcpConnection, never()).close();
  185 + // 断言:只尝试 1 次
  186 + verify(transport, times(1)).writeRequest(any(ModbusRequest.class));
  187 + }
  188 +
  189 + @Test
  190 + public void testExecuteRaw_normalUnitMismatch() throws Exception {
  191 + // mock 方法:事务号正确,unit 不对(j2mod 不核对 unit)
  192 + mockResponse(0, SLAVE_ID + 1, "0304" + "00FA0258");
  193 +
  194 + // 调用
  195 + Future<byte[]> result = executeRaw();
  196 +
  197 + // 断言:114,并在本有序任务内关闭连接
  198 + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode());
  199 + verify(tcpConnection, times(1)).close();
  200 + }
  201 +
  202 + @Test
  203 + public void testExecuteRaw_normalTransactionIdMismatch() throws Exception {
  204 + // mock 方法:事务号不对(j2mod 核对正常响应的事务号,只尝试 1 次就失败)
  205 + mockResponse(1, SLAVE_ID, "0304" + "00FA0258");
  206 +
  207 + // 调用
  208 + Future<byte[]> result = executeRaw();
  209 +
  210 + // 断言:传输失败,守卫在本有序任务内关闭连接;不重发
  211 + assertTrue(result.failed());
  212 + assertFalse(result.cause() instanceof ServiceException);
  213 + verify(tcpConnection, times(1)).close();
  214 + verify(transport, times(1)).writeRequest(any(ModbusRequest.class));
  215 + }
  216 +
  217 + @Test
  218 + public void testExecuteRaw_normalFunctionCodeMismatch() throws Exception {
  219 + // mock 方法:事务号、unit 正确,功能码不对
  220 + mockResponse(0, SLAVE_ID, "0404" + "00FA0258");
  221 +
  222 + // 调用
  223 + Future<byte[]> result = executeRaw();
  224 +
  225 + // 断言
  226 + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode());
  227 + verify(tcpConnection, times(1)).close();
  228 + }
  229 +
  230 + // ========== executeRaw:异常响应 ==========
  231 +
  232 + @Test
  233 + public void testExecuteRaw_exceptionCorrelated() throws Exception {
  234 + // mock 方法:设备按发出的事务号、unit 回 83 02
  235 + mockResponse(0, SLAVE_ID, "8302");
  236 +
  237 + // 调用
  238 + Future<byte[]> result = executeRaw();
  239 +
  240 + // 断言:取 transaction.getResponse() 的真实异常响应,还原为 83 02,作为成功结果;链路正常不关闭
  241 + assertTrue(result.succeeded());
  242 + assertArrayEquals(HexUtil.decodeHex("8302"), result.result());
  243 + verify(tcpConnection, never()).close();
  244 + }
  245 +
  246 + @Test
  247 + public void testExecuteRaw_exceptionTransactionIdMismatch() throws Exception {
  248 + // mock 方法:异常响应的事务号不对(j2mod 在核对事务号之前就抛出 ModbusSlaveException)
  249 + mockResponse(1, SLAVE_ID, "8302");
  250 +
  251 + // 调用
  252 + Future<byte[]> result = executeRaw();
  253 +
  254 + // 断言:不按当前功能码 + 异常码拼出归属:114,并关闭连接
  255 + assertFailedWith(result, MODBUS_RESPONSE_INVALID.getCode());
  256 + verify(tcpConnection, times(1)).close();
  257 + }
  258 +
  259 + @Test
  260 + public void testExecuteRaw_exceptionUnitOrFunctionCodeMismatch() throws Exception {
  261 + // mock 方法:unit 不对
  262 + mockResponse(0, SLAVE_ID + 1, "8302");
  263 +
  264 + // 调用 + 断言
  265 + assertFailedWith(executeRaw(), MODBUS_RESPONSE_INVALID.getCode());
  266 + verify(tcpConnection, times(1)).close();
  267 +
  268 + // mock 方法:功能码不对(FC03 的请求收到 84 02)
  269 + mockResponse(0, SLAVE_ID, "8402");
  270 +
  271 + // 调用 + 断言
  272 + assertFailedWith(executeRaw(), MODBUS_RESPONSE_INVALID.getCode());
  273 + verify(tcpConnection, times(2)).close();
  274 + }
  275 +
  276 + // ========== 辅助方法 ==========
  277 +
  278 + /**
  279 + * mock 传输层的读响应:事务号 = 写出的事务号 + transactionIdOffset
  280 + *
  281 + * @param transactionIdOffset 事务号偏移(0 表示正确)
  282 + * @param unitId 响应的 unit
  283 + * @param pduHex 响应 PDU
  284 + */
  285 + private void mockResponse(int transactionIdOffset, int unitId, String pduHex) throws Exception {
  286 + doAnswer(invocation -> {
  287 + byte[] pdu = HexUtil.decodeHex(pduHex);
  288 + int transactionId = (sentTransactionId.get() + transactionIdOffset) & 0xFFFF;
  289 + return readResponse(String.format("%04X0000%04X%02X", transactionId, pdu.length + 1, unitId) + pduHex);
  290 + }).when(transport).readResponse();
  291 + }
  292 +
  293 + private Future<byte[]> executeRaw() throws InterruptedException {
  294 + IotModbusTx tx = IotModbusTx.ofClient(null, HexUtil.decodeHex(READ_PDU),
  295 + System.nanoTime() + TimeUnit.SECONDS.toNanos(60), null);
  296 + return await(IotModbusTcpClientUtils.executeRaw(connection, SLAVE_ID, tx));
  297 + }
  298 +
  299 + /**
  300 + * 等待 Future 完成(成功或失败)
  301 + */
  302 + private static <T> Future<T> await(Future<T> result) throws InterruptedException {
  303 + CountDownLatch latch = new CountDownLatch(1);
  304 + result.onComplete(ar -> latch.countDown());
  305 + assertTrue(latch.await(5, TimeUnit.SECONDS));
  306 + return result;
  307 + }
  308 +
  309 + /**
  310 + * 与 ModbusTCPTransport.readResponse 相同:按功能码创建标准响应,再从完整 MBAP 帧读入
  311 + */
  312 + private static ModbusResponse readResponse(String hexFrame) throws IOException {
  313 + byte[] frame = HexUtil.decodeHex(hexFrame);
  314 + ModbusResponse response = ModbusResponse.createModbusResponse(frame[7] & 0xFF);
  315 + response.readFrom(new DataInputStream(new ByteArrayInputStream(frame)));
  316 + // ModbusTCPTransport.readResponse 读完后再设置一次事务号
  317 + response.setTransactionID(((frame[0] & 0xFF) << 8) | (frame[1] & 0xFF));
  318 + return response;
  319 + }
  320 +
  321 + private static void assertFailedWith(Future<byte[]> result, Integer code) {
  322 + assertTrue(result.failed());
  323 + assertInstanceOf(ServiceException.class, result.cause());
  324 + assertEquals(code, ((ServiceException) result.cause()).getCode());
  325 + }
  326 +
  327 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusRawRequestTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream;
  2 +
  3 +import cn.hutool.core.util.HexUtil;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import com.ghgande.j2mod.modbus.msg.ModbusResponse;
  6 +import org.junit.jupiter.api.Test;
  7 +
  8 +import java.io.ByteArrayInputStream;
  9 +import java.io.ByteArrayOutputStream;
  10 +import java.io.DataInputStream;
  11 +import java.io.DataOutputStream;
  12 +import java.io.IOException;
  13 +
  14 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_RESPONSE_INVALID;
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +
  17 +/**
  18 + * {@link IotModbusRawRequest} 的单元测试
  19 + *
  20 + * @author 芋道源码
  21 + */
  22 +public class IotModbusRawRequestTest {
  23 +
  24 + @Test
  25 + public void testWriteTo_fc03() throws IOException {
  26 + // 准备参数
  27 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002"));
  28 + request.setUnitID(1);
  29 + request.setTransactionID(7);
  30 +
  31 + // 调用
  32 + ByteArrayOutputStream out = new ByteArrayOutputStream();
  33 + request.writeTo(new DataOutputStream(out));
  34 +
  35 + // 断言:[MBAP: 事务号 7、协议 0、长度 6][unit 1][03 00 10 00 02]
  36 + assertArrayEquals(HexUtil.decodeHex("000700000006010300100002"), out.toByteArray());
  37 + // ModbusTCPTransport.writeMessage 写出的是 getMessage():PDU 去掉功能码之后的部分
  38 + assertArrayEquals(HexUtil.decodeHex("00100002"), request.getMessage());
  39 + assertEquals(3, request.getFunctionCode());
  40 + }
  41 +
  42 + @Test
  43 + public void testBuildResponsePdu_fc03() throws IOException {
  44 + // 准备参数
  45 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002"));
  46 + ModbusResponse response = readResponse("000700000007010304" + "00FA0258");
  47 +
  48 + // 调用 + 断言
  49 + assertArrayEquals(HexUtil.decodeHex("030400FA0258"), request.buildResponsePdu(response));
  50 + }
  51 +
  52 + @Test
  53 + public void testBuildResponsePdu_fc06() throws IOException {
  54 + // 准备参数
  55 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("06001000DC"));
  56 + ModbusResponse response = readResponse("0007000000060106001000DC");
  57 +
  58 + // 调用 + 断言:原样回显
  59 + assertArrayEquals(HexUtil.decodeHex("06001000DC"), request.buildResponsePdu(response));
  60 + }
  61 +
  62 + @Test
  63 + public void testBuildResponsePdu_fc10() throws IOException {
  64 + // 准备参数
  65 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("100010000204000A0102"));
  66 + ModbusResponse response = readResponse("000700000006011000100002");
  67 +
  68 + // 调用 + 断言:回显地址和数量
  69 + assertArrayEquals(HexUtil.decodeHex("1000100002"), request.buildResponsePdu(response));
  70 + }
  71 +
  72 + @Test
  73 + public void testBuildResponsePdu_shapeInvalid() throws IOException {
  74 + // 准备参数:读 2 个寄存器,设备只回了 1 个
  75 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002"));
  76 + ModbusResponse response = readResponse("00070000000501030200FA");
  77 +
  78 + // 调用 + 断言
  79 + ServiceException ex = assertThrows(ServiceException.class, () -> request.buildResponsePdu(response));
  80 + assertEquals(MODBUS_RESPONSE_INVALID.getCode(), ex.getCode());
  81 + }
  82 +
  83 + @Test
  84 + public void testBuildResponsePdu_exception() throws IOException {
  85 + // 准备参数:transaction.getResponse() 拿到的真实异常响应 83 02
  86 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002"));
  87 + ModbusResponse response = readResponse("00070000000301" + "8302");
  88 +
  89 + // 调用 + 断言:还原为 0x83 + 异常码
  90 + assertArrayEquals(HexUtil.decodeHex("8302"), request.buildResponsePdu(response));
  91 + }
  92 +
  93 + @Test
  94 + public void testBuildResponsePdu_exceptionFunctionCodeMismatch() throws IOException {
  95 + // 准备参数:FC03 的请求收到 84 02
  96 + IotModbusRawRequest request = new IotModbusRawRequest(HexUtil.decodeHex("0300100002"));
  97 + ModbusResponse response = readResponse("00070000000301" + "8402");
  98 +
  99 + // 调用 + 断言
  100 + ServiceException ex = assertThrows(ServiceException.class, () -> request.buildResponsePdu(response));
  101 + assertEquals(MODBUS_RESPONSE_INVALID.getCode(), ex.getCode());
  102 + }
  103 +
  104 + @Test
  105 + public void testIsResponseOf_normal() throws IOException {
  106 + // 准备参数:发出的身份 = 事务号 7、unit 1、FC03
  107 + ModbusResponse response = readResponse("000700000007010304" + "00FA0258");
  108 +
  109 + // 调用 + 断言:逐项核对事务号、unit、功能码
  110 + assertTrue(IotModbusRawRequest.isResponseOf(response, 7, 1, 3));
  111 + assertFalse(IotModbusRawRequest.isResponseOf(response, 8, 1, 3));
  112 + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 2, 3));
  113 + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 1, 4));
  114 + assertFalse(IotModbusRawRequest.isResponseOf(null, 7, 1, 3));
  115 + }
  116 +
  117 + @Test
  118 + public void testIsResponseOf_exception() throws IOException {
  119 + // 准备参数:j2mod 不核对异常响应的事务号,这里同样逐项核对
  120 + ModbusResponse response = readResponse("00070000000301" + "8302");
  121 +
  122 + // 调用 + 断言:异常响应的功能码是 fc | 0x80
  123 + assertTrue(IotModbusRawRequest.isResponseOf(response, 7, 1, 3));
  124 + assertFalse(IotModbusRawRequest.isResponseOf(response, 6, 1, 3));
  125 + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 2, 3));
  126 + assertFalse(IotModbusRawRequest.isResponseOf(response, 7, 1, 4));
  127 + }
  128 +
  129 + /**
  130 + * 与 ModbusTCPTransport.readResponse 相同:按功能码创建标准响应,再从完整 MBAP 帧读入
  131 + */
  132 + private static ModbusResponse readResponse(String hexFrame) throws IOException {
  133 + byte[] frame = HexUtil.decodeHex(hexFrame);
  134 + ModbusResponse response = ModbusResponse.createModbusResponse(frame[7] & 0xFF);
  135 + response.readFrom(new DataInputStream(new ByteArrayInputStream(frame)));
  136 + return response;
  137 + }
  138 +
  139 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriberTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  4 +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
  5 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  6 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol;
  7 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager;
  8 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection;
  9 +import io.vertx.core.AsyncResult;
  10 +import io.vertx.core.Context;
  11 +import io.vertx.core.Future;
  12 +import io.vertx.core.Handler;
  13 +import org.junit.jupiter.api.BeforeEach;
  14 +import org.junit.jupiter.api.Test;
  15 +import org.junit.jupiter.api.extension.ExtendWith;
  16 +import org.mockito.Mock;
  17 +import org.mockito.junit.jupiter.MockitoExtension;
  18 +
  19 +import java.util.concurrent.Callable;
  20 +import java.util.concurrent.RejectedExecutionException;
  21 +
  22 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*;
  23 +import static org.junit.jupiter.api.Assertions.*;
  24 +import static org.mockito.ArgumentMatchers.any;
  25 +import static org.mockito.ArgumentMatchers.eq;
  26 +import static org.mockito.Mockito.*;
  27 +
  28 +/**
  29 + * {@link IotModbusTcpClientDownstreamSubscriber} 的单元测试
  30 + *
  31 + * @author 芋道源码
  32 + */
  33 +@ExtendWith(MockitoExtension.class)
  34 +public class IotModbusTcpClientDownstreamSubscriberTest {
  35 +
  36 + private static final Long DEVICE_ID = 1L;
  37 + /**
  38 + * 03 00 10 00 02:读 2 个保持寄存器
  39 + */
  40 + private static final byte[] READ_PDU = {0x03, 0x00, 0x10, 0x00, 0x02};
  41 +
  42 + @Mock
  43 + private IotModbusTcpClientProtocol protocol;
  44 + @Mock
  45 + private IotModbusTcpClientDownstreamHandler downstreamHandler;
  46 + @Mock
  47 + private IotModbusTcpClientConnectionManager connectionManager;
  48 + @Mock
  49 + private IotMessageBus messageBus;
  50 +
  51 + private IotModbusTcpClientDownstreamSubscriber subscriber;
  52 +
  53 + @BeforeEach
  54 + public void setUp() {
  55 + subscriber = new IotModbusTcpClientDownstreamSubscriber(protocol, downstreamHandler, connectionManager, messageBus);
  56 + }
  57 +
  58 + @Test
  59 + public void testHandleRawMessage_pduInvalid() {
  60 + // 准备参数:开头多带了 slaveId 的 PDU(01 06 00 10 00 DC)
  61 + byte[] pdu = {0x01, 0x06, 0x00, 0x10, 0x00, (byte) 0xDC};
  62 +
  63 + // 调用
  64 + Future<Void> result = subscriber.handleRawMessage(buildMessage(), pdu, null);
  65 +
  66 + // 断言
  67 + assertFailedWith(result, MODBUS_PDU_INVALID.getCode());
  68 + }
  69 +
  70 + @Test
  71 + public void testHandleRawMessage_notConnected() {
  72 + // mock 方法
  73 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(null);
  74 +
  75 + // 调用
  76 + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null);
  77 +
  78 + // 断言
  79 + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode());
  80 + }
  81 +
  82 + @Test
  83 + public void testHandleRawMessage_rawSlotFull() {
  84 + // 准备参数:同一连接已有 32 个 raw 在途或排队
  85 + ModbusConnection connection = new ModbusConnection();
  86 + connection.addDevice(DEVICE_ID, 1);
  87 + for (int i = 0; i < IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING; i++) {
  88 + assertTrue(connection.tryAcquireRawSlot(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING));
  89 + }
  90 + // mock 方法
  91 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection);
  92 +
  93 + // 调用:第 33 个
  94 + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null);
  95 +
  96 + // 断言:立即 113,不进入 executeBlocking(connection 没有 Context),名额不泄漏
  97 + assertFailedWith(result, MODBUS_QUEUE_FULL.getCode());
  98 + assertEquals(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING, connection.getRawOutstanding().get());
  99 + // 释放一个后可以再次占用
  100 + connection.releaseRawSlot();
  101 + assertTrue(connection.tryAcquireRawSlot(IotModbusTcpClientDownstreamSubscriber.MAX_RAW_OUTSTANDING));
  102 + }
  103 +
  104 + @Test
  105 + @SuppressWarnings("unchecked")
  106 + public void testHandleRawMessage_submitRejected() {
  107 + // 准备参数:连接的 Context 已关闭,提交 executeBlocking 时同步拒绝
  108 + Context context = mock(Context.class);
  109 + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context);
  110 + connection.addDevice(DEVICE_ID, 1);
  111 + // mock 方法
  112 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection);
  113 + when(context.executeBlocking(any(Callable.class), eq(true)))
  114 + .thenThrow(new RejectedExecutionException("context closed"));
  115 +
  116 + // 调用:连续 2 次
  117 + for (int i = 0; i < 2; i++) {
  118 + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null);
  119 +
  120 + // 断言:返回失败 Future(由调用方记日志),名额只释放一次,不泄漏、不变负
  121 + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode());
  122 + assertEquals(0, connection.getRawOutstanding().get());
  123 + }
  124 + // 断言:没有进入事务,不回复 biz
  125 + verify(context, times(2)).executeBlocking(any(Callable.class), eq(true));
  126 + verify(protocol, never()).getVertx();
  127 + }
  128 +
  129 + @Test
  130 + @SuppressWarnings("unchecked")
  131 + public void testHandleRawMessage_onCompleteRejected() {
  132 + // 准备参数:提交成功,但注册完成回调时同步拒绝(Context 已关闭,无法把回调投递到 owner)
  133 + Context context = mock(Context.class);
  134 + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context);
  135 + connection.addDevice(DEVICE_ID, 1);
  136 + Future<byte[]> execute = mock(Future.class);
  137 + // mock 方法
  138 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection);
  139 + doReturn(execute).when(context).executeBlocking(any(Callable.class), eq(true));
  140 + when(execute.onComplete(any())).thenThrow(new RejectedExecutionException("event executor terminated"));
  141 +
  142 + // 调用:连续 2 次
  143 + for (int i = 0; i < 2; i++) {
  144 + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null);
  145 +
  146 + // 断言:返回失败 Future,名额释放,不泄漏、不变负
  147 + assertFailedWith(result, DEVICE_OFFLINE_OR_WRITE_FAILED.getCode());
  148 + assertEquals(0, connection.getRawOutstanding().get());
  149 + }
  150 + // 断言:不回复 biz
  151 + verify(protocol, never()).getVertx();
  152 + }
  153 +
  154 + @Test
  155 + @SuppressWarnings("unchecked")
  156 + public void testHandleRawMessage_onCompleteThrowsAfterCallback() {
  157 + // 准备参数:注册完成回调时,回调已经执行(事务已是终态),之后才抛出异常
  158 + Context context = mock(Context.class);
  159 + ModbusConnection connection = new ModbusConnection().setTimeout(1000).setContext(context);
  160 + connection.addDevice(DEVICE_ID, 1);
  161 + Future<byte[]> execute = mock(Future.class);
  162 + // mock 方法
  163 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection);
  164 + doReturn(execute).when(context).executeBlocking(any(Callable.class), eq(true));
  165 + doAnswer(invocation -> {
  166 + Handler<AsyncResult<byte[]>> handler = invocation.getArgument(0);
  167 + handler.handle(Future.failedFuture(new RuntimeException("Connection reset")));
  168 + throw new RejectedExecutionException("event executor terminated");
  169 + }).when(execute).onComplete(any());
  170 +
  171 + // 调用
  172 + Future<Void> result = subscriber.handleRawMessage(buildMessage(), READ_PDU, null);
  173 +
  174 + // 断言:事务终态时已成功完成 Future(网关判定的失败只记日志),之后的异常不改变它;名额只释放一次,不变负
  175 + assertTrue(result.succeeded());
  176 + assertEquals(0, connection.getRawOutstanding().get());
  177 + // 断言:不回复 biz
  178 + verify(protocol, never()).getVertx();
  179 + }
  180 +
  181 + private static IotDeviceMessage buildMessage() {
  182 + return new IotDeviceMessage().setDeviceId(DEVICE_ID).setRequestId("r1").setMethod("thing.raw.push");
  183 + }
  184 +
  185 + private static void assertFailedWith(Future<Void> result, Integer code) {
  186 + assertTrue(result.failed());
  187 + assertInstanceOf(ServiceException.class, result.cause());
  188 + assertEquals(code, ((ServiceException) result.cause()).getCode());
  189 + }
  190 +
  191 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollSchedulerTest.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager;
2 2  
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
3 4 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
4 5 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  7 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  8 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils;
  9 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler;
  10 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager.ModbusConnection;
5 11 import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler.ReadSegment;
  12 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  13 +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue;
  14 +import io.vertx.core.Context;
  15 +import io.vertx.core.Vertx;
6 16 import org.junit.jupiter.api.Test;
  17 +import org.mockito.ArgumentCaptor;
  18 +import org.mockito.Mock;
7 19  
8 20 import java.util.Arrays;
9 21 import java.util.Collections;
10 22 import java.util.List;
  23 +import java.util.concurrent.CompletableFuture;
  24 +import java.util.concurrent.CountDownLatch;
  25 +import java.util.concurrent.TimeUnit;
  26 +import java.util.concurrent.atomic.AtomicBoolean;
11 27  
12 28 import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_COILS;
13 29 import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS;
14 30 import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_INPUT_REGISTERS;
15 31 import static org.junit.jupiter.api.Assertions.*;
  32 +import static org.mockito.ArgumentMatchers.any;
  33 +import static org.mockito.ArgumentMatchers.eq;
  34 +import static org.mockito.Mockito.*;
16 35  
17 36 /**
18 37 * {@link IotModbusTcpClientPollScheduler} 的单元测试
19 38 *
20 39 * @author 芋道源码
21 40 */
22   -public class IotModbusTcpClientPollSchedulerTest {
  41 +public class IotModbusTcpClientPollSchedulerTest extends BaseMockitoUnitTest {
  42 +
  43 + private static final long TIMEOUT_SECONDS = 10;
  44 + private static final Long DEVICE_ID = 1L;
  45 + private static final Long POINT_ID = 1L;
  46 + private static final int SLAVE_ID = 1;
  47 + private static final String PRODUCT_KEY = "pk";
  48 + private static final String DEVICE_NAME = "dn";
  49 + private static final String SERVER_ID = "modbus-tcp-client";
  50 +
  51 + @Mock
  52 + private IotModbusTcpClientConnectionManager connectionManager;
  53 + @Mock
  54 + private IotModbusTcpClientConfigCacheService configCacheService;
  55 + @Mock
  56 + private IotDeviceMessageService messageService;
  57 + @Mock
  58 + private ModbusConnection connection;
  59 +
  60 + @Test
  61 + public void testPollPoint_reportNotBlockConnectionContext() throws Exception {
  62 + Vertx vertx = Vertx.vertx();
  63 + try {
  64 + // 准备参数:连接的 context 与它上面的上报队列;点位 FC03 读 1 个寄存器
  65 + Context context = vertx.getOrCreateContext();
  66 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  67 + IotModbusPointRespDTO point = randomPoint(POINT_ID, FC_READ_HOLDING_REGISTERS, 0, 1, 1000);
  68 + point.setIdentifier("temperature");
  69 + point.setRawDataType("INT16");
  70 + IotModbusDeviceConfigRespDTO config = randomConfig(point);
  71 + config.setProductKey(PRODUCT_KEY);
  72 + config.setDeviceName(DEVICE_NAME);
  73 + IotModbusTcpClientPollScheduler pollScheduler = new IotModbusTcpClientPollScheduler(vertx, connectionManager,
  74 + new IotModbusTcpClientUpstreamHandler(messageService, SERVER_ID), configCacheService);
  75 + CountDownLatch entered = new CountDownLatch(1);
  76 + CountDownLatch release = new CountDownLatch(1);
  77 + CountDownLatch delivered = new CountDownLatch(2);
  78 + AtomicBoolean onWorker = new AtomicBoolean(true);
  79 + // mock 方法:配置、连接、slave ID
  80 + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(config);
  81 + when(connectionManager.getConnection(DEVICE_ID)).thenReturn(connection);
  82 + when(connectionManager.getSlaveId(DEVICE_ID)).thenReturn(SLAVE_ID);
  83 + when(connection.getUpstreamQueue()).thenReturn(queue);
  84 + // mock 方法:与真实连接一样,读取在连接 context 的有序 executeBlocking 上完成,结果回调回到 context;两次读到 250、251
  85 + when(connection.executeBlocking(any())).thenAnswer(invocation -> context.executeBlocking(() -> new int[]{250}, true))
  86 + .thenAnswer(invocation -> context.executeBlocking(() -> new int[]{251}, true));
  87 + // mock 方法:发送到消息总线,第一条阻塞直到放行
  88 + doAnswer(invocation -> {
  89 + onWorker.compareAndSet(true, Context.isOnWorkerThread());
  90 + entered.countDown();
  91 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  92 + throw new IllegalStateException("等待放行超时");
  93 + }
  94 + delivered.countDown();
  95 + return null;
  96 + }).doAnswer(invocation -> {
  97 + onWorker.compareAndSet(true, Context.isOnWorkerThread());
  98 + delivered.countDown();
  99 + return null;
  100 + }).when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  101 +
  102 + try {
  103 + // 调用:第一次轮询,发送阻塞在 worker 上
  104 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID);
  105 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  106 + // 调用:发送仍阻塞时,第二次轮询;再在连接的 context 上执行一个哨兵任务
  107 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID);
  108 + CompletableFuture<Boolean> sentinel = new CompletableFuture<>();
  109 + context.runOnContext(v -> sentinel.complete(true));
  110 +
  111 + // 断言:context 没有被发送阻塞,哨兵及时执行;第二条上报排在第一条之后,还没有发送
  112 + assertTrue(sentinel.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  113 + verify(connection, timeout(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS)).times(2)).executeBlocking(any());
  114 + verify(messageService, times(1)).sendDeviceMessage(any(), any(), any(), any());
  115 + } finally {
  116 + release.countDown();
  117 + }
  118 + // 断言:放行后两条上报按读取顺序各投递一次,都在 worker 上发送
  119 + assertTrue(delivered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  120 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  121 + verify(messageService, times(2)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  122 + assertTrue(onWorker.get());
  123 + List<IotDeviceMessage> messages = captor.getAllValues();
  124 + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), messages.get(0).getMethod());
  125 + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{250}, point)),
  126 + messages.get(0).getParams());
  127 + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{251}, point)),
  128 + messages.get(1).getParams());
  129 + } finally {
  130 + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  131 + }
  132 + }
23 133  
24 134 @Test
25 135 public void testBuildReadSegments_mergeContinuousPoints() {
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriberTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream;
  2 +
  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.framework.test.core.ut.BaseMockitoUnitTest;
  7 +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
  8 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  9 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource;
  10 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx;
  11 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol;
  12 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager;
  13 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo;
  14 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel;
  15 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  16 +import io.vertx.core.Future;
  17 +import io.vertx.core.Vertx;
  18 +import org.junit.jupiter.api.AfterEach;
  19 +import org.junit.jupiter.api.BeforeEach;
  20 +import org.junit.jupiter.api.Test;
  21 +import org.mockito.ArgumentCaptor;
  22 +import org.mockito.Mock;
  23 +import org.mockito.MockedStatic;
  24 +
  25 +import java.util.concurrent.Callable;
  26 +
  27 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_EXCEPTION;
  28 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.MODBUS_TIMEOUT;
  29 +import static org.junit.jupiter.api.Assertions.*;
  30 +import static org.mockito.AdditionalMatchers.aryEq;
  31 +import static org.mockito.ArgumentMatchers.any;
  32 +import static org.mockito.ArgumentMatchers.eq;
  33 +import static org.mockito.Mockito.*;
  34 +
  35 +/**
  36 + * {@link IotModbusTcpServerDownstreamSubscriber} 的单元测试
  37 + *
  38 + * @author 芋道源码
  39 + */
  40 +public class IotModbusTcpServerDownstreamSubscriberTest extends BaseMockitoUnitTest {
  41 +
  42 + private static final Long DEVICE_ID = 1L;
  43 + private static final String SERVER_ID = "server-1";
  44 + /**
  45 + * 06 00 10 00 01:写单个寄存器
  46 + */
  47 + private static final byte[] WRITE_PDU = {0x06, 0x00, 0x10, 0x00, 0x01};
  48 +
  49 + @Mock
  50 + private IotModbusTcpServerProtocol protocol;
  51 + @Mock
  52 + private IotModbusTcpServerDownstreamHandler downstreamHandler;
  53 + @Mock
  54 + private IotModbusTcpServerConnectionManager connectionManager;
  55 + @Mock
  56 + private IotMessageBus messageBus;
  57 + @Mock
  58 + private IotModbusTcpServerTransactionChannel channel;
  59 + @Mock
  60 + private Vertx vertx;
  61 + @Mock
  62 + private IotDeviceMessageService deviceMessageService;
  63 +
  64 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  65 + private IotModbusTcpServerDownstreamSubscriber subscriber;
  66 +
  67 + private final IotScriptSource source = IotScriptSource.of(100L, "function decode(ctx) {}");
  68 +
  69 + @BeforeEach
  70 + public void setUp() {
  71 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  72 + subscriber = new IotModbusTcpServerDownstreamSubscriber(protocol, downstreamHandler, connectionManager, messageBus);
  73 + when(connectionManager.getConnectionInfoByDeviceId(DEVICE_ID)).thenReturn(new ConnectionInfo().setChannel(channel));
  74 + }
  75 +
  76 + @AfterEach
  77 + public void tearDown() {
  78 + springUtilMockedStatic.close();
  79 + }
  80 +
  81 + @Test
  82 + @SuppressWarnings("unchecked")
  83 + public void testHandleRawMessage_deviceExceptionResponse() {
  84 + // 准备参数
  85 + IotDeviceMessage message = buildMessage();
  86 + byte[] responsePdu = new byte[]{(byte) 0x86, 0x02};
  87 + // mock 方法:回复在 worker 上执行(这里在当前线程同步执行)
  88 + when(protocol.getServerId()).thenReturn(SERVER_ID);
  89 + when(protocol.getVertx()).thenReturn(vertx);
  90 + when(vertx.executeBlocking(any(Callable.class), eq(false))).thenAnswer(invocation -> {
  91 + Callable<Object> callable = invocation.getArgument(0);
  92 + return Future.succeededFuture(callable.call());
  93 + });
  94 + springUtilMockedStatic.when(() -> SpringUtil.getBean(IotDeviceMessageService.class)).thenReturn(deviceMessageService);
  95 +
  96 + // 调用:提交事务,事务终态之前 Future 不完成
  97 + Future<Void> result = subscriber.handleRawMessage(message, WRITE_PDU, source);
  98 + assertFalse(result.isComplete());
  99 + // 调用:设备返回异常响应 86 02
  100 + captureSubmittedTx().complete(responsePdu);
  101 +
  102 + // 断言:Future 在事务终态成功完成
  103 + assertTrue(result.succeeded());
  104 + // 断言:设备的实际响应回复给 biz,设备错误为 MODBUS_EXCEPTION(带异常码),带上 encode 时固定的脚本
  105 + ArgumentCaptor<ErrorCode> errorCaptor = ArgumentCaptor.forClass(ErrorCode.class);
  106 + verify(deviceMessageService).sendRawReply(same(message), same(source), aryEq(responsePdu),
  107 + errorCaptor.capture(), eq(SERVER_ID));
  108 + assertEquals(MODBUS_EXCEPTION.getCode(), errorCaptor.getValue().getCode());
  109 + assertEquals(StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2), errorCaptor.getValue().getMsg());
  110 + }
  111 +
  112 + @Test
  113 + public void testHandleRawMessage_gatewayFailureOnlyLog() {
  114 + // 调用:网关判定的失败(超时)、响应为空
  115 + Future<Void> timeout = subscriber.handleRawMessage(buildMessage(), WRITE_PDU, source);
  116 + captureSubmittedTx().fail(MODBUS_TIMEOUT);
  117 + Future<Void> empty = subscriber.handleRawMessage(buildMessage(), WRITE_PDU, null);
  118 + captureSubmittedTx().complete(new byte[0]);
  119 +
  120 + // 断言:Future 照常在事务终态成功完成;只记日志,不给 biz 构造回复
  121 + assertTrue(timeout.succeeded());
  122 + assertTrue(empty.succeeded());
  123 + verify(protocol, never()).getVertx();
  124 + springUtilMockedStatic.verifyNoInteractions();
  125 + verifyNoInteractions(deviceMessageService);
  126 + }
  127 +
  128 + private IotModbusTx captureSubmittedTx() {
  129 + ArgumentCaptor<IotModbusTx> captor = ArgumentCaptor.forClass(IotModbusTx.class);
  130 + verify(channel, atLeastOnce()).submitRaw(captor.capture());
  131 + return captor.getValue();
  132 + }
  133 +
  134 + private static IotDeviceMessage buildMessage() {
  135 + return new IotDeviceMessage().setDeviceId(DEVICE_ID).setRequestId("r1").setMethod("thing.property.set");
  136 + }
  137 +
  138 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.upstream;
  2 +
  3 +import cn.hutool.core.util.HexUtil;
  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.IotModbusDeviceConfigRespDTO;
  7 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
  8 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  9 +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum;
  10 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  11 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx;
  12 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils;
  13 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame;
  14 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder;
  15 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService;
  16 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager;
  17 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo;
  18 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager;
  19 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest;
  20 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler;
  21 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerTransactionChannel;
  22 +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
  23 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  24 +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue;
  25 +import io.vertx.core.Context;
  26 +import io.vertx.core.Future;
  27 +import io.vertx.core.Vertx;
  28 +import io.vertx.core.net.NetSocket;
  29 +import org.junit.jupiter.api.AfterEach;
  30 +import org.junit.jupiter.api.BeforeEach;
  31 +import org.junit.jupiter.api.Test;
  32 +import org.mockito.ArgumentCaptor;
  33 +import org.mockito.Mock;
  34 +
  35 +import java.util.Collections;
  36 +import java.util.List;
  37 +import java.util.concurrent.Callable;
  38 +import java.util.concurrent.CompletableFuture;
  39 +import java.util.concurrent.CountDownLatch;
  40 +import java.util.concurrent.TimeUnit;
  41 +import java.util.concurrent.atomic.AtomicBoolean;
  42 +import java.util.concurrent.atomic.AtomicInteger;
  43 +
  44 +import static org.junit.jupiter.api.Assertions.*;
  45 +import static org.mockito.ArgumentMatchers.any;
  46 +import static org.mockito.ArgumentMatchers.eq;
  47 +import static org.mockito.Mockito.*;
  48 +
  49 +/**
  50 + * {@link IotModbusTcpServerUpstreamHandler} 的单元测试
  51 + *
  52 + * @author 芋道源码
  53 + */
  54 +public class IotModbusTcpServerUpstreamHandlerTest extends BaseMockitoUnitTest {
  55 +
  56 + private static final long TIMEOUT_SECONDS = 10;
  57 + private static final Long DEVICE_ID = 1L;
  58 + private static final Long POINT_ID = 10L;
  59 + private static final int SLAVE_ID = 1;
  60 + private static final String PRODUCT_KEY = "pk";
  61 + private static final String DEVICE_NAME = "dn";
  62 + private static final String SERVER_ID = "modbus-tcp-server";
  63 +
  64 + @Mock
  65 + private IotDeviceCommonApi deviceApi;
  66 + @Mock
  67 + private IotDeviceMessageService messageService;
  68 + @Mock
  69 + private IotModbusTcpServerConfigCacheService configCacheService;
  70 + @Mock
  71 + private IotModbusTcpServerPollScheduler pollScheduler;
  72 + @Mock
  73 + private IotDeviceService deviceService;
  74 + @Mock
  75 + private IotModbusTcpServerTransactionChannel channel;
  76 + @Mock
  77 + private NetSocket socket;
  78 +
  79 + private final IotModbusTcpServerConnectionManager connectionManager = new IotModbusTcpServerConnectionManager();
  80 + private final IotModbusTcpServerPendingRequestManager pendingRequestManager = new IotModbusTcpServerPendingRequestManager();
  81 + private IotModbusTcpServerUpstreamHandler upstreamHandler;
  82 + private ConnectionInfo connectionInfo;
  83 + private Vertx vertx;
  84 + /**
  85 + * 连接的 owner(event loop Context)
  86 + */
  87 + private Context owner;
  88 +
  89 + @BeforeEach
  90 + public void setUp() {
  91 + upstreamHandler = new IotModbusTcpServerUpstreamHandler(deviceApi, messageService, new IotModbusFrameEncoder(65),
  92 + connectionManager, configCacheService, pendingRequestManager, pollScheduler, deviceService,
  93 + SERVER_ID, new AtomicInteger(), 60_000);
  94 + // 已认证的 RTU 连接
  95 + connectionInfo = new ConnectionInfo().setDeviceId(DEVICE_ID).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME)
  96 + .setSlaveId(SLAVE_ID).setFrameFormat(IotModbusFrameFormatEnum.MODBUS_RTU).setChannel(channel);
  97 + connectionManager.registerConnection(socket, connectionInfo);
  98 + vertx = Vertx.vertx();
  99 + owner = vertx.getOrCreateContext();
  100 + }
  101 +
  102 + @AfterEach
  103 + public void tearDown() throws Exception {
  104 + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  105 + }
  106 +
  107 + @Test
  108 + public void testHandleFrame_pollingReportNotBlockOwner() throws Exception {
  109 + // 准备参数:本连接的轮询请求 P(FC03 读 1 个)、透传请求 R(FC04 读 1 个)都在等响应
  110 + IotModbusPointRespDTO point = buildPoint();
  111 + PendingRequest poll = buildPollRequest();
  112 + PendingRequest raw = buildRawRequest();
  113 + pendingRequestManager.addRequest(poll);
  114 + pendingRequestManager.addRequest(raw);
  115 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(owner);
  116 + CountDownLatch entered = new CountDownLatch(1);
  117 + CountDownLatch release = new CountDownLatch(1);
  118 + CountDownLatch delivered = new CountDownLatch(1);
  119 + AtomicBoolean onWorker = new AtomicBoolean();
  120 + // mock 方法:事务通道的 worker 队列(在 owner 上);点位配置;发送到消息总线阻塞,直到放行
  121 + when(channel.getUpstreamQueue()).thenReturn(queue);
  122 + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(buildConfig(point));
  123 + doAnswer(invocation -> {
  124 + onWorker.set(Context.isOnWorkerThread());
  125 + entered.countDown();
  126 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  127 + throw new IllegalStateException("等待放行超时");
  128 + }
  129 + delivered.countDown();
  130 + return null;
  131 + }).when(messageService).sendDeviceMessage(any(IotDeviceMessage.class), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  132 +
  133 + IotModbusFrame rawResponse = buildReadResponseFrame(4, 0x0001);
  134 + try {
  135 + // 调用:owner 上收到 P 的响应,发送阻塞在 worker 上
  136 + runOnOwner(() -> upstreamHandler.handleFrame(socket, buildReadResponseFrame(3, 0x00FA),
  137 + IotModbusFrameFormatEnum.MODBUS_RTU));
  138 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  139 + // 调用:发送仍阻塞时,owner 上收到 R 的响应,再执行一个哨兵任务
  140 + runOnOwner(() -> upstreamHandler.handleFrame(socket, rawResponse, IotModbusFrameFormatEnum.MODBUS_RTU));
  141 + Boolean sentinel = callOnOwner(() -> Boolean.TRUE);
  142 +
  143 + // 断言:owner 没有被发送阻塞:R 的响应照常匹配并交给事务通道收尾,哨兵及时执行
  144 + verify(channel).onRawResponse(same(rawResponse), same(raw));
  145 + assertTrue(sentinel);
  146 + assertEquals(1, delivered.getCount());
  147 + } finally {
  148 + release.countDown();
  149 + }
  150 + // 断言:放行后属性上报只投递一次,在 worker 上发送
  151 + assertTrue(delivered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  152 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  153 + verify(messageService, times(1)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  154 + assertTrue(onWorker.get());
  155 + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), captor.getValue().getMethod());
  156 + assertEquals(Collections.singletonMap("temperature", IotModbusCommonUtils.convertToPropertyValue(new int[]{0x00FA}, point)),
  157 + captor.getValue().getParams());
  158 + }
  159 +
  160 + @Test
  161 + public void testSendOfflineMessage_afterPollingReport() throws Exception {
  162 + // 准备参数:本连接的轮询请求 P
  163 + pendingRequestManager.addRequest(buildPollRequest());
  164 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(owner);
  165 + CountDownLatch entered = new CountDownLatch(1);
  166 + CountDownLatch release = new CountDownLatch(1);
  167 + // mock 方法:事务通道的 worker 队列;点位配置;第一条(属性上报)阻塞直到放行
  168 + when(channel.getUpstreamQueue()).thenReturn(queue);
  169 + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(buildConfig(buildPoint()));
  170 + doAnswer(invocation -> {
  171 + entered.countDown();
  172 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  173 + throw new IllegalStateException("等待放行超时");
  174 + }
  175 + return null;
  176 + }).doNothing().when(messageService).sendDeviceMessage(any(IotDeviceMessage.class),
  177 + eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  178 +
  179 + Future<Void> offline;
  180 + try {
  181 + // 调用:owner 上收到 P 的响应,发送阻塞在 worker 上
  182 + runOnOwner(() -> upstreamHandler.handleFrame(socket, buildReadResponseFrame(3, 0x00FA),
  183 + IotModbusFrameFormatEnum.MODBUS_RTU));
  184 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  185 + // 调用:连接关闭(closeHandler 在 owner 上移除连接、提交下线消息),不等待发送
  186 + offline = callOnOwner(() -> {
  187 + connectionManager.removeConnection(socket);
  188 + return upstreamHandler.sendOfflineMessage(connectionInfo);
  189 + });
  190 +
  191 + // 断言:下线消息排在被阻塞的属性上报之后,还没有发送;之后本连接不再接收新任务
  192 + assertFalse(offline.isComplete());
  193 + verify(messageService, times(1)).sendDeviceMessage(any(), any(), any(), any());
  194 + assertTrue(callOnOwner(() -> queue.submit(() -> null)).failed());
  195 + } finally {
  196 + release.countDown();
  197 + }
  198 + // 断言:放行后先发属性上报,再发下线消息,各一次
  199 + await(offline);
  200 + assertTrue(offline.succeeded());
  201 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  202 + verify(messageService, times(2)).sendDeviceMessage(captor.capture(), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  203 + List<IotDeviceMessage> messages = captor.getAllValues();
  204 + assertEquals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), messages.get(0).getMethod());
  205 + assertEquals(IotDeviceMessage.buildStateOffline().getMethod(), messages.get(1).getMethod());
  206 + assertEquals(IotDeviceMessage.buildStateOffline().getParams(), messages.get(1).getParams());
  207 + }
  208 +
  209 + @Test
  210 + public void testSendOfflineMessage_skipWhenReconnected() throws Exception {
  211 + // 准备参数:旧连接关闭后,设备已在新连接上认证注册
  212 + NetSocket newSocket = mock(NetSocket.class);
  213 + connectionManager.removeConnection(socket);
  214 + connectionManager.registerConnection(newSocket, new ConnectionInfo().setDeviceId(DEVICE_ID)
  215 + .setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME).setSlaveId(SLAVE_ID)
  216 + .setFrameFormat(IotModbusFrameFormatEnum.MODBUS_RTU).setChannel(mock(IotModbusTcpServerTransactionChannel.class)));
  217 + // mock 方法:旧连接事务通道的 worker 队列
  218 + when(channel.getUpstreamQueue()).thenReturn(new IotWorkerTaskQueue(owner));
  219 +
  220 + // 调用:旧连接的下线消息
  221 + Future<Void> offline = callOnOwner(() -> upstreamHandler.sendOfflineMessage(connectionInfo));
  222 + await(offline);
  223 +
  224 + // 断言:执行时设备已在新连接上线,不发送下线消息
  225 + assertTrue(offline.succeeded());
  226 + verify(messageService, never()).sendDeviceMessage(any(), any(), any(), any());
  227 + }
  228 +
  229 + // ========== 辅助方法 ==========
  230 +
  231 + /**
  232 + * 点位:FC03 读 1 个寄存器(INT16)
  233 + */
  234 + private static IotModbusPointRespDTO buildPoint() {
  235 + IotModbusPointRespDTO point = new IotModbusPointRespDTO();
  236 + point.setId(POINT_ID);
  237 + point.setIdentifier("temperature");
  238 + point.setFunctionCode(3);
  239 + point.setRegisterAddress(0);
  240 + point.setRegisterCount(1);
  241 + point.setRawDataType("INT16");
  242 + return point;
  243 + }
  244 +
  245 + private static IotModbusDeviceConfigRespDTO buildConfig(IotModbusPointRespDTO point) {
  246 + IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO();
  247 + config.setDeviceId(DEVICE_ID);
  248 + config.setPoints(Collections.singletonList(point));
  249 + return config;
  250 + }
  251 +
  252 + private PendingRequest buildPollRequest() {
  253 + return new PendingRequest(DEVICE_ID, POINT_ID, "temperature", SLAVE_ID, 3, 0, 1,
  254 + null, System.currentTimeMillis() + 60_000, channel, null);
  255 + }
  256 +
  257 + private PendingRequest buildRawRequest() {
  258 + IotModbusTx tx = IotModbusTx.ofClient(null, HexUtil.decodeHex("0400000001"),
  259 + System.nanoTime() + TimeUnit.SECONDS.toNanos(60), null);
  260 + return new PendingRequest(DEVICE_ID, null, null, SLAVE_ID, 4, 0, 1,
  261 + null, System.currentTimeMillis() + 60_000, channel, tx);
  262 + }
  263 +
  264 + /**
  265 + * RTU 读响应帧:读 1 个寄存器(pdu = byteCount + 值)
  266 + */
  267 + private static IotModbusFrame buildReadResponseFrame(int functionCode, int value) {
  268 + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode)
  269 + .setPdu(new byte[]{0x02, (byte) (value >> 8), (byte) value});
  270 + }
  271 +
  272 + /**
  273 + * 在 owner 上执行(与 RecordParser、closeHandler 回调一致),等待执行完;owner 被阻塞时超时失败
  274 + */
  275 + private void runOnOwner(Runnable action) throws Exception {
  276 + callOnOwner(() -> {
  277 + action.run();
  278 + return null;
  279 + });
  280 + }
  281 +
  282 + private <T> T callOnOwner(Callable<T> action) throws Exception {
  283 + CompletableFuture<T> result = new CompletableFuture<>();
  284 + owner.runOnContext(v -> {
  285 + try {
  286 + result.complete(action.call());
  287 + } catch (Throwable e) {
  288 + result.completeExceptionally(e);
  289 + }
  290 + });
  291 + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  292 + }
  293 +
  294 + /**
  295 + * 等待 Future 完成(成功或失败)
  296 + */
  297 + private static void await(Future<?> future) throws InterruptedException {
  298 + CountDownLatch latch = new CountDownLatch(1);
  299 + future.onComplete(ar -> latch.countDown());
  300 + assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  301 + }
  302 +
  303 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManagerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager;
  2 +
  3 +import cn.hutool.core.util.HexUtil;
  4 +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum;
  5 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx;
  6 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame;
  7 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest;
  8 +import org.junit.jupiter.api.Test;
  9 +
  10 +import static org.junit.jupiter.api.Assertions.*;
  11 +import static org.mockito.Mockito.mock;
  12 +import static org.mockito.Mockito.when;
  13 +
  14 +/**
  15 + * {@link IotModbusTcpServerPendingRequestManager} 的单元测试
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +public class IotModbusTcpServerPendingRequestManagerTest {
  20 +
  21 + private static final Long DEVICE_ID = 1L;
  22 + private static final int SLAVE_ID = 1;
  23 +
  24 + private final IotModbusTcpServerPendingRequestManager pendingRequestManager = new IotModbusTcpServerPendingRequestManager();
  25 +
  26 + /**
  27 + * 旧连接 A 被新连接 B 替换
  28 + */
  29 + private final IotModbusTcpServerTransactionChannel channelA = mock(IotModbusTcpServerTransactionChannel.class);
  30 + private final IotModbusTcpServerTransactionChannel channelB = mock(IotModbusTcpServerTransactionChannel.class);
  31 +
  32 + // ========== TCP 帧:按 transactionId ==========
  33 +
  34 + @Test
  35 + public void testMatchResponse_tcpOnlyMatchCurrentChannel() {
  36 + // 准备参数:A、B 两条连接上各有一个事务号相同的轮询请求
  37 + PendingRequest requestA = buildPollRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000);
  38 + PendingRequest requestB = buildPollRequest(channelB, 5, 3, 2, System.currentTimeMillis() + 60_000);
  39 + pendingRequestManager.addRequest(requestA);
  40 + pendingRequestManager.addRequest(requestB);
  41 +
  42 + // 调用:B 收到响应
  43 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2),
  44 + IotModbusFrameFormatEnum.MODBUS_TCP, channelB);
  45 +
  46 + // 断言:只配给 B 的请求,A 的请求不被取走
  47 + assertSame(requestB, matched);
  48 + assertSame(requestA, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2),
  49 + IotModbusFrameFormatEnum.MODBUS_TCP, channelA));
  50 + }
  51 +
  52 + @Test
  53 + public void testMatchResponse_tcpRawUnitOrFunctionCodeMismatch() {
  54 + // 准备参数
  55 + PendingRequest request = buildRawRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000);
  56 + pendingRequestManager.addRequest(request);
  57 +
  58 + // 调用 + 断言:事务号相同,但功能码或 unit 不同:不出队
  59 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 4, 2),
  60 + IotModbusFrameFormatEnum.MODBUS_TCP, channelA));
  61 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2).setSlaveId(SLAVE_ID + 1),
  62 + IotModbusFrameFormatEnum.MODBUS_TCP, channelA));
  63 + assertSame(request, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(5, 3, 2),
  64 + IotModbusFrameFormatEnum.MODBUS_TCP, channelA));
  65 + }
  66 +
  67 + @Test
  68 + public void testMatchResponse_tcpExceptionOnlyMatchRaw() {
  69 + // 准备参数:轮询请求 5 号、透传请求 6 号
  70 + PendingRequest poll = buildPollRequest(channelA, 5, 3, 2, System.currentTimeMillis() + 60_000);
  71 + PendingRequest raw = buildRawRequest(channelA, 6, 3, 2, System.currentTimeMillis() + 60_000);
  72 + pendingRequestManager.addRequest(poll);
  73 + pendingRequestManager.addRequest(raw);
  74 +
  75 + // 调用 + 断言:轮询的异常响应照旧丢弃,不出队
  76 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(5, 3, 2),
  77 + IotModbusFrameFormatEnum.MODBUS_TCP, channelA));
  78 + // 调用 + 断言:透传的异常响应配给透传请求
  79 + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(6, 3, 2),
  80 + IotModbusFrameFormatEnum.MODBUS_TCP, channelA));
  81 + // 断言:轮询请求仍在,留给到期清理
  82 + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 5));
  83 + }
  84 +
  85 + // ========== RTU 帧:原有的 FIFO(slaveId + functionCode + registerCount) ==========
  86 +
  87 + @Test
  88 + public void testMatchResponse_rtuFifoBySlaveFunctionCodeCount() {
  89 + // 准备参数:同功能码、数量不同的两个轮询请求
  90 + PendingRequest request2 = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  91 + PendingRequest request1 = buildPollRequest(channelA, null, 3, 1, System.currentTimeMillis() + 60_000);
  92 + pendingRequestManager.addRequest(request2);
  93 + pendingRequestManager.addRequest(request1);
  94 +
  95 + // 调用 + 断言:unit 不同不匹配
  96 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1).setSlaveId(SLAVE_ID + 1),
  97 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  98 + // 调用 + 断言:按数量配给对应的请求,与先后顺序无关
  99 + assertSame(request1, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1),
  100 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  101 + assertSame(request2, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  102 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  103 + }
  104 +
  105 + /**
  106 + * 原有逻辑的限制(不是安全保证):RTU 帧没有事务号,同 unit、同功能码、同数量的请求无法区分,响应按 FIFO 配给排在前面的请求
  107 + */
  108 + @Test
  109 + public void testMatchResponse_rtuSameShapeMatchFirst_originalLimit() {
  110 + // 准备参数:轮询请求 P 在前,同形状的透传请求 R 在后
  111 + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  112 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  113 + pendingRequestManager.addRequest(poll);
  114 + pendingRequestManager.addRequest(raw);
  115 +
  116 + // 调用:无论这个响应实际是谁的,都配给排在前面的 P
  117 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  118 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA);
  119 +
  120 + // 断言:下一个同形状的正常响应才配给 R
  121 + assertSame(poll, matched);
  122 + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  123 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  124 + }
  125 +
  126 + @Test
  127 + public void testMatchResponse_rtuRawThenPollNormal() {
  128 + // 准备参数:透传请求 R 在前,同形状的轮询请求 P 在后
  129 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  130 + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  131 + pendingRequestManager.addRequest(raw);
  132 + pendingRequestManager.addRequest(poll);
  133 +
  134 + // 调用
  135 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  136 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA);
  137 +
  138 + // 断言:按 FIFO 先配给 R,下一个再配给 P
  139 + assertSame(raw, matched);
  140 + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  141 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  142 + }
  143 +
  144 + @Test
  145 + public void testMatchResponse_rtuSkipOldConnection() {
  146 + // 准备参数:旧连接 A 的请求排在前面,新连接 B 发出同 unit、同功能码、同数量的请求
  147 + PendingRequest requestA = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  148 + PendingRequest requestB = buildPollRequest(channelB, null, 3, 2, System.currentTimeMillis() + 60_000);
  149 + pendingRequestManager.addRequest(requestA);
  150 + pendingRequestManager.addRequest(requestB);
  151 +
  152 + // 调用:B 收到响应
  153 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  154 + IotModbusFrameFormatEnum.MODBUS_RTU, channelB);
  155 +
  156 + // 断言:响应配给 B 的请求,不是 A 的旧条目
  157 + assertSame(requestB, matched);
  158 + }
  159 +
  160 + @Test
  161 + public void testMatchResponse_rtuPollThenRawException() {
  162 + // 准备参数:FC03,轮询请求 P(读 1 个)在前,透传请求 R(读 2 个)在后
  163 + PendingRequest poll = buildPollRequest(channelA, null, 3, 1, System.currentTimeMillis() + 60_000);
  164 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  165 + pendingRequestManager.addRequest(poll);
  166 + pendingRequestManager.addRequest(raw);
  167 +
  168 + // 调用:设备回 83 02(解码器已还原原功能码,pdu[0] 是异常码)
  169 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2),
  170 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA);
  171 +
  172 + // 断言:按 FIFO 属于排在前面的 P:照旧丢弃,不跳过 P 去收尾 R;P、R 都不出队
  173 + assertNull(matched);
  174 + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 1),
  175 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  176 + // 调用 + 断言:P 出队后 R 排在最前面,下一个异常响应收尾 R
  177 + assertSame(raw, pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2),
  178 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  179 + }
  180 +
  181 + @Test
  182 + public void testMatchResponse_rtuRawThenPollException() {
  183 + // 准备参数:FC03,透传请求 R 在前,轮询请求 P 在后
  184 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  185 + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  186 + pendingRequestManager.addRequest(raw);
  187 + pendingRequestManager.addRequest(poll);
  188 +
  189 + // 调用:设备回 83 02
  190 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2),
  191 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA);
  192 +
  193 + // 断言:R 在最前面,由异常响应收尾;P 不被消费
  194 + assertSame(raw, matched);
  195 + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  196 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  197 + }
  198 +
  199 + @Test
  200 + public void testMatchResponse_rtuExceptionIgnoreOtherUnitOrFunctionCode() {
  201 + // 准备参数:排在前面的轮询请求是其它 unit、其它功能码,透传请求 R 在后
  202 + PendingRequest otherUnit = new PendingRequest(DEVICE_ID, 10L, "temperature", SLAVE_ID + 1, 3, 0, 2,
  203 + null, System.currentTimeMillis() + 60_000, channelA, null);
  204 + PendingRequest otherFunctionCode = buildPollRequest(channelA, null, 4, 2, System.currentTimeMillis() + 60_000);
  205 + PendingRequest raw = buildRawRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  206 + pendingRequestManager.addRequest(otherUnit);
  207 + pendingRequestManager.addRequest(otherFunctionCode);
  208 + pendingRequestManager.addRequest(raw);
  209 +
  210 + // 调用:设备回 83 02
  211 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2),
  212 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA);
  213 +
  214 + // 断言:只按同 unit、同功能码排队,R 是其中最前面的
  215 + assertSame(raw, matched);
  216 + }
  217 +
  218 + @Test
  219 + public void testMatchResponse_rtuExceptionWithoutRawDrop() {
  220 + // 准备参数:只有轮询请求
  221 + PendingRequest poll = buildPollRequest(channelA, null, 3, 2, System.currentTimeMillis() + 60_000);
  222 + pendingRequestManager.addRequest(poll);
  223 +
  224 + // 调用:轮询的异常响应
  225 + PendingRequest matched = pendingRequestManager.matchResponse(DEVICE_ID, buildExceptionFrame(null, 3, 2),
  226 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA);
  227 +
  228 + // 断言:照旧丢弃,轮询请求留给到期清理
  229 + assertNull(matched);
  230 + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  231 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  232 + }
  233 +
  234 + // ========== 清理 ==========
  235 +
  236 + @Test
  237 + public void testCleanupExpired() {
  238 + // 准备参数
  239 + PendingRequest expired = buildPollRequest(channelA, 1, 3, 2, System.currentTimeMillis() - 1);
  240 + PendingRequest alive = buildPollRequest(channelA, 2, 3, 2, System.currentTimeMillis() + 60_000);
  241 + pendingRequestManager.addRequest(expired);
  242 + pendingRequestManager.addRequest(alive);
  243 +
  244 + // 调用
  245 + pendingRequestManager.cleanupExpired();
  246 +
  247 + // 断言:只移除到期的条目
  248 + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 1));
  249 + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 2));
  250 + }
  251 +
  252 + @Test
  253 + public void testRemoveChannel_keepNewChannel() {
  254 + // 准备参数:旧连接 A、新连接 B 各有一个条目
  255 + pendingRequestManager.addRequest(buildPollRequest(channelA, 1, 3, 2, System.currentTimeMillis() + 60_000));
  256 + pendingRequestManager.addRequest(buildRawRequest(channelB, 2, 3, 2, System.currentTimeMillis() + 60_000));
  257 +
  258 + // 调用:旧连接关闭
  259 + pendingRequestManager.removeChannel(DEVICE_ID, channelA);
  260 +
  261 + // 断言:只删 A 的条目,B 的条目仍可匹配
  262 + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channelA, 1));
  263 + assertTrue(pendingRequestManager.containsTransactionId(DEVICE_ID, channelB, 2));
  264 + }
  265 +
  266 + @Test
  267 + public void testRemoveRequest_byReference() {
  268 + // 准备参数:两个字段完全相同的条目(PendingRequest 的 equals 按字段比较)
  269 + long expireAt = System.currentTimeMillis() + 60_000;
  270 + PendingRequest request1 = buildPollRequest(channelA, null, 3, 2, expireAt);
  271 + PendingRequest request2 = buildPollRequest(channelA, null, 3, 2, expireAt);
  272 + pendingRequestManager.addRequest(request1);
  273 + pendingRequestManager.addRequest(request2);
  274 +
  275 + // 调用
  276 + pendingRequestManager.removeRequest(request1);
  277 +
  278 + // 断言:只移除 request1 这个引用
  279 + assertSame(request2, pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  280 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  281 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, buildReadFrame(null, 3, 2),
  282 + IotModbusFrameFormatEnum.MODBUS_RTU, channelA));
  283 + }
  284 +
  285 + // ========== 辅助方法 ==========
  286 +
  287 + private static PendingRequest buildPollRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId,
  288 + int functionCode, int registerCount, long expireAt) {
  289 + return new PendingRequest(DEVICE_ID, 10L, "temperature", SLAVE_ID, functionCode, 0, registerCount,
  290 + transactionId, expireAt, channel, null);
  291 + }
  292 +
  293 + private static PendingRequest buildRawRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId,
  294 + int functionCode, int registerCount, long expireAt) {
  295 + when(channel.getRequestTimeout()).thenReturn(60_000);
  296 + byte[] pdu = HexUtil.decodeHex(String.format("%02X0000%04X", functionCode, registerCount));
  297 + IotModbusTx tx = IotModbusTx.ofServer(channel, pdu, null, null);
  298 + return new PendingRequest(DEVICE_ID, null, null, SLAVE_ID, functionCode, 0, registerCount,
  299 + transactionId, expireAt, channel, tx);
  300 + }
  301 +
  302 + private static IotModbusFrame buildReadFrame(Integer transactionId, int functionCode, int registerCount) {
  303 + byte[] pdu = new byte[1 + registerCount * 2];
  304 + pdu[0] = (byte) (registerCount * 2);
  305 + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode)
  306 + .setPdu(pdu).setTransactionId(transactionId);
  307 + }
  308 +
  309 + /**
  310 + * 异常响应帧:解码器已还原原功能码,pdu[0] 是异常码
  311 + */
  312 + private static IotModbusFrame buildExceptionFrame(Integer transactionId, int functionCode, int exceptionCode) {
  313 + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(functionCode)
  314 + .setPdu(new byte[]{(byte) exceptionCode}).setExceptionCode(exceptionCode)
  315 + .setTransactionId(transactionId);
  316 + }
  317 +
  318 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerTransactionChannelTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager;
  2 +
  3 +import cn.hutool.core.map.MapUtil;
  4 +import cn.hutool.core.util.HexUtil;
  5 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  6 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
  7 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
  8 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  9 +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum;
  10 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  11 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.IotModbusTx;
  12 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame;
  13 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder;
  14 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream.IotModbusTcpServerDownstreamHandler;
  15 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo;
  16 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest;
  17 +import io.vertx.core.Context;
  18 +import io.vertx.core.Future;
  19 +import io.vertx.core.Vertx;
  20 +import io.vertx.core.buffer.Buffer;
  21 +import io.vertx.core.net.NetSocket;
  22 +import org.junit.jupiter.api.AfterEach;
  23 +import org.junit.jupiter.api.BeforeEach;
  24 +import org.junit.jupiter.api.Test;
  25 +import org.mockito.ArgumentCaptor;
  26 +
  27 +import java.util.ArrayList;
  28 +import java.util.Arrays;
  29 +import java.util.Collections;
  30 +import java.util.List;
  31 +import java.util.concurrent.CountDownLatch;
  32 +import java.util.concurrent.TimeUnit;
  33 +import java.util.concurrent.atomic.AtomicInteger;
  34 +import java.util.concurrent.atomic.AtomicReference;
  35 +
  36 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*;
  37 +import static org.junit.jupiter.api.Assertions.*;
  38 +import static org.mockito.ArgumentMatchers.any;
  39 +import static org.mockito.Mockito.*;
  40 +
  41 +/**
  42 + * {@link IotModbusTcpServerTransactionChannel} 的单元测试
  43 + *
  44 + * @author 芋道源码
  45 + */
  46 +public class IotModbusTcpServerTransactionChannelTest {
  47 +
  48 + private static final Long DEVICE_ID = 1L;
  49 + private static final int SLAVE_ID = 1;
  50 + private static final int REQUEST_TIMEOUT = 60_000;
  51 + private static final Long POINT_ID = 10L;
  52 +
  53 + private Vertx vertx;
  54 + private Context owner;
  55 + private NetSocket socket;
  56 + private RecordingPendingRequestManager pendingRequestManager;
  57 +
  58 + /**
  59 + * socket.write 被调用时所在的线程
  60 + */
  61 + private final List<Thread> writeThreads = Collections.synchronizedList(new ArrayList<>());
  62 +
  63 + @BeforeEach
  64 + public void setUp() {
  65 + vertx = Vertx.vertx();
  66 + owner = vertx.getOrCreateContext();
  67 + socket = mock(NetSocket.class);
  68 + when(socket.write(any(Buffer.class))).thenAnswer(invocation -> {
  69 + writeThreads.add(Thread.currentThread());
  70 + return Future.succeededFuture();
  71 + });
  72 + pendingRequestManager = new RecordingPendingRequestManager();
  73 + }
  74 +
  75 + @AfterEach
  76 + public void tearDown() throws Exception {
  77 + vertx.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS);
  78 + }
  79 +
  80 + // ========== 透传:提交与名额 ==========
  81 +
  82 + @Test
  83 + public void testSubmitRaw_singleOwner() throws Exception {
  84 + // 准备参数
  85 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  86 + new AtomicInteger(), REQUEST_TIMEOUT);
  87 + Thread ownerThread = getOwnerThread();
  88 +
  89 + // 调用:4 个非 owner 线程并发提交,共 20 个透传
  90 + List<Thread> submitters = new ArrayList<>();
  91 + for (int i = 0; i < 4; i++) {
  92 + Thread submitter = new Thread(() -> {
  93 + for (int j = 0; j < 5; j++) {
  94 + channel.submitRaw(newTx(channel, new TxResult()));
  95 + }
  96 + });
  97 + submitters.add(submitter);
  98 + submitter.start();
  99 + }
  100 + for (Thread submitter : submitters) {
  101 + submitter.join();
  102 + }
  103 + awaitOwner();
  104 +
  105 + // 断言:写出和登记待响应请求都只发生在 owner 上
  106 + assertEquals(20, writeThreads.size());
  107 + writeThreads.forEach(thread -> assertSame(ownerThread, thread));
  108 + assertEquals(20, pendingRequestManager.addThreads.size());
  109 + pendingRequestManager.addThreads.forEach(thread -> assertSame(ownerThread, thread));
  110 + }
  111 +
  112 + @Test
  113 + public void testSubmitRaw_queueFull() throws Exception {
  114 + // 准备参数
  115 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  116 + new AtomicInteger(), REQUEST_TIMEOUT);
  117 + TxResult result = new TxResult();
  118 +
  119 + // 调用:设备不回应,第 33 个时排队 + 在途已满
  120 + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING + 1; i++) {
  121 + channel.submitRaw(newTx(channel, result));
  122 + }
  123 + awaitOwner();
  124 +
  125 + // 断言
  126 + assertEquals(IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING, writeThreads.size());
  127 + assertFailures(result, MODBUS_QUEUE_FULL);
  128 + }
  129 +
  130 + @Test
  131 + public void testAllocateTransactionId_skipPending() throws Exception {
  132 + // 准备参数:计数器从 65535 绕回 0
  133 + AtomicInteger transactionIdCounter = new AtomicInteger(65535);
  134 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  135 + transactionIdCounter, REQUEST_TIMEOUT);
  136 + channel.submitRaw(newTx(channel, new TxResult()));
  137 + awaitOwner();
  138 + // 本连接还有一个 2 号的轮询请求
  139 + pendingRequestManager.addRequest(buildPollRequest(channel, 2));
  140 +
  141 + // 调用 + 断言:0 号仍被在途透传占用,取下一个空闲编号 1
  142 + assertEquals(0, pendingRequestManager.added.get(0).getTransactionId());
  143 + assertEquals(0, captureWrites(1).get(0).getUnsignedShort(0));
  144 + transactionIdCounter.set(65535);
  145 + assertEquals(1, channel.allocateTransactionId());
  146 + // 调用 + 断言:2 号被轮询请求占用,跳过
  147 + assertEquals(3, channel.allocateTransactionId());
  148 + // 调用 + 断言:RTU 帧格式没有事务号
  149 + assertNull(createChannel(IotModbusFrameFormatEnum.MODBUS_RTU, transactionIdCounter, REQUEST_TIMEOUT)
  150 + .allocateTransactionId());
  151 + }
  152 +
  153 + // ========== 标准轮询、标准写:保持原逻辑,不受透传影响 ==========
  154 +
  155 + @Test
  156 + public void testPollPoint_notLimitedByRawQueue() throws Exception {
  157 + // 准备参数:RTU 下 1 个透传在途、31 个排队,第 33 个被拒
  158 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU,
  159 + new AtomicInteger(), REQUEST_TIMEOUT);
  160 + TxResult result = new TxResult();
  161 + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING + 1; i++) {
  162 + channel.submitRaw(newTx(channel, result));
  163 + }
  164 + awaitOwner();
  165 + assertFailures(result, MODBUS_QUEUE_FULL);
  166 + IotModbusTcpServerPollScheduler pollScheduler = createPollScheduler(channel, IotModbusFrameFormatEnum.MODBUS_RTU);
  167 +
  168 + // 调用:照常轮询两次
  169 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID);
  170 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID);
  171 +
  172 + // 断言:轮询不排队、不受透传名额限制,直接写出,并按原逻辑登记属于本连接的待响应请求
  173 + assertEquals(3, writeThreads.size());
  174 + List<PendingRequest> polls = pendingRequestManager.getPolls();
  175 + assertEquals(2, polls.size());
  176 + polls.forEach(poll -> assertSame(channel, poll.getChannel()));
  177 + }
  178 +
  179 + @Test
  180 + public void testOnCleanup_rtuTimeoutNotCloseAndPollContinues() throws Exception {
  181 + // 准备参数:RTU 下轮询 P、透传 R 都已发出
  182 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU,
  183 + new AtomicInteger(), REQUEST_TIMEOUT);
  184 + IotModbusTcpServerPollScheduler pollScheduler = createPollScheduler(channel, IotModbusFrameFormatEnum.MODBUS_RTU);
  185 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID);
  186 + TxResult result = new TxResult();
  187 + channel.submitRaw(newTx(channel, result));
  188 + awaitOwner();
  189 + // 设备都不回应:两个待响应请求到期
  190 + pendingRequestManager.added.forEach(request -> request.setExpireAt(System.currentTimeMillis() - 1));
  191 +
  192 + // 调用:同一个清理周期,先按原逻辑清理待响应请求,再通知通道;之后清理信号重复到达
  193 + pendingRequestManager.cleanupExpired();
  194 + channel.onCleanup();
  195 + channel.onCleanup();
  196 + awaitOwner();
  197 +
  198 + // 断言:R 只以 110 收尾一次,不断开连接
  199 + assertFailures(result, MODBUS_TIMEOUT);
  200 + verify(socket, never()).close();
  201 + // 调用 + 断言:下一次轮询照常写出;R 的在途位置已释放,下一个透传也照常写出
  202 + pollScheduler.pollPoint(DEVICE_ID, POINT_ID);
  203 + channel.submitRaw(newTx(channel, new TxResult()));
  204 + awaitOwner();
  205 + assertEquals(4, writeThreads.size());
  206 + }
  207 +
  208 + @Test
  209 + public void testWriteProperty_logOnSocketWriteWithoutWaitingResponse() throws Exception {
  210 + // 准备参数
  211 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  212 + new AtomicInteger(), REQUEST_TIMEOUT);
  213 + IotModbusTcpServerConnectionManager connectionManager = createConnectionManager(channel,
  214 + IotModbusFrameFormatEnum.MODBUS_TCP);
  215 + IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler(
  216 + connectionManager, mockConfigCacheService(), new IotModbusFrameEncoder(65));
  217 + IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(),
  218 + MapUtil.of("temperature", 220));
  219 + message.setDeviceId(DEVICE_ID);
  220 +
  221 + // 调用
  222 + downstreamHandler.handle(message);
  223 +
  224 + // 断言:写出 FC06 请求(socket 写出成功即记成功);不登记待响应请求,不等待设备回显
  225 + List<Buffer> writes = captureWrites(1);
  226 + assertArrayEquals(HexUtil.decodeHex("06000000DC"), writes.get(0).getBytes(7, 12));
  227 + assertTrue(pendingRequestManager.added.isEmpty());
  228 + }
  229 +
  230 + // ========== 透传:响应关联 ==========
  231 +
  232 + @Test
  233 + public void testOnRawResponse_tcpNormalAndException() throws Exception {
  234 + // 准备参数:两个透传同时在途
  235 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  236 + new AtomicInteger(), REQUEST_TIMEOUT);
  237 + TxResult result1 = new TxResult();
  238 + TxResult result2 = new TxResult();
  239 + channel.submitRaw(newTx(channel, result1));
  240 + channel.submitRaw(newTx(channel, result2));
  241 + awaitOwner();
  242 + int transactionId1 = pendingRequestManager.added.get(0).getTransactionId();
  243 + int transactionId2 = pendingRequestManager.added.get(1).getTransactionId();
  244 +
  245 + // 调用:先回第 2 个的异常响应,再回第 1 个的正常响应
  246 + receive(channel, IotModbusFrameFormatEnum.MODBUS_TCP, exceptionFrame(transactionId2));
  247 + receive(channel, IotModbusFrameFormatEnum.MODBUS_TCP, readResponseFrame(transactionId1));
  248 +
  249 + // 断言:按事务号各自回到原请求
  250 + assertResponse(result1, "030400FA0258");
  251 + assertResponse(result2, "8302");
  252 + }
  253 +
  254 + @Test
  255 + public void testOnRawResponse_rtuDeviceException() throws Exception {
  256 + // 准备参数:RTU 下透传 R 在途,之后才发出轮询 P(同功能码)
  257 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU,
  258 + new AtomicInteger(), REQUEST_TIMEOUT);
  259 + TxResult result = new TxResult();
  260 + channel.submitRaw(newTx(channel, result));
  261 + awaitOwner();
  262 + PendingRequest poll = buildPollRequest(channel, null);
  263 + pendingRequestManager.addRequest(poll);
  264 +
  265 + // 调用:设备回 83 02
  266 + receive(channel, IotModbusFrameFormatEnum.MODBUS_RTU, exceptionFrame(null));
  267 +
  268 + // 断言:R 排在最前面,设备的真实异常响应回到透传请求;轮询的待响应请求不被提前消费
  269 + assertResponse(result, "8302");
  270 + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null),
  271 + IotModbusFrameFormatEnum.MODBUS_RTU, channel));
  272 + }
  273 +
  274 + @Test
  275 + public void testOnRawResponse_rtuPollExceptionNotSkipped() throws Exception {
  276 + // 准备参数:RTU 下轮询 P(同功能码)在前,透传 R 在途
  277 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU,
  278 + new AtomicInteger(), REQUEST_TIMEOUT);
  279 + PendingRequest poll = buildPollRequest(channel, null);
  280 + pendingRequestManager.addRequest(poll);
  281 + TxResult result = new TxResult();
  282 + channel.submitRaw(newTx(channel, result));
  283 + awaitOwner();
  284 +
  285 + // 调用:设备回 83 02(按 FIFO 是 P 的异常响应)
  286 + AtomicReference<PendingRequest> matched = new AtomicReference<>();
  287 + owner.runOnContext(v -> matched.set(pendingRequestManager.matchResponse(DEVICE_ID, exceptionFrame(null),
  288 + IotModbusFrameFormatEnum.MODBUS_RTU, channel)));
  289 + awaitOwner();
  290 +
  291 + // 断言:照旧丢弃,不跳过 P 去收尾 R;R 仍在途
  292 + assertNull(matched.get());
  293 + assertTrue(result.responses.isEmpty());
  294 + assertTrue(result.failures.isEmpty());
  295 + // 调用 + 断言:P 照旧按 FIFO 先出队;之后 R 的响应照常回到 R
  296 + assertSame(poll, pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null),
  297 + IotModbusFrameFormatEnum.MODBUS_RTU, channel));
  298 + receive(channel, IotModbusFrameFormatEnum.MODBUS_RTU, readResponseFrame(null));
  299 + assertResponse(result, "030400FA0258");
  300 + }
  301 +
  302 + // ========== 透传:唯一终态 ==========
  303 +
  304 + @Test
  305 + public void testOnRawWriteFailed_releaseOnceAndKeepConnection() throws Exception {
  306 + // 准备参数:透传写出失败
  307 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  308 + new AtomicInteger(), REQUEST_TIMEOUT);
  309 + when(socket.write(any(Buffer.class))).thenReturn(Future.failedFuture("写出失败"));
  310 + TxResult result = new TxResult();
  311 +
  312 + // 调用
  313 + channel.submitRaw(newTx(channel, result));
  314 + awaitOwner();
  315 +
  316 + // 断言:只以 106 收尾一次,按引用移除待响应请求,不主动关闭连接
  317 + assertFailures(result, DEVICE_OFFLINE_OR_WRITE_FAILED);
  318 + assertFalse(pendingRequestManager.containsTransactionId(DEVICE_ID, channel,
  319 + pendingRequestManager.added.get(0).getTransactionId()));
  320 + verify(socket, never()).close();
  321 + // 调用 + 断言:名额已释放,写出恢复后可以再提交满 MAX_RAW_OUTSTANDING 个
  322 + when(socket.write(any(Buffer.class))).thenReturn(Future.succeededFuture());
  323 + TxResult next = new TxResult();
  324 + for (int i = 0; i < IotModbusTcpServerTransactionChannel.MAX_RAW_OUTSTANDING; i++) {
  325 + channel.submitRaw(newTx(channel, next));
  326 + }
  327 + awaitOwner();
  328 + assertTrue(next.failures.isEmpty());
  329 + }
  330 +
  331 + @Test
  332 + public void testOnCleanup_queuedExpired() throws Exception {
  333 + // 准备参数:RTU 下 R1 在途、R2 排队,超时时间很短
  334 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU,
  335 + new AtomicInteger(), 200);
  336 + TxResult result1 = new TxResult();
  337 + TxResult result2 = new TxResult();
  338 + channel.submitRaw(newTx(channel, result1));
  339 + channel.submitRaw(newTx(channel, result2));
  340 + awaitOwner();
  341 + Thread.sleep(300);
  342 +
  343 + // 调用:清理周期到达两次
  344 + channel.onCleanup();
  345 + channel.onCleanup();
  346 + awaitOwner();
  347 +
  348 + // 断言:在途的 R1、排队的 R2 都只以 110 收尾一次;R2 从未写出
  349 + assertFailures(result1, MODBUS_TIMEOUT);
  350 + assertFailures(result2, MODBUS_TIMEOUT);
  351 + assertEquals(1, writeThreads.size());
  352 + }
  353 +
  354 + @Test
  355 + public void testOnClose_idempotent() throws Exception {
  356 + // 准备参数:RTU 下 A 在途,B、C 排队;本连接还有一个轮询请求
  357 + IotModbusTcpServerTransactionChannel channel = createChannel(IotModbusFrameFormatEnum.MODBUS_RTU,
  358 + new AtomicInteger(), REQUEST_TIMEOUT);
  359 + pendingRequestManager.addRequest(buildPollRequest(channel, null));
  360 + TxResult resultA = new TxResult();
  361 + TxResult resultB = new TxResult();
  362 + TxResult resultC = new TxResult();
  363 + channel.submitRaw(newTx(channel, resultA));
  364 + channel.submitRaw(newTx(channel, resultB));
  365 + channel.submitRaw(newTx(channel, resultC));
  366 + awaitOwner();
  367 +
  368 + // 调用:closeHandler 与主动关闭各通知一次,之后清理信号迟到
  369 + channel.onClose();
  370 + channel.onClose();
  371 + channel.onCleanup();
  372 + awaitOwner();
  373 +
  374 + // 断言:每个透传都只以 115 收尾一次;本连接的待响应请求全部清理
  375 + for (TxResult result : Arrays.asList(resultA, resultB, resultC)) {
  376 + assertFailures(result, MODBUS_DISCONNECTED);
  377 + }
  378 + verify(socket, times(1)).write(any(Buffer.class));
  379 + assertNull(pendingRequestManager.matchResponse(DEVICE_ID, readResponseFrame(null),
  380 + IotModbusFrameFormatEnum.MODBUS_RTU, channel));
  381 + // 调用 + 断言:关闭之后提交的透传直接 115,不写出
  382 + TxResult late = new TxResult();
  383 + channel.submitRaw(newTx(channel, late));
  384 + awaitOwner();
  385 + assertFailures(late, MODBUS_DISCONNECTED);
  386 + verify(socket, times(1)).write(any(Buffer.class));
  387 + }
  388 +
  389 + @Test
  390 + public void testOnClose_oldNotClearNew() throws Exception {
  391 + // 准备参数:同一设备的旧连接 A、新连接 B 共享计数器,各有一个透传在途
  392 + AtomicInteger transactionIdCounter = new AtomicInteger();
  393 + IotModbusTcpServerTransactionChannel channelA = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  394 + transactionIdCounter, REQUEST_TIMEOUT);
  395 + IotModbusTcpServerTransactionChannel channelB = createChannel(IotModbusFrameFormatEnum.MODBUS_TCP,
  396 + transactionIdCounter, REQUEST_TIMEOUT);
  397 + TxResult resultA = new TxResult();
  398 + TxResult resultB = new TxResult();
  399 + channelA.submitRaw(newTx(channelA, resultA));
  400 + channelB.submitRaw(newTx(channelB, resultB));
  401 + awaitOwner();
  402 + int transactionIdB = pendingRequestManager.added.get(1).getTransactionId();
  403 +
  404 + // 调用:旧连接 A 关闭
  405 + channelA.onClose();
  406 + awaitOwner();
  407 +
  408 + // 断言:A 以 115 收尾;B 的待响应请求仍在,响应照常回到 B 的透传
  409 + assertFailures(resultA, MODBUS_DISCONNECTED);
  410 + receive(channelB, IotModbusFrameFormatEnum.MODBUS_TCP, readResponseFrame(transactionIdB));
  411 + assertResponse(resultB, "030400FA0258");
  412 + }
  413 +
  414 + // ========== 辅助方法 ==========
  415 +
  416 + private IotModbusTcpServerTransactionChannel createChannel(IotModbusFrameFormatEnum frameFormat,
  417 + AtomicInteger transactionIdCounter,
  418 + int requestTimeout) {
  419 + return new IotModbusTcpServerTransactionChannel(owner, socket, DEVICE_ID, SLAVE_ID, frameFormat,
  420 + new IotModbusFrameEncoder(65), pendingRequestManager, transactionIdCounter, requestTimeout);
  421 + }
  422 +
  423 + private IotModbusTcpServerConnectionManager createConnectionManager(IotModbusTcpServerTransactionChannel channel,
  424 + IotModbusFrameFormatEnum frameFormat) {
  425 + IotModbusTcpServerConnectionManager connectionManager = new IotModbusTcpServerConnectionManager();
  426 + connectionManager.registerConnection(socket, new ConnectionInfo().setDeviceId(DEVICE_ID)
  427 + .setSlaveId(SLAVE_ID).setFrameFormat(frameFormat).setChannel(channel));
  428 + return connectionManager;
  429 + }
  430 +
  431 + private IotModbusTcpServerPollScheduler createPollScheduler(IotModbusTcpServerTransactionChannel channel,
  432 + IotModbusFrameFormatEnum frameFormat) {
  433 + return new IotModbusTcpServerPollScheduler(vertx, createConnectionManager(channel, frameFormat),
  434 + new IotModbusFrameEncoder(65), pendingRequestManager, REQUEST_TIMEOUT, mockConfigCacheService());
  435 + }
  436 +
  437 + /**
  438 + * 设备配置:一个 FC03 读 1 个寄存器的点位(INT16,可写,单值用 FC06)
  439 + */
  440 + private static IotModbusTcpServerConfigCacheService mockConfigCacheService() {
  441 + IotModbusPointRespDTO point = new IotModbusPointRespDTO();
  442 + point.setId(POINT_ID);
  443 + point.setIdentifier("temperature");
  444 + point.setFunctionCode(3);
  445 + point.setRegisterAddress(0);
  446 + point.setRegisterCount(1);
  447 + point.setRawDataType("INT16");
  448 + IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO();
  449 + config.setDeviceId(DEVICE_ID);
  450 + config.setPoints(Collections.singletonList(point));
  451 + IotModbusTcpServerConfigCacheService configCacheService = mock(IotModbusTcpServerConfigCacheService.class);
  452 + when(configCacheService.getConfig(DEVICE_ID)).thenReturn(config);
  453 + return configCacheService;
  454 + }
  455 +
  456 + private static IotModbusTx newTx(IotModbusTcpServerTransactionChannel channel, TxResult result) {
  457 + return IotModbusTx.ofServer(channel, HexUtil.decodeHex("0300100002"), null,
  458 + (responsePdu, failure) -> {
  459 + if (failure != null) {
  460 + result.failures.add(failure);
  461 + } else {
  462 + result.responses.add(responsePdu);
  463 + }
  464 + });
  465 + }
  466 +
  467 + private static PendingRequest buildPollRequest(IotModbusTcpServerTransactionChannel channel, Integer transactionId) {
  468 + return new PendingRequest(DEVICE_ID, POINT_ID, "temperature", SLAVE_ID, 3, 0x10, 2,
  469 + transactionId, System.currentTimeMillis() + REQUEST_TIMEOUT, channel, null);
  470 + }
  471 +
  472 + private static IotModbusFrame readResponseFrame(Integer transactionId) {
  473 + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(3)
  474 + .setPdu(new byte[]{0x04, 0x00, (byte) 0xFA, 0x02, 0x58}).setTransactionId(transactionId);
  475 + }
  476 +
  477 + /**
  478 + * 异常响应帧 83 02:解码器已还原原功能码,pdu[0] 是异常码
  479 + */
  480 + private static IotModbusFrame exceptionFrame(Integer transactionId) {
  481 + return new IotModbusFrame().setSlaveId(SLAVE_ID).setFunctionCode(3)
  482 + .setPdu(new byte[]{0x02}).setExceptionCode(2).setTransactionId(transactionId);
  483 + }
  484 +
  485 + /**
  486 + * 在 owner 上模拟 handlePollingResponse:先匹配出队,命中透传就交给通道
  487 + */
  488 + private void receive(IotModbusTcpServerTransactionChannel channel, IotModbusFrameFormatEnum frameFormat,
  489 + IotModbusFrame frame) throws InterruptedException {
  490 + owner.runOnContext(v -> {
  491 + PendingRequest request = pendingRequestManager.matchResponse(DEVICE_ID, frame, frameFormat, channel);
  492 + assertNotNull(request);
  493 + assertNotNull(request.getTx());
  494 + channel.onRawResponse(frame, request);
  495 + });
  496 + awaitOwner();
  497 + }
  498 +
  499 + private List<Buffer> captureWrites(int times) {
  500 + ArgumentCaptor<Buffer> captor = ArgumentCaptor.forClass(Buffer.class);
  501 + verify(socket, times(times)).write(captor.capture());
  502 + return captor.getAllValues();
  503 + }
  504 +
  505 + private static void assertFailures(TxResult result, ErrorCode expected) {
  506 + assertEquals(1, result.failures.size());
  507 + assertEquals(expected.getCode(), result.failures.get(0).getCode());
  508 + }
  509 +
  510 + private static void assertResponse(TxResult result, String expectedHex) {
  511 + assertTrue(result.failures.isEmpty());
  512 + assertEquals(1, result.responses.size());
  513 + assertArrayEquals(HexUtil.decodeHex(expectedHex), result.responses.get(0));
  514 + }
  515 +
  516 + /**
  517 + * 等待 owner 上已经排队的任务全部执行完:同一个 Context 上的任务按提交顺序执行
  518 + */
  519 + private void awaitOwner() throws InterruptedException {
  520 + CountDownLatch latch = new CountDownLatch(1);
  521 + owner.runOnContext(v -> latch.countDown());
  522 + assertTrue(latch.await(5, TimeUnit.SECONDS));
  523 + }
  524 +
  525 + private Thread getOwnerThread() throws InterruptedException {
  526 + AtomicReference<Thread> thread = new AtomicReference<>();
  527 + CountDownLatch latch = new CountDownLatch(1);
  528 + owner.runOnContext(v -> {
  529 + thread.set(Thread.currentThread());
  530 + latch.countDown();
  531 + });
  532 + assertTrue(latch.await(5, TimeUnit.SECONDS));
  533 + return thread.get();
  534 + }
  535 +
  536 + /**
  537 + * 透传事务的终态结果
  538 + */
  539 + private static class TxResult {
  540 +
  541 + private final List<byte[]> responses = Collections.synchronizedList(new ArrayList<>());
  542 + private final List<ErrorCode> failures = Collections.synchronizedList(new ArrayList<>());
  543 +
  544 + }
  545 +
  546 + /**
  547 + * 记录 addRequest 调用线程与条目的待响应请求管理器
  548 + */
  549 + private static class RecordingPendingRequestManager extends IotModbusTcpServerPendingRequestManager {
  550 +
  551 + private final List<Thread> addThreads = Collections.synchronizedList(new ArrayList<>());
  552 + private final List<PendingRequest> added = Collections.synchronizedList(new ArrayList<>());
  553 +
  554 + @Override
  555 + public void addRequest(PendingRequest request) {
  556 + addThreads.add(Thread.currentThread());
  557 + added.add(request);
  558 + super.addRequest(request);
  559 + }
  560 +
  561 + private List<PendingRequest> getPolls() {
  562 + List<PendingRequest> polls = new ArrayList<>();
  563 + for (PendingRequest request : added) {
  564 + if (request.getTx() == null) {
  565 + polls.add(request);
  566 + }
  567 + }
  568 + return polls;
  569 + }
  570 +
  571 + }
  572 +
  573 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.downstream;
  2 +
  3 +import ch.qos.logback.classic.Level;
  4 +import ch.qos.logback.classic.Logger;
  5 +import ch.qos.logback.classic.spi.ILoggingEvent;
  6 +import ch.qos.logback.core.read.ListAppender;
  7 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  8 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  9 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  10 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  11 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  12 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
  13 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  14 +import io.netty.handler.codec.mqtt.MqttQoS;
  15 +import io.vertx.core.Future;
  16 +import io.vertx.core.Promise;
  17 +import io.vertx.core.buffer.Buffer;
  18 +import io.vertx.mqtt.MqttEndpoint;
  19 +import org.junit.jupiter.api.BeforeEach;
  20 +import org.junit.jupiter.api.Test;
  21 +import org.mockito.ArgumentCaptor;
  22 +import org.mockito.Mock;
  23 +import org.slf4j.LoggerFactory;
  24 +
  25 +import java.util.List;
  26 +import java.util.stream.Collectors;
  27 +
  28 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED;
  29 +import static org.junit.jupiter.api.Assertions.*;
  30 +import static org.mockito.ArgumentMatchers.any;
  31 +import static org.mockito.ArgumentMatchers.anyBoolean;
  32 +import static org.mockito.ArgumentMatchers.anyString;
  33 +import static org.mockito.ArgumentMatchers.eq;
  34 +import static org.mockito.Mockito.*;
  35 +
  36 +/**
  37 + * {@link IotMqttDownstreamHandler} 的单元测试
  38 + *
  39 + * @author 芋道源码
  40 + */
  41 +public class IotMqttDownstreamHandlerTest extends BaseMockitoUnitTest {
  42 +
  43 + private static final Long DEVICE_ID = 1024L;
  44 + private static final String RAW_PUSH_TOPIC = "/sys/pk/dn/thing/raw/push";
  45 + private static final String PROPERTY_SET_TOPIC = "/sys/pk/dn/thing/property/set";
  46 + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03};
  47 +
  48 + @Mock
  49 + private IotDeviceMessageService deviceMessageService;
  50 + @Mock
  51 + private MqttEndpoint endpoint;
  52 +
  53 + private IotMqttConnectionManager connectionManager;
  54 + private IotMqttDownstreamHandler downstreamHandler;
  55 +
  56 + @BeforeEach
  57 + public void setUp() {
  58 + connectionManager = new IotMqttConnectionManager();
  59 + downstreamHandler = new IotMqttDownstreamHandler(deviceMessageService, connectionManager);
  60 + }
  61 +
  62 + @Test
  63 + public void testHandle_propertySet() {
  64 + // mock 方法:设备在线,序列化成功
  65 + registerConnection();
  66 + IotDeviceMessage message = buildPropertySetMessage();
  67 + byte[] payload = "{\"id\":\"m1\"}".getBytes();
  68 + when(deviceMessageService.serializeDeviceMessage(message, "pk", "dn")).thenReturn(payload);
  69 + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)))
  70 + .thenReturn(Future.succeededFuture(1));
  71 +
  72 + // 调用
  73 + downstreamHandler.handle(message);
  74 + // 断言:发布到 property/set 主题,载荷为序列化结果
  75 + ArgumentCaptor<Buffer> payloadCaptor = ArgumentCaptor.forClass(Buffer.class);
  76 + verify(endpoint).publish(eq(PROPERTY_SET_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false));
  77 + assertArrayEquals(payload, payloadCaptor.getValue().getBytes());
  78 + }
  79 +
  80 + @Test
  81 + public void testHandle_offline() {
  82 + // 调用:设备未连接
  83 + downstreamHandler.handle(buildPropertySetMessage());
  84 + // 断言:不序列化,也不发布
  85 + verifyNoInteractions(deviceMessageService, endpoint);
  86 + }
  87 +
  88 + @Test
  89 + public void testHandle_serializeEmpty() {
  90 + // mock 方法:设备在线,但序列化结果为空
  91 + registerConnection();
  92 + IotDeviceMessage message = buildPropertySetMessage();
  93 + when(deviceMessageService.serializeDeviceMessage(message, "pk", "dn")).thenReturn(new byte[0]);
  94 +
  95 + // 调用:异常只记录日志,不向外抛出
  96 + assertDoesNotThrow(() -> downstreamHandler.handle(message));
  97 + // 断言:不发布
  98 + verify(endpoint, never()).publish(anyString(), any(Buffer.class), any(MqttQoS.class), anyBoolean(), anyBoolean());
  99 + }
  100 +
  101 + @Test
  102 + public void testHandleRaw_success() {
  103 + // mock 方法:设备在线,publish 成功
  104 + registerConnection();
  105 + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)))
  106 + .thenReturn(Future.succeededFuture(1));
  107 +
  108 + // 调用
  109 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  110 + // 断言:成功,并发布到 raw/push 主题,载荷不变
  111 + assertTrue(future.succeeded());
  112 + ArgumentCaptor<Buffer> payloadCaptor = ArgumentCaptor.forClass(Buffer.class);
  113 + verify(endpoint).publish(eq(RAW_PUSH_TOPIC), payloadCaptor.capture(), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false));
  114 + assertArrayEquals(BYTES, payloadCaptor.getValue().getBytes());
  115 + verifyNoInteractions(deviceMessageService);
  116 + }
  117 +
  118 + @Test
  119 + public void testHandleRaw_offline() {
  120 + // 调用:设备未连接
  121 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  122 + // 断言
  123 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  124 + }
  125 +
  126 + @Test
  127 + public void testHandleRaw_writeFailed() {
  128 + // mock 方法:设备在线,publish 已提交,写出结果稍后才返回
  129 + registerConnection();
  130 + Promise<Integer> write = Promise.promise();
  131 + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)))
  132 + .thenReturn(write.future());
  133 + ListAppender<ILoggingEvent> appender = attachAppender();
  134 + try {
  135 + // 调用
  136 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  137 + // 断言:不等待写出,提交即返回成功
  138 + assertTrue(future.succeeded());
  139 + assertTrue(getWarnMessages(appender).isEmpty());
  140 +
  141 + // 调用:写出失败(例如写出前连接已关闭)
  142 + write.fail(new IllegalStateException("Connection is closed"));
  143 + // 断言:晚到的写出失败记 warn 日志(设备、主题、原因)
  144 + List<String> warnMessages = getWarnMessages(appender);
  145 + assertEquals(1, warnMessages.size());
  146 + assertTrue(warnMessages.get(0).contains("发送消息失败"));
  147 + assertTrue(warnMessages.get(0).contains(String.valueOf(DEVICE_ID)));
  148 + assertTrue(warnMessages.get(0).contains(RAW_PUSH_TOPIC));
  149 + assertTrue(warnMessages.get(0).contains("Connection is closed"));
  150 + } finally {
  151 + detachAppender(appender);
  152 + }
  153 + }
  154 +
  155 + @Test
  156 + public void testHandleRaw_publishThrows() {
  157 + // mock 方法:设备在线,但 publish 同步抛出异常
  158 + registerConnection();
  159 + when(endpoint.publish(anyString(), any(Buffer.class), eq(MqttQoS.AT_LEAST_ONCE), eq(false), eq(false)))
  160 + .thenThrow(new IllegalStateException("Connection not accepted yet"));
  161 +
  162 + // 调用
  163 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  164 + // 断言
  165 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  166 + }
  167 +
  168 + private void registerConnection() {
  169 + connectionManager.registerConnection(endpoint, new IotMqttConnectionManager.ConnectionInfo()
  170 + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRemoteAddress("127.0.0.1:1883"));
  171 + }
  172 +
  173 + private static IotDeviceMessage buildMessage() {
  174 + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID)
  175 + .setMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod());
  176 + }
  177 +
  178 + private static IotDeviceMessage buildPropertySetMessage() {
  179 + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID)
  180 + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod());
  181 + }
  182 +
  183 + private static ListAppender<ILoggingEvent> attachAppender() {
  184 + ListAppender<ILoggingEvent> appender = new ListAppender<>();
  185 + appender.start();
  186 + ((Logger) LoggerFactory.getLogger(IotMqttConnectionManager.class)).addAppender(appender);
  187 + return appender;
  188 + }
  189 +
  190 + private static void detachAppender(ListAppender<ILoggingEvent> appender) {
  191 + ((Logger) LoggerFactory.getLogger(IotMqttConnectionManager.class)).detachAppender(appender);
  192 + appender.stop();
  193 + }
  194 +
  195 + private static List<String> getWarnMessages(ListAppender<ILoggingEvent> appender) {
  196 + return appender.list.stream().filter(event -> event.getLevel() == Level.WARN)
  197 + .map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList());
  198 + }
  199 +
  200 + private static void assertFailed(Future<Void> future, ErrorCode errorCode) {
  201 + assertTrue(future.failed());
  202 + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause());
  203 + assertEquals(errorCode.getCode(), exception.getCode());
  204 + }
  205 +
  206 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  5 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  6 +import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
  7 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  8 +import cn.iocoder.yudao.module.iot.gateway.util.IotWorkerTaskQueue;
  9 +import io.vertx.core.Context;
  10 +import io.vertx.core.Future;
  11 +import io.vertx.core.Vertx;
  12 +import io.vertx.mqtt.MqttEndpoint;
  13 +import org.junit.jupiter.api.AfterEach;
  14 +import org.junit.jupiter.api.BeforeEach;
  15 +import org.junit.jupiter.api.Test;
  16 +import org.mockito.ArgumentCaptor;
  17 +import org.mockito.Mock;
  18 +
  19 +import java.util.Arrays;
  20 +import java.util.Collections;
  21 +import java.util.List;
  22 +import java.util.concurrent.Callable;
  23 +import java.util.concurrent.CompletableFuture;
  24 +import java.util.concurrent.CopyOnWriteArrayList;
  25 +import java.util.concurrent.CountDownLatch;
  26 +import java.util.concurrent.RejectedExecutionException;
  27 +import java.util.concurrent.TimeUnit;
  28 +
  29 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  30 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.SCRIPT_TIMEOUT;
  31 +import static org.junit.jupiter.api.Assertions.*;
  32 +import static org.mockito.AdditionalMatchers.aryEq;
  33 +import static org.mockito.ArgumentMatchers.any;
  34 +import static org.mockito.ArgumentMatchers.anyLong;
  35 +import static org.mockito.ArgumentMatchers.eq;
  36 +import static org.mockito.Mockito.*;
  37 +
  38 +/**
  39 + * {@link IotMqttUpstreamHandler} 的单元测试
  40 + *
  41 + * @author 芋道源码
  42 + */
  43 +public class IotMqttUpstreamHandlerTest extends BaseMockitoUnitTest {
  44 +
  45 + private static final long TIMEOUT_SECONDS = 10;
  46 + private static final String PRODUCT_KEY = "pk";
  47 + private static final String DEVICE_NAME = "dn";
  48 + private static final String SERVER_ID = "mqtt-server";
  49 + private static final String RAW_POST_TOPIC = "/sys/pk/dn/thing/raw/post";
  50 + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x03};
  51 +
  52 + @Mock
  53 + private IotDeviceMessageService deviceMessageService;
  54 + @Mock
  55 + private MqttEndpoint endpoint;
  56 +
  57 + private final IotMqttConnectionManager connectionManager = new IotMqttConnectionManager();
  58 + private IotMqttUpstreamHandler upstreamHandler;
  59 + private Vertx vertx;
  60 + private Context context;
  61 +
  62 + @BeforeEach
  63 + public void setUp() {
  64 + upstreamHandler = new IotMqttUpstreamHandler(connectionManager, deviceMessageService, SERVER_ID);
  65 + lenient().when(endpoint.clientIdentifier()).thenReturn("client-1");
  66 + connectionManager.registerConnection(endpoint, new IotMqttConnectionManager.ConnectionInfo()
  67 + .setDeviceId(1L).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME).setRemoteAddress("127.0.0.1:1883"));
  68 + vertx = Vertx.vertx();
  69 + context = vertx.getOrCreateContext();
  70 + }
  71 +
  72 + @AfterEach
  73 + public void tearDown() throws Exception {
  74 + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  75 + }
  76 +
  77 + @Test
  78 + public void testHandleBusinessRequest_rawPostOrderedAck() throws Exception {
  79 + // 准备参数
  80 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  81 + CountDownLatch entered = new CountDownLatch(1);
  82 + CountDownLatch release = new CountDownLatch(1);
  83 + List<Boolean> onWorker = new CopyOnWriteArrayList<>();
  84 + // mock 方法:第一条透传上报阻塞,第二条立即完成;记录执行线程
  85 + when(deviceMessageService.sendRawMessage(aryEq(PAYLOAD), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID),
  86 + eq(IotProtocolTypeEnum.MQTT), anyLong())).thenAnswer(invocation -> {
  87 + onWorker.add(Context.isOnWorkerThread());
  88 + entered.countDown();
  89 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  90 + throw new IllegalStateException("等待释放超时");
  91 + }
  92 + return "M1";
  93 + }).thenAnswer(invocation -> {
  94 + onWorker.add(Context.isOnWorkerThread());
  95 + return "M2";
  96 + });
  97 +
  98 + // 调用:记录每条消息交给 handler 前后的时刻
  99 + long firstBeginNanos = System.nanoTime();
  100 + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  101 + long firstEndNanos = System.nanoTime();
  102 + long secondBeginNanos = System.nanoTime();
  103 + Future<Void> second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  104 + long secondEndNanos = System.nanoTime();
  105 +
  106 + try {
  107 + // 断言:处理完成之前不完成(不确认);第二条排在第一条之后
  108 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  109 + assertFalse(first.isComplete());
  110 + assertFalse(second.isComplete());
  111 + } finally {
  112 + release.countDown();
  113 + }
  114 + // 断言:释放后按顺序完成;在 worker 上处理
  115 + await(first);
  116 + await(second);
  117 + assertEquals(Arrays.asList(true, true), onWorker);
  118 + // 断言:收到的时刻在交给 handler 时(入队之前)取得:第二条在第一条阻塞期间排队,它的时刻仍在入队前后之间
  119 + ArgumentCaptor<Long> receivedCaptor = ArgumentCaptor.forClass(Long.class);
  120 + verify(deviceMessageService, times(2)).sendRawMessage(any(), any(), any(), any(), any(), receivedCaptor.capture());
  121 + assertBetween(firstBeginNanos, receivedCaptor.getAllValues().get(0), firstEndNanos);
  122 + assertBetween(secondBeginNanos, receivedCaptor.getAllValues().get(1), secondEndNanos);
  123 + verify(deviceMessageService, never()).deserializeDeviceMessage(any(), any(), any());
  124 + }
  125 +
  126 + @Test
  127 + public void testHandleBusinessRequest_rawPostFailureStillAck() throws Exception {
  128 + // 准备参数
  129 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  130 + // mock 方法:处理失败(脚本超时、未知异常)
  131 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong()))
  132 + .thenThrow(exception(SCRIPT_TIMEOUT))
  133 + .thenThrow(new IllegalStateException("boom"));
  134 +
  135 + // 调用
  136 + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  137 + Future<Void> second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  138 +
  139 + // 断言:只记日志,照常完成(照常确认),与原来一致
  140 + await(first);
  141 + await(second);
  142 + assertTrue(first.succeeded());
  143 + assertTrue(second.succeeded());
  144 + }
  145 +
  146 + @Test
  147 + public void testHandleBusinessRequest_standard() throws Exception {
  148 + // 准备参数
  149 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  150 + String topic = "/sys/pk/dn/thing/property/post";
  151 + byte[] payload = "{\"method\":\"thing.property.post\"}".getBytes();
  152 + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1));
  153 + // mock 方法
  154 + when(deviceMessageService.deserializeDeviceMessage(payload, PRODUCT_KEY, DEVICE_NAME)).thenReturn(message);
  155 +
  156 + // 调用
  157 + await(callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, topic, payload)));
  158 +
  159 + // 断言:反序列化后按标准路径发送,不走透传
  160 + verify(deviceMessageService).sendDeviceMessage(same(message), eq(PRODUCT_KEY), eq(DEVICE_NAME), eq(SERVER_ID));
  161 + verify(deviceMessageService, never()).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  162 + }
  163 +
  164 + @Test
  165 + public void testHandleBusinessRequest_invalidRequest() throws Exception {
  166 + // 准备参数
  167 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  168 + MqttEndpoint unknownEndpoint = mock(MqttEndpoint.class);
  169 +
  170 + // 调用:伪造其它设备的主题、未认证的连接、空载荷
  171 + Future<Void> forged = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue,
  172 + "/sys/pk/other/thing/raw/post", PAYLOAD));
  173 + Future<Void> unknown = callOnContext(() -> upstreamHandler.handleBusinessRequest(unknownEndpoint, queue,
  174 + RAW_POST_TOPIC, PAYLOAD));
  175 + Future<Void> empty = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue,
  176 + RAW_POST_TOPIC, new byte[0]));
  177 +
  178 + // 断言:只记日志,照常完成(照常确认),不处理
  179 + await(forged);
  180 + await(unknown);
  181 + await(empty);
  182 + assertTrue(forged.succeeded());
  183 + assertTrue(unknown.succeeded());
  184 + assertTrue(empty.succeeded());
  185 + verifyNoInteractions(deviceMessageService);
  186 + }
  187 +
  188 + @Test
  189 + public void testHandleBusinessRequest_invalidRequestOrderedAck() throws Exception {
  190 + // 准备参数
  191 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  192 + CountDownLatch entered = new CountDownLatch(1);
  193 + CountDownLatch release = new CountDownLatch(1);
  194 + // mock 方法:第一条透传上报阻塞
  195 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> {
  196 + entered.countDown();
  197 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  198 + throw new IllegalStateException("等待释放超时");
  199 + }
  200 + return "M1";
  201 + });
  202 +
  203 + // 调用:第二条是空载荷,不处理
  204 + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  205 + Future<Void> empty = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue,
  206 + RAW_POST_TOPIC, new byte[0]));
  207 +
  208 + try {
  209 + // 断言:不处理的消息也排在前一条之后完成,确认(PUBACK)不越过前一条
  210 + assertTrue(entered.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  211 + assertFalse(first.isComplete());
  212 + assertFalse(empty.isComplete());
  213 + } finally {
  214 + release.countDown();
  215 + }
  216 + await(first);
  217 + await(empty);
  218 + assertTrue(empty.succeeded());
  219 + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  220 + }
  221 +
  222 + @Test
  223 + public void testHandleBusinessRequest_queueFull() throws Exception {
  224 + // 准备参数:队列最多 1 个待处理任务
  225 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 1);
  226 + CountDownLatch release = new CountDownLatch(1);
  227 + // mock 方法:第一条阻塞
  228 + when(deviceMessageService.sendRawMessage(any(), any(), any(), any(), any(), anyLong())).thenAnswer(invocation -> {
  229 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  230 + throw new IllegalStateException("等待释放超时");
  231 + }
  232 + return "M1";
  233 + });
  234 +
  235 + try {
  236 + // 调用
  237 + Future<Void> first = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  238 + Future<Void> second = callOnContext(() -> upstreamHandler.handleBusinessRequest(endpoint, queue, RAW_POST_TOPIC, PAYLOAD));
  239 +
  240 + // 断言:超过上限立即失败(调用方断开连接,不确认)
  241 + assertTrue(second.failed());
  242 + assertInstanceOf(RejectedExecutionException.class, second.cause());
  243 + assertFalse(first.isComplete());
  244 + release.countDown();
  245 + await(first);
  246 + assertTrue(first.succeeded());
  247 + // 断言:被拒绝的那条不处理
  248 + verify(deviceMessageService, times(1)).sendRawMessage(any(), any(), any(), any(), any(), anyLong());
  249 + } finally {
  250 + release.countDown();
  251 + }
  252 + }
  253 +
  254 + // ========== 辅助方法 ==========
  255 +
  256 + /**
  257 + * 在连接所在的 context 线程上执行(与 MQTT 的 publishHandler 一致),等待并返回结果
  258 + */
  259 + private <T> T callOnContext(Callable<T> action) throws Exception {
  260 + CompletableFuture<T> result = new CompletableFuture<>();
  261 + context.runOnContext(v -> {
  262 + try {
  263 + result.complete(action.call());
  264 + } catch (Throwable e) {
  265 + result.completeExceptionally(e);
  266 + }
  267 + });
  268 + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  269 + }
  270 +
  271 + private static void await(Future<?> future) throws Exception {
  272 + CountDownLatch latch = new CountDownLatch(1);
  273 + future.onComplete(ar -> latch.countDown());
  274 + assertTrue(latch.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  275 + }
  276 +
  277 + private static void assertBetween(long beginNanos, long actualNanos, long endNanos) {
  278 + assertTrue(actualNanos >= beginNanos && actualNanos <= endNanos,
  279 + String.format("%d 不在 [%d, %d] 之间", actualNanos, beginNanos, endNanos));
  280 + }
  281 +
  282 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  7 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  8 +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec;
  9 +import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager;
  10 +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer;
  11 +import io.vertx.core.Future;
  12 +import io.vertx.core.buffer.Buffer;
  13 +import io.vertx.core.net.NetSocket;
  14 +import org.junit.jupiter.api.BeforeEach;
  15 +import org.junit.jupiter.api.Test;
  16 +import org.mockito.ArgumentCaptor;
  17 +import org.mockito.Mock;
  18 +
  19 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED;
  20 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.RAW_NOT_SUPPORTED;
  21 +import static org.junit.jupiter.api.Assertions.*;
  22 +import static org.mockito.ArgumentMatchers.any;
  23 +import static org.mockito.Mockito.*;
  24 +
  25 +/**
  26 + * {@link IotTcpDownstreamHandler} 的单元测试
  27 + *
  28 + * @author 芋道源码
  29 + */
  30 +public class IotTcpDownstreamHandlerTest extends BaseMockitoUnitTest {
  31 +
  32 + private static final Long DEVICE_ID = 1024L;
  33 + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03};
  34 + /**
  35 + * 帧编码后的数据(模拟 codec 加了帧头)
  36 + */
  37 + private static final byte[] FRAME = new byte[]{0x00, 0x03, 0x01, 0x02, 0x03};
  38 +
  39 + @Mock
  40 + private IotTcpFrameCodec codec;
  41 + @Mock
  42 + private IotMessageSerializer serializer;
  43 + @Mock
  44 + private NetSocket socket;
  45 + @Mock
  46 + private NetSocket jsonSocket;
  47 +
  48 + private IotTcpConnectionManager connectionManager;
  49 + private IotTcpDownstreamHandler downstreamHandler;
  50 +
  51 + @BeforeEach
  52 + public void setUp() {
  53 + connectionManager = new IotTcpConnectionManager(10);
  54 + downstreamHandler = new IotTcpDownstreamHandler(connectionManager, codec, serializer);
  55 + }
  56 +
  57 + @Test
  58 + public void testHandle_propertySet() {
  59 + // mock 方法:普通连接,序列化 + 帧编码成功
  60 + registerConnection(false);
  61 + IotDeviceMessage message = buildPropertySetMessage();
  62 + when(serializer.serialize(message)).thenReturn(BYTES);
  63 + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME));
  64 +
  65 + // 调用
  66 + downstreamHandler.handle(message);
  67 + // 断言:写出 codec 加帧后的数据
  68 + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class);
  69 + verify(socket).write(dataCaptor.capture());
  70 + assertArrayEquals(FRAME, dataCaptor.getValue().getBytes());
  71 + }
  72 +
  73 + @Test
  74 + public void testHandle_notPropertySet() {
  75 + // mock 方法:普通连接
  76 + registerConnection(false);
  77 +
  78 + // 调用:非属性设置消息(例如服务调用)
  79 + downstreamHandler.handle(new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID)
  80 + .setMethod(IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod()));
  81 + // 断言:忽略,不序列化也不写出
  82 + verifyNoInteractions(serializer, codec);
  83 + verify(socket, never()).write(any(Buffer.class));
  84 + }
  85 +
  86 + @Test
  87 + public void testHandle_offline() {
  88 + // 调用:设备未连接
  89 + downstreamHandler.handle(buildPropertySetMessage());
  90 + // 断言:不序列化也不写出
  91 + verifyNoInteractions(serializer, codec, socket);
  92 + }
  93 +
  94 + @Test
  95 + public void testHandleRaw_success() {
  96 + // mock 方法:透传连接,write 成功
  97 + registerConnection(true);
  98 + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME));
  99 + when(socket.write(any(Buffer.class))).thenReturn(Future.succeededFuture());
  100 +
  101 + // 调用
  102 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  103 + // 断言:成功,并写出 codec 加帧后的数据
  104 + assertTrue(future.succeeded());
  105 + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class);
  106 + verify(socket).write(dataCaptor.capture());
  107 + assertArrayEquals(FRAME, dataCaptor.getValue().getBytes());
  108 + verifyNoInteractions(serializer);
  109 + }
  110 +
  111 + @Test
  112 + public void testHandleRaw_offline() {
  113 + // 调用:设备未连接
  114 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  115 + // 断言
  116 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  117 + verifyNoInteractions(codec);
  118 + }
  119 +
  120 + @Test
  121 + public void testHandleRaw_notRawConnection() {
  122 + // mock 方法:普通(非透传)连接
  123 + registerConnection(false);
  124 +
  125 + // 调用
  126 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  127 + // 断言:105,不写出
  128 + assertFailed(future, RAW_NOT_SUPPORTED);
  129 + verifyNoInteractions(codec);
  130 + verify(socket, never()).write(any(Buffer.class));
  131 + }
  132 +
  133 + @Test
  134 + public void testHandleRaw_writeFailed() {
  135 + // mock 方法:透传连接,但 write 异步失败(例如写出前对端已关闭)
  136 + registerConnection(true);
  137 + when(codec.encode(BYTES)).thenReturn(Buffer.buffer(FRAME));
  138 + when(socket.write(any(Buffer.class))).thenReturn(Future.failedFuture(new IllegalStateException("Connection is closed")));
  139 +
  140 + // 调用
  141 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  142 + // 断言:异步失败转成 106,而不是成功
  143 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  144 + }
  145 +
  146 + @Test
  147 + public void testHandleRaw_encodeFailed() {
  148 + // mock 方法:透传连接,但帧编码失败(例如超过固定长度)
  149 + registerConnection(true);
  150 + when(codec.encode(BYTES)).thenThrow(new IllegalArgumentException("数据长度 3 超过固定长度 2"));
  151 +
  152 + // 调用
  153 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  154 + // 断言
  155 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  156 + verify(socket, never()).write(any(Buffer.class));
  157 + }
  158 +
  159 + @Test
  160 + public void testHandleRaw_connectionReplaced() {
  161 + // mock 方法:透传连接;检查之后、写出之前,设备换成了普通 JSON 连接
  162 + registerConnection(true);
  163 + when(codec.encode(BYTES)).thenAnswer(invocation -> {
  164 + connectionManager.registerConnection(jsonSocket, DEVICE_ID, new IotTcpConnectionManager.ConnectionInfo()
  165 + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRawMode(false));
  166 + return Buffer.buffer(FRAME);
  167 + });
  168 +
  169 + // 调用
  170 + Future<Void> future = downstreamHandler.handleRawMessage(buildMessage(), BYTES);
  171 + // 断言:写出失败(106),不写到新的 JSON 连接上,也不重试
  172 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  173 + verify(jsonSocket, never()).write(any(Buffer.class));
  174 + verify(socket, never()).write(any(Buffer.class));
  175 + assertSame(jsonSocket, connectionManager.getSocketByDeviceId(DEVICE_ID));
  176 + }
  177 +
  178 + private void registerConnection(boolean rawMode) {
  179 + connectionManager.registerConnection(socket, DEVICE_ID, new IotTcpConnectionManager.ConnectionInfo()
  180 + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setRawMode(rawMode));
  181 + }
  182 +
  183 + private static IotDeviceMessage buildMessage() {
  184 + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID)
  185 + .setMethod(IotDeviceMessageMethodEnum.RAW_PUSH.getMethod());
  186 + }
  187 +
  188 + private static IotDeviceMessage buildPropertySetMessage() {
  189 + return new IotDeviceMessage().setId("m1").setDeviceId(DEVICE_ID)
  190 + .setMethod(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod());
  191 + }
  192 +
  193 + private static void assertFailed(Future<Void> future, ErrorCode errorCode) {
  194 + assertTrue(future.failed());
  195 + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause());
  196 + assertEquals(errorCode.getCode(), exception.getCode());
  197 + }
  198 +
  199 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandlerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.upstream;
  2 +
  3 +import cn.hutool.core.util.ArrayUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
  6 +import org.junit.jupiter.api.Test;
  7 +
  8 +import static org.junit.jupiter.api.Assertions.*;
  9 +
  10 +/**
  11 + * {@link IotUdpUpstreamHandler} 的单元测试
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +public class IotUdpUpstreamHandlerTest {
  16 +
  17 + private static final String TOKEN = "eyJhbGciOiJIUzI1NiJ9.e30.token";
  18 + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, 0x7E, 0x7B};
  19 +
  20 + @Test
  21 + public void testIsRawPacket() {
  22 + assertTrue(IotUdpUpstreamHandler.isRawPacket(buildUpstreamPacket(TOKEN, PAYLOAD)));
  23 + // JSON、二进制序列化、长度不足、空
  24 + assertFalse(IotUdpUpstreamHandler.isRawPacket(StrUtil.utf8Bytes("{\"method\":\"auth\"}")));
  25 + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[]{0x7E, 0x01, 0x02}));
  26 + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[]{0x49}));
  27 + assertFalse(IotUdpUpstreamHandler.isRawPacket(new byte[0]));
  28 + assertFalse(IotUdpUpstreamHandler.isRawPacket(null));
  29 + }
  30 +
  31 + @Test
  32 + public void testDecodeRawPacket_success() {
  33 + // 准备参数
  34 + byte[] data = buildUpstreamPacket(TOKEN, PAYLOAD);
  35 +
  36 + // 调用
  37 + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(data);
  38 + // 断言
  39 + assertEquals(TOKEN, packet.getToken());
  40 + assertArrayEquals(PAYLOAD, packet.getPayload());
  41 + }
  42 +
  43 + @Test
  44 + public void testDecodeRawPacket_maxPayload() {
  45 + // 准备参数:payload 正好 4096 字节
  46 + byte[] payload = new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES];
  47 + payload[payload.length - 1] = 0x55;
  48 +
  49 + // 调用
  50 + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(buildUpstreamPacket(TOKEN, payload));
  51 + // 断言
  52 + assertArrayEquals(payload, packet.getPayload());
  53 + }
  54 +
  55 + @Test
  56 + public void testDecodeRawPacket_invalid() {
  57 + byte[] valid = buildUpstreamPacket(TOKEN, PAYLOAD);
  58 + // 魔术字不正确
  59 + assertThrows(IllegalArgumentException.class,
  60 + () -> IotUdpUpstreamHandler.decodeRawPacket(StrUtil.utf8Bytes("{\"method\":\"auth\"}")));
  61 + // 报文头不完整
  62 + assertThrows(IllegalArgumentException.class,
  63 + () -> IotUdpUpstreamHandler.decodeRawPacket(new byte[]{0x49, 0x52, 0x01, 0x00}));
  64 + // 版本不支持
  65 + byte[] wrongVersion = valid.clone();
  66 + wrongVersion[2] = 0x02;
  67 + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(wrongVersion));
  68 + // 缺少 token(tokenLen = 0,例如误把下行报文发上来)
  69 + assertThrows(IllegalArgumentException.class,
  70 + () -> IotUdpUpstreamHandler.decodeRawPacket(IotUdpUpstreamHandler.encodeRawDownstream(PAYLOAD)));
  71 + // tokenLen 超过报文长度
  72 + byte[] tokenTooLong = valid.clone();
  73 + tokenTooLong[3] = (byte) 0xFF;
  74 + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(tokenTooLong));
  75 + // 缺少 payload
  76 + assertThrows(IllegalArgumentException.class,
  77 + () -> IotUdpUpstreamHandler.decodeRawPacket(buildUpstreamPacket(TOKEN, new byte[0])));
  78 + // payload 超过 4096 字节
  79 + assertThrows(IllegalArgumentException.class, () -> IotUdpUpstreamHandler.decodeRawPacket(
  80 + buildUpstreamPacket(TOKEN, new byte[IotDeviceMessageUtils.RAW_PAYLOAD_MAX_BYTES + 1])));
  81 + }
  82 +
  83 + @Test
  84 + public void testEncodeRawDownstream() {
  85 + // 调用
  86 + byte[] data = IotUdpUpstreamHandler.encodeRawDownstream(PAYLOAD);
  87 + // 断言:0x49 0x52 | 0x01 | 0x0000 | payload
  88 + assertArrayEquals(ArrayUtil.addAll(new byte[]{0x49, 0x52, 0x01, 0x00, 0x00}, PAYLOAD), data);
  89 + assertTrue(IotUdpUpstreamHandler.isRawPacket(data));
  90 + }
  91 +
  92 + @Test
  93 + public void testEncodeDecode_roundTrip() {
  94 + // 准备参数:设备上行的报文
  95 + byte[] upstream = buildUpstreamPacket(TOKEN, PAYLOAD);
  96 +
  97 + // 调用:解码上行,再把解出的 payload 编码为下行(例如 ack 回显)
  98 + IotUdpUpstreamHandler.RawPacket packet = IotUdpUpstreamHandler.decodeRawPacket(upstream);
  99 + byte[] downstream = IotUdpUpstreamHandler.encodeRawDownstream(packet.getPayload());
  100 + // 断言:payload 原样保留,下行报文头 tokenLen = 0
  101 + assertEquals(TOKEN, packet.getToken());
  102 + assertArrayEquals(ArrayUtil.sub(upstream, upstream.length - PAYLOAD.length, upstream.length),
  103 + ArrayUtil.sub(downstream, 5, downstream.length));
  104 + assertEquals(0, downstream[3]);
  105 + assertEquals(0, downstream[4]);
  106 + }
  107 +
  108 + /**
  109 + * 按协议构建上行报文:0x49 0x52 | 0x01 | tokenLen(u16 BE) | token(UTF-8) | payload
  110 + */
  111 + private static byte[] buildUpstreamPacket(String token, byte[] payload) {
  112 + byte[] tokenBytes = StrUtil.utf8Bytes(token);
  113 + byte[] header = new byte[]{0x49, 0x52, 0x01,
  114 + (byte) ((tokenBytes.length >> 8) & 0xFF), (byte) (tokenBytes.length & 0xFF)};
  115 + return ArrayUtil.addAll(header, tokenBytes, payload);
  116 + }
  117 +
  118 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/manager/IotUdpSessionManagerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import io.vertx.core.Future;
  7 +import io.vertx.core.Promise;
  8 +import io.vertx.core.buffer.Buffer;
  9 +import io.vertx.core.datagram.DatagramSocket;
  10 +import org.junit.jupiter.api.BeforeEach;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.mockito.ArgumentCaptor;
  13 +import org.mockito.Mock;
  14 +
  15 +import java.net.InetSocketAddress;
  16 +
  17 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED;
  18 +import static org.junit.jupiter.api.Assertions.*;
  19 +import static org.mockito.ArgumentMatchers.any;
  20 +import static org.mockito.ArgumentMatchers.anyInt;
  21 +import static org.mockito.ArgumentMatchers.anyString;
  22 +import static org.mockito.ArgumentMatchers.eq;
  23 +import static org.mockito.Mockito.*;
  24 +
  25 +/**
  26 + * {@link IotUdpSessionManager} 的单元测试
  27 + *
  28 + * @author 芋道源码
  29 + */
  30 +public class IotUdpSessionManagerTest extends BaseMockitoUnitTest {
  31 +
  32 + private static final Long DEVICE_ID = 1024L;
  33 + private static final byte[] DATA = new byte[]{0x49, 0x52, 0x01, 0x00, 0x00, 0x01};
  34 + private static final InetSocketAddress ADDRESS = new InetSocketAddress("127.0.0.1", 18093);
  35 +
  36 + @Mock
  37 + private DatagramSocket socket;
  38 +
  39 + private IotUdpSessionManager sessionManager;
  40 +
  41 + @BeforeEach
  42 + public void setUp() {
  43 + sessionManager = new IotUdpSessionManager(10, 60000L);
  44 + }
  45 +
  46 + @Test
  47 + public void testSendToDeviceAsync_success() {
  48 + // mock 方法:会话存在,send 成功
  49 + registerSession();
  50 + when(socket.send(any(Buffer.class), anyInt(), anyString())).thenReturn(Future.succeededFuture());
  51 +
  52 + // 调用
  53 + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket);
  54 + // 断言:发送到会话地址
  55 + assertTrue(future.succeeded());
  56 + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class);
  57 + verify(socket).send(dataCaptor.capture(), eq(ADDRESS.getPort()), eq(ADDRESS.getHostString()));
  58 + assertArrayEquals(DATA, dataCaptor.getValue().getBytes());
  59 + }
  60 +
  61 + @Test
  62 + public void testSendToDeviceAsync_notWait() {
  63 + // mock 方法:会话存在,send 一直未完成
  64 + registerSession();
  65 + Promise<Void> promise = Promise.promise();
  66 + when(socket.send(any(Buffer.class), anyInt(), anyString())).thenReturn(promise.future());
  67 +
  68 + // 调用:不等待发送结果,立即返回未完成的 Future(不走 sendToDevice 的 5 秒同步等待)
  69 + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket);
  70 + assertFalse(future.isComplete());
  71 + // 发送失败后,转成 106
  72 + promise.fail(new IllegalStateException("Socket closed"));
  73 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  74 + }
  75 +
  76 + @Test
  77 + public void testSendToDeviceAsync_sessionNotExists() {
  78 + // 调用:会话不存在(或已过期)
  79 + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket);
  80 + // 断言
  81 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  82 + verifyNoInteractions(socket);
  83 + }
  84 +
  85 + @Test
  86 + public void testSendToDeviceAsync_socketNotAvailable() {
  87 + // mock 方法:会话存在,但 UDP Socket 不可用
  88 + registerSession();
  89 +
  90 + // 调用,并断言
  91 + assertFailed(sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, null), DEVICE_OFFLINE_OR_WRITE_FAILED);
  92 + }
  93 +
  94 + @Test
  95 + public void testSendToDeviceAsync_sendFailed() {
  96 + // mock 方法:会话存在,但 send 异步失败
  97 + registerSession();
  98 + when(socket.send(any(Buffer.class), anyInt(), anyString()))
  99 + .thenReturn(Future.failedFuture(new IllegalStateException("Socket closed")));
  100 +
  101 + // 调用
  102 + Future<Void> future = sessionManager.sendToDeviceAsync(DEVICE_ID, DATA, socket);
  103 + // 断言:异步失败转成 106,而不是成功
  104 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  105 + }
  106 +
  107 + private void registerSession() {
  108 + sessionManager.registerSession(DEVICE_ID, new IotUdpSessionManager.SessionInfo()
  109 + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn").setAddress(ADDRESS));
  110 + }
  111 +
  112 + private static void assertFailed(Future<Void> future, ErrorCode errorCode) {
  113 + assertTrue(future.failed());
  114 + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause());
  115 + assertEquals(errorCode.getCode(), exception.getCode());
  116 + }
  117 +
  118 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManagerTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  4 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import io.vertx.core.Future;
  7 +import io.vertx.core.buffer.Buffer;
  8 +import io.vertx.core.http.ServerWebSocket;
  9 +import org.junit.jupiter.api.BeforeEach;
  10 +import org.junit.jupiter.api.Test;
  11 +import org.mockito.ArgumentCaptor;
  12 +import org.mockito.Mock;
  13 +
  14 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_OFFLINE_OR_WRITE_FAILED;
  15 +import static org.junit.jupiter.api.Assertions.*;
  16 +import static org.mockito.ArgumentMatchers.any;
  17 +import static org.mockito.Mockito.*;
  18 +
  19 +/**
  20 + * {@link IotWebSocketConnectionManager} 的单元测试
  21 + *
  22 + * @author 芋道源码
  23 + */
  24 +public class IotWebSocketConnectionManagerTest extends BaseMockitoUnitTest {
  25 +
  26 + private static final Long DEVICE_ID = 1024L;
  27 + private static final byte[] BYTES = new byte[]{0x01, 0x02, 0x03};
  28 +
  29 + @Mock
  30 + private ServerWebSocket socket;
  31 +
  32 + private IotWebSocketConnectionManager connectionManager;
  33 +
  34 + @BeforeEach
  35 + public void setUp() {
  36 + connectionManager = new IotWebSocketConnectionManager();
  37 + }
  38 +
  39 + @Test
  40 + public void testSendBinaryToDeviceAsync_success() {
  41 + // mock 方法:设备在线,writeBinaryMessage 成功
  42 + registerConnection();
  43 + when(socket.writeBinaryMessage(any(Buffer.class))).thenReturn(Future.succeededFuture());
  44 +
  45 + // 调用
  46 + Future<Void> future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES);
  47 + // 断言:以 binary 帧写出原始字节,不写 text 帧
  48 + assertTrue(future.succeeded());
  49 + ArgumentCaptor<Buffer> dataCaptor = ArgumentCaptor.forClass(Buffer.class);
  50 + verify(socket).writeBinaryMessage(dataCaptor.capture());
  51 + assertArrayEquals(BYTES, dataCaptor.getValue().getBytes());
  52 + verify(socket, never()).writeTextMessage(anyString());
  53 + }
  54 +
  55 + @Test
  56 + public void testSendBinaryToDeviceAsync_offline() {
  57 + // 调用:设备未连接
  58 + Future<Void> future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES);
  59 + // 断言
  60 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  61 + verifyNoInteractions(socket);
  62 + }
  63 +
  64 + @Test
  65 + public void testSendBinaryToDeviceAsync_writeFailed() {
  66 + // mock 方法:设备在线,但 writeBinaryMessage 异步失败(例如写出前连接已关闭)
  67 + registerConnection();
  68 + when(socket.writeBinaryMessage(any(Buffer.class)))
  69 + .thenReturn(Future.failedFuture(new IllegalStateException("WebSocket is closed")));
  70 +
  71 + // 调用
  72 + Future<Void> future = connectionManager.sendBinaryToDeviceAsync(DEVICE_ID, BYTES);
  73 + // 断言:异步失败转成 106,而不是成功
  74 + assertFailed(future, DEVICE_OFFLINE_OR_WRITE_FAILED);
  75 + }
  76 +
  77 + private void registerConnection() {
  78 + connectionManager.registerConnection(socket, DEVICE_ID, new IotWebSocketConnectionManager.ConnectionInfo()
  79 + .setDeviceId(DEVICE_ID).setProductKey("pk").setDeviceName("dn"));
  80 + }
  81 +
  82 + private static void assertFailed(Future<Void> future, ErrorCode errorCode) {
  83 + assertTrue(future.failed());
  84 + ServiceException exception = assertInstanceOf(ServiceException.class, future.cause());
  85 + assertEquals(errorCode.getCode(), exception.getCode());
  86 + }
  87 +
  88 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/service/device/message/IotDeviceMessageServiceImplTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.service.device.message;
  2 +
  3 +import cn.hutool.core.util.ReflectUtil;
  4 +import cn.hutool.core.util.StrUtil;
  5 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  6 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  7 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  8 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  9 +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
  10 +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
  11 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
  12 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  13 +import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
  14 +import cn.iocoder.yudao.module.iot.core.script.IotScriptExecutor;
  15 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptResult;
  16 +import cn.iocoder.yudao.module.iot.core.script.dto.IotScriptSource;
  17 +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
  18 +import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager;
  19 +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
  20 +import cn.iocoder.yudao.module.iot.gateway.service.device.message.dto.IotDownstreamEncodeRespDTO;
  21 +import com.fasterxml.jackson.databind.JsonNode;
  22 +import org.junit.jupiter.api.BeforeEach;
  23 +import org.junit.jupiter.api.Test;
  24 +import org.mockito.ArgumentCaptor;
  25 +import org.mockito.InjectMocks;
  26 +import org.mockito.Mock;
  27 +
  28 +import java.time.Duration;
  29 +import java.time.LocalDateTime;
  30 +import java.util.Arrays;
  31 +import java.util.Base64;
  32 +import java.util.Collections;
  33 +import java.util.List;
  34 +import java.util.concurrent.TimeUnit;
  35 +
  36 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
  37 +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.SUCCESS;
  38 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  39 +import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.*;
  40 +import static org.junit.jupiter.api.Assertions.*;
  41 +import static org.mockito.ArgumentMatchers.any;
  42 +import static org.mockito.ArgumentMatchers.anyLong;
  43 +import static org.mockito.ArgumentMatchers.anyString;
  44 +import static org.mockito.ArgumentMatchers.eq;
  45 +import static org.mockito.Mockito.*;
  46 +
  47 +/**
  48 + * {@link IotDeviceMessageServiceImpl} 的单元测试
  49 + *
  50 + * @author 芋道源码
  51 + */
  52 +public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest {
  53 +
  54 + private static final Long DEVICE_ID = 10L;
  55 + private static final Long TENANT_ID = 1L;
  56 + private static final Long PRODUCT_ID = 100L;
  57 + private static final String PRODUCT_KEY = "pk";
  58 + private static final String DEVICE_NAME = "dn";
  59 + private static final String SERVER_ID = "server-1";
  60 + private static final byte[] PAYLOAD = new byte[]{0x01, 0x02, (byte) 0xFF};
  61 + private static final String RAW = Base64.getEncoder().encodeToString(PAYLOAD);
  62 +
  63 + private static final String SCRIPT = "function decode(ctx) {}";
  64 +
  65 + private static final String DECODE_PROPERTY_POST = "{\"method\":\"thing.property.post\",\"params\":{\"temperature\":25.5}}";
  66 +
  67 + @InjectMocks
  68 + private IotDeviceMessageServiceImpl deviceMessageService;
  69 +
  70 + @Mock
  71 + private IotDeviceService deviceService;
  72 + @Mock
  73 + private IotDeviceMessageProducer deviceMessageProducer;
  74 + @Mock
  75 + private IotMessageSerializerManager messageSerializerManager;
  76 + @Mock
  77 + private IotScriptExecutor scriptExecutor;
  78 +
  79 + private final IotGatewayProperties gatewayProperties = new IotGatewayProperties();
  80 + private final IotDeviceRespDTO device = new IotDeviceRespDTO().setId(DEVICE_ID).setTenantId(TENANT_ID)
  81 + .setProductId(PRODUCT_ID).setProductKey(PRODUCT_KEY).setDeviceName(DEVICE_NAME)
  82 + .setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType())
  83 + .setScriptEnabled(true).setScriptContent(SCRIPT);
  84 + private final IotScriptSource source = IotScriptSource.of(PRODUCT_ID, SCRIPT);
  85 +
  86 + @BeforeEach
  87 + public void setUp() {
  88 + ReflectUtil.setFieldValue(deviceMessageService, "gatewayProperties", gatewayProperties);
  89 + ReflectUtil.setFieldValue(deviceMessageService, "scriptExecutor", scriptExecutor);
  90 + }
  91 +
  92 + // ========== sendRawMessage ==========
  93 +
  94 + @Test
  95 + public void testSendRawMessage_decodeSuccess() {
  96 + // 准备参数:截止 = 收到的时刻 + gateway.script.deadline
  97 + gatewayProperties.getScript().setEnabled(true);
  98 + long receivedNanos = System.nanoTime();
  99 + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos();
  100 + // mock 方法
  101 + mockDevice();
  102 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos)))
  103 + .thenReturn(IotScriptResult.success(DECODE_PROPERTY_POST, 1));
  104 +
  105 + // 调用
  106 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  107 + IotProtocolTypeEnum.TCP, receivedNanos);
  108 +
  109 + // 断言:只投递 1 条解析出的标准消息,raw 带上原始帧
  110 + IotDeviceMessage message = captureSentMessage();
  111 + assertEquals(messageId, message.getId());
  112 + assertNotNull(message.getId());
  113 + assertEquals(message.getId(), message.getRequestId()); // 没有 requestId 时默认 = id
  114 + assertNotNull(message.getReportTime());
  115 + assertEquals("thing.property.post", message.getMethod());
  116 + assertEquals(Collections.singletonMap("temperature", 25.5), message.getParams());
  117 + assertEquals(RAW, message.getRaw());
  118 + assertDeviceIdentity(message);
  119 + // 断言:decode 的入参
  120 + ArgumentCaptor<String> ctxCaptor = ArgumentCaptor.forClass(String.class);
  121 + verify(scriptExecutor).execute(sameScript(), eq("decode"), ctxCaptor.capture(), eq(deadlineNanos));
  122 + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue());
  123 + assertEquals("decode", ctx.get("direction").asText());
  124 + assertEquals(IotProtocolTypeEnum.TCP.getType(), ctx.get("protocol").asText());
  125 + assertEquals(PRODUCT_KEY, ctx.get("productKey").asText());
  126 + assertEquals(DEVICE_NAME, ctx.get("deviceName").asText());
  127 + assertEquals("0102FF", ctx.get("hex").asText());
  128 + assertEquals(255, ctx.get("bytes").get(2).asInt());
  129 + assertNull(ctx.get("request"));
  130 + }
  131 +
  132 + @Test
  133 + public void testSendRawMessage_decodeKeepRequestId() {
  134 + // 准备参数
  135 + gatewayProperties.getScript().setEnabled(true);
  136 + // mock 方法
  137 + mockDevice();
  138 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  139 + .thenReturn(IotScriptResult.success("{\"method\":\"thing.event.post\",\"requestId\":\"dev-1\","
  140 + + "\"params\":{\"identifier\":\"alarm\",\"value\":{\"level\":2}}}", 1));
  141 +
  142 + // 调用
  143 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  144 + IotProtocolTypeEnum.MQTT, System.nanoTime());
  145 +
  146 + // 断言:脚本返回的 requestId 保留,id 由服务端生成
  147 + IotDeviceMessage message = captureSentMessage();
  148 + assertEquals(messageId, message.getId());
  149 + assertEquals("dev-1", message.getRequestId());
  150 + assertNotEquals("dev-1", message.getId());
  151 + assertEquals("thing.event.post", message.getMethod());
  152 + assertEquals(RAW, message.getRaw());
  153 + assertDeviceIdentity(message);
  154 + }
  155 +
  156 + @Test
  157 + public void testSendRawMessage_decodeReturnNull() {
  158 + // 准备参数
  159 + gatewayProperties.getScript().setEnabled(true);
  160 + // mock 方法
  161 + mockDevice();
  162 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  163 + .thenReturn(IotScriptResult.success(null, 1));
  164 +
  165 + // 调用
  166 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  167 + IotProtocolTypeEnum.TCP, System.nanoTime());
  168 +
  169 + // 断言:只投递 1 条 thing.raw.post
  170 + IotDeviceMessage message = captureSentMessage();
  171 + assertEquals(messageId, message.getId());
  172 + assertRawPost(message);
  173 + }
  174 +
  175 + @Test
  176 + public void testSendRawMessage_scriptError() {
  177 + // 准备参数
  178 + gatewayProperties.getScript().setEnabled(true);
  179 + // mock 方法
  180 + mockDevice();
  181 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  182 + .thenReturn(IotScriptResult.error("boom", 1));
  183 +
  184 + // 调用,并断言异常
  185 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  186 + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_EXECUTE_FAILED, "boom");
  187 + // 断言:解析失败不投递任何消息(只记错误日志)
  188 + verifyNoInteractions(deviceMessageProducer);
  189 + }
  190 +
  191 + @Test
  192 + public void testSendRawMessage_scriptTimeout() {
  193 + // 准备参数
  194 + gatewayProperties.getScript().setEnabled(true);
  195 + // mock 方法
  196 + mockDevice();
  197 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  198 + .thenReturn(IotScriptResult.timeout("超过执行预算"));
  199 +
  200 + // 调用,并断言异常
  201 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  202 + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_TIMEOUT);
  203 + // 断言:解析失败不投递任何消息(只记错误日志)
  204 + verifyNoInteractions(deviceMessageProducer);
  205 + }
  206 +
  207 + @Test
  208 + public void testSendRawMessage_scriptRejected() {
  209 + // 准备参数
  210 + gatewayProperties.getScript().setEnabled(true);
  211 + // mock 方法
  212 + mockDevice();
  213 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  214 + .thenReturn(IotScriptResult.rejected("已熔断"));
  215 +
  216 + // 调用,并断言异常
  217 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  218 + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_REJECTED);
  219 + // 断言:解析失败不投递任何消息(只记错误日志)
  220 + verifyNoInteractions(deviceMessageProducer);
  221 + }
  222 +
  223 + @Test
  224 + public void testSendRawMessage_scriptResultOutputInvalid() {
  225 + // 准备参数
  226 + gatewayProperties.getScript().setEnabled(true);
  227 + // mock 方法:执行器自己判定输出不合法(例如结果过大)
  228 + mockDevice();
  229 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  230 + .thenReturn(IotScriptResult.of(IotScriptResult.Status.OUTPUT_INVALID, "输出过大", -1, 1));
  231 +
  232 + // 调用,并断言异常
  233 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  234 + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_OUTPUT_INVALID, "输出过大");
  235 + // 断言:解析失败不投递任何消息(只记错误日志)
  236 + verifyNoInteractions(deviceMessageProducer);
  237 + }
  238 +
  239 + @Test
  240 + public void testSendRawMessage_outputInvalid() {
  241 + // 准备参数:脚本试图伪造可信字段(raw、deviceId、id、tenantId、serverId、reportTime)、返回 messages 包装、ack、
  242 + // 非对象、不支持的方法、缺少 params
  243 + gatewayProperties.getScript().setEnabled(true);
  244 + List<String> outputs = Arrays.asList(
  245 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"raw\":\"AQID\"}",
  246 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"deviceId\":999}",
  247 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"id\":\"forged\"}",
  248 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"tenantId\":999}",
  249 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"serverId\":\"other\"}",
  250 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"reportTime\":1}",
  251 + "{\"messages\":[{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}]}",
  252 + "{\"method\":\"thing.property.post\",\"params\":{\"t\":1},\"ack\":[1]}",
  253 + "[{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}]",
  254 + "{\"method\":\"thing.raw.post\",\"params\":{\"t\":1}}",
  255 + "{\"method\":\"thing.property.post\"}");
  256 + // mock 方法
  257 + mockDevice();
  258 + for (String output : outputs) {
  259 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), anyLong()))
  260 + .thenReturn(IotScriptResult.success(output, 1));
  261 +
  262 + // 调用
  263 + ServiceException exception = assertThrows(ServiceException.class, () -> deviceMessageService.sendRawMessage(
  264 + PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, IotProtocolTypeEnum.TCP, System.nanoTime()), output);
  265 +
  266 + // 断言:抛出 SCRIPT_OUTPUT_INVALID
  267 + assertEquals(SCRIPT_OUTPUT_INVALID.getCode(), exception.getCode(), output);
  268 + }
  269 + // 断言:解析失败不投递任何消息(伪造的可信字段也不会被投递出去)
  270 + verifyNoInteractions(deviceMessageProducer);
  271 + }
  272 +
  273 + @Test
  274 + public void testSendRawMessage_switchOff() {
  275 + // 准备参数:功能开关默认关闭
  276 + // mock 方法
  277 + mockDevice();
  278 +
  279 + // 调用
  280 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  281 + IotProtocolTypeEnum.TCP, System.nanoTime());
  282 +
  283 + // 断言:只投递 1 条 thing.raw.post;不执行脚本
  284 + IotDeviceMessage message = captureSentMessage();
  285 + assertEquals(messageId, message.getId());
  286 + assertRawPost(message);
  287 + verifyNoInteractions(scriptExecutor);
  288 + }
  289 +
  290 + @Test
  291 + public void testSendRawMessage_scriptDisabled() {
  292 + // 准备参数
  293 + gatewayProperties.getScript().setEnabled(true);
  294 + device.setScriptEnabled(false);
  295 + // mock 方法
  296 + mockDevice();
  297 +
  298 + // 调用
  299 + String messageId = deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  300 + IotProtocolTypeEnum.TCP, System.nanoTime());
  301 +
  302 + // 断言:只投递 1 条 thing.raw.post;不执行脚本
  303 + IotDeviceMessage message = captureSentMessage();
  304 + assertEquals(messageId, message.getId());
  305 + assertRawPost(message);
  306 + verifyNoInteractions(scriptExecutor);
  307 + }
  308 +
  309 + @Test
  310 + public void testSendRawMessage_scriptNotConfigured() {
  311 + // 准备参数:产品从未配置脚本
  312 + gatewayProperties.getScript().setEnabled(true);
  313 + device.setScriptEnabled(null).setScriptContent(null);
  314 + // mock 方法
  315 + mockDevice();
  316 +
  317 + // 调用
  318 + deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  319 + IotProtocolTypeEnum.TCP, System.nanoTime());
  320 +
  321 + // 断言:与停用一致,只投递 1 条 thing.raw.post;不执行脚本
  322 + assertRawPost(captureSentMessage());
  323 + verifyNoInteractions(scriptExecutor);
  324 + }
  325 +
  326 + @Test
  327 + public void testSendRawMessage_scriptContentEmpty() {
  328 + // 准备参数:已启用,但脚本内容为空(配置不合法)
  329 + gatewayProperties.getScript().setEnabled(true);
  330 + // mock 方法
  331 + mockDevice();
  332 + for (String content : Arrays.asList(null, "", " ")) {
  333 + device.setScriptContent(content);
  334 +
  335 + // 调用,并断言异常:fail-closed,不当作停用
  336 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  337 + IotProtocolTypeEnum.TCP, System.nanoTime()), SCRIPT_EXECUTE_FAILED, "已启用但脚本内容为空");
  338 + }
  339 + // 断言:不投递任何消息(只记错误日志),也不执行脚本
  340 + verifyNoInteractions(deviceMessageProducer, scriptExecutor);
  341 + }
  342 +
  343 + @Test
  344 + public void testSendRawMessage_queuedPastDeadline() {
  345 + // 准备参数:开启功能,收到的时刻已早于截止时长之前(在 worker 队列里排队期间到期)
  346 + gatewayProperties.getScript().setEnabled(true);
  347 + long receivedNanos = System.nanoTime() - gatewayProperties.getScript().getDeadline().toNanos() - 1;
  348 + // mock 方法
  349 + mockDevice();
  350 +
  351 + // 调用,并断言异常
  352 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  353 + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT);
  354 + // 断言:不投递任何消息(只记错误日志),也不执行脚本
  355 + verifyNoInteractions(deviceMessageProducer, scriptExecutor);
  356 + }
  357 +
  358 + @Test
  359 + public void testSendRawMessage_scriptFinishedPastDeadline() {
  360 + // 准备参数:截止 500ms,脚本执行到截止之后才返回
  361 + gatewayProperties.getScript().setEnabled(true).setDeadline(Duration.ofMillis(500));
  362 + long receivedNanos = System.nanoTime();
  363 + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500);
  364 + // mock 方法
  365 + mockDevice();
  366 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> {
  367 + sleepUntil(deadlineNanos);
  368 + return IotScriptResult.success(DECODE_PROPERTY_POST, 300);
  369 + });
  370 +
  371 + // 调用,并断言异常
  372 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  373 + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT);
  374 + // 断言:迟到的业务消息不投递,也不投递原始消息(只记错误日志)
  375 + verifyNoInteractions(deviceMessageProducer);
  376 + }
  377 +
  378 + @Test
  379 + public void testSendRawMessage_scriptReturnNullPastDeadline() {
  380 + // 准备参数:截止 500ms,脚本执行到截止之后才返回 null
  381 + gatewayProperties.getScript().setEnabled(true).setDeadline(Duration.ofMillis(500));
  382 + long receivedNanos = System.nanoTime();
  383 + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500);
  384 + // mock 方法
  385 + mockDevice();
  386 + when(scriptExecutor.execute(sameScript(), eq("decode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> {
  387 + sleepUntil(deadlineNanos);
  388 + return IotScriptResult.success(null, 300);
  389 + });
  390 +
  391 + // 调用,并断言异常
  392 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  393 + IotProtocolTypeEnum.TCP, receivedNanos), SCRIPT_TIMEOUT);
  394 + // 断言:迟到的 null 结果不记为 SUCCESS,不投递任何消息(只记错误日志)
  395 + verifyNoInteractions(deviceMessageProducer);
  396 + }
  397 +
  398 + @Test
  399 + public void testSendRawMessage_payloadInvalid() {
  400 + // 调用,并断言:长度 0、4097、null 都直接拒绝,不查设备、不投递
  401 + for (byte[] payload : Arrays.asList(new byte[0], new byte[4097], null)) {
  402 + ServiceException exception = assertThrows(ServiceException.class, () -> deviceMessageService.sendRawMessage(
  403 + payload, PRODUCT_KEY, DEVICE_NAME, SERVER_ID, IotProtocolTypeEnum.TCP, System.nanoTime()));
  404 + assertEquals(BAD_REQUEST.getCode(), exception.getCode());
  405 + }
  406 + verifyNoInteractions(deviceService, deviceMessageProducer, scriptExecutor);
  407 + }
  408 +
  409 + @Test
  410 + public void testSendRawMessage_maxPayload() {
  411 + // 准备参数:正好 4096 字节
  412 + byte[] payload = new byte[4096];
  413 + payload[4095] = 0x55;
  414 + // mock 方法
  415 + mockDevice();
  416 +
  417 + // 调用
  418 + deviceMessageService.sendRawMessage(payload, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  419 + IotProtocolTypeEnum.TCP, System.nanoTime());
  420 +
  421 + // 断言:原始帧完整保留
  422 + IotDeviceMessage message = captureSentMessage();
  423 + assertArrayEquals(payload, Base64.getDecoder().decode(message.getRaw()));
  424 + }
  425 +
  426 + @Test
  427 + public void testSendRawMessage_deviceNotExists() {
  428 + // 调用,并断言异常:不投递
  429 + assertServiceException(() -> deviceMessageService.sendRawMessage(PAYLOAD, PRODUCT_KEY, DEVICE_NAME, SERVER_ID,
  430 + IotProtocolTypeEnum.TCP, System.nanoTime()), DEVICE_NOT_EXISTS, PRODUCT_KEY, DEVICE_NAME);
  431 + verifyNoInteractions(deviceMessageProducer, scriptExecutor);
  432 + }
  433 +
  434 + // ========== sendDeviceMessage ==========
  435 +
  436 + @Test
  437 + public void testSendDeviceMessage_clearForgedRaw() {
  438 + // 准备参数:设备经标准路径上报,试图伪造 raw、编号、时间、身份
  439 + LocalDateTime forgedTime = LocalDateTime.of(2000, 1, 1, 0, 0);
  440 + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1))
  441 + .setId("forged-id").setReportTime(forgedTime).setRaw(RAW)
  442 + .setDeviceId(999L).setTenantId(999L).setServerId("forged-server");
  443 + // mock 方法
  444 + mockDevice();
  445 +
  446 + // 调用
  447 + deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID);
  448 +
  449 + // 断言:raw 被清空;编号、时间、身份由服务端设置
  450 + IotDeviceMessage sent = captureSentMessage();
  451 + assertSame(message, sent);
  452 + assertNull(sent.getRaw());
  453 + assertNotEquals("forged-id", sent.getId());
  454 + assertNotEquals(forgedTime, sent.getReportTime());
  455 + assertEquals(sent.getId(), sent.getRequestId());
  456 + assertDeviceIdentity(sent);
  457 + }
  458 +
  459 + @Test
  460 + public void testSendDeviceMessage_keepRequestId() {
  461 + // 准备参数
  462 + IotDeviceMessage message = IotDeviceMessage.requestOf("req-1", "thing.property.post",
  463 + Collections.singletonMap("t", 1));
  464 + // mock 方法
  465 + mockDevice();
  466 +
  467 + // 调用
  468 + deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID);
  469 +
  470 + // 断言:设备的 requestId 保留
  471 + IotDeviceMessage sent = captureSentMessage();
  472 + assertEquals("req-1", sent.getRequestId());
  473 + assertNull(sent.getRaw());
  474 + }
  475 +
  476 + @Test
  477 + public void testSendDeviceMessage_rawMethodReserved() {
  478 + // 调用,并断言异常:标准路径不接受 thing.raw.post / thing.raw.push
  479 + for (IotDeviceMessageMethodEnum method : Arrays.asList(IotDeviceMessageMethodEnum.RAW_POST,
  480 + IotDeviceMessageMethodEnum.RAW_PUSH)) {
  481 + IotDeviceMessage message = IotDeviceMessage.requestOf(method.getMethod(), null).setRaw(RAW);
  482 + assertServiceException(() -> deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME,
  483 + SERVER_ID), DEVICE_MESSAGE_RAW_METHOD_RESERVED);
  484 + }
  485 + // 断言:不查设备、不投递
  486 + verifyNoInteractions(deviceService, deviceMessageProducer);
  487 + }
  488 +
  489 + @Test
  490 + public void testSendDeviceMessage_deviceNotExists() {
  491 + // 准备参数
  492 + IotDeviceMessage message = IotDeviceMessage.requestOf("thing.property.post", Collections.singletonMap("t", 1));
  493 +
  494 + // 调用,并断言异常
  495 + assertServiceException(() -> deviceMessageService.sendDeviceMessage(message, PRODUCT_KEY, DEVICE_NAME, SERVER_ID),
  496 + DEVICE_NOT_EXISTS, PRODUCT_KEY, DEVICE_NAME);
  497 + verifyNoInteractions(deviceMessageProducer);
  498 + }
  499 +
  500 + // ========== encodeDownstreamCommand ==========
  501 +
  502 + @Test
  503 + public void testEncodeDownstreamCommand_deviceQueryFailed() {
  504 + // mock 方法
  505 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenThrow(new IllegalStateException("rpc 失败"));
  506 +
  507 + // 调用
  508 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  509 + IotProtocolTypeEnum.TCP, System.nanoTime());
  510 +
  511 + // 断言:按设备不存在处理,走标准路径,不丢弃指令
  512 + assertTrue(result.isStandard());
  513 + verifyNoInteractions(scriptExecutor, deviceMessageProducer);
  514 + }
  515 +
  516 + @Test
  517 + public void testEncodeDownstreamCommand_deviceNotExists() {
  518 + // 调用
  519 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  520 + IotProtocolTypeEnum.TCP, System.nanoTime());
  521 +
  522 + // 断言:与未启用脚本一致,走标准路径
  523 + assertTrue(result.isStandard());
  524 + verifyNoInteractions(scriptExecutor, deviceMessageProducer);
  525 + }
  526 +
  527 + @Test
  528 + public void testEncodeDownstreamCommand_scriptDisabledOrNotConfigured() {
  529 + for (Boolean scriptEnabled : Arrays.asList(false, null)) {
  530 + // mock 方法
  531 + device.setScriptEnabled(scriptEnabled);
  532 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  533 +
  534 + // 调用
  535 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  536 + IotProtocolTypeEnum.TCP, System.nanoTime());
  537 +
  538 + // 断言:走标准路径,不执行脚本
  539 + assertTrue(result.isStandard(), String.valueOf(scriptEnabled));
  540 + }
  541 + verifyNoInteractions(scriptExecutor, deviceMessageProducer);
  542 + }
  543 +
  544 + @Test
  545 + public void testEncodeDownstreamCommand_scriptContentEmpty() {
  546 + // mock 方法:已启用,但脚本内容为空(配置不合法)
  547 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  548 + for (String content : Arrays.asList(null, "", " ")) {
  549 + device.setScriptContent(content);
  550 +
  551 + // 调用,并断言:不下发(不写设备,也不改发 JSON)
  552 + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  553 + IotProtocolTypeEnum.TCP, System.nanoTime()), String.valueOf(content));
  554 + }
  555 + verifyNoInteractions(scriptExecutor, deviceMessageProducer);
  556 + }
  557 +
  558 + @Test
  559 + public void testEncodeDownstreamCommand_encodeBytes() {
  560 + // 准备参数:截止 = 收到的时刻 + gateway.script.deadline
  561 + IotDeviceMessage message = buildPropertySet();
  562 + long receivedNanos = System.nanoTime();
  563 + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos();
  564 + // mock 方法
  565 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  566 + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos)))
  567 + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2,255]}", 1));
  568 +
  569 + // 调用
  570 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(message,
  571 + IotProtocolTypeEnum.TCP, receivedNanos);
  572 +
  573 + // 断言:透传 encode 的字节,带上固定的脚本
  574 + assertFalse(result.isStandard());
  575 + assertArrayEquals(new byte[]{1, 2, (byte) 0xFF}, result.getBytes());
  576 + assertEquals(source.getKey(), result.getSource().getKey());
  577 + verifyNoInteractions(deviceMessageProducer);
  578 + // 断言:encode 的入参
  579 + ArgumentCaptor<String> ctxCaptor = ArgumentCaptor.forClass(String.class);
  580 + verify(scriptExecutor).execute(sameScript(), eq("encode"), ctxCaptor.capture(), eq(deadlineNanos));
  581 + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue());
  582 + assertEquals("encode", ctx.get("direction").asText());
  583 + assertEquals(IotProtocolTypeEnum.TCP.getType(), ctx.get("protocol").asText());
  584 + assertEquals("thing.property.set", ctx.get("message").get("method").asText());
  585 + assertEquals("req-1", ctx.get("message").get("requestId").asText());
  586 + assertEquals(1, ctx.get("message").get("params").get("power").asInt());
  587 + }
  588 +
  589 + @Test
  590 + public void testEncodeDownstreamCommand_encodeStandard() {
  591 + // mock 方法
  592 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  593 + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong()))
  594 + .thenReturn(IotScriptResult.success("{\"standard\":true}", 1));
  595 +
  596 + // 调用
  597 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  598 + IotProtocolTypeEnum.TCP, System.nanoTime());
  599 +
  600 + // 断言:走标准路径
  601 + assertTrue(result.isStandard());
  602 + assertNull(result.getBytes());
  603 + }
  604 +
  605 + @Test
  606 + public void testEncodeDownstreamCommand_encodeNotWrite() {
  607 + // 准备参数:encode 返回 null、执行失败、超时、繁忙、输出不合法(未知键、bytes 为空、超过 255、同时有两种输出)
  608 + List<IotScriptResult> results = Arrays.asList(IotScriptResult.success(null, 1),
  609 + IotScriptResult.error("boom", 1), IotScriptResult.timeout("slow"), IotScriptResult.rejected("full"),
  610 + IotScriptResult.success("{\"other\":1}", 1), IotScriptResult.success("{\"bytes\":[]}", 1),
  611 + IotScriptResult.success("{\"bytes\":[256]}", 1),
  612 + IotScriptResult.success("{\"bytes\":[1],\"standard\":true}", 1));
  613 + // mock 方法
  614 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  615 + for (IotScriptResult result : results) {
  616 + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong())).thenReturn(result);
  617 +
  618 + // 调用,并断言:不下发(不写设备,也不改走标准路径)
  619 + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  620 + IotProtocolTypeEnum.TCP, System.nanoTime()), String.valueOf(result.getJson()));
  621 + }
  622 + // 断言:不给 biz 构造失败回复
  623 + verifyNoInteractions(deviceMessageProducer);
  624 + }
  625 +
  626 + @Test
  627 + public void testEncodeDownstreamCommand_modbusPdu() {
  628 + // mock 方法
  629 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  630 + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), anyLong()))
  631 + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2]}", 1),
  632 + IotScriptResult.success("{\"bytes\":[6,0,16,0,1]}", 1));
  633 +
  634 + // 调用,并断言:Modbus 协议,encode 输出不是合法的请求 PDU → 不下发
  635 + assertNull(deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  636 + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, System.nanoTime()));
  637 + // 调用,并断言:合法的请求 PDU → 透传
  638 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  639 + IotProtocolTypeEnum.MODBUS_TCP_CLIENT, System.nanoTime());
  640 + assertArrayEquals(new byte[]{6, 0, 16, 0, 1}, result.getBytes());
  641 + }
  642 +
  643 + @Test
  644 + public void testEncodeDownstreamCommand_encodeFinishedPastDeadline() {
  645 + // 准备参数:截止 500ms,encode 执行到截止之后才返回
  646 + gatewayProperties.getScript().setDeadline(Duration.ofMillis(500));
  647 + long receivedNanos = System.nanoTime();
  648 + long deadlineNanos = receivedNanos + TimeUnit.MILLISECONDS.toNanos(500);
  649 + // mock 方法
  650 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  651 + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos))).thenAnswer(invocation -> {
  652 + sleepUntil(deadlineNanos);
  653 + return IotScriptResult.success("{\"bytes\":[1,2]}", 300);
  654 + });
  655 +
  656 + // 调用
  657 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  658 + IotProtocolTypeEnum.TCP, receivedNanos);
  659 +
  660 + // 断言:迟到的结果不下发(不写设备,也不改走标准路径)
  661 + assertNull(result);
  662 + verifyNoInteractions(deviceMessageProducer);
  663 + }
  664 +
  665 + @Test
  666 + public void testEncodeDownstreamCommand_queuedPastDeadline() {
  667 + // 准备参数:收到指令的时刻已早于截止时长之前(在设备的指令队列里排队期间到期)
  668 + long receivedNanos = System.nanoTime() - gatewayProperties.getScript().getDeadline().toNanos() - 1;
  669 + long deadlineNanos = receivedNanos + gatewayProperties.getScript().getDeadline().toNanos();
  670 + // mock 方法:脚本执行器拿到已过的截止时间(真实的执行器不执行脚本,直接返回超时);即使这里返回了字节,也不下发
  671 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  672 + when(scriptExecutor.execute(sameScript(), eq("encode"), anyString(), eq(deadlineNanos)))
  673 + .thenReturn(IotScriptResult.success("{\"bytes\":[1,2]}", 1));
  674 +
  675 + // 调用
  676 + IotDownstreamEncodeRespDTO result = deviceMessageService.encodeDownstreamCommand(buildPropertySet(),
  677 + IotProtocolTypeEnum.TCP, receivedNanos);
  678 +
  679 + // 断言:不下发(不写设备,也不改走标准路径)
  680 + assertNull(result);
  681 + verifyNoInteractions(deviceMessageProducer);
  682 + }
  683 +
  684 + // ========== sendRawReply ==========
  685 +
  686 + @Test
  687 + public void testSendRawReply_rawPushResponse() {
  688 + // 准备参数:raw.push 的设备正常响应
  689 + IotDeviceMessage request = buildRawPush();
  690 + byte[] responsePdu = new byte[]{0x03, 0x04, 0x00, 0x01, 0x00, 0x02};
  691 + // mock 方法
  692 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  693 +
  694 + // 调用
  695 + deviceMessageService.sendRawReply(request, null, responsePdu, null, SERVER_ID);
  696 +
  697 + // 断言:回复 code = 0,raw = 响应 PDU;不执行脚本
  698 + IotDeviceMessage reply = captureSentMessage();
  699 + assertEquals("req-raw", reply.getRequestId());
  700 + assertNotEquals("req-raw", reply.getId());
  701 + assertEquals("thing.raw.push", reply.getMethod());
  702 + assertEquals(SUCCESS.getCode(), reply.getCode());
  703 + assertEquals(SUCCESS.getMsg(), reply.getMsg());
  704 + assertNull(reply.getParams());
  705 + assertNull(reply.getData());
  706 + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw());
  707 + assertDeviceIdentity(reply);
  708 + verifyNoInteractions(scriptExecutor);
  709 + }
  710 +
  711 + @Test
  712 + public void testSendRawReply_rawPushDeviceError() {
  713 + // 准备参数:raw.push 的设备异常响应(83 02),结果码由协议判定
  714 + IotDeviceMessage request = buildRawPush();
  715 + byte[] responsePdu = new byte[]{(byte) 0x83, 0x02};
  716 + ErrorCode deviceError = new ErrorCode(MODBUS_EXCEPTION.getCode(), StrUtil.format(MODBUS_EXCEPTION.getMsg(), 2));
  717 + // mock 方法
  718 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  719 +
  720 + // 调用
  721 + deviceMessageService.sendRawReply(request, null, responsePdu, deviceError, SERVER_ID);
  722 +
  723 + // 断言:回复协议判定的结果码与文案;raw = 响应 PDU
  724 + IotDeviceMessage reply = captureSentMessage();
  725 + assertEquals("req-raw", reply.getRequestId());
  726 + assertEquals(deviceError.getCode(), reply.getCode());
  727 + assertEquals(deviceError.getMsg(), reply.getMsg());
  728 + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw());
  729 + verifyNoInteractions(scriptExecutor);
  730 + }
  731 +
  732 + @Test
  733 + public void testSendRawReply_encodeResponse() {
  734 + // 准备参数:encode 指令的设备响应;设备缓存里已是新版本脚本,必须用 encode 时固定的脚本
  735 + gatewayProperties.getScript().setEnabled(true);
  736 + IotDeviceMessage request = buildPropertySet();
  737 + byte[] responsePdu = new byte[]{0x06, 0x00, 0x10, 0x00, 0x01};
  738 + device.setScriptContent("function decode(ctx) { return null; }");
  739 + // mock 方法
  740 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  741 + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong()))
  742 + .thenReturn(IotScriptResult.success("{\"method\":\"thing.property.set\",\"requestId\":\"req-1\","
  743 + + "\"code\":0,\"data\":{\"power\":1}}", 1));
  744 +
  745 + // 调用
  746 + long beginNanos = System.nanoTime();
  747 + deviceMessageService.sendRawReply(request, source, responsePdu, null, SERVER_ID);
  748 +
  749 + // 断言:用固定的脚本 decode,入参带原请求、截止时间 = 现在 + deadline
  750 + ArgumentCaptor<String> ctxCaptor = ArgumentCaptor.forClass(String.class);
  751 + ArgumentCaptor<Long> deadlineCaptor = ArgumentCaptor.forClass(Long.class);
  752 + verify(scriptExecutor).execute(same(source), eq("decode"), ctxCaptor.capture(), deadlineCaptor.capture());
  753 + verify(scriptExecutor, never()).execute(argThat(script -> script != source), any(), any(), anyLong());
  754 + JsonNode ctx = JsonUtils.parseTree(ctxCaptor.getValue());
  755 + assertEquals(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType(), ctx.get("protocol").asText());
  756 + assertEquals("0600100001", ctx.get("hex").asText());
  757 + assertEquals("thing.property.set", ctx.get("request").get("method").asText());
  758 + assertEquals("req-1", ctx.get("request").get("requestId").asText());
  759 + assertTrue(deadlineCaptor.getValue() - beginNanos
  760 + >= gatewayProperties.getScript().getDeadline().toNanos());
  761 + // 断言:回复带上 raw = 响应 PDU
  762 + IotDeviceMessage reply = captureSentMessage();
  763 + assertEquals("req-1", reply.getRequestId());
  764 + assertEquals("thing.property.set", reply.getMethod());
  765 + assertEquals(SUCCESS.getCode(), reply.getCode());
  766 + assertEquals(Collections.singletonMap("power", 1), reply.getData());
  767 + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw());
  768 + assertDeviceIdentity(reply);
  769 + }
  770 +
  771 + @Test
  772 + public void testSendRawReply_scriptDisabledBeforeResponse() {
  773 + // 准备参数
  774 + gatewayProperties.getScript().setEnabled(true);
  775 + for (Boolean scriptEnabled : Arrays.asList(false, null)) {
  776 + // mock 方法:等待响应期间,设备缓存里已看到脚本停用(或未配置)
  777 + device.setScriptEnabled(scriptEnabled);
  778 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  779 +
  780 + // 调用
  781 + deviceMessageService.sendRawReply(buildPropertySet(), source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01},
  782 + null, SERVER_ID);
  783 + }
  784 +
  785 + // 断言:不解析响应,不回复
  786 + verifyNoInteractions(scriptExecutor, deviceMessageProducer);
  787 + }
  788 +
  789 + @Test
  790 + public void testSendRawReply_switchOffBeforeResponse() {
  791 + // 准备参数:等待响应期间功能开关被关闭;产品脚本仍是启用状态
  792 + gatewayProperties.getScript().setEnabled(false);
  793 + assertTrue(device.getScriptEnabled());
  794 + byte[] responsePdu = new byte[]{0x06, 0x00, 0x10, 0x00, 0x01};
  795 + // mock 方法
  796 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  797 +
  798 + // 调用:encode 指令的设备响应(固定的脚本)
  799 + deviceMessageService.sendRawReply(buildPropertySet(), source, responsePdu, null, SERVER_ID);
  800 + // 断言:不执行脚本,不回复
  801 + verifyNoInteractions(scriptExecutor, deviceMessageProducer);
  802 +
  803 + // 调用:raw.push 的设备响应(没有脚本)
  804 + deviceMessageService.sendRawReply(buildRawPush(), null, responsePdu, null, SERVER_ID);
  805 + // 断言:照常直接回复设备的实际响应
  806 + IotDeviceMessage reply = captureSentMessage();
  807 + assertEquals("req-raw", reply.getRequestId());
  808 + assertEquals(Base64.getEncoder().encodeToString(responsePdu), reply.getRaw());
  809 + verifyNoInteractions(scriptExecutor);
  810 + }
  811 +
  812 + @Test
  813 + public void testSendRawReply_decodeRequestIdMismatch() {
  814 + // 准备参数
  815 + gatewayProperties.getScript().setEnabled(true);
  816 + IotDeviceMessage request = buildPropertySet();
  817 + // mock 方法:decode 返回的 requestId 与原请求不一致
  818 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  819 + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong()))
  820 + .thenReturn(IotScriptResult.success("{\"method\":\"thing.property.set\",\"requestId\":\"other\","
  821 + + "\"code\":0}", 1));
  822 +
  823 + // 调用
  824 + deviceMessageService.sendRawReply(request, source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01}, null, SERVER_ID);
  825 +
  826 + // 断言:不回复
  827 + verifyNoInteractions(deviceMessageProducer);
  828 + }
  829 +
  830 + @Test
  831 + public void testSendRawReply_decodeFailure() {
  832 + // 准备参数:decode 执行失败、返回 null(指令的响应必须有回执)、返回请求而不是回执
  833 + gatewayProperties.getScript().setEnabled(true);
  834 + List<IotScriptResult> results = Arrays.asList(IotScriptResult.error("boom", 1), IotScriptResult.success(null, 1),
  835 + IotScriptResult.success("{\"method\":\"thing.property.post\",\"params\":{\"t\":1}}", 1));
  836 + // mock 方法
  837 + when(deviceService.getDeviceFromCache(DEVICE_ID)).thenReturn(device);
  838 + for (IotScriptResult result : results) {
  839 + when(scriptExecutor.execute(same(source), eq("decode"), anyString(), anyLong())).thenReturn(result);
  840 +
  841 + // 调用
  842 + deviceMessageService.sendRawReply(buildPropertySet(), source, new byte[]{0x06, 0x00, 0x10, 0x00, 0x01},
  843 + null, SERVER_ID);
  844 + }
  845 +
  846 + // 断言:只记日志,不回复
  847 + verifyNoInteractions(deviceMessageProducer);
  848 + }
  849 +
  850 + @Test
  851 + public void testSendRawReply_deviceNotExists() {
  852 + // 调用
  853 + deviceMessageService.sendRawReply(buildRawPush(), null, new byte[]{0x03, 0x02, 0x00, 0x01}, null, SERVER_ID);
  854 +
  855 + // 断言:不回复
  856 + verifyNoInteractions(deviceMessageProducer, scriptExecutor);
  857 + }
  858 +
  859 + // ========== 辅助方法 ==========
  860 +
  861 + private void mockDevice() {
  862 + when(deviceService.getDeviceFromCache(PRODUCT_KEY, DEVICE_NAME)).thenReturn(device);
  863 + }
  864 +
  865 + /**
  866 + * 匹配按设备信息构造的脚本:服务每次构造新的 {@link IotScriptSource},按 key(productId:hash)匹配
  867 + */
  868 + private IotScriptSource sameScript() {
  869 + return argThat(script -> script != null && source.getKey().equals(script.getKey()));
  870 + }
  871 +
  872 + private static IotDeviceMessage buildPropertySet() {
  873 + return IotDeviceMessage.requestOf("req-1", IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(),
  874 + Collections.singletonMap("power", 1)).setDeviceId(DEVICE_ID);
  875 + }
  876 +
  877 + private static IotDeviceMessage buildRawPush() {
  878 + return IotDeviceMessage.requestOf("req-raw", IotDeviceMessageMethodEnum.RAW_PUSH.getMethod(), null)
  879 + .setDeviceId(DEVICE_ID).setRaw(Base64.getEncoder().encodeToString(new byte[]{0x03, 0x00, 0x10, 0x00, 0x02}));
  880 + }
  881 +
  882 + /**
  883 + * 断言 producer 恰好发送了 1 条消息,并返回它
  884 + */
  885 + private IotDeviceMessage captureSentMessage() {
  886 + ArgumentCaptor<IotDeviceMessage> captor = ArgumentCaptor.forClass(IotDeviceMessage.class);
  887 + verify(deviceMessageProducer, times(1)).sendDeviceMessage(captor.capture());
  888 + return captor.getValue();
  889 + }
  890 +
  891 + private static void assertDeviceIdentity(IotDeviceMessage message) {
  892 + assertEquals(DEVICE_ID, message.getDeviceId());
  893 + assertEquals(TENANT_ID, message.getTenantId());
  894 + assertEquals(SERVER_ID, message.getServerId());
  895 + }
  896 +
  897 + /**
  898 + * 断言是一条 thing.raw.post:params、data 为空,raw 为原始帧,requestId = id
  899 + */
  900 + private static void assertRawPost(IotDeviceMessage message) {
  901 + assertEquals(IotDeviceMessageMethodEnum.RAW_POST.getMethod(), message.getMethod());
  902 + assertNull(message.getParams());
  903 + assertNull(message.getCode());
  904 + assertEquals(RAW, message.getRaw());
  905 + assertNotNull(message.getId());
  906 + assertEquals(message.getId(), message.getRequestId());
  907 + assertNotNull(message.getReportTime());
  908 + assertDeviceIdentity(message);
  909 + assertNull(message.getData());
  910 + }
  911 +
  912 + private static void sleepUntil(long deadlineNanos) throws InterruptedException {
  913 + long remaining;
  914 + while ((remaining = deadlineNanos - System.nanoTime()) >= 0) {
  915 + TimeUnit.NANOSECONDS.sleep(remaining + TimeUnit.MILLISECONDS.toNanos(1));
  916 + }
  917 + }
  918 +
  919 +}
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtilsTest.java
... ... @@ -4,6 +4,8 @@ import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
4 4 import org.junit.jupiter.api.Test;
5 5  
6 6 import static org.junit.jupiter.api.Assertions.assertEquals;
  7 +import static org.junit.jupiter.api.Assertions.assertFalse;
  8 +import static org.junit.jupiter.api.Assertions.assertTrue;
7 9  
8 10 /**
9 11 * {@link IotMqttTopicUtils} 的单元测试
... ... @@ -34,4 +36,57 @@ public class IotMqttTopicUtilsTest {
34 36 assertEquals("/sys/pk/dn/thing/service/invoke_reply", topic);
35 37 }
36 38  
  39 + @Test
  40 + public void testBuildRawPushTopic() {
  41 + // 调用
  42 + String topic = IotMqttTopicUtils.buildRawPushTopic("pk", "dn");
  43 + // 断言
  44 + assertEquals("/sys/pk/dn/thing/raw/push", topic);
  45 + }
  46 +
  47 + @Test
  48 + public void testIsRawPostTopic() {
  49 + // 正确格式
  50 + assertTrue(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post"));
  51 + // 其它主题
  52 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/push"));
  53 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/property/post"));
  54 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post_reply"));
  55 + // 格式不正确:缺少 / 多出段、productKey 或 deviceName 为空、前缀不对
  56 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/thing/raw/post"));
  57 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/x/thing/raw/post"));
  58 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys//dn/thing/raw/post"));
  59 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk//thing/raw/post"));
  60 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/abc/pk/dn/thing/raw/post"));
  61 + assertFalse(IotMqttTopicUtils.isRawPostTopic("/sys/pk/dn/thing/raw/post/"));
  62 + assertFalse(IotMqttTopicUtils.isRawPostTopic(""));
  63 + assertFalse(IotMqttTopicUtils.isRawPostTopic(null));
  64 + }
  65 +
  66 + @Test
  67 + public void testIsRawPushTopic() {
  68 + assertTrue(IotMqttTopicUtils.isRawPushTopic("/sys/pk/dn/thing/raw/push"));
  69 + assertFalse(IotMqttTopicUtils.isRawPushTopic("/sys/pk/dn/thing/raw/post"));
  70 + assertFalse(IotMqttTopicUtils.isRawPushTopic("/sys/pk/thing/raw/push"));
  71 + assertFalse(IotMqttTopicUtils.isRawPushTopic(null));
  72 + }
  73 +
  74 + @Test
  75 + public void testIsTopicPublishAllowed_rawPost() {
  76 + // 自己的 raw/post:允许
  77 + assertTrue(IotMqttTopicUtils.isTopicPublishAllowed("/sys/pk/dn/thing/raw/post", "pk", "dn"));
  78 + // 别的设备的 raw/post:拒绝
  79 + assertFalse(IotMqttTopicUtils.isTopicPublishAllowed("/sys/pk/other/thing/raw/post", "pk", "dn"));
  80 + assertFalse(IotMqttTopicUtils.isTopicPublishAllowed("/sys/other/dn/thing/raw/post", "pk", "dn"));
  81 + }
  82 +
  83 + @Test
  84 + public void testIsTopicSubscribeAllowed_rawPush() {
  85 + // 自己的 raw/push:允许
  86 + assertTrue(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/pk/dn/thing/raw/push", "pk", "dn"));
  87 + // 别的设备的 raw/push:拒绝
  88 + assertFalse(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/pk/other/thing/raw/push", "pk", "dn"));
  89 + assertFalse(IotMqttTopicUtils.isTopicSubscribeAllowed("/sys/+/+/thing/raw/push", "pk", "dn"));
  90 + }
  91 +
37 92 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/util/IotWorkerTaskQueueTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.gateway.util;
  2 +
  3 +import io.vertx.core.Context;
  4 +import io.vertx.core.Future;
  5 +import io.vertx.core.Vertx;
  6 +import org.junit.jupiter.api.AfterEach;
  7 +import org.junit.jupiter.api.BeforeEach;
  8 +import org.junit.jupiter.api.Test;
  9 +
  10 +import java.util.ArrayList;
  11 +import java.util.Arrays;
  12 +import java.util.List;
  13 +import java.util.concurrent.Callable;
  14 +import java.util.concurrent.CompletableFuture;
  15 +import java.util.concurrent.CopyOnWriteArrayList;
  16 +import java.util.concurrent.CountDownLatch;
  17 +import java.util.concurrent.RejectedExecutionException;
  18 +import java.util.concurrent.TimeUnit;
  19 +import java.util.concurrent.atomic.AtomicBoolean;
  20 +import java.util.concurrent.atomic.AtomicInteger;
  21 +
  22 +import static org.junit.jupiter.api.Assertions.*;
  23 +
  24 +/**
  25 + * {@link IotWorkerTaskQueue} 的单元测试
  26 + *
  27 + * @author 芋道源码
  28 + */
  29 +public class IotWorkerTaskQueueTest {
  30 +
  31 + private static final long TIMEOUT_SECONDS = 10;
  32 +
  33 + private Vertx vertx;
  34 + private Context context;
  35 + /**
  36 + * context 所在的 event loop 线程
  37 + */
  38 + private Thread contextThread;
  39 +
  40 + @BeforeEach
  41 + public void setUp() throws Exception {
  42 + vertx = Vertx.vertx();
  43 + context = vertx.getOrCreateContext();
  44 + contextThread = callOnContext(Thread::currentThread);
  45 + }
  46 +
  47 + @AfterEach
  48 + public void tearDown() throws Exception {
  49 + vertx.close().toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  50 + }
  51 +
  52 + @Test
  53 + public void testSubmit_orderedSequential() throws Exception {
  54 + // 准备参数:越早提交的任务执行越久;第 3 个任务失败
  55 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context);
  56 + int count = 5;
  57 + List<Integer> started = new CopyOnWriteArrayList<>();
  58 + List<Integer> completed = new CopyOnWriteArrayList<>();
  59 + AtomicInteger running = new AtomicInteger();
  60 + AtomicInteger maxRunning = new AtomicInteger();
  61 + AtomicBoolean completedOnContext = new AtomicBoolean(true);
  62 + CountDownLatch done = new CountDownLatch(count);
  63 +
  64 + // 调用:在 context 上依次提交
  65 + List<Future<Integer>> futures = callOnContext(() -> {
  66 + List<Future<Integer>> list = new ArrayList<>();
  67 + for (int i = 0; i < count; i++) {
  68 + int index = i;
  69 + Future<Integer> future = queue.submit(() -> {
  70 + maxRunning.accumulateAndGet(running.incrementAndGet(), Math::max);
  71 + started.add(index);
  72 + try {
  73 + Thread.sleep((count - index) * 30L);
  74 + if (index == 2) {
  75 + throw new IllegalStateException("task-2 failed");
  76 + }
  77 + return index;
  78 + } finally {
  79 + running.decrementAndGet();
  80 + }
  81 + });
  82 + future.onComplete(ar -> {
  83 + if (Thread.currentThread() != contextThread) {
  84 + completedOnContext.set(false);
  85 + }
  86 + completed.add(index);
  87 + done.countDown();
  88 + });
  89 + list.add(future);
  90 + }
  91 + return list;
  92 + });
  93 + assertTrue(done.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  94 +
  95 + // 断言:按提交顺序逐个执行(同一时刻只有一个在执行)、逐个完成,结果回到 context 线程
  96 + assertEquals(Arrays.asList(0, 1, 2, 3, 4), started);
  97 + assertEquals(Arrays.asList(0, 1, 2, 3, 4), completed);
  98 + assertEquals(1, maxRunning.get());
  99 + assertTrue(completedOnContext.get());
  100 + // 断言:第 3 个任务失败,之后的任务照常执行
  101 + for (int i = 0; i < count; i++) {
  102 + Future<Integer> future = futures.get(i);
  103 + if (i == 2) {
  104 + assertTrue(future.failed());
  105 + assertInstanceOf(IllegalStateException.class, future.cause());
  106 + assertEquals("task-2 failed", future.cause().getMessage());
  107 + } else {
  108 + assertTrue(future.succeeded());
  109 + assertEquals(Integer.valueOf(i), future.result());
  110 + }
  111 + }
  112 + }
  113 +
  114 + @Test
  115 + public void testSubmit_queuesParallel() throws Exception {
  116 + // 准备参数:同一个 context 上的两个队列(两个连接)
  117 + IotWorkerTaskQueue queueA = new IotWorkerTaskQueue(context);
  118 + IotWorkerTaskQueue queueB = new IotWorkerTaskQueue(context);
  119 + CountDownLatch releaseA = new CountDownLatch(1);
  120 + CountDownLatch startedA = new CountDownLatch(1);
  121 +
  122 + // 调用:A 的第一个任务阻塞,A 的第二个任务排在它之后
  123 + List<Future<String>> futuresA = callOnContext(() -> Arrays.asList(
  124 + queueA.submit(() -> {
  125 + startedA.countDown();
  126 + return block(releaseA, "A1");
  127 + }),
  128 + queueA.submit(() -> "A2")));
  129 + assertTrue(startedA.await(TIMEOUT_SECONDS, TimeUnit.SECONDS));
  130 + // 调用:B 提交一个任务
  131 + Future<String> futureB = callOnContext(() -> queueB.submit(() -> "B"));
  132 +
  133 + try {
  134 + // 断言:A 阻塞时 B 照常完成;A 的第二个任务仍在等待第一个
  135 + assertEquals("B", await(futureB));
  136 + assertFalse(futuresA.get(0).isComplete());
  137 + assertFalse(futuresA.get(1).isComplete());
  138 + } finally {
  139 + releaseA.countDown();
  140 + }
  141 + // 断言:释放后 A 按顺序完成
  142 + assertEquals("A1", await(futuresA.get(0)));
  143 + assertEquals("A2", await(futuresA.get(1)));
  144 + }
  145 +
  146 + @Test
  147 + public void testSubmit_maxPending() throws Exception {
  148 + // 准备参数:最多 2 个待处理任务
  149 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 2);
  150 + CountDownLatch release = new CountDownLatch(1);
  151 + AtomicBoolean rejectedImmediately = new AtomicBoolean();
  152 +
  153 + // 调用:前两个任务阻塞,第三个超过上限
  154 + List<Future<String>> futures = callOnContext(() -> {
  155 + List<Future<String>> list = Arrays.asList(
  156 + queue.submit(() -> block(release, "1")),
  157 + queue.submit(() -> block(release, "2")),
  158 + queue.submit(() -> "3"));
  159 + rejectedImmediately.set(list.get(2).failed());
  160 + return list;
  161 + });
  162 +
  163 + try {
  164 + // 断言:第三个立即失败为 RejectedExecutionException
  165 + assertTrue(rejectedImmediately.get());
  166 + assertInstanceOf(RejectedExecutionException.class, futures.get(2).cause());
  167 + assertFalse(futures.get(0).isComplete());
  168 + assertFalse(futures.get(1).isComplete());
  169 + } finally {
  170 + release.countDown();
  171 + }
  172 + // 断言:前两个完成后名额释放,可以再次提交
  173 + assertEquals("1", await(futures.get(0)));
  174 + assertEquals("2", await(futures.get(1)));
  175 + assertEquals("4", await(callOnContext(() -> queue.submit(() -> "4"))));
  176 + }
  177 +
  178 + @Test
  179 + public void testSubmitLast() throws Exception {
  180 + // 准备参数:最多 1 个待处理任务
  181 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, true, 1);
  182 + CountDownLatch release = new CountDownLatch(1);
  183 + List<String> executed = new CopyOnWriteArrayList<>();
  184 + AtomicBoolean lastRejectedImmediately = new AtomicBoolean(true);
  185 +
  186 + // 调用:第一个任务阻塞;队列已满时 submitLast 不受上限;之后 submit / submitLast 都失败
  187 + List<Future<String>> futures = callOnContext(() -> {
  188 + List<Future<String>> list = Arrays.asList(
  189 + queue.submit(() -> {
  190 + block(release, "1");
  191 + executed.add("1");
  192 + return "1";
  193 + }),
  194 + queue.submit(() -> "full"),
  195 + queue.submitLast(() -> {
  196 + executed.add("last");
  197 + return "last";
  198 + }),
  199 + queue.submit(() -> "closed"),
  200 + queue.submitLast(() -> "last-again"));
  201 + lastRejectedImmediately.set(list.get(2).failed());
  202 + return list;
  203 + });
  204 +
  205 + try {
  206 + // 断言:已满的 submit 被拒绝;submitLast 被接受,排在阻塞任务之后
  207 + assertRejected(futures.get(1));
  208 + assertFalse(lastRejectedImmediately.get());
  209 + assertFalse(futures.get(2).isComplete());
  210 + // 断言:关闭之后 submit、submitLast 都被拒绝
  211 + assertRejected(futures.get(3));
  212 + assertRejected(futures.get(4));
  213 + } finally {
  214 + release.countDown();
  215 + }
  216 + // 断言:最后一个任务在之前的任务之后执行
  217 + assertEquals("1", await(futures.get(0)));
  218 + assertEquals("last", await(futures.get(2)));
  219 + assertEquals(Arrays.asList("1", "last"), executed);
  220 + // 断言:已关闭,之后的任务都被拒绝
  221 + assertRejected(callOnContext(() -> queue.submit(() -> "after")));
  222 + }
  223 +
  224 + @Test
  225 + public void testSubmit_unordered() throws Exception {
  226 + // 准备参数:无序,最多 2 个待处理任务
  227 + IotWorkerTaskQueue queue = new IotWorkerTaskQueue(context, false, 2);
  228 + CountDownLatch release = new CountDownLatch(1);
  229 +
  230 + try {
  231 + // 调用:第一个任务阻塞
  232 + Future<String> blocked = callOnContext(() -> queue.submit(() -> block(release, "A")));
  233 + // 断言:之后的任务不排在它之后,直接完成
  234 + assertEquals("B", await(callOnContext(() -> queue.submit(() -> "B"))));
  235 + assertFalse(blocked.isComplete());
  236 +
  237 + // 调用:A 仍在执行,再提交 C 后达到上限,D 被拒绝
  238 + List<Future<String>> futures = callOnContext(() -> Arrays.asList(
  239 + queue.submit(() -> block(release, "C")),
  240 + queue.submit(() -> "D")));
  241 + // 断言:只限制数量
  242 + assertRejected(futures.get(1));
  243 + assertFalse(futures.get(0).isComplete());
  244 +
  245 + // 调用:释放
  246 + release.countDown();
  247 + // 断言
  248 + assertEquals("A", await(blocked));
  249 + assertEquals("C", await(futures.get(0)));
  250 + } finally {
  251 + release.countDown();
  252 + }
  253 + }
  254 +
  255 + // ========== 辅助方法 ==========
  256 +
  257 + /**
  258 + * 在 context 线程上执行(与 Vert.x 处理器一致),等待并返回结果
  259 + */
  260 + private <T> T callOnContext(Callable<T> action) throws Exception {
  261 + CompletableFuture<T> result = new CompletableFuture<>();
  262 + context.runOnContext(v -> {
  263 + try {
  264 + result.complete(action.call());
  265 + } catch (Throwable e) {
  266 + result.completeExceptionally(e);
  267 + }
  268 + });
  269 + return result.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  270 + }
  271 +
  272 + private static <T> T await(Future<T> future) throws Exception {
  273 + return future.toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
  274 + }
  275 +
  276 + /**
  277 + * worker 线程上阻塞,直到释放
  278 + */
  279 + private static String block(CountDownLatch release, String value) throws InterruptedException {
  280 + if (!release.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
  281 + throw new IllegalStateException("等待释放超时");
  282 + }
  283 + return value;
  284 + }
  285 +
  286 + private static void assertRejected(Future<?> future) {
  287 + assertTrue(future.failed());
  288 + assertInstanceOf(RejectedExecutionException.class, future.cause());
  289 + }
  290 +
  291 +}
... ...