diff --git a/yudao-module-iot/yudao-module-iot-protocol/pom.xml b/yudao-module-iot/yudao-module-iot-protocol/pom.xml index 16c84608c6..3a5a9e1158 100644 --- a/yudao-module-iot/yudao-module-iot-protocol/pom.xml +++ b/yudao-module-iot/yudao-module-iot-protocol/pom.xml @@ -53,6 +53,18 @@ vertx-web provided + + + + org.junit.jupiter + junit-jupiter + test + + + org.springframework.boot + spring-boot-starter-test + test + \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfiguration.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfiguration.java index 4c3952fc64..fa5b172321 100644 --- a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfiguration.java @@ -1,7 +1,12 @@ package cn.iocoder.yudao.module.iot.protocol.config; +import cn.iocoder.yudao.module.iot.protocol.convert.IotProtocolConverter; +import cn.iocoder.yudao.module.iot.protocol.convert.impl.DefaultIotProtocolConverter; +import cn.iocoder.yudao.module.iot.protocol.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.protocol.message.IotMessageParser; import cn.iocoder.yudao.module.iot.protocol.message.impl.IotAlinkMessageParser; +import cn.iocoder.yudao.module.iot.protocol.message.impl.IotHttpMessageParser; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -13,13 +18,58 @@ import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) public class IotProtocolAutoConfiguration { + /** + * Bean 名称常量 + */ + public static final String IOT_ALINK_MESSAGE_PARSER_BEAN_NAME = "iotAlinkMessageParser"; + public static final String IOT_HTTP_MESSAGE_PARSER_BEAN_NAME = "iotHttpMessageParser"; + /** * 注册 Alink 协议消息解析器 * * @return Alink 协议消息解析器 */ @Bean + @ConditionalOnMissingBean(name = IOT_ALINK_MESSAGE_PARSER_BEAN_NAME) public IotMessageParser iotAlinkMessageParser() { return new IotAlinkMessageParser(); } -} \ No newline at end of file + + /** + * 注册 HTTP 协议消息解析器 + * + * @return HTTP 协议消息解析器 + */ + @Bean + @ConditionalOnMissingBean(name = IOT_HTTP_MESSAGE_PARSER_BEAN_NAME) + public IotMessageParser iotHttpMessageParser() { + return new IotHttpMessageParser(); + } + + /** + * 注册默认协议转换器 + *

+ * 如果用户没有自定义协议转换器,则使用默认实现 + * 默认会注册 Alink 和 HTTP 协议解析器 + * + * @param iotAlinkMessageParser Alink 协议解析器 + * @param iotHttpMessageParser HTTP 协议解析器 + * @return 默认协议转换器 + */ + @Bean + @ConditionalOnMissingBean + public IotProtocolConverter iotProtocolConverter(IotMessageParser iotAlinkMessageParser, + IotMessageParser iotHttpMessageParser) { + DefaultIotProtocolConverter converter = new DefaultIotProtocolConverter(); + + // 注册 HTTP 协议解析器 + converter.registerParser(IotProtocolTypeEnum.HTTP.getCode(), iotHttpMessageParser); + + // 注意:Alink 协议解析器已经在 DefaultIotProtocolConverter 构造函数中注册 + // 如果需要使用自定义的 Alink 解析器实例,可以重新注册 + // converter.registerParser(IotProtocolTypeEnum.ALINK.getCode(), + // iotAlinkMessageParser); + + return converter; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotHttpConstants.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotHttpConstants.java new file mode 100644 index 0000000000..aeb4b3240f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotHttpConstants.java @@ -0,0 +1,166 @@ +package cn.iocoder.yudao.module.iot.protocol.constants; + +/** + * IoT HTTP 协议常量类 + *

+ * 用于统一管理 HTTP 协议中的常量,包括路径、字段名、默认值等 + * + * @author haohao + */ +public class IotHttpConstants { + + /** + * 路径常量 + */ + public static class Path { + /** + * 认证路径 + */ + public static final String AUTH = "/auth"; + + /** + * 主题路径前缀 + */ + public static final String TOPIC_PREFIX = "/topic"; + } + + /** + * 认证字段常量 + */ + public static class AuthField { + /** + * 产品Key + */ + public static final String PRODUCT_KEY = "productKey"; + + /** + * 设备名称 + */ + public static final String DEVICE_NAME = "deviceName"; + + /** + * 客户端ID + */ + public static final String CLIENT_ID = "clientId"; + + /** + * 时间戳 + */ + public static final String TIMESTAMP = "timestamp"; + + /** + * 签名 + */ + public static final String SIGN = "sign"; + + /** + * 签名方法 + */ + public static final String SIGN_METHOD = "signmethod"; + + /** + * 版本 + */ + public static final String VERSION = "version"; + } + + /** + * 消息字段常量 + */ + public static class MessageField { + /** + * 消息ID + */ + public static final String ID = "id"; + + /** + * 方法名 + */ + public static final String METHOD = "method"; + + /** + * 版本 + */ + public static final String VERSION = "version"; + + /** + * 参数 + */ + public static final String PARAMS = "params"; + + /** + * 数据 + */ + public static final String DATA = "data"; + } + + /** + * 响应字段常量 + */ + public static class ResponseField { + /** + * 状态码 + */ + public static final String CODE = "code"; + + /** + * 消息 + */ + public static final String MESSAGE = "message"; + + /** + * 信息 + */ + public static final String INFO = "info"; + + /** + * 令牌 + */ + public static final String TOKEN = "token"; + + /** + * 消息ID + */ + public static final String MESSAGE_ID = "messageId"; + } + + /** + * 默认值常量 + */ + public static class DefaultValue { + /** + * 默认签名方法 + */ + public static final String SIGN_METHOD = "hmacmd5"; + + /** + * 默认版本 + */ + public static final String VERSION = "default"; + + /** + * 默认消息版本 + */ + public static final String MESSAGE_VERSION = "1.0"; + + /** + * 未知方法名 + */ + public static final String UNKNOWN_METHOD = "unknown"; + } + + /** + * 方法名常量 + */ + public static class Method { + /** + * 设备认证 + */ + public static final String DEVICE_AUTH = "device.auth"; + + /** + * 自定义消息 + */ + public static final String CUSTOM_MESSAGE = "custom.message"; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotLogConstants.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotLogConstants.java new file mode 100644 index 0000000000..05b7179870 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotLogConstants.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.iot.protocol.constants; + +/** + * IoT 协议日志消息常量类 + *

+ * 用于统一管理协议模块中的日志消息常量 + * + * @author haohao + */ +public class IotLogConstants { + + /** + * HTTP 协议日志消息 + */ + public static class Http { + /** + * 收到空消息内容 + */ + public static final String RECEIVED_EMPTY_MESSAGE = "[HTTP] 收到空消息内容, topic={}"; + + /** + * 不支持的路径格式 + */ + public static final String UNSUPPORTED_PATH_FORMAT = "[HTTP] 不支持的路径格式, topic={}"; + + /** + * 解析消息失败 + */ + public static final String PARSE_MESSAGE_FAILED = "[HTTP] 解析消息失败, topic={}"; + + /** + * 认证消息非JSON格式 + */ + public static final String AUTH_MESSAGE_NOT_JSON = "[HTTP] 认证消息非JSON格式, message={}"; + + /** + * 认证消息缺少必需字段 + */ + public static final String AUTH_MESSAGE_MISSING_REQUIRED_FIELDS = "[HTTP] 认证消息缺少必需字段, message={}"; + + /** + * 格式化响应失败 + */ + public static final String FORMAT_RESPONSE_FAILED = "[HTTP] 格式化响应失败"; + } + + /** + * 协议转换器日志消息 + */ + public static class Converter { + /** + * 注册协议解析器 + */ + public static final String REGISTER_PARSER = "[协议转换器] 注册协议解析器: protocol={}, parser={}"; + + /** + * 移除协议解析器 + */ + public static final String REMOVE_PARSER = "[协议转换器] 移除协议解析器: protocol={}"; + + /** + * 不支持的协议类型 + */ + public static final String UNSUPPORTED_PROTOCOL = "[协议转换器] 不支持的协议类型: protocol={}"; + + /** + * 转换消息失败 + */ + public static final String CONVERT_MESSAGE_FAILED = "[协议转换器] 转换消息失败: protocol={}, topic={}"; + + /** + * 格式化响应失败 + */ + public static final String FORMAT_RESPONSE_FAILED = "[协议转换器] 格式化响应失败: protocol={}"; + + /** + * 自动选择协议 + */ + public static final String AUTO_SELECT_PROTOCOL = "[协议转换器] 自动选择协议: protocol={}, topic={}"; + + /** + * 协议解析失败,尝试下一个 + */ + public static final String PROTOCOL_PARSE_FAILED_TRY_NEXT = "[协议转换器] 协议解析失败,尝试下一个: protocol={}, topic={}"; + + /** + * 无法自动识别协议 + */ + public static final String CANNOT_AUTO_RECOGNIZE_PROTOCOL = "[协议转换器] 无法自动识别协议: topic={}"; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotTopicConstants.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotTopicConstants.java index fd0ebb0656..59453518cd 100644 --- a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotTopicConstants.java +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/constants/IotTopicConstants.java @@ -69,4 +69,89 @@ public class IotTopicConstants { */ public static final String REPLY_SUFFIX = "_reply"; -} \ No newline at end of file + /** + * 方法名前缀常量 + */ + public static class MethodPrefix { + /** + * 物模型服务前缀 + */ + public static final String THING_SERVICE = "thing.service."; + + /** + * 物模型事件前缀 + */ + public static final String THING_EVENT = "thing.event."; + } + + /** + * 完整方法名常量 + */ + public static class Method { + /** + * 属性设置方法 + */ + public static final String PROPERTY_SET = "thing.service.property.set"; + + /** + * 属性获取方法 + */ + public static final String PROPERTY_GET = "thing.service.property.get"; + + /** + * 属性上报方法 + */ + public static final String PROPERTY_POST = "thing.event.property.post"; + + /** + * 配置设置方法 + */ + public static final String CONFIG_SET = "thing.service.config.set"; + + /** + * OTA升级方法 + */ + public static final String OTA_UPGRADE = "thing.service.ota.upgrade"; + + /** + * 设备上线方法 + */ + public static final String DEVICE_ONLINE = "device.online"; + + /** + * 设备下线方法 + */ + public static final String DEVICE_OFFLINE = "device.offline"; + + /** + * 心跳方法 + */ + public static final String HEARTBEAT = "heartbeat"; + } + + /** + * 主题关键字常量 + */ + public static class Keyword { + /** + * 事件关键字 + */ + public static final String EVENT = "event"; + + /** + * 服务关键字 + */ + public static final String SERVICE = "service"; + + /** + * 属性关键字 + */ + public static final String PROPERTY = "property"; + + /** + * 上报关键字 + */ + public static final String POST = "post"; + } + +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/convert/IotProtocolConverter.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/convert/IotProtocolConverter.java new file mode 100644 index 0000000000..f659edb7b4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/convert/IotProtocolConverter.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.protocol.convert; + +import cn.iocoder.yudao.module.iot.protocol.message.IotAlinkMessage; +import cn.iocoder.yudao.module.iot.protocol.message.IotStandardResponse; + +/** + * IoT 协议转换器接口 + *

+ * 用于在不同协议之间进行转换 + * + * @author haohao + */ +public interface IotProtocolConverter { + + /** + * 将字节数组转换为标准消息 + * + * @param topic 主题 + * @param payload 消息负载 + * @param protocol 协议类型 + * @return 标准消息对象,转换失败返回 null + */ + IotAlinkMessage convertToStandardMessage(String topic, byte[] payload, String protocol); + + /** + * 将标准响应转换为字节数组 + * + * @param response 标准响应 + * @param protocol 协议类型 + * @return 字节数组,转换失败返回空数组 + */ + byte[] convertFromStandardResponse(IotStandardResponse response, String protocol); + + /** + * 检查是否支持指定协议 + * + * @param protocol 协议类型 + * @return 如果支持返回 true,否则返回 false + */ + boolean supportsProtocol(String protocol); + + /** + * 获取支持的协议类型列表 + * + * @return 协议类型数组 + */ + String[] getSupportedProtocols(); +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/convert/impl/DefaultIotProtocolConverter.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/convert/impl/DefaultIotProtocolConverter.java new file mode 100644 index 0000000000..e5d4703ff2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/convert/impl/DefaultIotProtocolConverter.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.module.iot.protocol.convert.impl; + +import cn.iocoder.yudao.module.iot.protocol.constants.IotLogConstants; +import cn.iocoder.yudao.module.iot.protocol.convert.IotProtocolConverter; +import cn.iocoder.yudao.module.iot.protocol.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.protocol.message.IotAlinkMessage; +import cn.iocoder.yudao.module.iot.protocol.message.IotMessageParser; +import cn.iocoder.yudao.module.iot.protocol.message.IotStandardResponse; +import cn.iocoder.yudao.module.iot.protocol.message.impl.IotAlinkMessageParser; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * 默认 IoT 协议转换器实现 + *

+ * 支持多种协议的转换,可以通过注册不同的消息解析器来扩展支持的协议 + * + * @author haohao + */ +@Slf4j +public class DefaultIotProtocolConverter implements IotProtocolConverter { + + /** + * 消息解析器映射 + * Key: 协议类型,Value: 消息解析器 + */ + private final Map parsers = new HashMap<>(); + + /** + * 构造函数,初始化默认支持的协议 + */ + public DefaultIotProtocolConverter() { + // 注册 Alink 协议解析器 + registerParser(IotProtocolTypeEnum.ALINK.getCode(), new IotAlinkMessageParser()); + } + + /** + * 注册消息解析器 + * + * @param protocol 协议类型 + * @param parser 消息解析器 + */ + public void registerParser(String protocol, IotMessageParser parser) { + parsers.put(protocol, parser); + log.info(IotLogConstants.Converter.REGISTER_PARSER, protocol, parser.getClass().getSimpleName()); + } + + /** + * 移除消息解析器 + * + * @param protocol 协议类型 + */ + public void removeParser(String protocol) { + parsers.remove(protocol); + log.info(IotLogConstants.Converter.REMOVE_PARSER, protocol); + } + + @Override + public IotAlinkMessage convertToStandardMessage(String topic, byte[] payload, String protocol) { + IotMessageParser parser = parsers.get(protocol); + if (parser == null) { + log.warn(IotLogConstants.Converter.UNSUPPORTED_PROTOCOL, protocol); + return null; + } + + try { + return parser.parse(topic, payload); + } catch (Exception e) { + log.error(IotLogConstants.Converter.CONVERT_MESSAGE_FAILED, protocol, topic, e); + return null; + } + } + + @Override + public byte[] convertFromStandardResponse(IotStandardResponse response, String protocol) { + IotMessageParser parser = parsers.get(protocol); + if (parser == null) { + log.warn(IotLogConstants.Converter.UNSUPPORTED_PROTOCOL, protocol); + return new byte[0]; + } + + try { + return parser.formatResponse(response); + } catch (Exception e) { + log.error(IotLogConstants.Converter.FORMAT_RESPONSE_FAILED, protocol, e); + return new byte[0]; + } + } + + @Override + public boolean supportsProtocol(String protocol) { + return parsers.containsKey(protocol); + } + + @Override + public String[] getSupportedProtocols() { + Set protocols = parsers.keySet(); + return protocols.toArray(new String[0]); + } + + /** + * 根据主题自动选择合适的协议解析器 + * + * @param topic 主题 + * @param payload 消息负载 + * @return 解析后的标准消息,如果无法解析返回 null + */ + public IotAlinkMessage autoConvert(String topic, byte[] payload) { + // 遍历所有解析器,找到能处理该主题的解析器 + for (Map.Entry entry : parsers.entrySet()) { + IotMessageParser parser = entry.getValue(); + if (parser.canHandle(topic)) { + try { + IotAlinkMessage message = parser.parse(topic, payload); + if (message != null) { + log.debug(IotLogConstants.Converter.AUTO_SELECT_PROTOCOL, entry.getKey(), topic); + return message; + } + } catch (Exception e) { + log.debug(IotLogConstants.Converter.PROTOCOL_PARSE_FAILED_TRY_NEXT, entry.getKey(), topic); + } + } + } + + log.warn(IotLogConstants.Converter.CANNOT_AUTO_RECOGNIZE_PROTOCOL, topic); + return null; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotMessageDirectionEnum.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotMessageDirectionEnum.java new file mode 100644 index 0000000000..6cce13894e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotMessageDirectionEnum.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.iot.protocol.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * IoT 消息方向枚举 + * + * @author haohao + */ +@Getter +@AllArgsConstructor +public enum IotMessageDirectionEnum { + + /** + * 上行消息(设备到平台) + */ + UPSTREAM("upstream", "上行"), + + /** + * 下行消息(平台到设备) + */ + DOWNSTREAM("downstream", "下行"); + + /** + * 方向编码 + */ + private final String code; + + /** + * 方向名称 + */ + private final String name; + + /** + * 根据编码获取消息方向 + * + * @param code 方向编码 + * @return 消息方向枚举,如果未找到返回 null + */ + public static IotMessageDirectionEnum getByCode(String code) { + for (IotMessageDirectionEnum direction : values()) { + if (direction.getCode().equals(code)) { + return direction; + } + } + return null; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotMessageTypeEnum.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotMessageTypeEnum.java new file mode 100644 index 0000000000..b2425dd991 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotMessageTypeEnum.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.iot.protocol.enums; + +import cn.iocoder.yudao.module.iot.protocol.constants.IotTopicConstants; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * IoT 消息类型枚举 + * + * @author haohao + */ +@Getter +@AllArgsConstructor +public enum IotMessageTypeEnum { + + /** + * 属性上报 + */ + PROPERTY_POST("property.post", "属性上报"), + + /** + * 属性设置 + */ + PROPERTY_SET("property.set", "属性设置"), + + /** + * 属性获取 + */ + PROPERTY_GET("property.get", "属性获取"), + + /** + * 事件上报 + */ + EVENT_POST("event.post", "事件上报"), + + /** + * 服务调用 + */ + SERVICE_INVOKE("service.invoke", "服务调用"), + + /** + * 配置设置 + */ + CONFIG_SET("config.set", "配置设置"), + + /** + * OTA 升级 + */ + OTA_UPGRADE("ota.upgrade", "OTA升级"), + + /** + * 设备上线 + */ + DEVICE_ONLINE("device.online", "设备上线"), + + /** + * 设备下线 + */ + DEVICE_OFFLINE("device.offline", "设备下线"), + + /** + * 心跳 + */ + HEARTBEAT("heartbeat", "心跳"); + + /** + * 消息类型编码 + */ + private final String code; + + /** + * 消息类型名称 + */ + private final String name; + + /** + * 根据编码获取消息类型 + * + * @param code 消息类型编码 + * @return 消息类型枚举,如果未找到返回 null + */ + public static IotMessageTypeEnum getByCode(String code) { + for (IotMessageTypeEnum type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } + + /** + * 根据方法名获取消息类型 + * + * @param method 方法名 + * @return 消息类型枚举,如果未找到返回 null + */ + public static IotMessageTypeEnum getByMethod(String method) { + if (method == null) { + return null; + } + + // 处理 thing.service.xxx 格式 + if (method.startsWith(IotTopicConstants.MethodPrefix.THING_SERVICE)) { + String servicePart = method.substring(IotTopicConstants.MethodPrefix.THING_SERVICE.length()); + if ("property.set".equals(servicePart)) { + return PROPERTY_SET; + } else if ("property.get".equals(servicePart)) { + return PROPERTY_GET; + } else if ("config.set".equals(servicePart)) { + return CONFIG_SET; + } else if ("ota.upgrade".equals(servicePart)) { + return OTA_UPGRADE; + } else { + return SERVICE_INVOKE; + } + } + + // 处理 thing.event.xxx 格式 + if (method.startsWith(IotTopicConstants.MethodPrefix.THING_EVENT)) { + String eventPart = method.substring(IotTopicConstants.MethodPrefix.THING_EVENT.length()); + if ("property.post".equals(eventPart)) { + return PROPERTY_POST; + } else { + return EVENT_POST; + } + } + + // 其他类型 + switch (method) { + case IotTopicConstants.Method.DEVICE_ONLINE: + return DEVICE_ONLINE; + case IotTopicConstants.Method.DEVICE_OFFLINE: + return DEVICE_OFFLINE; + case IotTopicConstants.Method.HEARTBEAT: + return HEARTBEAT; + default: + return null; + } + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotProtocolTypeEnum.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotProtocolTypeEnum.java new file mode 100644 index 0000000000..33b808a443 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/enums/IotProtocolTypeEnum.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.protocol.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * IoT 协议类型枚举 + * + * @author haohao + */ +@Getter +@AllArgsConstructor +public enum IotProtocolTypeEnum { + + /** + * Alink 协议(阿里云物联网协议) + */ + ALINK("alink", "Alink 协议"), + + /** + * MQTT 原始协议 + */ + MQTT_RAW("mqtt_raw", "MQTT 原始协议"), + + /** + * HTTP 协议 + */ + HTTP("http", "HTTP 协议"), + + /** + * TCP 协议 + */ + TCP("tcp", "TCP 协议"), + + /** + * UDP 协议 + */ + UDP("udp", "UDP 协议"), + + /** + * 自定义协议 + */ + CUSTOM("custom", "自定义协议"); + + /** + * 协议编码 + */ + private final String code; + + /** + * 协议名称 + */ + private final String name; + + /** + * 根据编码获取协议类型 + * + * @param code 协议编码 + * @return 协议类型枚举,如果未找到返回 null + */ + public static IotProtocolTypeEnum getByCode(String code) { + for (IotProtocolTypeEnum type : values()) { + if (type.getCode().equals(code)) { + return type; + } + } + return null; + } + + /** + * 检查是否为有效的协议编码 + * + * @param code 协议编码 + * @return 如果有效返回 true,否则返回 false + */ + public static boolean isValidCode(String code) { + return getByCode(code) != null; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/IotAlinkMessage.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/IotAlinkMessage.java index faae56b90d..1d5ee4709f 100644 --- a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/IotAlinkMessage.java +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/IotAlinkMessage.java @@ -11,9 +11,9 @@ import java.util.Map; * IoT Alink 消息模型 *

* 基于阿里云 Alink 协议规范实现的标准消息格式 - * @see 阿里云物联网 —— Alink 协议 * * @author haohao + * @see 阿里云物联网 —— Alink 协议 */ @Data @Builder diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotAlinkMessageParser.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotAlinkMessageParser.java index 1fdb3e4222..745c653120 100644 --- a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotAlinkMessageParser.java +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotAlinkMessageParser.java @@ -40,7 +40,7 @@ public class IotAlinkMessageParser implements IotMessageParser { JSONObject json = JSONUtil.parseObj(message); String id = json.getStr("id"); String method = json.getStr("method"); - + if (StrUtil.isBlank(method)) { // 尝试从 topic 中解析方法 method = IotTopicUtils.parseMethodFromTopic(topic); diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotHttpMessageParser.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotHttpMessageParser.java new file mode 100644 index 0000000000..10b4c49d7c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotHttpMessageParser.java @@ -0,0 +1,348 @@ +package cn.iocoder.yudao.module.iot.protocol.message.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.module.iot.protocol.constants.IotHttpConstants; +import cn.iocoder.yudao.module.iot.protocol.constants.IotLogConstants; +import cn.iocoder.yudao.module.iot.protocol.constants.IotTopicConstants; +import cn.iocoder.yudao.module.iot.protocol.message.IotAlinkMessage; +import cn.iocoder.yudao.module.iot.protocol.message.IotMessageParser; +import cn.iocoder.yudao.module.iot.protocol.message.IotStandardResponse; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * IoT HTTP 协议消息解析器实现 + *

+ * 参考阿里云IoT平台HTTPS协议标准,支持设备认证和数据上报两种消息类型: + *

+ * 1. 设备认证消息格式: + * + *

+ * POST /auth HTTP/1.1
+ * Content-Type: application/json
+ * {
+ *   "productKey": "a1AbC***",
+ *   "deviceName": "device01",
+ *   "clientId": "device01_001",
+ *   "timestamp": "1501668289957",
+ *   "sign": "xxxxx",
+ *   "signmethod": "hmacsha1",
+ *   "version": "default"
+ * }
+ * 
+ *

+ * 2. 数据上报消息格式: + * + *

+ * POST /topic/${topic} HTTP/1.1
+ * password: ${token}
+ * Content-Type: application/octet-stream
+ * ${payload}
+ * 
+ * + * @author haohao + */ +@Slf4j +public class IotHttpMessageParser implements IotMessageParser { + + /** + * 认证路径 + */ + public static final String AUTH_PATH = IotHttpConstants.Path.AUTH; + + /** + * 主题路径前缀 + */ + public static final String TOPIC_PATH_PREFIX = IotHttpConstants.Path.TOPIC_PREFIX; + + @Override + public IotAlinkMessage parse(String topic, byte[] payload) { + if (payload == null || payload.length == 0) { + log.warn(IotLogConstants.Http.RECEIVED_EMPTY_MESSAGE, topic); + return null; + } + + try { + String message = new String(payload, StandardCharsets.UTF_8); + + // 判断是认证请求还是数据上报 + if (AUTH_PATH.equals(topic)) { + return parseAuthMessage(message); + } else if (topic.startsWith(TOPIC_PATH_PREFIX)) { + return parseDataMessage(topic, message); + } else { + log.warn(IotLogConstants.Http.UNSUPPORTED_PATH_FORMAT, topic); + return null; + } + + } catch (Exception e) { + log.error(IotLogConstants.Http.PARSE_MESSAGE_FAILED, topic, e); + return null; + } + } + + /** + * 解析设备认证消息 + * + * @param message 认证消息JSON + * @return 标准消息格式 + */ + private IotAlinkMessage parseAuthMessage(String message) { + if (!JSONUtil.isTypeJSON(message)) { + log.warn(IotLogConstants.Http.AUTH_MESSAGE_NOT_JSON, message); + return null; + } + + JSONObject json = JSONUtil.parseObj(message); + + // 验证必需字段 + String productKey = json.getStr(IotHttpConstants.AuthField.PRODUCT_KEY); + String deviceName = json.getStr(IotHttpConstants.AuthField.DEVICE_NAME); + String clientId = json.getStr(IotHttpConstants.AuthField.CLIENT_ID); + String sign = json.getStr(IotHttpConstants.AuthField.SIGN); + + if (StrUtil.hasBlank(productKey, deviceName, clientId, sign)) { + log.warn(IotLogConstants.Http.AUTH_MESSAGE_MISSING_REQUIRED_FIELDS, message); + return null; + } + + // 构建认证消息 + Map params = new HashMap<>(); + params.put(IotHttpConstants.AuthField.PRODUCT_KEY, productKey); + params.put(IotHttpConstants.AuthField.DEVICE_NAME, deviceName); + params.put(IotHttpConstants.AuthField.CLIENT_ID, clientId); + params.put(IotHttpConstants.AuthField.TIMESTAMP, json.getStr(IotHttpConstants.AuthField.TIMESTAMP)); + params.put(IotHttpConstants.AuthField.SIGN, sign); + params.put(IotHttpConstants.AuthField.SIGN_METHOD, + json.getStr(IotHttpConstants.AuthField.SIGN_METHOD, IotHttpConstants.DefaultValue.SIGN_METHOD)); + + return IotAlinkMessage.builder() + .id(generateMessageId()) + .method(IotHttpConstants.Method.DEVICE_AUTH) + .version(json.getStr(IotHttpConstants.AuthField.VERSION, IotHttpConstants.DefaultValue.VERSION)) + .params(params) + .build(); + } + + /** + * 解析数据上报消息 + * + * @param topic 主题路径,格式:/topic/${actualTopic} + * @param message 消息内容 + * @return 标准消息格式 + */ + private IotAlinkMessage parseDataMessage(String topic, String message) { + // 提取实际的主题,去掉 /topic 前缀 + String actualTopic = topic.substring(TOPIC_PATH_PREFIX.length()); // 直接移除/topic前缀 + + // 尝试解析为JSON格式 + if (JSONUtil.isTypeJSON(message)) { + return parseJsonDataMessage(actualTopic, message); + } else { + // 原始数据格式 + return parseRawDataMessage(actualTopic, message); + } + } + + /** + * 解析JSON格式的数据消息 + * + * @param topic 实际主题 + * @param message JSON消息 + * @return 标准消息格式 + */ + private IotAlinkMessage parseJsonDataMessage(String topic, String message) { + JSONObject json = JSONUtil.parseObj(message); + + // 生成消息ID + String messageId = json.getStr(IotHttpConstants.MessageField.ID); + if (StrUtil.isBlank(messageId)) { + messageId = generateMessageId(); + } + + // 获取方法名 + String method = json.getStr(IotHttpConstants.MessageField.METHOD); + if (StrUtil.isBlank(method)) { + // 根据主题推断方法名 + method = inferMethodFromTopic(topic); + } + + // 获取参数 + Object params = json.get(IotHttpConstants.MessageField.PARAMS); + Map paramsMap = new HashMap<>(); + if (params instanceof Map) { + paramsMap.putAll((Map) params); + } else if (params != null) { + paramsMap.put(IotHttpConstants.MessageField.DATA, params); + } + + return IotAlinkMessage.builder() + .id(messageId) + .method(method) + .version(json.getStr(IotHttpConstants.MessageField.VERSION, + IotHttpConstants.DefaultValue.MESSAGE_VERSION)) + .params(paramsMap) + .build(); + } + + /** + * 解析原始数据消息 + * + * @param topic 实际主题 + * @param message 原始消息 + * @return 标准消息格式 + */ + private IotAlinkMessage parseRawDataMessage(String topic, String message) { + Map params = new HashMap<>(); + params.put(IotHttpConstants.MessageField.DATA, message); + + return IotAlinkMessage.builder() + .id(generateMessageId()) + .method(inferMethodFromTopic(topic)) + .version(IotHttpConstants.DefaultValue.MESSAGE_VERSION) + .params(params) + .build(); + } + + /** + * 根据主题推断方法名 + * + * @param topic 主题 + * @return 方法名 + */ + private String inferMethodFromTopic(String topic) { + if (StrUtil.isBlank(topic)) { + return IotHttpConstants.DefaultValue.UNKNOWN_METHOD; + } + + // 标准系统主题解析 + if (topic.startsWith(IotTopicConstants.SYS_TOPIC_PREFIX)) { + if (topic.contains(IotTopicConstants.PROPERTY_SET_TOPIC)) { + return IotTopicConstants.Method.PROPERTY_SET; + } else if (topic.contains(IotTopicConstants.PROPERTY_GET_TOPIC)) { + return IotTopicConstants.Method.PROPERTY_GET; + } else if (topic.contains(IotTopicConstants.PROPERTY_POST_TOPIC)) { + return IotTopicConstants.Method.PROPERTY_POST; + } else if (topic.contains(IotTopicConstants.EVENT_POST_TOPIC_PREFIX) + && topic.endsWith(IotTopicConstants.EVENT_POST_TOPIC_SUFFIX)) { + // 自定义事件上报 + String[] parts = topic.split("/"); + // 查找event关键字的位置 + for (int i = 0; i < parts.length; i++) { + if (IotTopicConstants.Keyword.EVENT.equals(parts[i]) && i + 1 < parts.length) { + String eventId = parts[i + 1]; + return IotTopicConstants.MethodPrefix.THING_EVENT + eventId + ".post"; + } + } + } else if (topic.contains(IotTopicConstants.SERVICE_TOPIC_PREFIX) + && !topic.contains(IotTopicConstants.Keyword.PROPERTY)) { + // 自定义服务调用 + String[] parts = topic.split("/"); + // 查找service关键字的位置 + for (int i = 0; i < parts.length; i++) { + if (IotTopicConstants.Keyword.SERVICE.equals(parts[i]) && i + 1 < parts.length) { + String serviceId = parts[i + 1]; + return IotTopicConstants.MethodPrefix.THING_SERVICE + serviceId; + } + } + } + } + + // 自定义主题 + return IotHttpConstants.Method.CUSTOM_MESSAGE; + } + + /** + * 生成消息ID + * + * @return 消息ID + */ + private String generateMessageId() { + return IotAlinkMessage.generateRequestId(); + } + + @Override + public byte[] formatResponse(IotStandardResponse response) { + try { + JSONObject httpResponse = new JSONObject(); + + // 判断是否为认证响应 + if (IotHttpConstants.Method.DEVICE_AUTH.equals(response.getMethod())) { + // 认证响应格式 + httpResponse.set(IotHttpConstants.ResponseField.CODE, response.getCode()); + httpResponse.set(IotHttpConstants.ResponseField.MESSAGE, response.getMessage()); + + if (response.getCode() == 200 && response.getData() != null) { + JSONObject info = new JSONObject(); + if (response.getData() instanceof Map) { + Map dataMap = (Map) response.getData(); + info.putAll(dataMap); + } else { + info.set(IotHttpConstants.ResponseField.TOKEN, response.getData().toString()); + } + httpResponse.set(IotHttpConstants.ResponseField.INFO, info); + } + } else { + // 数据上报响应格式 + httpResponse.set(IotHttpConstants.ResponseField.CODE, response.getCode()); + httpResponse.set(IotHttpConstants.ResponseField.MESSAGE, response.getMessage()); + + if (response.getCode() == 200) { + JSONObject info = new JSONObject(); + info.set(IotHttpConstants.ResponseField.MESSAGE_ID, response.getId()); + httpResponse.set(IotHttpConstants.ResponseField.INFO, info); + } + } + + String json = httpResponse.toString(); + return json.getBytes(StandardCharsets.UTF_8); + } catch (Exception e) { + log.error(IotLogConstants.Http.FORMAT_RESPONSE_FAILED, e); + return new byte[0]; + } + } + + @Override + public boolean canHandle(String topic) { + // 支持认证路径和主题路径 + return topic != null && (AUTH_PATH.equals(topic) || topic.startsWith(TOPIC_PATH_PREFIX)); + } + + /** + * 从设备标识中解析产品Key和设备名称 + * + * @param deviceKey 设备标识,格式:productKey/deviceName + * @return 包含产品Key和设备名称的数组,[0]为产品Key,[1]为设备名称 + */ + public static String[] parseDeviceKey(String deviceKey) { + if (StrUtil.isBlank(deviceKey)) { + return null; + } + + String[] parts = deviceKey.split("/"); + if (parts.length != 2) { + return null; + } + + return new String[]{parts[0], parts[1]}; + } + + /** + * 构建设备标识 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @return 设备标识,格式:productKey/deviceName + */ + public static String buildDeviceKey(String productKey, String deviceName) { + if (StrUtil.isBlank(productKey) || StrUtil.isBlank(deviceName)) { + return null; + } + return productKey + "/" + deviceName; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotHttpTopicUtils.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotHttpTopicUtils.java new file mode 100644 index 0000000000..4e0aeb851c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotHttpTopicUtils.java @@ -0,0 +1,279 @@ +package cn.iocoder.yudao.module.iot.protocol.util; + +import cn.hutool.core.util.StrUtil; + +/** + * IoT HTTP 协议主题工具类 + *

+ * 参考阿里云IoT平台HTTPS协议标准,支持以下路径格式: + * 1. 设备认证:/auth + * 2. 数据上报:/topic/${actualTopic} + *

+ * 其中 actualTopic 遵循MQTT主题规范,例如: + * - /sys/{productKey}/{deviceName}/thing/service/property/set + * - /{productKey}/{deviceName}/user/get + * + * @author haohao + */ +public class IotHttpTopicUtils { + + /** + * 设备认证路径 + */ + public static final String AUTH_PATH = "/auth"; + + /** + * 数据上报路径前缀 + */ + public static final String TOPIC_PATH_PREFIX = "/topic"; + + /** + * 系统主题前缀 + */ + public static final String SYS_TOPIC_PREFIX = "/sys"; + + /** + * 构建设备认证路径 + * + * @return 认证路径 + */ + public static String buildAuthPath() { + return AUTH_PATH; + } + + /** + * 构建数据上报路径 + * + * @param actualTopic 实际的MQTT主题 + * @return HTTP数据上报路径 + */ + public static String buildTopicPath(String actualTopic) { + if (StrUtil.isBlank(actualTopic)) { + return null; + } + return TOPIC_PATH_PREFIX + actualTopic; + } + + /** + * 构建系统属性设置路径 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @return HTTP路径 + */ + public static String buildPropertySetPath(String productKey, String deviceName) { + if (StrUtil.hasBlank(productKey, deviceName)) { + return null; + } + String actualTopic = SYS_TOPIC_PREFIX + "/" + productKey + "/" + deviceName + "/thing/service/property/set"; + return buildTopicPath(actualTopic); + } + + /** + * 构建系统属性获取路径 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @return HTTP路径 + */ + public static String buildPropertyGetPath(String productKey, String deviceName) { + if (StrUtil.hasBlank(productKey, deviceName)) { + return null; + } + String actualTopic = SYS_TOPIC_PREFIX + "/" + productKey + "/" + deviceName + "/thing/service/property/get"; + return buildTopicPath(actualTopic); + } + + /** + * 构建系统属性上报路径 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @return HTTP路径 + */ + public static String buildPropertyPostPath(String productKey, String deviceName) { + if (StrUtil.hasBlank(productKey, deviceName)) { + return null; + } + String actualTopic = SYS_TOPIC_PREFIX + "/" + productKey + "/" + deviceName + "/thing/event/property/post"; + return buildTopicPath(actualTopic); + } + + /** + * 构建系统事件上报路径 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @param eventIdentifier 事件标识符 + * @return HTTP路径 + */ + public static String buildEventPostPath(String productKey, String deviceName, String eventIdentifier) { + if (StrUtil.hasBlank(productKey, deviceName, eventIdentifier)) { + return null; + } + String actualTopic = SYS_TOPIC_PREFIX + "/" + productKey + "/" + deviceName + "/thing/event/" + eventIdentifier + + "/post"; + return buildTopicPath(actualTopic); + } + + /** + * 构建系统服务调用路径 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @param serviceIdentifier 服务标识符 + * @return HTTP路径 + */ + public static String buildServiceInvokePath(String productKey, String deviceName, String serviceIdentifier) { + if (StrUtil.hasBlank(productKey, deviceName, serviceIdentifier)) { + return null; + } + String actualTopic = SYS_TOPIC_PREFIX + "/" + productKey + "/" + deviceName + "/thing/service/" + + serviceIdentifier; + return buildTopicPath(actualTopic); + } + + /** + * 构建自定义主题路径 + * + * @param productKey 产品Key + * @param deviceName 设备名称 + * @param customPath 自定义路径 + * @return HTTP路径 + */ + public static String buildCustomTopicPath(String productKey, String deviceName, String customPath) { + if (StrUtil.hasBlank(productKey, deviceName, customPath)) { + return null; + } + String actualTopic = "/" + productKey + "/" + deviceName + "/" + customPath; + return buildTopicPath(actualTopic); + } + + /** + * 从HTTP路径中提取实际主题 + * + * @param httpPath HTTP路径,格式:/topic/${actualTopic} + * @return 实际主题,如果解析失败返回null + */ + public static String extractActualTopic(String httpPath) { + if (StrUtil.isBlank(httpPath) || !httpPath.startsWith(TOPIC_PATH_PREFIX)) { + return null; + } + return httpPath.substring(TOPIC_PATH_PREFIX.length()); // 直接移除/topic前缀 + } + + /** + * 从主题中解析产品Key + * + * @param topic 主题,支持系统主题和自定义主题 + * @return 产品Key,如果无法解析则返回null + */ + public static String parseProductKeyFromTopic(String topic) { + if (StrUtil.isBlank(topic)) { + return null; + } + + String[] parts = topic.split("/"); + + // 系统主题格式:/sys/{productKey}/{deviceName}/... + if (parts.length >= 4 && "sys".equals(parts[1])) { + return parts[2]; + } + + // 自定义主题格式:/{productKey}/{deviceName}/... + // 确保不是不完整的系统主题格式 + if (parts.length >= 3 && StrUtil.isNotBlank(parts[1]) && !"sys".equals(parts[1])) { + return parts[1]; + } + + return null; + } + + /** + * 从主题中解析设备名称 + * + * @param topic 主题,支持系统主题和自定义主题 + * @return 设备名称,如果无法解析则返回null + */ + public static String parseDeviceNameFromTopic(String topic) { + if (StrUtil.isBlank(topic)) { + return null; + } + + String[] parts = topic.split("/"); + + // 系统主题格式:/sys/{productKey}/{deviceName}/... + if (parts.length >= 4 && "sys".equals(parts[1])) { + return parts[3]; + } + + // 自定义主题格式:/{productKey}/{deviceName}/... + // 确保不是不完整的系统主题格式 + if (parts.length >= 3 && StrUtil.isNotBlank(parts[2]) && !"sys".equals(parts[1])) { + return parts[2]; + } + + return null; + } + + /** + * 检查是否为认证路径 + * + * @param path 路径 + * @return 如果是认证路径返回true,否则返回false + */ + public static boolean isAuthPath(String path) { + return AUTH_PATH.equals(path); + } + + /** + * 检查是否为数据上报路径 + * + * @param path 路径 + * @return 如果是数据上报路径返回true,否则返回false + */ + public static boolean isTopicPath(String path) { + return path != null && path.startsWith(TOPIC_PATH_PREFIX); + } + + /** + * 检查是否为有效的HTTP路径 + * + * @param path 路径 + * @return 如果是有效的HTTP路径返回true,否则返回false + */ + public static boolean isValidHttpPath(String path) { + return isAuthPath(path) || isTopicPath(path); + } + + /** + * 检查是否为系统主题 + * + * @param topic 主题 + * @return 如果是系统主题返回true,否则返回false + */ + public static boolean isSystemTopic(String topic) { + return topic != null && topic.startsWith(SYS_TOPIC_PREFIX); + } + + /** + * 构建响应主题路径 + * + * @param requestPath 请求路径 + * @return 响应路径,如果无法构建返回null + */ + public static String buildReplyPath(String requestPath) { + String actualTopic = extractActualTopic(requestPath); + if (actualTopic == null) { + return null; + } + + // 为系统主题添加_reply后缀 + if (isSystemTopic(actualTopic)) { + String replyTopic = actualTopic + "_reply"; + return buildTopicPath(replyTopic); + } + + return null; + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicParser.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicParser.java new file mode 100644 index 0000000000..05873d2bdb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicParser.java @@ -0,0 +1,237 @@ +package cn.iocoder.yudao.module.iot.protocol.util; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.protocol.constants.IotTopicConstants; +import cn.iocoder.yudao.module.iot.protocol.enums.IotMessageDirectionEnum; +import cn.iocoder.yudao.module.iot.protocol.enums.IotMessageTypeEnum; +import lombok.Data; + +/** + * IoT 主题解析器 + *

+ * 用于解析各种格式的 IoT 主题,提取其中的关键信息 + * + * @author haohao + */ +public class IotTopicParser { + + /** + * 主题解析结果 + */ + @Data + public static class TopicInfo { + /** + * 产品Key + */ + private String productKey; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 消息类型 + */ + private IotMessageTypeEnum messageType; + + /** + * 消息方向 + */ + private IotMessageDirectionEnum direction; + + /** + * 服务标识符(仅服务调用时有效) + */ + private String serviceIdentifier; + + /** + * 事件标识符(仅事件上报时有效) + */ + private String eventIdentifier; + + /** + * 是否为响应主题 + */ + private boolean isReply; + + /** + * 原始主题 + */ + private String originalTopic; + } + + /** + * 解析主题 + * + * @param topic 主题字符串 + * @return 解析结果,如果解析失败返回 null + */ + public static TopicInfo parse(String topic) { + if (StrUtil.isBlank(topic)) { + return null; + } + + TopicInfo info = new TopicInfo(); + info.setOriginalTopic(topic); + + // 检查是否为响应主题 + boolean isReply = topic.endsWith(IotTopicConstants.REPLY_SUFFIX); + info.setReply(isReply); + + // 移除响应后缀,便于后续解析 + String normalizedTopic = isReply ? topic.substring(0, topic.length() - IotTopicConstants.REPLY_SUFFIX.length()) + : topic; + + // 解析系统主题 + if (normalizedTopic.startsWith(IotTopicConstants.SYS_TOPIC_PREFIX)) { + return parseSystemTopic(info, normalizedTopic); + } + + // 解析自定义主题 + return parseCustomTopic(info, normalizedTopic); + } + + /** + * 解析系统主题 + * 格式:/sys/{productKey}/{deviceName}/thing/service/{identifier} + * 或:/sys/{productKey}/{deviceName}/thing/event/{identifier}/post + */ + private static TopicInfo parseSystemTopic(TopicInfo info, String topic) { + String[] parts = topic.split("/"); + if (parts.length < 6) { + return null; + } + + // 解析产品Key和设备名称 + info.setProductKey(parts[2]); + info.setDeviceName(parts[3]); + + // 判断消息方向:包含 /post 通常是上行,其他是下行 + info.setDirection(topic.contains("/post") || topic.contains("/reply") ? IotMessageDirectionEnum.UPSTREAM + : IotMessageDirectionEnum.DOWNSTREAM); + + // 解析具体的消息类型 + if (topic.contains("/thing/service/")) { + return parseServiceTopic(info, topic, parts); + } else if (topic.contains("/thing/event/")) { + return parseEventTopic(info, topic, parts); + } + + return null; + } + + /** + * 解析服务相关主题 + */ + private static TopicInfo parseServiceTopic(TopicInfo info, String topic, String[] parts) { + // 查找 service 关键字的位置 + int serviceIndex = -1; + for (int i = 0; i < parts.length; i++) { + if ("service".equals(parts[i])) { + serviceIndex = i; + break; + } + } + + if (serviceIndex == -1 || serviceIndex + 1 >= parts.length) { + return null; + } + + String serviceType = parts[serviceIndex + 1]; + + // 根据服务类型确定消息类型 + switch (serviceType) { + case "property": + if (serviceIndex + 2 < parts.length) { + String operation = parts[serviceIndex + 2]; + if ("set".equals(operation)) { + info.setMessageType(IotMessageTypeEnum.PROPERTY_SET); + } else if ("get".equals(operation)) { + info.setMessageType(IotMessageTypeEnum.PROPERTY_GET); + } + } + break; + case "config": + if (serviceIndex + 2 < parts.length && "set".equals(parts[serviceIndex + 2])) { + info.setMessageType(IotMessageTypeEnum.CONFIG_SET); + } + break; + case "ota": + if (serviceIndex + 2 < parts.length && "upgrade".equals(parts[serviceIndex + 2])) { + info.setMessageType(IotMessageTypeEnum.OTA_UPGRADE); + } + break; + default: + // 自定义服务 + info.setMessageType(IotMessageTypeEnum.SERVICE_INVOKE); + info.setServiceIdentifier(serviceType); + break; + } + + return info; + } + + /** + * 解析事件相关主题 + */ + private static TopicInfo parseEventTopic(TopicInfo info, String topic, String[] parts) { + // 查找 event 关键字的位置 + int eventIndex = -1; + for (int i = 0; i < parts.length; i++) { + if ("event".equals(parts[i])) { + eventIndex = i; + break; + } + } + + if (eventIndex == -1 || eventIndex + 1 >= parts.length) { + return null; + } + + String eventType = parts[eventIndex + 1]; + + if ("property".equals(eventType) && eventIndex + 2 < parts.length && "post".equals(parts[eventIndex + 2])) { + info.setMessageType(IotMessageTypeEnum.PROPERTY_POST); + } else { + // 自定义事件 + info.setMessageType(IotMessageTypeEnum.EVENT_POST); + info.setEventIdentifier(eventType); + } + + return info; + } + + /** + * 解析自定义主题 + * 这里可以根据实际需求扩展自定义主题的解析逻辑 + */ + private static TopicInfo parseCustomTopic(TopicInfo info, String topic) { + // TODO: 根据业务需要实现自定义主题解析逻辑 + return info; + } + + /** + * 检查主题是否为有效的系统主题 + * + * @param topic 主题 + * @return 如果是有效的系统主题返回 true,否则返回 false + */ + public static boolean isValidSystemTopic(String topic) { + TopicInfo info = parse(topic); + return info != null && + StrUtil.isNotBlank(info.getProductKey()) && + StrUtil.isNotBlank(info.getDeviceName()) && + info.getMessageType() != null; + } + + /** + * 检查主题是否为响应主题 + * + * @param topic 主题 + * @return 如果是响应主题返回 true,否则返回 false + */ + public static boolean isReplyTopic(String topic) { + return topic != null && topic.endsWith(IotTopicConstants.REPLY_SUFFIX); + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtils.java b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtils.java index 6520ce375d..6bd447e5a9 100644 --- a/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtils.java +++ b/yudao-module-iot/yudao-module-iot-protocol/src/main/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtils.java @@ -126,12 +126,12 @@ public class IotTopicUtils { if (StrUtil.isBlank(topic) || !topic.startsWith(IotTopicConstants.SYS_TOPIC_PREFIX)) { return null; } - + String[] parts = topic.split("/"); if (parts.length < 4) { return null; } - + return parts[2]; } @@ -146,12 +146,12 @@ public class IotTopicUtils { if (StrUtil.isBlank(topic) || !topic.startsWith(IotTopicConstants.SYS_TOPIC_PREFIX)) { return null; } - + String[] parts = topic.split("/"); if (parts.length < 4) { return null; } - + return parts[3]; } @@ -166,19 +166,19 @@ public class IotTopicUtils { if (StrUtil.isBlank(topic) || !topic.startsWith(IotTopicConstants.SYS_TOPIC_PREFIX)) { return null; } - + // 服务调用主题 if (topic.contains("/thing/service/")) { String servicePart = topic.substring(topic.indexOf("/thing/service/") + "/thing/service/".length()); return servicePart.replace("/", "."); } - + // 事件上报主题 if (topic.contains("/thing/event/")) { String eventPart = topic.substring(topic.indexOf("/thing/event/") + "/thing/event/".length()); return "event." + eventPart.replace("/", "."); } - + return null; } } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfigurationTest.java b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfigurationTest.java new file mode 100644 index 0000000000..b27cc5f0db --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/config/IotProtocolAutoConfigurationTest.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.iot.protocol.config; + +import cn.iocoder.yudao.module.iot.protocol.convert.IotProtocolConverter; +import cn.iocoder.yudao.module.iot.protocol.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.protocol.message.IotMessageParser; +import cn.iocoder.yudao.module.iot.protocol.message.impl.IotAlinkMessageParser; +import cn.iocoder.yudao.module.iot.protocol.message.impl.IotHttpMessageParser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotProtocolAutoConfiguration} 单元测试 + * + * @author haohao + */ +class IotProtocolAutoConfigurationTest { + + private IotProtocolAutoConfiguration configuration; + + @BeforeEach + void setUp() { + configuration = new IotProtocolAutoConfiguration(); + } + + @Test + void testIotAlinkMessageParser() { + // 测试 Alink 协议解析器 Bean 创建 + IotMessageParser parser = configuration.iotAlinkMessageParser(); + + assertNotNull(parser); + assertInstanceOf(IotAlinkMessageParser.class, parser); + } + + @Test + void testIotHttpMessageParser() { + // 测试 HTTP 协议解析器 Bean 创建 + IotMessageParser parser = configuration.iotHttpMessageParser(); + + assertNotNull(parser); + assertInstanceOf(IotHttpMessageParser.class, parser); + } + + @Test + void testIotProtocolConverter() { + // 创建解析器实例 + IotMessageParser alinkParser = configuration.iotAlinkMessageParser(); + IotMessageParser httpParser = configuration.iotHttpMessageParser(); + + // 测试协议转换器 Bean 创建 + IotProtocolConverter converter = configuration.iotProtocolConverter(alinkParser, httpParser); + + assertNotNull(converter); + + // 验证支持的协议 + assertTrue(converter.supportsProtocol(IotProtocolTypeEnum.ALINK.getCode())); + assertTrue(converter.supportsProtocol(IotProtocolTypeEnum.HTTP.getCode())); + + // 验证支持的协议数量 + String[] supportedProtocols = converter.getSupportedProtocols(); + assertEquals(2, supportedProtocols.length); + } + + @Test + void testBeanNameConstants() { + // 测试 Bean 名称常量定义 + assertEquals("iotAlinkMessageParser", IotProtocolAutoConfiguration.IOT_ALINK_MESSAGE_PARSER_BEAN_NAME); + assertEquals("iotHttpMessageParser", IotProtocolAutoConfiguration.IOT_HTTP_MESSAGE_PARSER_BEAN_NAME); + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/example/AliyunHttpProtocolExample.java b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/example/AliyunHttpProtocolExample.java new file mode 100644 index 0000000000..a1c1dae562 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/example/AliyunHttpProtocolExample.java @@ -0,0 +1,166 @@ +package cn.iocoder.yudao.module.iot.protocol.example; + +import cn.hutool.json.JSONObject; +import cn.iocoder.yudao.module.iot.protocol.util.IotHttpTopicUtils; + +/** + * 阿里云IoT平台HTTPS协议示例 + *

+ * 参考阿里云IoT平台HTTPS连接通信标准,演示设备认证和数据上报的完整流程 + * + * @author haohao + */ +public class AliyunHttpProtocolExample { + + public static void main(String[] args) { + System.out.println("=== 阿里云IoT平台HTTPS协议演示 ===\n"); + + // 演示设备认证 + demonstrateDeviceAuth(); + + // 演示数据上报 + demonstrateDataUpload(); + + // 演示路径构建 + demonstratePathBuilding(); + } + + /** + * 演示设备认证流程 + */ + private static void demonstrateDeviceAuth() { + System.out.println("1. 设备认证流程:"); + System.out.println("认证路径: " + IotHttpTopicUtils.buildAuthPath()); + + // 构建认证请求消息 + JSONObject authRequest = new JSONObject(); + authRequest.set("productKey", "a1GFjLP****"); + authRequest.set("deviceName", "device123"); + authRequest.set("clientId", "device123_001"); + authRequest.set("timestamp", String.valueOf(System.currentTimeMillis())); + authRequest.set("sign", "4870141D4067227128CBB4377906C3731CAC221C"); + authRequest.set("signmethod", "hmacsha1"); + authRequest.set("version", "default"); + + System.out.println("认证请求消息:"); + System.out.println(authRequest.toString()); + + // 模拟认证响应 + JSONObject authResponse = new JSONObject(); + authResponse.set("code", 0); + authResponse.set("message", "success"); + + JSONObject info = new JSONObject(); + info.set("token", "6944e5bfb92e4d4ea3918d1eda39****"); + authResponse.set("info", info); + + System.out.println("认证响应:"); + System.out.println(authResponse.toString()); + System.out.println(); + } + + /** + * 演示数据上报流程 + */ + private static void demonstrateDataUpload() { + System.out.println("2. 数据上报流程:"); + + String productKey = "a1GFjLP****"; + String deviceName = "device123"; + + // 属性上报 + String propertyPostPath = IotHttpTopicUtils.buildPropertyPostPath(productKey, deviceName); + System.out.println("属性上报路径: " + propertyPostPath); + + // Alink格式的属性上报消息 + JSONObject propertyMessage = new JSONObject(); + propertyMessage.set("id", "123456"); + propertyMessage.set("version", "1.0"); + propertyMessage.set("method", "thing.event.property.post"); + + JSONObject propertyParams = new JSONObject(); + JSONObject properties = new JSONObject(); + properties.set("temperature", 25.6); + properties.set("humidity", 60.3); + propertyParams.set("properties", properties); + propertyMessage.set("params", propertyParams); + + System.out.println("属性上报消息:"); + System.out.println(propertyMessage.toString()); + + // 事件上报 + String eventPostPath = IotHttpTopicUtils.buildEventPostPath(productKey, deviceName, "temperatureAlert"); + System.out.println("\n事件上报路径: " + eventPostPath); + + JSONObject eventMessage = new JSONObject(); + eventMessage.set("id", "123457"); + eventMessage.set("version", "1.0"); + eventMessage.set("method", "thing.event.temperatureAlert.post"); + + JSONObject eventParams = new JSONObject(); + eventParams.set("value", new JSONObject().set("alertLevel", "high").set("currentTemp", 45.2)); + eventParams.set("time", System.currentTimeMillis()); + eventMessage.set("params", eventParams); + + System.out.println("事件上报消息:"); + System.out.println(eventMessage.toString()); + + // 模拟数据上报响应 + JSONObject uploadResponse = new JSONObject(); + uploadResponse.set("code", 0); + uploadResponse.set("message", "success"); + + JSONObject responseInfo = new JSONObject(); + responseInfo.set("messageId", 892687470447040L); + uploadResponse.set("info", responseInfo); + + System.out.println("\n数据上报响应:"); + System.out.println(uploadResponse.toString()); + System.out.println(); + } + + /** + * 演示路径构建功能 + */ + private static void demonstratePathBuilding() { + System.out.println("3. 路径构建功能:"); + + String productKey = "smartProduct"; + String deviceName = "sensor001"; + + // 系统主题路径 + System.out.println("系统主题路径:"); + System.out.println(" 属性设置: " + IotHttpTopicUtils.buildPropertySetPath(productKey, deviceName)); + System.out.println(" 属性获取: " + IotHttpTopicUtils.buildPropertyGetPath(productKey, deviceName)); + System.out.println(" 属性上报: " + IotHttpTopicUtils.buildPropertyPostPath(productKey, deviceName)); + System.out.println(" 事件上报: " + IotHttpTopicUtils.buildEventPostPath(productKey, deviceName, "alarm")); + System.out.println(" 服务调用: " + IotHttpTopicUtils.buildServiceInvokePath(productKey, deviceName, "reboot")); + + // 自定义主题路径 + System.out.println("\n自定义主题路径:"); + System.out.println(" 用户主题: " + IotHttpTopicUtils.buildCustomTopicPath(productKey, deviceName, "user/get")); + + // 响应路径 + String requestPath = IotHttpTopicUtils.buildPropertySetPath(productKey, deviceName); + String replyPath = IotHttpTopicUtils.buildReplyPath(requestPath); + System.out.println("\n响应路径:"); + System.out.println(" 请求路径: " + requestPath); + System.out.println(" 响应路径: " + replyPath); + + // 路径解析 + System.out.println("\n路径解析:"); + String testPath = "/topic/sys/smartProduct/sensor001/thing/service/property/set"; + String actualTopic = IotHttpTopicUtils.extractActualTopic(testPath); + System.out.println(" HTTP路径: " + testPath); + System.out.println(" 实际主题: " + actualTopic); + System.out.println(" 产品Key: " + IotHttpTopicUtils.parseProductKeyFromTopic(actualTopic)); + System.out.println(" 设备名称: " + IotHttpTopicUtils.parseDeviceNameFromTopic(actualTopic)); + System.out.println(" 是否为系统主题: " + IotHttpTopicUtils.isSystemTopic(actualTopic)); + + // 路径类型检查 + System.out.println("\n路径类型检查:"); + System.out.println(" 认证路径检查: " + IotHttpTopicUtils.isAuthPath("/auth")); + System.out.println(" 数据路径检查: " + IotHttpTopicUtils.isTopicPath("/topic/test")); + System.out.println(" 有效路径检查: " + IotHttpTopicUtils.isValidHttpPath("/topic/sys/test/device/property")); + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotHttpMessageParserTest.java b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotHttpMessageParserTest.java new file mode 100644 index 0000000000..9241d9b20d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/message/impl/IotHttpMessageParserTest.java @@ -0,0 +1,259 @@ +package cn.iocoder.yudao.module.iot.protocol.message.impl; + +import cn.hutool.json.JSONObject; +import cn.iocoder.yudao.module.iot.protocol.message.IotAlinkMessage; +import cn.iocoder.yudao.module.iot.protocol.message.IotStandardResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotHttpMessageParser} 单元测试 + *

+ * 测试阿里云IoT平台HTTPS协议标准的消息解析功能 + * + * @author haohao + */ +class IotHttpMessageParserTest { + + private IotHttpMessageParser parser; + + @BeforeEach + void setUp() { + parser = new IotHttpMessageParser(); + } + + @Test + void testCanHandle() { + // 测试能处理的路径 + assertTrue(parser.canHandle("/auth")); + assertTrue(parser.canHandle("/topic/sys/test/device1/thing/service/property/set")); + assertTrue(parser.canHandle("/topic/test/device1/user/get")); + + // 测试不能处理的路径 + assertFalse(parser.canHandle("/sys/test/device1/thing/service/property/set")); + assertFalse(parser.canHandle("/unknown/path")); + assertFalse(parser.canHandle(null)); + assertFalse(parser.canHandle("")); + } + + @Test + void testParseAuthMessage() { + // 构建认证消息 + JSONObject authMessage = new JSONObject(); + authMessage.set("productKey", "a1GFjLP****"); + authMessage.set("deviceName", "device123"); + authMessage.set("clientId", "device123_001"); + authMessage.set("timestamp", "1501668289957"); + authMessage.set("sign", "4870141D4067227128CBB4377906C3731CAC221C"); + authMessage.set("signmethod", "hmacsha1"); + authMessage.set("version", "default"); + + String topic = "/auth"; + byte[] payload = authMessage.toString().getBytes(StandardCharsets.UTF_8); + + // 解析消息 + IotAlinkMessage result = parser.parse(topic, payload); + + // 验证结果 + assertNotNull(result); + assertNotNull(result.getId()); + assertEquals("device.auth", result.getMethod()); + assertEquals("default", result.getVersion()); + assertNotNull(result.getParams()); + + Map params = result.getParams(); + assertEquals("a1GFjLP****", params.get("productKey")); + assertEquals("device123", params.get("deviceName")); + assertEquals("device123_001", params.get("clientId")); + assertEquals("1501668289957", params.get("timestamp")); + assertEquals("4870141D4067227128CBB4377906C3731CAC221C", params.get("sign")); + assertEquals("hmacsha1", params.get("signmethod")); + } + + @Test + void testParseAuthMessageWithMissingFields() { + // 构建缺少必需字段的认证消息 + JSONObject authMessage = new JSONObject(); + authMessage.set("productKey", "a1GFjLP****"); + authMessage.set("deviceName", "device123"); + // 缺少 clientId 和 sign + + String topic = "/auth"; + byte[] payload = authMessage.toString().getBytes(StandardCharsets.UTF_8); + + // 解析消息 + IotAlinkMessage result = parser.parse(topic, payload); + + // 验证结果 + assertNull(result); + } + + @Test + void testParseJsonDataMessage() { + // 构建JSON格式的数据消息 + JSONObject dataMessage = new JSONObject(); + dataMessage.set("id", "123456"); + dataMessage.set("version", "1.0"); + dataMessage.set("method", "thing.event.property.post"); + + JSONObject params = new JSONObject(); + JSONObject properties = new JSONObject(); + properties.set("temperature", 25.6); + properties.set("humidity", 60.3); + params.set("properties", properties); + dataMessage.set("params", params); + + String topic = "/topic/sys/a1GFjLP****/device123/thing/event/property/post"; + byte[] payload = dataMessage.toString().getBytes(StandardCharsets.UTF_8); + + // 解析消息 + IotAlinkMessage result = parser.parse(topic, payload); + + // 验证结果 + assertNotNull(result); + assertEquals("123456", result.getId()); + assertEquals("thing.event.property.post", result.getMethod()); + assertEquals("1.0", result.getVersion()); + assertNotNull(result.getParams()); + assertNotNull(result.getParams().get("properties")); + } + + @Test + void testParseRawDataMessage() { + // 原始数据消息 + String rawData = "temperature:25.6,humidity:60.3"; + String topic = "/topic/sys/a1GFjLP****/device123/thing/event/property/post"; + byte[] payload = rawData.getBytes(StandardCharsets.UTF_8); + + // 解析消息 + IotAlinkMessage result = parser.parse(topic, payload); + + // 验证结果 + assertNotNull(result); + assertNotNull(result.getId()); + assertEquals("thing.event.property.post", result.getMethod()); + assertEquals("1.0", result.getVersion()); + assertNotNull(result.getParams()); + assertEquals(rawData, result.getParams().get("data")); + } + + @Test + void testInferMethodFromTopic() { + // 测试系统主题方法推断 + testInferMethod("/sys/test/device/thing/service/property/set", "thing.service.property.set"); + testInferMethod("/sys/test/device/thing/service/property/get", "thing.service.property.get"); + testInferMethod("/sys/test/device/thing/event/property/post", "thing.event.property.post"); + testInferMethod("/sys/test/device/thing/event/alarm/post", "thing.event.alarm.post"); + testInferMethod("/sys/test/device/thing/service/reboot", "thing.service.reboot"); + + // 测试自定义主题 + testInferMethod("/test/device/user/get", "custom.message"); + } + + private void testInferMethod(String actualTopic, String expectedMethod) { + String topic = "/topic" + actualTopic; + String rawData = "test data"; + byte[] payload = rawData.getBytes(StandardCharsets.UTF_8); + + IotAlinkMessage result = parser.parse(topic, payload); + assertNotNull(result); + assertEquals(expectedMethod, result.getMethod()); + } + + @Test + void testFormatAuthResponse() { + // 创建认证成功响应 + Map data = new HashMap<>(); + data.put("token", "6944e5bfb92e4d4ea3918d1eda39****"); + + IotStandardResponse response = IotStandardResponse.success("auth123", "device.auth", data); + + // 格式化响应 + byte[] result = parser.formatResponse(response); + + // 验证结果 + assertNotNull(result); + assertTrue(result.length > 0); + + String responseStr = new String(result, StandardCharsets.UTF_8); + JSONObject responseJson = new JSONObject(responseStr); + + assertEquals(200, responseJson.getInt("code")); + assertEquals("success", responseJson.getStr("message")); + assertNotNull(responseJson.get("info")); + + JSONObject info = responseJson.getJSONObject("info"); + assertEquals("6944e5bfb92e4d4ea3918d1eda39****", info.getStr("token")); + } + + @Test + void testFormatDataResponse() { + // 创建数据上报响应 + IotStandardResponse response = IotStandardResponse.success("123456", "thing.event.property.post", null); + + // 格式化响应 + byte[] result = parser.formatResponse(response); + + // 验证结果 + assertNotNull(result); + assertTrue(result.length > 0); + + String responseStr = new String(result, StandardCharsets.UTF_8); + JSONObject responseJson = new JSONObject(responseStr); + + assertEquals(200, responseJson.getInt("code")); + assertEquals("success", responseJson.getStr("message")); + assertNotNull(responseJson.get("info")); + + JSONObject info = responseJson.getJSONObject("info"); + assertEquals("123456", info.getStr("messageId")); + } + + @Test + void testParseInvalidMessage() { + String topic = "/topic/sys/test/device/thing/service/property/set"; + + // 测试空消息 + assertNull(parser.parse(topic, null)); + assertNull(parser.parse(topic, new byte[0])); + + // 测试不支持的路径 + byte[] validPayload = "test data".getBytes(StandardCharsets.UTF_8); + assertNull(parser.parse("/unknown/path", validPayload)); + } + + @Test + void testParseDeviceKey() { + // 测试有效的设备标识 + String[] result1 = IotHttpMessageParser.parseDeviceKey("productKey/deviceName"); + assertNotNull(result1); + assertEquals(2, result1.length); + assertEquals("productKey", result1[0]); + assertEquals("deviceName", result1[1]); + + // 测试无效的设备标识 + assertNull(IotHttpMessageParser.parseDeviceKey(null)); + assertNull(IotHttpMessageParser.parseDeviceKey("")); + assertNull(IotHttpMessageParser.parseDeviceKey("invalid")); + assertNull(IotHttpMessageParser.parseDeviceKey("product/device/extra")); + } + + @Test + void testBuildDeviceKey() { + // 测试构建设备标识 + assertEquals("productKey/deviceName", + IotHttpMessageParser.buildDeviceKey("productKey", "deviceName")); + + // 测试无效参数 + assertNull(IotHttpMessageParser.buildDeviceKey(null, "deviceName")); + assertNull(IotHttpMessageParser.buildDeviceKey("productKey", null)); + assertNull(IotHttpMessageParser.buildDeviceKey("", "deviceName")); + assertNull(IotHttpMessageParser.buildDeviceKey("productKey", "")); + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/util/IotHttpTopicUtilsTest.java b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/util/IotHttpTopicUtilsTest.java new file mode 100644 index 0000000000..836bc8f95a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/util/IotHttpTopicUtilsTest.java @@ -0,0 +1,186 @@ +package cn.iocoder.yudao.module.iot.protocol.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotHttpTopicUtils} 单元测试 + * + * @author haohao + */ +class IotHttpTopicUtilsTest { + + @Test + void testBuildAuthPath() { + assertEquals("/auth", IotHttpTopicUtils.buildAuthPath()); + } + + @Test + void testBuildTopicPath() { + // 测试正常路径 + assertEquals("/topic/sys/test/device/thing/service/property/set", + IotHttpTopicUtils.buildTopicPath("/sys/test/device/thing/service/property/set")); + + // 测试空路径 + assertNull(IotHttpTopicUtils.buildTopicPath(null)); + assertNull(IotHttpTopicUtils.buildTopicPath("")); + } + + @Test + void testBuildPropertySetPath() { + String result = IotHttpTopicUtils.buildPropertySetPath("testProduct", "testDevice"); + assertEquals("/topic/sys/testProduct/testDevice/thing/service/property/set", result); + + // 测试无效参数 + assertNull(IotHttpTopicUtils.buildPropertySetPath(null, "testDevice")); + assertNull(IotHttpTopicUtils.buildPropertySetPath("testProduct", null)); + assertNull(IotHttpTopicUtils.buildPropertySetPath("", "testDevice")); + assertNull(IotHttpTopicUtils.buildPropertySetPath("testProduct", "")); + } + + @Test + void testBuildPropertyGetPath() { + String result = IotHttpTopicUtils.buildPropertyGetPath("testProduct", "testDevice"); + assertEquals("/topic/sys/testProduct/testDevice/thing/service/property/get", result); + } + + @Test + void testBuildPropertyPostPath() { + String result = IotHttpTopicUtils.buildPropertyPostPath("testProduct", "testDevice"); + assertEquals("/topic/sys/testProduct/testDevice/thing/event/property/post", result); + } + + @Test + void testBuildEventPostPath() { + String result = IotHttpTopicUtils.buildEventPostPath("testProduct", "testDevice", "alarm"); + assertEquals("/topic/sys/testProduct/testDevice/thing/event/alarm/post", result); + + // 测试无效参数 + assertNull(IotHttpTopicUtils.buildEventPostPath(null, "testDevice", "alarm")); + assertNull(IotHttpTopicUtils.buildEventPostPath("testProduct", null, "alarm")); + assertNull(IotHttpTopicUtils.buildEventPostPath("testProduct", "testDevice", null)); + } + + @Test + void testBuildServiceInvokePath() { + String result = IotHttpTopicUtils.buildServiceInvokePath("testProduct", "testDevice", "reboot"); + assertEquals("/topic/sys/testProduct/testDevice/thing/service/reboot", result); + + // 测试无效参数 + assertNull(IotHttpTopicUtils.buildServiceInvokePath(null, "testDevice", "reboot")); + assertNull(IotHttpTopicUtils.buildServiceInvokePath("testProduct", null, "reboot")); + assertNull(IotHttpTopicUtils.buildServiceInvokePath("testProduct", "testDevice", null)); + } + + @Test + void testBuildCustomTopicPath() { + String result = IotHttpTopicUtils.buildCustomTopicPath("testProduct", "testDevice", "user/get"); + assertEquals("/topic/testProduct/testDevice/user/get", result); + + // 测试无效参数 + assertNull(IotHttpTopicUtils.buildCustomTopicPath(null, "testDevice", "user/get")); + assertNull(IotHttpTopicUtils.buildCustomTopicPath("testProduct", null, "user/get")); + assertNull(IotHttpTopicUtils.buildCustomTopicPath("testProduct", "testDevice", null)); + } + + @Test + void testExtractActualTopic() { + // 测试正常提取 + String actualTopic = IotHttpTopicUtils + .extractActualTopic("/topic/sys/testProduct/testDevice/thing/service/property/set"); + assertEquals("/sys/testProduct/testDevice/thing/service/property/set", actualTopic); + + // 测试无效路径 + assertNull(IotHttpTopicUtils.extractActualTopic("/auth")); + assertNull(IotHttpTopicUtils.extractActualTopic("/unknown/path")); + assertNull(IotHttpTopicUtils.extractActualTopic(null)); + assertNull(IotHttpTopicUtils.extractActualTopic("")); + } + + @Test + void testParseProductKeyFromTopic() { + // 测试系统主题 + assertEquals("testProduct", + IotHttpTopicUtils.parseProductKeyFromTopic("/sys/testProduct/testDevice/thing/service/property/set")); + + // 测试自定义主题 + assertEquals("testProduct", IotHttpTopicUtils.parseProductKeyFromTopic("/testProduct/testDevice/user/get")); + + // 测试无效主题 + assertNull(IotHttpTopicUtils.parseProductKeyFromTopic("/sys")); + assertNull(IotHttpTopicUtils.parseProductKeyFromTopic("/single")); + assertNull(IotHttpTopicUtils.parseProductKeyFromTopic("")); + assertNull(IotHttpTopicUtils.parseProductKeyFromTopic(null)); + } + + @Test + void testParseDeviceNameFromTopic() { + // 测试系统主题 + assertEquals("testDevice", + IotHttpTopicUtils.parseDeviceNameFromTopic("/sys/testProduct/testDevice/thing/service/property/set")); + + // 测试自定义主题 + assertEquals("testDevice", IotHttpTopicUtils.parseDeviceNameFromTopic("/testProduct/testDevice/user/get")); + + // 测试无效主题 + assertNull(IotHttpTopicUtils.parseDeviceNameFromTopic("/sys/testProduct")); + assertNull(IotHttpTopicUtils.parseDeviceNameFromTopic("/testProduct")); + assertNull(IotHttpTopicUtils.parseDeviceNameFromTopic("")); + assertNull(IotHttpTopicUtils.parseDeviceNameFromTopic(null)); + } + + @Test + void testIsAuthPath() { + assertTrue(IotHttpTopicUtils.isAuthPath("/auth")); + assertFalse(IotHttpTopicUtils.isAuthPath("/topic/test")); + assertFalse(IotHttpTopicUtils.isAuthPath("/unknown")); + assertFalse(IotHttpTopicUtils.isAuthPath(null)); + assertFalse(IotHttpTopicUtils.isAuthPath("")); + } + + @Test + void testIsTopicPath() { + assertTrue(IotHttpTopicUtils.isTopicPath("/topic/sys/test/device/property")); + assertTrue(IotHttpTopicUtils.isTopicPath("/topic/test")); + assertFalse(IotHttpTopicUtils.isTopicPath("/auth")); + assertFalse(IotHttpTopicUtils.isTopicPath("/unknown")); + assertFalse(IotHttpTopicUtils.isTopicPath(null)); + assertFalse(IotHttpTopicUtils.isTopicPath("")); + } + + @Test + void testIsValidHttpPath() { + assertTrue(IotHttpTopicUtils.isValidHttpPath("/auth")); + assertTrue(IotHttpTopicUtils.isValidHttpPath("/topic/test")); + assertFalse(IotHttpTopicUtils.isValidHttpPath("/unknown")); + assertFalse(IotHttpTopicUtils.isValidHttpPath(null)); + assertFalse(IotHttpTopicUtils.isValidHttpPath("")); + } + + @Test + void testIsSystemTopic() { + assertTrue(IotHttpTopicUtils.isSystemTopic("/sys/testProduct/testDevice/thing/service/property/set")); + assertFalse(IotHttpTopicUtils.isSystemTopic("/testProduct/testDevice/user/get")); + assertFalse(IotHttpTopicUtils.isSystemTopic("/unknown")); + assertFalse(IotHttpTopicUtils.isSystemTopic(null)); + assertFalse(IotHttpTopicUtils.isSystemTopic("")); + } + + @Test + void testBuildReplyPath() { + // 测试系统主题响应路径 + String requestPath = "/topic/sys/testProduct/testDevice/thing/service/property/set"; + String replyPath = IotHttpTopicUtils.buildReplyPath(requestPath); + assertEquals("/topic/sys/testProduct/testDevice/thing/service/property/set_reply", replyPath); + + // 测试非系统主题 + String customPath = "/topic/testProduct/testDevice/user/get"; + assertNull(IotHttpTopicUtils.buildReplyPath(customPath)); + + // 测试无效路径 + assertNull(IotHttpTopicUtils.buildReplyPath("/auth")); + assertNull(IotHttpTopicUtils.buildReplyPath("/unknown")); + assertNull(IotHttpTopicUtils.buildReplyPath(null)); + } +} \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtilsTest.java b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtilsTest.java new file mode 100644 index 0000000000..fa882c3aa0 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-protocol/src/test/java/cn/iocoder/yudao/module/iot/protocol/util/IotTopicUtilsTest.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.iot.protocol.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * {@link IotTopicUtils} 单元测试 + * + * @author haohao + */ +class IotTopicUtilsTest { + + @Test + void testBuildPropertySetTopic() { + String topic = IotTopicUtils.buildPropertySetTopic("testProduct", "testDevice"); + assertEquals("/sys/testProduct/testDevice/thing/service/property/set", topic); + } + + @Test + void testBuildPropertyGetTopic() { + String topic = IotTopicUtils.buildPropertyGetTopic("testProduct", "testDevice"); + assertEquals("/sys/testProduct/testDevice/thing/service/property/get", topic); + } + + @Test + void testBuildEventPostTopic() { + String topic = IotTopicUtils.buildEventPostTopic("testProduct", "testDevice", "temperature"); + assertEquals("/sys/testProduct/testDevice/thing/event/temperature/post", topic); + } + + @Test + void testGetReplyTopic() { + String requestTopic = "/sys/testProduct/testDevice/thing/service/property/set"; + String replyTopic = IotTopicUtils.getReplyTopic(requestTopic); + assertEquals("/sys/testProduct/testDevice/thing/service/property/set_reply", replyTopic); + } + + @Test + void testParseProductKeyFromTopic() { + String topic = "/sys/testProduct/testDevice/thing/service/property/set"; + String productKey = IotTopicUtils.parseProductKeyFromTopic(topic); + assertEquals("testProduct", productKey); + } + + @Test + void testParseDeviceNameFromTopic() { + String topic = "/sys/testProduct/testDevice/thing/service/property/set"; + String deviceName = IotTopicUtils.parseDeviceNameFromTopic(topic); + assertEquals("testDevice", deviceName); + } + + @Test + void testParseMethodFromTopic() { + // 测试属性设置 + String topic1 = "/sys/testProduct/testDevice/thing/service/property/set"; + String method1 = IotTopicUtils.parseMethodFromTopic(topic1); + assertEquals("property.set", method1); + + // 测试事件上报 + String topic2 = "/sys/testProduct/testDevice/thing/event/temperature/post"; + String method2 = IotTopicUtils.parseMethodFromTopic(topic2); + assertEquals("event.temperature.post", method2); + + // 测试无效主题 + String method3 = IotTopicUtils.parseMethodFromTopic("/invalid/topic"); + assertNull(method3); + } + + @Test + void testParseInvalidTopic() { + // 测试空主题 + assertNull(IotTopicUtils.parseProductKeyFromTopic("")); + assertNull(IotTopicUtils.parseProductKeyFromTopic(null)); + + // 测试格式错误的主题 + assertNull(IotTopicUtils.parseProductKeyFromTopic("/invalid")); + assertNull(IotTopicUtils.parseDeviceNameFromTopic("/sys/product")); + } +} \ No newline at end of file