From 8ff336369fc6d12445e7e672a062bed9a01b7822 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Wed, 21 Oct 2026 21:36:03 +0800 Subject: [PATCH] feat(iot): 支持 TDengine 与 IoTDB 存储切换 --- yudao-dependencies/pom.xml | 7 +++++++ yudao-module-iot/yudao-module-iot-biz/pom.xml | 4 ++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java | 3 --- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java | 2 +- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelProperty.java | 10 ++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IoTDBSystemMapper.java | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDeviceMessageDAO.java | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/package-info.java | 7 +++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageTDengineDAO.java | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java | 131 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java | 5 +++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtils.java | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/annotation/IoTDBDS.java | 19 +++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/package-info.java | 4 ++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java | 17 +++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageProperties.java | 29 +++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/enums/IotStorageTypeEnum.java | 27 +++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtils.java | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/package-info.java | 4 ++++ yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/config/TDengineTableInitRunner.java | 34 ---------------------------------- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableField.java | 19 +++---------------- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java | 7 ------- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java | 36 +++++++----------------------------- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java | 99 ++++++++++++++++++++++++++++----------------------------------------------------------------------- yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java | 6 +++--- yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml | 6 +++--- yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDeviceMessageIoTDBMapper.xml | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtilsTest.java | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtilsTest.java | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableFieldTest.java | 27 --------------------------- yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java | 122 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------- yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImplTest.java | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql | 27 +++++++++++++++------------ yudao-server/src/main/resources/application-dev.yaml | 18 ++++++++++++++++++ yudao-server/src/main/resources/application-local.yaml | 12 +++++++++++- yudao-server/src/main/resources/application.yaml | 2 ++ 46 files changed, 2477 insertions(+), 307 deletions(-) create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IoTDBSystemMapper.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDeviceMessageDAO.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/package-info.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageTDengineDAO.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtils.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/annotation/IoTDBDS.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/package-info.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageProperties.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/enums/IotStorageTypeEnum.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtils.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/package-info.java delete mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/config/TDengineTableInitRunner.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDeviceMessageIoTDBMapper.xml create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtilsTest.java create mode 100644 yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtilsTest.java delete mode 100644 yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableFieldTest.java diff --git a/yudao-dependencies/pom.xml b/yudao-dependencies/pom.xml index 271417e..8872c9e 100644 --- a/yudao-dependencies/pom.xml +++ b/yudao-dependencies/pom.xml @@ -34,6 +34,7 @@ 9.0.1.jre7 7.0.0-RC3-og 3.9.2 + 2.0.8 2.3.6 @@ -312,6 +313,12 @@ ${taos.version} + + org.apache.iotdb + iotdb-jdbc + ${iotdb.version} + + cn.iocoder.boot diff --git a/yudao-module-iot/yudao-module-iot-biz/pom.xml b/yudao-module-iot/yudao-module-iot-biz/pom.xml index 75cdd32..b671a62 100644 --- a/yudao-module-iot/yudao-module-iot-biz/pom.xml +++ b/yudao-module-iot/yudao-module-iot-biz/pom.xml @@ -50,6 +50,10 @@ com.taosdata.jdbc taos-jdbcdriver + + org.apache.iotdb + iotdb-jdbc + cn.iocoder.boot diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java index 03461d5..61d936a 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceMessageController.java @@ -10,7 +10,6 @@ import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceM import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessageSendReqVO; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; -import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; @@ -41,8 +40,6 @@ public class IotDeviceMessageController { private IotDeviceService deviceService; @Resource private IotThingModelService thingModelService; - @Resource - private IotDeviceMessageMapper deviceMessageMapper; @GetMapping("/page") @Operation(summary = "获得设备消息分页") diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java index 233b2c1..3b24aa9 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java @@ -11,7 +11,7 @@ import lombok.NoArgsConstructor; /** * IoT 设备消息数据 DO * - * 目前使用 TDengine 存储 + * 支持 TDengine、IoTDB 存储,由 yudao.iot.storage.type 选择 * * @author alwayssuper */ diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelProperty.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelProperty.java index 2fe103a..96d3cfd 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelProperty.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/thingmodel/model/ThingModelProperty.java @@ -21,6 +21,16 @@ import java.util.List; public class ThingModelProperty { /** + * STRUCT、ARRAY 等 JSON 属性值的长度上限:按 UTF-8 编码后的字节数计算,不是字符数 + * + * 1. 写入前:JSON 字符串超过该长度时,转换为 null,不写入该属性 + * 2. 各存储后端共用,例如说 TDengine 的 VARCHAR 长度 + * + * 注意:TDengine 一行(所有列)有总长度上限,调大该长度,会减少一个产品可以定义的 STRUCT、ARRAY 属性数量 + */ + public static final int LENGTH_JSON = 1024; + + /** * 属性标识符 */ @NotEmpty(message = "属性标识符不能为空") diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IoTDBSystemMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IoTDBSystemMapper.java new file mode 100644 index 0000000..4c5c55b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IoTDBSystemMapper.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.iocoder.yudao.module.iot.framework.iotdb.core.IoTDBUtils; +import cn.iocoder.yudao.module.iot.framework.iotdb.core.annotation.IoTDBDS; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * IoTDB 系统信息 Mapper:只读取配置,用于启动前提检查 + * + * @author 芋道源码 + */ +@Mapper +@IoTDBDS +@InterceptorIgnore(tenantLine = "true", dataPermission = "true") // 避免 SQL 解析,因为 JSqlParser 对 IoTDB 的 SQL 解析会报错 +public interface IoTDBSystemMapper { + + @Select("SHOW VARIABLES") + List> selectVariableList(); + + @Select("SHOW DATANODES") + List> selectDataNodeList(); + + /** + * 获得指定 DataNode 的全部配置 + * + * @param nodeId DataNode 编号,来自 SHOW DATANODES 的结果,不接收外部输入 + * @return 配置列表 + */ + @Select("SHOW ALL CONFIGURATION ON ${nodeId}") + List> selectAllConfigurationList(@Param("nodeId") Integer nodeId); + + /** + * 校验 IoTDB 的前提条件,不满足时抛出异常。只检查,不修改任何配置: + * 1. 数据库已预建:连接 URL 中的数据库不存在时,获取连接即失败 + * 2. TimestampPrecision 为 ms + * 3. 所有 DataNode 为 Running,且 enable_partial_insert=false + */ + default void verifyPrerequisites() { + IoTDBUtils.validateTimestampPrecision(selectVariableList()); + List nodeIds = IoTDBUtils.validateDataNodes(selectDataNodeList()); + for (Integer nodeId : nodeIds) { + IoTDBUtils.validatePartialInsertDisabled(nodeId, selectAllConfigurationList(nodeId)); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java new file mode 100644 index 0000000..95afc90 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBCodec.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; + +/** + * IoTDB 设备消息的编解码器 + * + * 为什么需要编码?IoTDB 相同 (device_id, time) 再次写入时,NULL 值不会清空旧值,导致“整行替换”的语义不成立, + * 例如说同一设备、同一毫秒的两条消息(id 不同),会混合成一行。因此,所有 FIELD 都编码为非 NULL 值,每次写入都设置全部 FIELD + * (id 为 FIELD,原样存储,不编码): + * 1. Boolean:-1 为 null,0 为 false,1 为 true + * 2. 其它:n: 为 null,v: + 字符串为非 null 值。例如说:null -> "n:","" -> "v:",123 -> "v:123" + * + * 查询条件中的值,也需要使用相同的编码 + * + * @author 芋道源码 + */ +public class IotDeviceMessageIoTDBCodec { + + public static final int BOOLEAN_NULL = -1; + public static final int BOOLEAN_FALSE = 0; + public static final int BOOLEAN_TRUE = 1; + + public static final String PREFIX_NULL = "n:"; + public static final String PREFIX_VALUE = "v:"; + + // ========== 消息 ========== + + public static IotDeviceMessageIoTDBRow encode(IotDeviceMessageDO message) { + Assert.notNull(message.getTs(), "消息的 ts 不能为空"); + Assert.notNull(message.getDeviceId(), "消息的 deviceId 不能为空"); + Assert.notEmpty(message.getId(), "消息的 id 不能为空"); + return new IotDeviceMessageIoTDBRow().setTs(message.getTs()) + .setDeviceId(encodeDeviceId(message.getDeviceId())).setId(message.getId()) + .setUpstream(encodeBoolean(message.getUpstream())).setReply(encodeBoolean(message.getReply())) + .setMethod(encodeString(message.getMethod())).setIdentifier(encodeString(message.getIdentifier())) + .setRequestId(encodeString(message.getRequestId())).setServerId(encodeString(message.getServerId())) + .setParams(encodeString(toJsonString(message.getParams()))) + .setData(encodeString(toJsonString(message.getData()))) + .setMsg(encodeString(message.getMsg())).setReportTime(encodeString(message.getReportTime())) + .setTenantId(encodeString(message.getTenantId())).setCode(encodeString(message.getCode())); + } + + public static IotDeviceMessageDO decode(IotDeviceMessageIoTDBRow row) { + String reportTime = decodeString(row.getReportTime()); + String tenantId = decodeString(row.getTenantId()); + String code = decodeString(row.getCode()); + return new IotDeviceMessageDO().setTs(row.getTs()) + .setDeviceId(row.getDeviceId() != null ? Long.valueOf(row.getDeviceId()) : null).setId(row.getId()) + .setUpstream(decodeBoolean(row.getUpstream())).setReply(decodeBoolean(row.getReply())) + .setMethod(decodeString(row.getMethod())).setIdentifier(decodeString(row.getIdentifier())) + .setRequestId(decodeString(row.getRequestId())).setServerId(decodeString(row.getServerId())) + .setParams(decodeString(row.getParams())).setData(decodeString(row.getData())) + .setMsg(decodeString(row.getMsg())) + .setReportTime(reportTime != null ? Long.valueOf(reportTime) : null) + .setTenantId(tenantId != null ? Long.valueOf(tenantId) : null) + .setCode(code != null ? Integer.valueOf(code) : null); + } + + /** + * 转换 params、data 为字符串:Service 写入前已转为 JSON 字符串,这里兼容其它对象 + */ + private static String toJsonString(Object value) { + if (value == null || value instanceof String) { + return (String) value; + } + return JsonUtils.toJsonString(value); + } + + // ========== 单个值 ========== + + public static String encodeDeviceId(Long deviceId) { + return deviceId != null ? deviceId.toString() : null; + } + + public static int encodeBoolean(Boolean value) { + if (value == null) { + return BOOLEAN_NULL; + } + return value ? BOOLEAN_TRUE : BOOLEAN_FALSE; + } + + /** + * 解码 Boolean + * + * @param value 存储值;为 null 时,说明该行不是本系统写入的,按 null 处理 + * @return Boolean + */ + public static Boolean decodeBoolean(Integer value) { + if (value == null || value == BOOLEAN_NULL) { + return null; + } + if (value == BOOLEAN_TRUE) { + return true; + } + if (value == BOOLEAN_FALSE) { + return false; + } + throw new IllegalArgumentException(StrUtil.format("无法解码 Boolean 存储值({})", value)); + } + + public static String encodeString(Object value) { + if (value == null) { + return PREFIX_NULL; + } + return PREFIX_VALUE + value; + } + + /** + * 解码字符串 + * + * @param value 存储值;为 null 时,说明该行不是本系统写入的,按 null 处理 + * @return 字符串 + */ + public static String decodeString(String value) { + if (value == null || PREFIX_NULL.equals(value)) { + return null; + } + if (value.startsWith(PREFIX_VALUE)) { + return value.substring(PREFIX_VALUE.length()); + } + throw new IllegalArgumentException(StrUtil.format("无法解码字符串存储值({})", value)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java new file mode 100644 index 0000000..1af22d9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBDAO.java @@ -0,0 +1,208 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.PageUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; +import cn.iocoder.yudao.module.iot.framework.iotdb.core.IoTDBUtils; +import cn.iocoder.yudao.module.iot.framework.storage.config.IotStorageProperties; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.iot.dal.iotdb.IotDeviceMessageIoTDBCodec.*; + +/** + * 基于 IoTDB(表模型)的 {@link IotDeviceMessageDAO} 实现类 + * + * 一张全局表 device_message,身份为 (device_id, time),编解码见 {@link IotDeviceMessageIoTDBCodec} + * + * @author 芋道源码 + */ +@Repository +@ConditionalOnProperty(prefix = IotStorageProperties.PREFIX, name = "type", havingValue = "iotdb") +@Slf4j +public class IotDeviceMessageIoTDBDAO implements IotDeviceMessageDAO { + + private static final String CATEGORY_TAG = "TAG"; + private static final String CATEGORY_FIELD = "FIELD"; + + /** + * 唯一的 TAG 列 + */ + private static final String COLUMN_DEVICE_ID = "device_id"; + /** + * 消息表的列:列名 -> 数据类型。除 {@link #COLUMN_DEVICE_ID} 为 TAG 外,其它都是 FIELD + */ + private static final Map COLUMN_TYPES = MapUtil.builder(new LinkedHashMap()) + .put(COLUMN_DEVICE_ID, "STRING").put("id", "STRING") + .put("upstream", "INT32").put("reply", "INT32") + .put("method", "STRING").put("identifier", "STRING").put("request_id", "STRING").put("server_id", "STRING") + .put("params", "STRING").put("data", "STRING").put("msg", "STRING") + .put("report_time", "STRING").put("tenant_id", "STRING").put("code", "STRING") + .build(); + + @Resource + private IotDeviceMessageIoTDBMapper deviceMessageMapper; + @Resource + private IoTDBSystemMapper systemMapper; + + @Override + public void initialize() { + // 1. 校验前提条件:只读取配置,不修改 + systemMapper.verifyPrerequisites(); + // 2. 获得消息表的列:表不存在时,创建后再读取 + List> columns = getColumnList(); + if (columns == null) { + deviceMessageMapper.createTable(); + log.info("[initialize][设备消息表不存在,创建成功]"); + columns = deviceMessageMapper.selectColumnList(); + } + // 3. 校验表结构:不兼容时,需要人工处理,不会自动修改、删除 + validateColumns(columns); + log.info("[initialize][设备消息表已就绪]"); + } + + /** + * 获得消息表的列 + * + * @return 列列表;表不存在时,返回 null + */ + private List> getColumnList() { + try { + return deviceMessageMapper.selectColumnList(); + } catch (Exception exception) { + // 只有表不存在(550)返回 null;权限、连接等其它错误原样抛出 + if (IoTDBUtils.isTableNotExists(exception)) { + return null; + } + throw exception; + } + } + + /** + * 校验消息表的结构:device_id 为唯一的 TAG,其它列都是类型一致的 FIELD + * + * 例如说,旧结构的 id 为 TAG,身份不同,且每条消息会产生一个 IoTDB 设备,所以不兼容 + * + * @param columns DESC 的结果,包括 ColumnName、DataType、Category + */ + private static void validateColumns(List> columns) { + // 1. 解析 DB 里的列:列名 -> 类别、类型 + Map categories = new HashMap<>(); + Map types = new HashMap<>(); + for (Map column : columns) { + String name = StrUtil.toStringOrNull(IoTDBUtils.getIgnoreCase(column, "ColumnName")); + categories.put(name, StrUtil.toStringOrNull(IoTDBUtils.getIgnoreCase(column, "Category"))); + types.put(name, StrUtil.toStringOrNull(IoTDBUtils.getIgnoreCase(column, "DataType"))); + } + + // 2.1 TAG 只能是 device_id:多出的 TAG 会改变身份 + List errors = new ArrayList<>(); + Set tags = new TreeSet<>(); + categories.forEach((name, category) -> { + if (StrUtil.equalsIgnoreCase(category, CATEGORY_TAG)) { + tags.add(name); + } + }); + if (!tags.equals(Collections.singleton(COLUMN_DEVICE_ID))) { + errors.add(StrUtil.format("TAG 只能为 {},当前为 {}", COLUMN_DEVICE_ID, tags)); + } + // 2.2 各列的类别、类型 + COLUMN_TYPES.forEach((name, type) -> { + if (!categories.containsKey(name)) { + errors.add(StrUtil.format("缺少列 {}", name)); + return; + } + String category = COLUMN_DEVICE_ID.equals(name) ? CATEGORY_TAG : CATEGORY_FIELD; + if (!StrUtil.equalsIgnoreCase(categories.get(name), category) + || !StrUtil.equalsIgnoreCase(types.get(name), type)) { + errors.add(StrUtil.format("列 {} 应为 {} {},当前为 {} {}", + name, type, category, types.get(name), categories.get(name))); + } + }); + if (errors.isEmpty()) { + return; + } + throw new IllegalStateException(StrUtil.format("[IoTDB 设备消息表] device_message 的结构与当前版本不兼容:{}。" + + "请人工处理(例如说,导出需要保留的数据后删除该表,或者更换数据库),应用不会自动修改、删除表", + String.join(";", errors))); + } + + @Override + public void insert(IotDeviceMessageDO message) { + deviceMessageMapper.insert(encode(message)); + } + + @Override + public PageResult selectPage(IotDeviceMessagePageReqVO reqVO) { + // 1. 编码查询条件:空字符串与 TDengine 保持一致,视为不过滤 + String deviceId = encodeDeviceId(reqVO.getDeviceId()); + String method = StrUtil.isNotEmpty(reqVO.getMethod()) ? encodeString(reqVO.getMethod()) : null; + Integer upstream = reqVO.getUpstream() != null ? encodeBoolean(reqVO.getUpstream()) : null; + Integer reply = reqVO.getReply() != null ? encodeBoolean(reqVO.getReply()) : null; + String identifier = StrUtil.isNotEmpty(reqVO.getIdentifier()) ? encodeString(reqVO.getIdentifier()) : null; + try { + // 2.1 查询总数 + Long total = deviceMessageMapper.selectCount(deviceId, method, upstream, reply, identifier); + if (total == null || total == 0) { + return PageResult.empty(); + } + // 2.2 查询列表:pageSize 为 -1 时,不分页 + boolean pageable = ObjUtil.notEqual(reqVO.getPageSize(), PageParam.PAGE_SIZE_NONE); + List rows = deviceMessageMapper.selectList(deviceId, method, upstream, reply, identifier, + pageable ? PageUtils.getStart(reqVO) : null, pageable ? reqVO.getPageSize() : null); + return new PageResult<>(convertList(rows, IotDeviceMessageIoTDBCodec::decode), total); + } catch (Exception exception) { + if (IoTDBUtils.isTableNotExists(exception)) { + return PageResult.empty(); + } + throw exception; + } + } + + @Override + public List selectListByRequestIdsAndReply(Long deviceId, Collection requestIds, Boolean reply) { + // reply 为空时,与 TDengine 的 reply = NULL 一致,匹配不到任何消息 + if (reply == null) { + return Collections.emptyList(); + } + // requestId 为空的消息,存储值为 n:,不应被 null 匹配到,所以过滤掉 + List encodedRequestIds = new ArrayList<>(); + if (requestIds != null) { + requestIds.stream().filter(Objects::nonNull).distinct() + .forEach(requestId -> encodedRequestIds.add(encodeString(requestId))); + } + if (CollUtil.isEmpty(encodedRequestIds)) { + return Collections.emptyList(); + } + List rows = deviceMessageMapper.selectListByRequestIdsAndReply( + encodeDeviceId(deviceId), encodedRequestIds, encodeBoolean(reply)); + return convertList(rows, IotDeviceMessageIoTDBCodec::decode); + } + + @Override + public Long selectCountByCreateTime(Long createTime) { + return deviceMessageMapper.selectCountByCreateTime(createTime); + } + + @Override + public List> selectDeviceMessageCountGroupByDate(Long startTime, Long endTime) { + List> counts = deviceMessageMapper.selectCountGroupByHour(startTime, endTime); + return convertList(counts, count -> IotDeviceMessageDAO.buildMessageCount( + IoTDBUtils.getIgnoreCase(count, COUNT_TIME), + IoTDBUtils.getIgnoreCase(count, COUNT_UPSTREAM), + IoTDBUtils.getIgnoreCase(count, COUNT_DOWNSTREAM))); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java new file mode 100644 index 0000000..f64156c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBMapper.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.iocoder.yudao.module.iot.framework.iotdb.core.annotation.IoTDBDS; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * IoTDB 设备消息 Mapper + * + * 注意:参数均为 {@link IotDeviceMessageIoTDBCodec} 编码后的存储值;分页通过手写 LIMIT/OFFSET 实现,不依赖 MyBatis Plus 分页插件 + * + * @author 芋道源码 + */ +@Mapper +@IoTDBDS +@InterceptorIgnore(tenantLine = "true", dataPermission = "true") // 避免 SQL 解析,因为 JSqlParser 对 IoTDB 的 SQL 解析会报错;租户由 Service 层控制 +public interface IotDeviceMessageIoTDBMapper { + + /** + * 获得消息表的列 + * + * @return 列列表,包括 ColumnName、DataType、Category 等 + */ + List> selectColumnList(); + + void createTable(); + + void insert(IotDeviceMessageIoTDBRow row); + + List selectList(@Param("deviceId") String deviceId, + @Param("method") String method, + @Param("upstream") Integer upstream, + @Param("reply") Integer reply, + @Param("identifier") String identifier, + @Param("offset") Integer offset, + @Param("limit") Integer limit); + + Long selectCount(@Param("deviceId") String deviceId, + @Param("method") String method, + @Param("upstream") Integer upstream, + @Param("reply") Integer reply, + @Param("identifier") String identifier); + + List selectListByRequestIdsAndReply(@Param("deviceId") String deviceId, + @Param("requestIds") Collection requestIds, + @Param("reply") Integer reply); + + Long selectCountByCreateTime(@Param("createTime") Long createTime); + + List> selectCountGroupByHour(@Param("startTime") Long startTime, + @Param("endTime") Long endTime); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java new file mode 100644 index 0000000..1cc9eb6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDeviceMessageIoTDBRow.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * IoTDB 设备消息表 device_message 的一行,字段为编码后的存储值 + * + * 身份为 (deviceId, ts);编解码见 {@link IotDeviceMessageIoTDBCodec} + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class IotDeviceMessageIoTDBRow { + + /** + * 存储时间(毫秒),对应隐式 time 列 + */ + private Long ts; + /** + * 设备编号,唯一的 TAG + */ + private String deviceId; + /** + * 消息编号,FIELD,原样存储 + */ + private String id; + + /** + * 是否上行:-1 为空,0 为否,1 为是 + */ + private Integer upstream; + /** + * 是否回复:-1 为空,0 为否,1 为是 + */ + private Integer reply; + + // ========== 以下字段,统一使用 n:/v: 编码 ========== + + private String method; + private String identifier; + private String requestId; + private String serverId; + private String params; + private String data; + private String msg; + private String reportTime; + private String tenantId; + private String code; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java new file mode 100644 index 0000000..403237a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBDAO.java @@ -0,0 +1,220 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.dal.storage.IotDevicePropertyDAO; +import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; +import cn.iocoder.yudao.module.iot.framework.iotdb.core.IoTDBUtils; +import cn.iocoder.yudao.module.iot.framework.storage.config.IotStorageProperties; +import cn.iocoder.yudao.module.iot.framework.storage.core.util.IotStorageFieldUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 基于 IoTDB(表模型)的 {@link IotDevicePropertyDAO} 实现类 + * + * 每个产品一张表 product_property_{productId},设备共用产品表,通过 device_id TAG 区分。 + * 用户属性列统一为 p_ + 中立字段名,避免与 time、device_id、report_time 等内置列冲突。 + * + * 与 TDengine 的差异: + * 1. STRING 没有长度,物模型 TEXT 的长度变化不会改表;长度由写入前的业务转换校验 + * 2. INT、ENUM、BOOL 都映射为 INT32,三者之间的逻辑类型变化不会改表,历史数据保留 + * 3. 物理类型变化通过 DROP + ADD 完成,旧列的历史数据不会保留 + * 4. IoTDB 的 DDL 不参与事务,也无法回滚;执行中途失败时,再次定义即可收敛(ADD/DROP 均为幂等语句) + * + * @author 芋道源码 + */ +@Repository +@ConditionalOnProperty(prefix = IotStorageProperties.PREFIX, name = "type", havingValue = "iotdb") +@Slf4j +public class IotDevicePropertyIoTDBDAO implements IotDevicePropertyDAO { + + /** + * 用户属性列的前缀 + */ + public static final String COLUMN_PREFIX = "p_"; + + public static final String TYPE_INT32 = "INT32"; + public static final String TYPE_FLOAT = "FLOAT"; + public static final String TYPE_DOUBLE = "DOUBLE"; + public static final String TYPE_STRING = "STRING"; + public static final String TYPE_TIMESTAMP = "TIMESTAMP"; + + /** + * DESC 结果中,FIELD 列的类别 + */ + private static final String CATEGORY_FIELD = "FIELD"; + + /** + * 物模型的数据类型,与 IoTDB 数据类型的映射关系 + * + * @see IoTDB 数据类型 + */ + private static final Map TYPE_MAPPING = MapUtil.builder() + .put(IotDataSpecsDataTypeEnum.INT.getDataType(), TYPE_INT32) + .put(IotDataSpecsDataTypeEnum.FLOAT.getDataType(), TYPE_FLOAT) + .put(IotDataSpecsDataTypeEnum.DOUBLE.getDataType(), TYPE_DOUBLE) + .put(IotDataSpecsDataTypeEnum.ENUM.getDataType(), TYPE_INT32) + .put(IotDataSpecsDataTypeEnum.BOOL.getDataType(), TYPE_INT32) + .put(IotDataSpecsDataTypeEnum.TEXT.getDataType(), TYPE_STRING) + .put(IotDataSpecsDataTypeEnum.DATE.getDataType(), TYPE_TIMESTAMP) + .put(IotDataSpecsDataTypeEnum.STRUCT.getDataType(), TYPE_STRING) + .put(IotDataSpecsDataTypeEnum.ARRAY.getDataType(), TYPE_STRING) + .build(); + + @Resource + private IotDevicePropertyIoTDBMapper devicePropertyMapper; + @Resource + private IoTDBSystemMapper systemMapper; + + @Override + public void initialize() { + // 产品属性表在产品发布、手动同步时创建,启动时只校验前提条件 + systemMapper.verifyPrerequisites(); + } + + @Override + public void defineProductPropertyTable(Long productId, List propertyThingModels) { + // 1.1 校验属性标识符,可以作为列名 + IotStorageFieldUtils.validatePropertyIdentifiers(convertList(propertyThingModels, IotThingModelDO::getIdentifier)); + List newColumns = buildColumnList(propertyThingModels); + // 1.2 解析 DB 里的用户属性列 + Map oldColumns = getUserColumnTypes(productId); + + // 2.1 情况一:如果是新增的时候,需要创建表 + if (oldColumns == null) { + if (CollUtil.isEmpty(newColumns)) { + log.info("[defineProductPropertyTable][productId({}) 没有需要定义的属性]", productId); + return; + } + devicePropertyMapper.createTable(productId, newColumns); + return; + } + // 2.2 情况二:如果是修改的时候,需要更新表 + Set newColumnNames = new HashSet<>(convertList(newColumns, IotDevicePropertyIoTDBMapper.Column::getName)); + // 2.2.1 删除不再需要的列 + oldColumns.keySet().stream().filter(name -> !newColumnNames.contains(name)) + .forEach(name -> devicePropertyMapper.dropColumn(productId, name)); + for (IotDevicePropertyIoTDBMapper.Column column : newColumns) { + String oldType = oldColumns.get(column.getName()); + // 2.2.2 新增的列 + if (oldType == null) { + devicePropertyMapper.addColumn(productId, column); + continue; + } + // 2.2.3 物理类型变化的列:IoTDB 不支持修改列类型,只能 DROP + ADD,旧列的数据不会保留 + if (!StrUtil.equalsIgnoreCase(oldType, column.getType())) { + log.warn("[defineProductPropertyTable][productId({}) 列({}) 类型从 {} 变为 {},删除后重建,历史数据不保留]", + productId, column.getName(), oldType, column.getType()); + devicePropertyMapper.dropColumn(productId, column.getName()); + devicePropertyMapper.addColumn(productId, column); + } + } + } + + /** + * 获得产品属性表的用户属性列 + * + * @param productId 产品编号 + * @return 列名与类型的映射;表不存在时,返回 null + */ + private Map getUserColumnTypes(Long productId) { + List> columns; + try { + columns = devicePropertyMapper.selectColumnList(productId); + } catch (Exception exception) { + if (IoTDBUtils.isTableNotExists(exception)) { + return null; + } + throw exception; + } + // 只比较 p_ 开头的 FIELD 列:time、device_id、report_time 由系统维护,不参与差异 + Map userColumns = new LinkedHashMap<>(); + for (Map column : columns) { + String name = StrUtil.toStringOrNull(IoTDBUtils.getIgnoreCase(column, "ColumnName")); + String category = StrUtil.toStringOrNull(IoTDBUtils.getIgnoreCase(column, "Category")); + if (StrUtil.startWith(name, COLUMN_PREFIX) && StrUtil.equalsIgnoreCase(category, CATEGORY_FIELD)) { + userColumns.put(name, StrUtil.toStringOrNull(IoTDBUtils.getIgnoreCase(column, "DataType"))); + } + } + return userColumns; + } + + private List buildColumnList(List thingModels) { + return convertList(thingModels, thingModel -> { + String type = TYPE_MAPPING.get(thingModel.getProperty().getDataType()); + if (type == null) { + throw new IllegalArgumentException(StrUtil.format("属性({}) 的数据类型({}) 不支持存储", + thingModel.getIdentifier(), thingModel.getProperty().getDataType())); + } + return new IotDevicePropertyIoTDBMapper.Column(buildColumnName(thingModel.getIdentifier()), type, null); + }); + } + + @Override + public void insert(IotDeviceDO device, Map properties, Long ts, Long reportTime) { + // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 + IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet()); + if (CollUtil.isEmpty(properties)) { + return; + } + List columns = new ArrayList<>(properties.size()); + properties.forEach((identifier, value) -> columns.add( + new IotDevicePropertyIoTDBMapper.Column(buildColumnName(identifier), null, convertValue(value)))); + devicePropertyMapper.insert(device.getProductId(), String.valueOf(device.getId()), ts, reportTime, columns); + } + + /** + * 转换属性值为 IoTDB 驱动支持的类型 + * + * 原因:ENUM、BOOL 业务转换后为 Byte(对应 TDengine 的 TINYINT),而 IoTDB 驱动不支持 setByte,需要转为 INT32 对应的 Integer + */ + private static Object convertValue(Object value) { + if (value instanceof Byte) { + return ((Byte) value).intValue(); + } + return value; + } + + @Override + public List selectHistoryList(Long productId, IotDevicePropertyHistoryListReqVO reqVO) { + try { + return devicePropertyMapper.selectHistoryList(productId, buildColumnName(reqVO.getIdentifier()), + String.valueOf(reqVO.getDeviceId()), + LocalDateTimeUtil.toEpochMilli(reqVO.getTimes()[0]), + LocalDateTimeUtil.toEpochMilli(reqVO.getTimes()[1])); + } catch (Exception exception) { + // 只有表不存在(产品尚未定义属性)返回空;列不存在等其它错误原样抛出 + if (IoTDBUtils.isTableNotExists(exception)) { + return Collections.emptyList(); + } + throw exception; + } + } + + /** + * 构建属性列名:p_ + 中立字段名 + * + * @param identifier 属性标识符,调用前需已通过 {@link IotStorageFieldUtils#validatePropertyIdentifiers(Collection)} 校验 + * @return 列名 + */ + public static String buildColumnName(String identifier) { + if (!IotStorageFieldUtils.isPropertyFieldName(identifier)) { + throw new IllegalArgumentException(StrUtil.format("属性标识符({}) 不能作为存储列", identifier)); + } + return COLUMN_PREFIX + IotStorageFieldUtils.buildFieldName(identifier); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java new file mode 100644 index 0000000..bbbb44b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/iotdb/IotDevicePropertyIoTDBMapper.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.iot.dal.iotdb; + +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; +import cn.iocoder.yudao.module.iot.framework.iotdb.core.annotation.IoTDBDS; +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * IoTDB 设备属性 Mapper + * + * 注意:表名、列名需要动态拼接,只允许传入 Long 产品编号、DAO 归一化并校验后的列名、白名单内的类型;值统一使用 #{} 绑定 + * + * @author 芋道源码 + */ +@Mapper +@IoTDBDS +@InterceptorIgnore(tenantLine = "true", dataPermission = "true") // 避免 SQL 解析,因为 JSqlParser 对 IoTDB 的 SQL 解析会报错;租户由 Service 层控制 +public interface IotDevicePropertyIoTDBMapper { + + /** + * 获得产品属性表的列 + * + * @param productId 产品编号 + * @return 列列表,包括 ColumnName、DataType、Category 等 + */ + List> selectColumnList(@Param("productId") Long productId); + + void createTable(@Param("productId") Long productId, + @Param("columns") List columns); + + void addColumn(@Param("productId") Long productId, + @Param("column") Column column); + + void dropColumn(@Param("productId") Long productId, + @Param("columnName") String columnName); + + void insert(@Param("productId") Long productId, + @Param("deviceId") String deviceId, + @Param("ts") Long ts, + @Param("reportTime") Long reportTime, + @Param("columns") List columns); + + List selectHistoryList(@Param("productId") Long productId, + @Param("columnName") String columnName, + @Param("deviceId") String deviceId, + @Param("startTime") Long startTime, + @Param("endTime") Long endTime); + + /** + * 属性列 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + class Column { + + /** + * 列名,格式为 p_ + 中立字段名 + */ + private String name; + /** + * 数据类型,例如说 INT32、STRING;写入时为空 + */ + private String type; + /** + * 值;DDL 时为空 + */ + private Object value; + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDeviceMessageDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDeviceMessageDAO.java new file mode 100644 index 0000000..084016d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDeviceMessageDAO.java @@ -0,0 +1,121 @@ +package cn.iocoder.yudao.module.iot.dal.storage; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; + +import javax.annotation.Nullable; +import java.sql.Timestamp; +import java.util.*; + +/** + * IoT 设备【消息】日志的存储 DAO 接口 + * + * 由 yudao.iot.storage.type 选择 TDengine、IoTDB 其中一个实现。各实现的消息身份不同: + * 1. TDengine:(deviceId, ts, id),按 ts DESC, id DESC 排序。同一设备、同一毫秒、不同 id 的消息都会保留 + * 2. IoTDB:(deviceId, ts),id 为普通字段,按 ts DESC 排序。同一设备、同一毫秒再次写入时,后写的整行替换先写的,即使 id 不同。 + * 原因:id 作为 TAG 时,每个设备下每出现一个新的消息 id,就产生一个 IoTDB 元数据设备,元数据随消息量无限增长 + * + * 重复写入时: + * 1. 相同 (deviceId, ts, id):两者都只保留一行。IoTDB 由编码保证整行替换(null 字段也会清空旧值);TDengine 沿用原有写入语义 + * 2. 重投、重发等重新获取 ts 的写入,按各自的物理键判断:TDengine 为 (deviceId, ts, id),与已有行都不同时新增,否则覆盖该行; + * IoTDB 为 (deviceId, ts),该设备该毫秒没有行时新增,已有行时覆盖该行(可能是另一条消息) + * + * @author 芋道源码 + */ +public interface IotDeviceMessageDAO { + + /** + * 按小时统计结果的 key:整点时间,类型为 {@link Timestamp} + */ + String COUNT_TIME = "time"; + /** + * 按小时统计结果的 key:上行消息数,类型为 {@link Long} + */ + String COUNT_UPSTREAM = "upstream_count"; + /** + * 按小时统计结果的 key:下行消息数,类型为 {@link Long} + */ + String COUNT_DOWNSTREAM = "downstream_count"; + + /** + * 初始化:系统启动时,校验存储后端的前提条件,并创建消息表 + */ + void initialize(); + + /** + * 写入设备消息 + * + * @param message 设备消息 + */ + void insert(IotDeviceMessageDO message); + + /** + * 获得设备消息分页 + * + * 注意:与现有行为保持一致,不按 reqVO.times 过滤 + * + * @param reqVO 分页查询条件 + * @return 设备消息分页;消息表不存在时,返回空分页 + */ + PageResult selectPage(IotDeviceMessagePageReqVO reqVO); + + /** + * 按照 requestIds 批量查询消息 + * + * 各存储后端保持一致:requestIds 中的 null 忽略、重复去重;忽略后为空,或 reply 为 null 时,返回空列表 + * + * @param deviceId 设备编号 + * @param requestIds 请求编号集合 + * @param reply 是否回复消息 + * @return 消息列表 + */ + List selectListByRequestIdsAndReply(Long deviceId, Collection requestIds, Boolean reply); + + /** + * 统计设备消息数量 + * + * @param createTime 创建时间(毫秒),如果为空,则统计所有消息数量 + * @return 消息数量 + */ + Long selectCountByCreateTime(@Nullable Long createTime); + + /** + * 按照时间范围(小时),统计设备的消息数量 + * + * @param startTime 开始时间(毫秒),包含 + * @param endTime 结束时间(毫秒),包含 + * @return 每个整点的统计,key 见 {@link #COUNT_TIME}、{@link #COUNT_UPSTREAM}、{@link #COUNT_DOWNSTREAM} + */ + List> selectDeviceMessageCountGroupByDate(Long startTime, Long endTime); + + /** + * 构建按小时统计的一行结果,统一各存储后端的 key 与 Java 类型 + * + * @param time 整点时间,支持 Timestamp、毫秒数值 + * @param upstreamCount 上行消息数,支持任意 Number + * @param downstreamCount 下行消息数,支持任意 Number + * @return 统计结果 + */ + @SuppressWarnings("PatternVariableCanBeUsed") // 保持 JDK8 兼容性 + static Map buildMessageCount(Object time, Object upstreamCount, Object downstreamCount) { + // 1. 整点时间 + Timestamp timestamp; + if (time instanceof Timestamp) { + timestamp = (Timestamp) time; + } else if (time instanceof Date) { + timestamp = new Timestamp(((Date) time).getTime()); + } else if (time instanceof Number) { + timestamp = new Timestamp(((Number) time).longValue()); + } else { + throw new IllegalArgumentException("无法转换为整点时间:" + time); + } + // 2. 上下行数量:SUM、COUNT 等聚合结果的 Number 子类因库而异,统一转为 Long + Map count = new HashMap<>(); + count.put(COUNT_TIME, timestamp); + count.put(COUNT_UPSTREAM, upstreamCount != null ? ((Number) upstreamCount).longValue() : 0L); + count.put(COUNT_DOWNSTREAM, downstreamCount != null ? ((Number) downstreamCount).longValue() : 0L); + return count; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java new file mode 100644 index 0000000..2f72214 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/IotDevicePropertyDAO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.iot.dal.storage; + +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; + +import java.util.List; +import java.util.Map; + +// TODO DONE @AI:storage 包下,是不是加个 package-info.java,说明这个包的作用?已新增 package-info.java +/** + * IoT 设备【属性】历史数据的存储 DAO 接口 + * + * 由 yudao.iot.storage.type 选择 TDengine、IoTDB 其中一个实现。 + * 厂商相关的字段映射、DDL、字段差异、缺表判断等,都在实现类中处理。 + * + * @author 芋道源码 + */ +public interface IotDevicePropertyDAO { + + /** + * 初始化:系统启动时,校验存储后端的前提条件 + */ + void initialize(); + + /** + * 定义(创建或修改)产品的属性表结构 + * + * @param productId 产品编号 + * @param propertyThingModels 产品的属性类物模型列表 + */ + void defineProductPropertyTable(Long productId, List propertyThingModels); + + /** + * 写入设备属性。只记录本次上报的属性 + * + * @param device 设备 + * @param properties 属性,key 为物模型 identifier,value 为 Service 转换后的值 + * @param ts 存储时间(毫秒) + * @param reportTime 上报时间(毫秒) + */ + void insert(IotDeviceDO device, Map properties, Long ts, Long reportTime); + + /** + * 查询设备属性的历史数据,按存储时间倒序 + * + * 调用前,Service 已经校验:设备存在(包括逻辑删除)、属性标识符合法、时间范围完整 + * + * @param productId 产品编号 + * @param reqVO 查询条件 + * @return 历史数据;属性表不存在时,返回空列表 + */ + List selectHistoryList(Long productId, IotDevicePropertyHistoryListReqVO reqVO); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/package-info.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/package-info.java new file mode 100644 index 0000000..d962086 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/storage/package-info.java @@ -0,0 +1,7 @@ +/** + * iot 模块的时序数据存储 DAO 接口:设备属性、设备消息的统一契约,与具体的时序库无关 + * + * 1. Service 只依赖本包的 DAO 接口 + * 2. 实现类分别在 dal/tdengine、dal/iotdb 包下,由 yudao.iot.storage.type 统一选择其中一个 + */ +package cn.iocoder.yudao.module.iot.dal.storage; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageTDengineDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageTDengineDAO.java new file mode 100644 index 0000000..b6d02b0 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDeviceMessageTDengineDAO.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.iot.dal.tdengine; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; +import cn.iocoder.yudao.module.iot.framework.storage.config.IotStorageProperties; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 基于 TDengine 的 {@link IotDeviceMessageDAO} 实现类 + * + * 一张超级表 device_message,每个设备一张子表 device_message_{deviceId} + * + * @author 芋道源码 + */ +@Repository +@ConditionalOnProperty(prefix = IotStorageProperties.PREFIX, name = "type", havingValue = "tdengine", matchIfMissing = true) +@Slf4j +public class IotDeviceMessageTDengineDAO implements IotDeviceMessageDAO { + + @Resource + private IotDeviceMessageMapper deviceMessageMapper; + + @Override + public void initialize() { + if (StrUtil.isNotEmpty(deviceMessageMapper.showSTable())) { + log.info("[initialize][设备消息超级表已存在,创建跳过]"); + return; + } + log.info("[initialize][设备消息超级表不存在,创建开始...]"); + deviceMessageMapper.createSTable(); + log.info("[initialize][设备消息超级表不存在,创建成功]"); + } + + @Override + public void insert(IotDeviceMessageDO message) { + deviceMessageMapper.insert(message); + } + + @Override + public PageResult selectPage(IotDeviceMessagePageReqVO reqVO) { + try { + IPage page = deviceMessageMapper.selectPage( + new Page<>(reqVO.getPageNo(), reqVO.getPageSize()), reqVO); + return new PageResult<>(fillDeviceId(page.getRecords(), reqVO.getDeviceId()), page.getTotal()); + } catch (Exception exception) { + if (StrUtil.contains(exception.getMessage(), "Table does not exist")) { + return PageResult.empty(); + } + throw exception; + } + } + + @Override + public List selectListByRequestIdsAndReply(Long deviceId, Collection requestIds, Boolean reply) { + // reply 为空时,reply = NULL 匹配不到任何消息,无需查询 + if (reply == null || requestIds == null) { + return Collections.emptyList(); + } + // 与 IoTDB 保持一致:忽略 null、去重,避免 IN 列表中绑定 NULL + List validRequestIds = requestIds.stream().filter(Objects::nonNull).distinct() + .collect(Collectors.toList()); + if (CollUtil.isEmpty(validRequestIds)) { + return Collections.emptyList(); + } + return fillDeviceId(deviceMessageMapper.selectListByRequestIdsAndReply(deviceId, validRequestIds, reply), deviceId); + } + + /** + * 补全消息的设备编号 + * + * 原因:查询的是设备子表 device_message_{deviceId},SELECT 未包含 device_id 标签,读出的 deviceId 为空 + * + * @param messages 消息列表 + * @param deviceId 设备编号 + * @return 消息列表 + */ + private static List fillDeviceId(List messages, Long deviceId) { + if (messages != null) { + messages.forEach(message -> message.setDeviceId(deviceId)); + } + return messages; + } + + @Override + public Long selectCountByCreateTime(Long createTime) { + return deviceMessageMapper.selectCountByCreateTime(createTime); + } + + @Override + public List> selectDeviceMessageCountGroupByDate(Long startTime, Long endTime) { + List> counts = deviceMessageMapper.selectDeviceMessageCountGroupByDate(startTime, endTime); + return convertList(counts, count -> IotDeviceMessageDAO.buildMessageCount( + count.get(COUNT_TIME), count.get(COUNT_UPSTREAM), count.get(COUNT_DOWNSTREAM))); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java new file mode 100644 index 0000000..4b017f5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/tdengine/IotDevicePropertyTDengineDAO.java @@ -0,0 +1,131 @@ +package cn.iocoder.yudao.module.iot.dal.tdengine; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDateOrTextDataSpecs; +import cn.iocoder.yudao.module.iot.dal.storage.IotDevicePropertyDAO; +import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; +import cn.iocoder.yudao.module.iot.framework.storage.config.IotStorageProperties; +import cn.iocoder.yudao.module.iot.framework.storage.core.util.IotStorageFieldUtils; +import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 基于 TDengine 的 {@link IotDevicePropertyDAO} 实现类 + * + * 每个产品一张超级表 product_property_{productId},每个设备一张子表 device_property_{deviceId} + * + * @author 芋道源码 + */ +@Repository +@ConditionalOnProperty(prefix = IotStorageProperties.PREFIX, name = "type", havingValue = "tdengine", matchIfMissing = true) +@Slf4j +public class IotDevicePropertyTDengineDAO implements IotDevicePropertyDAO { + + /** + * 物模型的数据类型,与 TDengine 数据类型的映射关系 + * + * @see TDEngine 数据类型 + */ + private static final Map TYPE_MAPPING = MapUtil.builder() + .put(IotDataSpecsDataTypeEnum.INT.getDataType(), TDengineTableField.TYPE_INT) + .put(IotDataSpecsDataTypeEnum.FLOAT.getDataType(), TDengineTableField.TYPE_FLOAT) + .put(IotDataSpecsDataTypeEnum.DOUBLE.getDataType(), TDengineTableField.TYPE_DOUBLE) + .put(IotDataSpecsDataTypeEnum.ENUM.getDataType(), TDengineTableField.TYPE_TINYINT) + .put(IotDataSpecsDataTypeEnum.BOOL.getDataType(), TDengineTableField.TYPE_TINYINT) + .put(IotDataSpecsDataTypeEnum.TEXT.getDataType(), TDengineTableField.TYPE_VARCHAR) + .put(IotDataSpecsDataTypeEnum.DATE.getDataType(), TDengineTableField.TYPE_TIMESTAMP) + .put(IotDataSpecsDataTypeEnum.STRUCT.getDataType(), TDengineTableField.TYPE_VARCHAR) + .put(IotDataSpecsDataTypeEnum.ARRAY.getDataType(), TDengineTableField.TYPE_VARCHAR) + .build(); + + @Resource + private IotDevicePropertyMapper devicePropertyMapper; + + @Override + public void initialize() { + // 产品属性表在产品发布、手动同步时创建,启动时无需初始化 + } + + @Override + public void defineProductPropertyTable(Long productId, List propertyThingModels) { + // 1.1 校验属性标识符,可以作为字段名 + IotStorageFieldUtils.validatePropertyIdentifiers(convertList(propertyThingModels, IotThingModelDO::getIdentifier)); + // 1.2 解析 DB 里的字段 + List oldFields = new ArrayList<>(); + try { + oldFields.addAll(devicePropertyMapper.getProductPropertySTableFieldList(productId)); + } catch (Exception e) { + if (!isTableNotExists(e)) { + throw e; + } + } + + // 2.1 情况一:如果是新增的时候,需要创建表 + List newFields = buildTableFieldList(propertyThingModels); + if (CollUtil.isEmpty(oldFields)) { + if (CollUtil.isEmpty(newFields)) { + log.info("[defineProductPropertyTable][productId({}) 没有需要定义的属性]", productId); + return; + } + devicePropertyMapper.createProductPropertySTable(productId, newFields); + return; + } + // 2.2 情况二:如果是修改的时候,需要更新表 + devicePropertyMapper.alterProductPropertySTable(productId, oldFields, newFields); + } + + private List buildTableFieldList(List thingModels) { + return convertList(thingModels, thingModel -> { + TDengineTableField field = new TDengineTableField( + IotStorageFieldUtils.buildFieldName(thingModel.getIdentifier()), + TYPE_MAPPING.get(thingModel.getProperty().getDataType())); + String dataType = thingModel.getProperty().getDataType(); + if (Objects.equals(dataType, IotDataSpecsDataTypeEnum.TEXT.getDataType())) { + field.setLength(((ThingModelDateOrTextDataSpecs) thingModel.getProperty().getDataSpecs()).getLength()); + } else if (ObjectUtils.equalsAny(dataType, IotDataSpecsDataTypeEnum.STRUCT.getDataType(), + IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { + field.setLength(ThingModelProperty.LENGTH_JSON); + } + return field; + }); + } + + @Override + public void insert(IotDeviceDO device, Map properties, Long ts, Long reportTime) { + // 校验属性标识符:在拼接 SQL 之前,避免保留字段、归一化后重名的属性写入 + IotStorageFieldUtils.validatePropertyIdentifiers(properties.keySet()); + devicePropertyMapper.insert(device, properties, ts, reportTime); + } + + @Override + public List selectHistoryList(Long productId, IotDevicePropertyHistoryListReqVO reqVO) { + try { + return devicePropertyMapper.selectListByHistory(reqVO); + } catch (Exception exception) { + if (isTableNotExists(exception)) { + return Collections.emptyList(); + } + throw exception; + } + } + + private static boolean isTableNotExists(Exception exception) { + return StrUtil.contains(exception.getMessage(), "Table does not exist"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java index a3bb1f2..1afd0fb 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java @@ -46,6 +46,11 @@ public interface ErrorCodeConstants { ErrorCode DEVICE_REGISTER_DISABLED = new ErrorCode(1_050_003_210, "该产品未开启动态注册功能"); ErrorCode DEVICE_REGISTER_SECRET_INVALID = new ErrorCode(1_050_003_211, "产品密钥验证失败"); ErrorCode DEVICE_REGISTER_ALREADY_EXISTS = new ErrorCode(1_050_003_212, "设备已存在,不允许重复注册"); + // 设备属性存储相关错误码 1-050-003-300 + ErrorCode DEVICE_PROPERTY_HISTORY_TIMES_INVALID = new ErrorCode(1_050_003_300, "设备属性历史的时间范围,必须包含开始、结束两个时间"); + ErrorCode DEVICE_PROPERTY_FIELD_NAME_INVALID = new ErrorCode(1_050_003_301, "属性标识符({}) 不合法,无法作为存储字段"); + ErrorCode DEVICE_PROPERTY_FIELD_NAME_RESERVED = new ErrorCode(1_050_003_302, "属性标识符({}) 与存储保留字段({}) 冲突"); + ErrorCode DEVICE_PROPERTY_FIELD_NAME_DUPLICATE = new ErrorCode(1_050_003_303, "属性标识符({}) 与({}) 的存储字段({}) 重复"); // ========== 产品分类 1-050-004-000 ========== ErrorCode PRODUCT_CATEGORY_NOT_EXISTS = new ErrorCode(1_050_004_000, "产品分类不存在"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtils.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtils.java new file mode 100644 index 0000000..4cf2f8a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtils.java @@ -0,0 +1,150 @@ +package cn.iocoder.yudao.module.iot.framework.iotdb.core; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import org.apache.iotdb.rpc.StatementExecutionException; + +import java.sql.SQLException; +import java.util.*; + +/** + * IoTDB 工具类 + * + * 1. 异常分类:识别“表不存在” + * 2. 启动前提检查:只读取配置并校验,不修改 IoTDB 的任何配置 + * + * @author 芋道源码 + */ +public class IoTDBUtils { + + /** + * IoTDB 状态码 - 表不存在 + */ + public static final int STATUS_CODE_TABLE_NOT_EXISTS = 550; + + /** + * 要求的时间精度:毫秒 + */ + public static final String TIMESTAMP_PRECISION_MS = "ms"; + /** + * 要求关闭的配置项:部分写入。开启时,一行中类型错误的列会失败,其它列仍然写入 + */ + public static final String CONFIG_ENABLE_PARTIAL_INSERT = "enable_partial_insert"; + /** + * DataNode 的运行状态 + */ + public static final String DATA_NODE_STATUS_RUNNING = "Running"; + + // ========== 异常分类 ========== + + /** + * 判断异常是否为“表不存在” + * + * 只识别明确的 550 状态码,并沿 cause 链查找:外层 SQLException 的 errorCode 可能为 0, + * 真实状态码在内层的 SQLException 或 {@link StatementExecutionException} 中。 + * 列不存在(616)、类型不匹配(507)、权限等其它错误,都返回 false,由调用方原样抛出。 + * + * @param throwable 异常 + * @return 是否表不存在 + */ + @SuppressWarnings("PatternVariableCanBeUsed") // 保持 JDK8 兼容性 + public static boolean isTableNotExists(Throwable throwable) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable cause = throwable; cause != null && visited.add(cause); cause = cause.getCause()) { + if (cause instanceof StatementExecutionException + && ((StatementExecutionException) cause).getStatusCode() == STATUS_CODE_TABLE_NOT_EXISTS) { + return true; + } + if (cause instanceof SQLException + && ((SQLException) cause).getErrorCode() == STATUS_CODE_TABLE_NOT_EXISTS) { + return true; + } + } + return false; + } + + // ========== 启动前提检查 ========== + + /** + * 校验时间精度为毫秒 + * + * @param variables SHOW VARIABLES 的结果 + */ + public static void validateTimestampPrecision(List> variables) { + Map variable = CollUtil.findOne(variables, + row -> StrUtil.equalsIgnoreCase(getString(row, "Variable"), "TimestampPrecision")); + if (variable == null) { + throw new IllegalStateException("[IoTDB 前提检查] 无法读取 TimestampPrecision,请确认账号有查询权限"); + } + String precision = getString(variable, "Value"); + if (!StrUtil.equals(TIMESTAMP_PRECISION_MS, precision)) { + throw new IllegalStateException(StrUtil.format( + "[IoTDB 前提检查] TimestampPrecision 必须为 ms,当前为 {}", precision)); + } + } + + /** + * 校验所有 DataNode 都处于运行状态,并返回它们的编号 + * + * @param dataNodes SHOW DATANODES 的结果 + * @return DataNode 编号列表 + */ + public static List validateDataNodes(List> dataNodes) { + if (CollUtil.isEmpty(dataNodes)) { + throw new IllegalStateException("[IoTDB 前提检查] 无法读取 DataNode 列表,请确认账号有查询权限"); + } + List nodeIds = new ArrayList<>(dataNodes.size()); + for (Map dataNode : dataNodes) { + Object nodeId = getIgnoreCase(dataNode, "NodeID"); + String status = getString(dataNode, "Status"); + if (!(nodeId instanceof Number)) { + throw new IllegalStateException(StrUtil.format("[IoTDB 前提检查] 无法识别 DataNode 编号:{}", dataNode)); + } + if (!StrUtil.equalsIgnoreCase(DATA_NODE_STATUS_RUNNING, status)) { + throw new IllegalStateException(StrUtil.format( + "[IoTDB 前提检查] DataNode({}) 状态为 {},必须全部为 Running 才能确认部分写入配置", nodeId, status)); + } + nodeIds.add(((Number) nodeId).intValue()); + } + return nodeIds; + } + + /** + * 校验 DataNode 关闭了部分写入(enable_partial_insert=false) + * + * @param nodeId DataNode 编号 + * @param configurations SHOW ALL CONFIGURATION ON nodeId 的结果 + */ + public static void validatePartialInsertDisabled(Integer nodeId, List> configurations) { + Map configuration = CollUtil.findOne(configurations, + row -> StrUtil.equals(getString(row, "name"), CONFIG_ENABLE_PARTIAL_INSERT)); + if (configuration == null) { + throw new IllegalStateException(StrUtil.format( + "[IoTDB 前提检查] DataNode({}) 无法读取 {} 配置,请确认 IoTDB 版本与账号权限", nodeId, CONFIG_ENABLE_PARTIAL_INSERT)); + } + String value = getString(configuration, "value"); + if (!StrUtil.equalsIgnoreCase(Boolean.FALSE.toString(), value)) { + throw new IllegalStateException(StrUtil.format( + "[IoTDB 前提检查] DataNode({}) 的 {} 必须为 false,当前为 {}。请修改所有 DataNode 的配置文件,应用不会自动修改", + nodeId, CONFIG_ENABLE_PARTIAL_INSERT, value)); + } + } + + /** + * 忽略大小写,获得结果行的列值。原因:IoTDB 系统语句的列名为 NodeID、Variable 等,不同版本大小写可能不同 + */ + public static Object getIgnoreCase(Map row, String column) { + for (Map.Entry entry : row.entrySet()) { + if (StrUtil.equalsIgnoreCase(entry.getKey(), column)) { + return entry.getValue(); + } + } + return null; + } + + private static String getString(Map row, String column) { + Object value = getIgnoreCase(row, column); + return value != null ? value.toString() : null; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/annotation/IoTDBDS.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/annotation/IoTDBDS.java new file mode 100644 index 0000000..5bb355c --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/annotation/IoTDBDS.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.iot.framework.iotdb.core.annotation; + +import com.baomidou.dynamic.datasource.annotation.DS; + +import java.lang.annotation.*; + +/** + * IoTDB 数据源 + * + * 对应 spring.datasource.dynamic.datasource.iotdb 条目 + * + * @author 芋道源码 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@DS("iotdb") +public @interface IoTDBDS { +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/package-info.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/package-info.java new file mode 100644 index 0000000..bcbafe2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/iotdb/package-info.java @@ -0,0 +1,4 @@ +/** + * iot 模块的 IoTDB 拓展封装 + */ +package cn.iocoder.yudao.module.iot.framework.iotdb; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java new file mode 100644 index 0000000..0798dc9 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageConfiguration.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.iot.framework.storage.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除重复的装配单测 IotStorageConfigurationTest,实际装配由实库验收覆盖 +/** + * IoT 时序存储的配置类 + * + * 由 yudao.iot.storage.type 选择存储后端,对应的 DAO 实现通过 @ConditionalOnProperty 装配 + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(IotStorageProperties.class) +public class IotStorageConfiguration { +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java new file mode 100644 index 0000000..40093a5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageInitRunner.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.iot.framework.storage.config; + +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; +import cn.iocoder.yudao.module.iot.dal.storage.IotDevicePropertyDAO; +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +// TODO DONE @AI:按照项目的习惯,这个是不是不用写单测?已移除独立单测 IotStorageInitRunnerTest,关键的缺数据源防回落 master 断言并入实库验收 +/** + * IoT 时序存储的初始化 Runner:校验数据源、前提条件,并创建消息表 + * + * 只初始化 yudao.iot.storage.type 选中的存储后端。失败时退出系统,避免带着不可用的存储运行 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class IotStorageInitRunner implements ApplicationRunner { + + @Resource + private IotStorageProperties storageProperties; + @Resource + private DataSource dataSource; + + @Resource + private IotDevicePropertyDAO devicePropertyDAO; + @Resource + private IotDeviceMessageDAO deviceMessageDAO; + + @Override + public void run(ApplicationArguments args) { + try { + initialize(); + } catch (Exception ex) { + // 初始化失败时打印错误消息并退出系统 + log.error("[run][IoT 时序存储({}) 初始化失败,系统无法正常运行,即将退出]", storageProperties.getType(), ex); + System.exit(1); + } + } + + /** + * 初始化选中的存储后端 + */ + public void initialize() { + // 1. 校验数据源已配置:dynamic-datasource 找不到数据源时,会回退到 master,必须提前拦截 + validateDataSourceExists(); + // 2. 初始化 DAO + devicePropertyDAO.initialize(); + deviceMessageDAO.initialize(); + log.info("[initialize][IoT 时序存储({}) 初始化完成]", storageProperties.getType()); + } + + private void validateDataSourceExists() { + String dataSourceName = storageProperties.getType().getDataSourceName(); + if (!(dataSource instanceof DynamicRoutingDataSource)) { + throw new IllegalStateException(String.format("[IoT 时序存储] 需要使用 dynamic-datasource 多数据源,当前为 %s", + dataSource.getClass().getName())); + } + if (!((DynamicRoutingDataSource) dataSource).getDataSources().containsKey(dataSourceName)) { + throw new IllegalStateException(String.format("[IoT 时序存储] 未配置数据源 spring.datasource.dynamic.datasource.%s," + + "请检查 yudao.iot.storage.type=%s 对应的数据源配置", dataSourceName, storageProperties.getType().getType())); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageProperties.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageProperties.java new file mode 100644 index 0000000..6bbda4f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/config/IotStorageProperties.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.iot.framework.storage.config; + +import cn.iocoder.yudao.module.iot.framework.storage.core.enums.IotStorageTypeEnum; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * IoT 时序数据存储的配置项 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = IotStorageProperties.PREFIX) +@Validated +@Data +public class IotStorageProperties { + + public static final String PREFIX = "yudao.iot.storage"; + + /** + * 存储类型,同时决定属性历史、消息日志的存储后端 + * + * 写错值时,配置绑定失败,应用启动失败 + */ + @NotNull(message = "IoT 存储类型不能为空") + private IotStorageTypeEnum type = IotStorageTypeEnum.TDENGINE; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/enums/IotStorageTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/enums/IotStorageTypeEnum.java new file mode 100644 index 0000000..6bc16d5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/enums/IotStorageTypeEnum.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.iot.framework.storage.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * IoT 时序数据(属性历史、消息日志)的存储类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum IotStorageTypeEnum { + + TDENGINE("tdengine", "tdengine"), + IOTDB("iotdb", "iotdb"); + + /** + * 类型,对应 yudao.iot.storage.type 配置项 + */ + private final String type; + /** + * 数据源名,对应 spring.datasource.dynamic.datasource 下的条目 + */ + private final String dataSourceName; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtils.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtils.java new file mode 100644 index 0000000..ad72612 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtils.java @@ -0,0 +1,103 @@ +package cn.iocoder.yudao.module.iot.framework.storage.core.util; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.collection.SetUtils; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; + +/** + * IoT 时序存储的字段名工具类 + * + * 属性标识符(identifier)统一归一化为小写下划线的“中立字段名”,各存储后端在此基础上映射物理列名: + * 1. TDengine:直接使用中立字段名 + * 2. IoTDB:使用 p_ + 中立字段名,避免与 time 等内置列冲突 + * + * @author 芋道源码 + */ +public class IotStorageFieldUtils { + + /** + * 系统字段 - 存储时间 + */ + public static final String FIELD_TS = "ts"; + /** + * 系统字段 - 上报时间 + */ + public static final String FIELD_REPORT_TIME = "report_time"; + /** + * 系统字段 - 设备编号 + */ + public static final String FIELD_DEVICE_ID = "device_id"; + + /** + * 保留字段名:属性标识符归一化后,不允许与它们相同 + */ + public static final Set RESERVED_FIELD_NAMES = SetUtils.asSet(FIELD_TS, FIELD_REPORT_TIME, FIELD_DEVICE_ID); + + /** + * 中立字段名的格式:只允许小写字母、数字、下划线,且以字母开头 + * + * 动态拼接到 SQL 结构名(列名)前,必须通过该格式校验 + */ + private static final Pattern FIELD_NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9_]{0,63}$"); + + /** + * 构建中立字段名:转为小写下划线,需要和建表、写入、查询保持一致。 + * 例如:PT -> pt,PfT -> pf_t。 + * + * @param identifier 属性标识符 + * @return 中立字段名 + */ + public static String buildFieldName(String identifier) { + if (StrUtil.isBlank(identifier)) { + return identifier; + } + return StrUtil.toUnderlineCase(identifier).toLowerCase(Locale.ROOT); + } + + /** + * 判断属性标识符,归一化后是否为合法的属性字段名(格式合法,且不是保留字段) + * + * @param identifier 属性标识符 + * @return 是否合法 + */ + public static boolean isPropertyFieldName(String identifier) { + String fieldName = buildFieldName(identifier); + return fieldName != null && FIELD_NAME_PATTERN.matcher(fieldName).matches() + && !RESERVED_FIELD_NAMES.contains(fieldName); + } + + /** + * 校验属性标识符列表,可以作为存储字段。define、insert 拼接 SQL 之前,都需要校验原始标识符: + * 1. 归一化后格式合法 + * 2. 归一化后不是 ts、report_time、device_id 保留字段 + * 3. 归一化后不重复,例如说 fooBar 与 foo_bar + * + * @param identifiers 属性标识符列表 + */ + public static void validatePropertyIdentifiers(Collection identifiers) { + Map fieldNames = new HashMap<>(); + for (String identifier : identifiers) { + String fieldName = buildFieldName(identifier); + if (fieldName == null || !FIELD_NAME_PATTERN.matcher(fieldName).matches()) { + throw exception(DEVICE_PROPERTY_FIELD_NAME_INVALID, identifier); + } + if (RESERVED_FIELD_NAMES.contains(fieldName)) { + throw exception(DEVICE_PROPERTY_FIELD_NAME_RESERVED, identifier, fieldName); + } + String existsIdentifier = fieldNames.putIfAbsent(fieldName, identifier); + if (existsIdentifier != null) { + throw exception(DEVICE_PROPERTY_FIELD_NAME_DUPLICATE, identifier, existsIdentifier, fieldName); + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/package-info.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/package-info.java new file mode 100644 index 0000000..46e49db --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/storage/package-info.java @@ -0,0 +1,4 @@ +/** + * iot 模块的时序数据存储封装:通过 yudao.iot.storage.type 选择 TDengine 或 IoTDB + */ +package cn.iocoder.yudao.module.iot.framework.storage; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/config/TDengineTableInitRunner.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/config/TDengineTableInitRunner.java deleted file mode 100644 index 1de2dcd..0000000 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/config/TDengineTableInitRunner.java +++ /dev/null @@ -1,34 +0,0 @@ -package cn.iocoder.yudao.module.iot.framework.tdengine.config; - -import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.stereotype.Component; - -/** - * TDengine 表初始化的 Configuration - * - * @author alwayssuper - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class TDengineTableInitRunner implements ApplicationRunner { - - private final IotDeviceMessageService deviceMessageService; - - @Override - public void run(ApplicationArguments args) { - try { - // 初始化设备消息表 - deviceMessageService.defineDeviceMessageStable(); - } catch (Exception ex) { - // 初始化失败时打印错误消息并退出系统 - log.error("[run][TDengine初始化设备消息表结构失败,系统无法正常运行,即将退出]", ex); - System.exit(1); - } - } - -} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableField.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableField.java index 010e369..d472ec3 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableField.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableField.java @@ -1,12 +1,10 @@ package cn.iocoder.yudao.module.iot.framework.tdengine.core; -import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.Locale; - /** * TDEngine 表字段 */ @@ -30,9 +28,9 @@ public class TDengineTableField { public static final String TYPE_TIMESTAMP = "TIMESTAMP"; /** - * 字段长度 - VARCHAR 默认长度 + * 字段长度 - VARCHAR 默认长度,与 STRUCT、ARRAY 等 JSON 属性的长度上限保持一致 */ - public static final int LENGTH_VARCHAR = 1024; + public static final int LENGTH_VARCHAR = ThingModelProperty.LENGTH_JSON; /** * 注释 - TAG 字段 @@ -64,15 +62,4 @@ public class TDengineTableField { this.type = type; } - /** - * 构建字段名:TDengine 默认会将字段名转为小写,需要和建表、写入、查询保持一致。 - * 例如:PT -> pt,PfT -> pf_t。 - */ - public static String buildFieldName(String field) { - if (StrUtil.isBlank(field)) { - return field; - } - return StrUtil.toUnderlineCase(field).toLowerCase(Locale.ROOT); - } - } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java index e28f489..5323cca 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageService.java @@ -21,13 +21,6 @@ import java.util.List; public interface IotDeviceMessageService { /** - * 初始化设备消息的 TDengine 超级表 - * - * 系统启动时,会自动初始化一次 - */ - void defineDeviceMessageStable(); - - /** * 发送设备消息 * * @param message 消息(“codec(编解码)字段” 部分字段) diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java index dae8855..77f113b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImpl.java @@ -25,12 +25,10 @@ import cn.iocoder.yudao.module.iot.core.topic.property.IotDevicePropertyPostReqD import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; -import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.google.common.base.Objects; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -67,22 +65,11 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { private IotOtaTaskRecordService otaTaskRecordService; @Resource - private IotDeviceMessageMapper deviceMessageMapper; + private IotDeviceMessageDAO deviceMessageDAO; @Resource private IotDeviceMessageProducer deviceMessageProducer; - @Override - public void defineDeviceMessageStable() { - if (StrUtil.isNotEmpty(deviceMessageMapper.showSTable())) { - log.info("[defineDeviceMessageStable][设备消息超级表已存在,创建跳过]"); - return; - } - log.info("[defineDeviceMessageStable][设备消息超级表不存在,创建开始...]"); - deviceMessageMapper.createSTable(); - log.info("[defineDeviceMessageStable][设备消息超级表不存在,创建成功]"); - } - @Async void createDeviceLogAsync(IotDeviceMessage message) { IotDeviceMessageDO messageDO = BeanUtils.toBean(message, IotDeviceMessageDO.class) @@ -102,7 +89,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { messageDO.setTs(System.currentTimeMillis()); } try { - deviceMessageMapper.insert(messageDO); + deviceMessageDAO.insert(messageDO); } catch (Exception ex) { // 特殊:@Async 方法的异常默认会被 handler 吞掉,这里显式记录便于排查 log.error("[createDeviceLogAsync][消息日志写入失败 deviceId({}) messageId({}) paramsLen({}) dataLen({})]", @@ -337,16 +324,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { @Override public PageResult getDeviceMessagePage(IotDeviceMessagePageReqVO pageReqVO) { - try { - IPage page = deviceMessageMapper.selectPage( - new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize()), pageReqVO); - return new PageResult<>(page.getRecords(), page.getTotal()); - } catch (Exception exception) { - if (exception.getMessage().contains("Table does not exist")) { - return PageResult.empty(); - } - throw exception; - } + return deviceMessageDAO.selectPage(pageReqVO); } @Override @@ -354,12 +332,12 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { if (CollUtil.isEmpty(requestIds)) { return ListUtil.of(); } - return deviceMessageMapper.selectListByRequestIdsAndReply(deviceId, requestIds, reply); + return deviceMessageDAO.selectListByRequestIdsAndReply(deviceId, requestIds, reply); } @Override public Long getDeviceMessageCount(LocalDateTime createTime) { - return deviceMessageMapper.selectCountByCreateTime( + return deviceMessageDAO.selectCountByCreateTime( createTime != null ? LocalDateTimeUtil.toEpochMilli(createTime) : null); } @@ -367,7 +345,7 @@ public class IotDeviceMessageServiceImpl implements IotDeviceMessageService { public List getDeviceMessageSummaryByDate( IotStatisticsDeviceMessageReqVO reqVO) { // 1. 按小时统计,获取分项统计数据 - List> countList = deviceMessageMapper.selectDeviceMessageCountGroupByDate( + List> countList = deviceMessageDAO.selectDeviceMessageCountGroupByDate( LocalDateTimeUtil.toEpochMilli(reqVO.getTimes()[0]), LocalDateTimeUtil.toEpochMilli(reqVO.getTimes()[1])); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java index 77db754..dbd09cb 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java @@ -2,11 +2,9 @@ package cn.iocoder.yudao.module.iot.service.device.property; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.date.LocalDateTimeUtil; -import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; -import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; @@ -14,14 +12,12 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; -import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDateOrTextDataSpecs; import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO; import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceReportTimeRedisDAO; import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceServerIdRedisDAO; -import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper; -import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; +import cn.iocoder.yudao.module.iot.dal.storage.IotDevicePropertyDAO; import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum; -import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField; +import cn.iocoder.yudao.module.iot.framework.storage.core.util.IotStorageFieldUtils; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; @@ -35,8 +31,10 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.*; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.getBigDecimal; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_PROPERTY_HISTORY_TIMES_INVALID; /** * IoT 设备【属性】数据 Service 实现类 @@ -47,23 +45,6 @@ import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.getBigD @Slf4j public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { - /** - * 物模型的数据类型,与 TDengine 数据类型的映射关系 - * - * @see TDEngine 数据类型 - */ - private static final Map TYPE_MAPPING = MapUtil.builder() - .put(IotDataSpecsDataTypeEnum.INT.getDataType(), TDengineTableField.TYPE_INT) - .put(IotDataSpecsDataTypeEnum.FLOAT.getDataType(), TDengineTableField.TYPE_FLOAT) - .put(IotDataSpecsDataTypeEnum.DOUBLE.getDataType(), TDengineTableField.TYPE_DOUBLE) - .put(IotDataSpecsDataTypeEnum.ENUM.getDataType(), TDengineTableField.TYPE_TINYINT) - .put(IotDataSpecsDataTypeEnum.BOOL.getDataType(), TDengineTableField.TYPE_TINYINT) - .put(IotDataSpecsDataTypeEnum.TEXT.getDataType(), TDengineTableField.TYPE_VARCHAR) - .put(IotDataSpecsDataTypeEnum.DATE.getDataType(), TDengineTableField.TYPE_TIMESTAMP) - .put(IotDataSpecsDataTypeEnum.STRUCT.getDataType(), TDengineTableField.TYPE_VARCHAR) - .put(IotDataSpecsDataTypeEnum.ARRAY.getDataType(), TDengineTableField.TYPE_VARCHAR) - .build(); - @Resource private IotThingModelService thingModelService; @Resource @@ -81,54 +62,18 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { private DeviceServerIdRedisDAO deviceServerIdRedisDAO; @Resource - private IotDevicePropertyMapper devicePropertyMapper; + private IotDevicePropertyDAO devicePropertyDAO; // ========== 设备属性相关操作 ========== @Override public void defineDevicePropertyData(Long productId) { - // 1.1 查询产品和物模型 + // 1. 查询产品和物模型 IotProductDO product = productService.validateProductExists(productId); List thingModels = filterList(thingModelService.getThingModelListByProductId(productId), thingModel -> IotThingModelTypeEnum.PROPERTY.getType().equals(thingModel.getType())); - // 1.2 解析 DB 里的字段 - List oldFields = new ArrayList<>(); - try { - oldFields.addAll(devicePropertyMapper.getProductPropertySTableFieldList(product.getId())); - } catch (Exception e) { - if (!e.getMessage().contains("Table does not exist")) { - throw e; - } - } - - // 2.1 情况一:如果是新增的时候,需要创建表 - List newFields = buildTableFieldList(thingModels); - if (CollUtil.isEmpty(oldFields)) { - if (CollUtil.isEmpty(newFields)) { - log.info("[defineDevicePropertyData][productId({}) 没有需要定义的属性]", productId); - return; - } - devicePropertyMapper.createProductPropertySTable(product.getId(), newFields); - return; - } - // 2.2 情况二:如果是修改的时候,需要更新表 - devicePropertyMapper.alterProductPropertySTable(product.getId(), oldFields, newFields); - } - - private List buildTableFieldList(List thingModels) { - return convertList(thingModels, thingModel -> { - TDengineTableField field = new TDengineTableField( - TDengineTableField.buildFieldName(thingModel.getIdentifier()), - TYPE_MAPPING.get(thingModel.getProperty().getDataType())); - String dataType = thingModel.getProperty().getDataType(); - if (Objects.equals(dataType, IotDataSpecsDataTypeEnum.TEXT.getDataType())) { - field.setLength(((ThingModelDateOrTextDataSpecs) thingModel.getProperty().getDataSpecs()).getLength()); - } else if (ObjectUtils.equalsAny(dataType, IotDataSpecsDataTypeEnum.STRUCT.getDataType(), - IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { - field.setLength(TDengineTableField.LENGTH_VARCHAR); - } - return field; - }); + // 2. 定义存储结构:字段映射、差异比较、建表改表,由存储 DAO 实现 + devicePropertyDAO.defineProductPropertyTable(product.getId(), thingModels); } @Override @@ -174,7 +119,7 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { log.error("[saveDeviceProperty][消息({}) 没有合法的属性]", message); } else { // 2.1 保存设备属性【数据】 - devicePropertyMapper.insert(device, properties, + devicePropertyDAO.insert(device, properties, System.currentTimeMillis(), LocalDateTimeUtil.toEpochMilli(message.getReportTime())); @@ -213,14 +158,26 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { @Override public List getHistoryDevicePropertyList(IotDevicePropertyHistoryListReqVO listReqVO) { - try { - return devicePropertyMapper.selectListByHistory(listReqVO); - } catch (Exception exception) { - if (exception.getMessage().contains("Table does not exist")) { - return Collections.emptyList(); - } - throw exception; + // 1.1 校验时间范围:必须包含开始、结束两个时间;任一为空时,无法确定范围,返回空 + LocalDateTime[] times = listReqVO.getTimes(); + if (times == null || times.length != 2) { + throw exception(DEVICE_PROPERTY_HISTORY_TIMES_INVALID); + } + if (times[0] == null || times[1] == null) { + return Collections.emptyList(); + } + // 1.2 校验属性标识符:无法作为存储字段的标识符,不会有历史数据 + if (!IotStorageFieldUtils.isPropertyFieldName(listReqVO.getIdentifier())) { + return Collections.emptyList(); } + // 1.3 校验设备:按当前租户查询,且过滤已删除的设备;不存在时,不查询历史数据 + IotDeviceDO device = deviceService.getDevice(listReqVO.getDeviceId()); + if (device == null) { + return Collections.emptyList(); + } + + // 2. 查询历史数据 + return devicePropertyDAO.selectHistoryList(device.getProductId(), listReqVO); } // ========== 设备时间相关操作 ========== diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java index 9029960..9b645ba 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java @@ -18,6 +18,7 @@ import cn.iocoder.yudao.module.iot.controller.admin.thingmodel.vo.IotThingModelS import cn.iocoder.yudao.module.iot.convert.thingmodel.IotThingModelConvert; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelBoolOrEnumDataSpecs; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDataSpecs; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDateOrTextDataSpecs; @@ -25,7 +26,6 @@ import cn.iocoder.yudao.module.iot.dal.mysql.thingmodel.IotThingModelMapper; import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; -import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField; import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.annotation.Resource; @@ -193,8 +193,8 @@ public class IotThingModelServiceImpl implements IotThingModelService { String dataType = thingModel.getProperty().getDataType(); if (ObjectUtils.equalsAny(dataType, IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { - // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,没有对应数据类型,只能通过 JSON 来存储 - return convertToVarchar(JsonUtils.toJsonString(value), TDengineTableField.LENGTH_VARCHAR); + // 特殊:STRUCT 和 ARRAY 类型,在时序库里,没有对应数据类型,只能通过 JSON 来存储 + return convertToVarchar(JsonUtils.toJsonString(value), ThingModelProperty.LENGTH_JSON); } if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) { return convertToInt(value); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml index c47c5a5..2003c55 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/device/IotDevicePropertyMapper.xml @@ -51,7 +51,7 @@ TAGS ('${device.id}') (ts, report_time, - ${@cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField@buildFieldName(key)} + ${@cn.iocoder.yudao.module.iot.framework.storage.core.util.IotStorageFieldUtils@buildFieldName(key)} ) VALUES @@ -68,9 +68,9 @@ + DESC device_message + + + + CREATE TABLE IF NOT EXISTS device_message ( + device_id STRING TAG, + id STRING FIELD, + upstream INT32 FIELD, + reply INT32 FIELD, + method STRING FIELD, + identifier STRING FIELD, + request_id STRING FIELD, + server_id STRING FIELD, + params STRING FIELD, + data STRING FIELD, + msg STRING FIELD, + report_time STRING FIELD, + tenant_id STRING FIELD, + code STRING FIELD + ) + + + + INSERT INTO device_message ( + time, device_id, id, upstream, reply, + method, identifier, request_id, server_id, params, + data, msg, report_time, tenant_id, code + ) + VALUES ( + #{ts}, #{deviceId}, #{id}, #{upstream}, #{reply}, + #{method}, #{identifier}, #{requestId}, #{serverId}, #{params}, + #{data}, #{msg}, #{reportTime}, #{tenantId}, #{code} + ) + + + + SELECT time AS ts, device_id, id, upstream, reply, + method, identifier, request_id, server_id, params, + data, msg, report_time, tenant_id, code + FROM device_message + + + + WHERE device_id = #{deviceId} + + AND method = #{method} + + + AND upstream = #{upstream} + + + AND reply = #{reply} + + + AND identifier = #{identifier} + + + + + + + + + + + + + + + diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml new file mode 100644 index 0000000..7530c27 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/resources/mapper/iotdb/IotDevicePropertyIoTDBMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + + + CREATE TABLE IF NOT EXISTS product_property_${productId} ( + device_id STRING TAG, + report_time TIMESTAMP FIELD + + , "${column.name}" ${column.type} FIELD + + ) + + + + ALTER TABLE product_property_${productId} + ADD COLUMN IF NOT EXISTS "${column.name}" ${column.type} FIELD + + + + ALTER TABLE product_property_${productId} + DROP COLUMN IF EXISTS "${columnName}" + + + + INSERT INTO product_property_${productId} (time, device_id + + , report_time + + + , "${column.name}" + + ) + VALUES (#{ts}, #{deviceId} + + , #{reportTime} + + + , #{column.value} + + ) + + + + + diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtilsTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtilsTest.java new file mode 100644 index 0000000..0696fa0 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/iotdb/core/IoTDBUtilsTest.java @@ -0,0 +1,153 @@ +package cn.iocoder.yudao.module.iot.framework.iotdb.core; + +import cn.hutool.core.map.MapUtil; +import org.apache.iotdb.common.rpc.thrift.TSStatus; +import org.apache.iotdb.jdbc.IoTDBSQLException; +import org.apache.iotdb.rpc.StatementExecutionException; +import org.apache.iotdb.rpc.TSStatusCode; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.UncategorizedSQLException; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IoTDBUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class IoTDBUtilsTest { + + // ========== isTableNotExists ========== + + @Test + public void testIsTableNotExists_statusCode() { + assertEquals(TSStatusCode.TABLE_NOT_EXISTS.getStatusCode(), IoTDBUtils.STATUS_CODE_TABLE_NOT_EXISTS); + } + + @Test + public void testIsTableNotExists_true() { + // 驱动抛出的异常:errorCode 为 IoTDB 状态码 + IoTDBSQLException sqlException = new IoTDBSQLException("550: Table 'db.product_property_1' does not exist", + new TSStatus(TSStatusCode.TABLE_NOT_EXISTS.getStatusCode())); + assertTrue(IoTDBUtils.isTableNotExists(sqlException)); + // Spring、MyBatis 包装后的异常:沿 cause 链查找 + assertTrue(IoTDBUtils.isTableNotExists(new UncategorizedSQLException("select", "SELECT 1", sqlException))); + assertTrue(IoTDBUtils.isTableNotExists(new RuntimeException(new RuntimeException(sqlException)))); + // Session 风格的异常 + assertTrue(IoTDBUtils.isTableNotExists(new RuntimeException( + new StatementExecutionException(new TSStatus(TSStatusCode.TABLE_NOT_EXISTS.getStatusCode()))))); + } + + @Test + public void testIsTableNotExists_false() { + // 列不存在、类型不匹配:不是表不存在,需要原样抛出 + assertFalse(IoTDBUtils.isTableNotExists(new IoTDBSQLException("616", + new TSStatus(TSStatusCode.COLUMN_NOT_EXISTS.getStatusCode())))); + assertFalse(IoTDBUtils.isTableNotExists(new StatementExecutionException( + new TSStatus(TSStatusCode.DATA_TYPE_MISMATCH.getStatusCode())))); + // 只靠异常文案,不识别为表不存在 + assertFalse(IoTDBUtils.isTableNotExists(new RuntimeException("Table does not exist"))); + assertFalse(IoTDBUtils.isTableNotExists(null)); + } + + @Test + public void testIsTableNotExists_causeLoop() { + // 准备参数:cause 链成环 + RuntimeException a = new RuntimeException("a"); + RuntimeException b = new RuntimeException("b"); + a.initCause(b); + b.initCause(a); + + // 调用,并断言:不会死循环 + assertFalse(IoTDBUtils.isTableNotExists(a)); + } + + // ========== validateTimestampPrecision ========== + + @Test + public void testValidateTimestampPrecision_success() { + List> variables = Arrays.asList( + row("Variable", "ClusterName", "Value", "defaultCluster"), + row("variable", "TimestampPrecision", "value", "ms")); + assertDoesNotThrow(() -> IoTDBUtils.validateTimestampPrecision(variables)); + } + + @Test + public void testValidateTimestampPrecision_failure() { + // 精度不是 ms + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateTimestampPrecision( + Collections.singletonList(row("Variable", "TimestampPrecision", "Value", "ns")))); + // 无法读取 + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateTimestampPrecision( + Collections.singletonList(row("Variable", "ClusterName", "Value", "defaultCluster")))); + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateTimestampPrecision(Collections.emptyList())); + } + + // ========== validateDataNodes ========== + + @Test + public void testValidateDataNodes_success() { + List> dataNodes = Arrays.asList( + row("NodeID", 1, "Status", "Running"), + row("NodeID", 3, "Status", "running")); + assertEquals(Arrays.asList(1, 3), IoTDBUtils.validateDataNodes(dataNodes)); + } + + @Test + public void testValidateDataNodes_failure() { + // 没有 DataNode + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateDataNodes(Collections.emptyList())); + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateDataNodes(null)); + // 有 DataNode 不是 Running:无法确认它的配置 + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateDataNodes(Arrays.asList( + row("NodeID", 1, "Status", "Running"), + row("NodeID", 2, "Status", "Unknown")))); + // 编号无法识别 + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateDataNodes( + Collections.singletonList(row("NodeID", "abc", "Status", "Running")))); + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validateDataNodes( + Collections.singletonList(row("Status", "Running")))); + } + + // ========== validatePartialInsertDisabled ========== + + @Test + public void testValidatePartialInsertDisabled_success() { + List> configurations = Arrays.asList( + row("name", "dn_rpc_port", "value", "6667", "default_value", "6667"), + row("name", "enable_partial_insert", "value", "false", "default_value", "true")); + assertDoesNotThrow(() -> IoTDBUtils.validatePartialInsertDisabled(1, configurations)); + } + + @Test + public void testValidatePartialInsertDisabled_failure() { + // 开启了部分写入 + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validatePartialInsertDisabled(1, + Collections.singletonList(row("name", "enable_partial_insert", "value", "true")))); + // 无法读取配置 + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validatePartialInsertDisabled(1, + Collections.singletonList(row("name", "dn_rpc_port", "value", "6667")))); + assertThrows(IllegalStateException.class, () -> IoTDBUtils.validatePartialInsertDisabled(1, + Collections.singletonList(row("name", "enable_partial_insert")))); + } + + // ========== getIgnoreCase ========== + + @Test + public void testGetIgnoreCase() { + Map row = row("NodeID", 1); + assertEquals(1, IoTDBUtils.getIgnoreCase(row, "nodeid")); + assertNull(IoTDBUtils.getIgnoreCase(row, "Status")); + } + + private static Map row(Object... keyValues) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + row.put((String) keyValues[i], keyValues[i + 1]); + } + return MapUtil.unmodifiable(row); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtilsTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtilsTest.java new file mode 100644 index 0000000..e773e42 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/storage/core/util/IotStorageFieldUtilsTest.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.iot.framework.storage.core.util; + +import cn.hutool.core.util.StrUtil; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link IotStorageFieldUtils} 的单元测试 + * + * @author 芋道源码 + */ +public class IotStorageFieldUtilsTest { + + @Test + public void testBuildFieldName_success() { + assertEquals("ua", IotStorageFieldUtils.buildFieldName("Ua")); + assertEquals("pf_t", IotStorageFieldUtils.buildFieldName("PfT")); + assertEquals("pt", IotStorageFieldUtils.buildFieldName("PT")); + assertEquals("pa", IotStorageFieldUtils.buildFieldName("PA")); + assertEquals("geo_location", IotStorageFieldUtils.buildFieldName("GeoLocation")); + assertEquals("light_status", IotStorageFieldUtils.buildFieldName("LightStatus")); + assertEquals("foo_bar", IotStorageFieldUtils.buildFieldName("foo_bar")); + } + + @Test + public void testBuildFieldName_blank() { + // 空白原样返回,由 isPropertyFieldName、validatePropertyIdentifiers 判定为不合法 + assertNull(IotStorageFieldUtils.buildFieldName(null)); + assertEquals("", IotStorageFieldUtils.buildFieldName("")); + assertEquals(" ", IotStorageFieldUtils.buildFieldName(" ")); + } + + @Test + public void testIsPropertyFieldName_true() { + assertTrue(IotStorageFieldUtils.isPropertyFieldName("Temperature")); + assertTrue(IotStorageFieldUtils.isPropertyFieldName("PfT")); + assertTrue(IotStorageFieldUtils.isPropertyFieldName("a1_b2")); + assertTrue(IotStorageFieldUtils.isPropertyFieldName(StrUtil.repeat('a', 64))); + } + + @Test + public void testIsPropertyFieldName_false() { + // 保留字段:归一化后与系统字段相同 + assertFalse(IotStorageFieldUtils.isPropertyFieldName("ts")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("TS")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("reportTime")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("report_time")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("deviceId")); + // 格式不合法:不能安全地拼接到 SQL 结构名 + assertFalse(IotStorageFieldUtils.isPropertyFieldName(null)); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName(" ")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("1abc")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("温度")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("a-b")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("a\"b")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName("a` OR 1=1")); + assertFalse(IotStorageFieldUtils.isPropertyFieldName(StrUtil.repeat('a', 65))); + } + + @Test + public void testValidatePropertyIdentifiers_success() { + // 调用,并断言 + assertDoesNotThrow(() -> IotStorageFieldUtils.validatePropertyIdentifiers( + Arrays.asList("Temperature", "PfT", "geo_location"))); + assertDoesNotThrow(() -> IotStorageFieldUtils.validatePropertyIdentifiers(Collections.emptyList())); + } + + @Test + public void testValidatePropertyIdentifiers_invalid() { + assertServiceException(() -> IotStorageFieldUtils.validatePropertyIdentifiers( + Arrays.asList("Temperature", "温度")), DEVICE_PROPERTY_FIELD_NAME_INVALID, "温度"); + assertServiceException(() -> IotStorageFieldUtils.validatePropertyIdentifiers( + Collections.singletonList(null)), DEVICE_PROPERTY_FIELD_NAME_INVALID, (Object) null); + } + + @Test + public void testValidatePropertyIdentifiers_reserved() { + assertServiceException(() -> IotStorageFieldUtils.validatePropertyIdentifiers( + Collections.singletonList("reportTime")), DEVICE_PROPERTY_FIELD_NAME_RESERVED, "reportTime", "report_time"); + assertServiceException(() -> IotStorageFieldUtils.validatePropertyIdentifiers( + Collections.singletonList("deviceId")), DEVICE_PROPERTY_FIELD_NAME_RESERVED, "deviceId", "device_id"); + } + + @Test + public void testValidatePropertyIdentifiers_duplicate() { + assertServiceException(() -> IotStorageFieldUtils.validatePropertyIdentifiers( + Arrays.asList("fooBar", "foo_bar")), DEVICE_PROPERTY_FIELD_NAME_DUPLICATE, "foo_bar", "fooBar", "foo_bar"); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableFieldTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableFieldTest.java deleted file mode 100644 index df2cb42..0000000 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/framework/tdengine/core/TDengineTableFieldTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package cn.iocoder.yudao.module.iot.framework.tdengine.core; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -public class TDengineTableFieldTest { - - @Test - public void testBuildFieldName() { - assertEquals("ua", TDengineTableField.buildFieldName("Ua")); - assertEquals("pf_t", TDengineTableField.buildFieldName("PfT")); - assertEquals("pt", TDengineTableField.buildFieldName("PT")); - assertEquals("pa", TDengineTableField.buildFieldName("PA")); - assertEquals("geo_location", TDengineTableField.buildFieldName("GeoLocation")); - assertEquals("light_status", TDengineTableField.buildFieldName("LightStatus")); - } - - @Test - public void testBuildFieldName_blank() { - assertNull(TDengineTableField.buildFieldName(null)); - assertEquals("", TDengineTableField.buildFieldName("")); - assertEquals(" ", TDengineTableField.buildFieldName(" ")); - } - -} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java index e880621..8bb9b64 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/message/IotDeviceMessageServiceImplTest.java @@ -2,39 +2,43 @@ package cn.iocoder.yudao.module.iot.service.device.message; import cn.hutool.core.date.LocalDateTimeUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.enums.DateIntervalEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.message.IotDeviceMessagePageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceMessageDO; -import cn.iocoder.yudao.module.iot.dal.tdengine.IotDeviceMessageMapper; +import cn.iocoder.yudao.module.iot.dal.storage.IotDeviceMessageDAO; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.ota.IotOtaTaskRecordService; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import java.sql.Timestamp; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.*; /** * {@link IotDeviceMessageServiceImpl} 的单元测试 * - * 注:TDengine 数据源没有 embedded 替代,mapper 与依赖 service 走 mock; + * 注:时序库没有 embedded 替代,存储 DAO 与依赖 service 走 mock; * handleUpstreamDeviceMessage 与 sendDeviceMessage 下行成功路径依赖 SpringUtil.getBean 的自调用 * createDeviceLogAsync,更适合放到集成测试,本类不展开。 * @@ -52,36 +56,10 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { @Mock private IotOtaTaskRecordService otaTaskRecordService; @Mock - private IotDeviceMessageMapper deviceMessageMapper; + private IotDeviceMessageDAO deviceMessageDAO; @Mock private IotDeviceMessageProducer deviceMessageProducer; - // ========== defineDeviceMessageStable ========== - - @Test - public void testDefineDeviceMessageStable_whenTableExists_skipCreate() { - // 准备:showSTable 返回非空 → 表已存在 - when(deviceMessageMapper.showSTable()).thenReturn("device_message"); - - // 调用 - service.defineDeviceMessageStable(); - - // 断言:跳过 createSTable - verify(deviceMessageMapper, never()).createSTable(); - } - - @Test - public void testDefineDeviceMessageStable_whenTableMissing_create() { - // 准备:showSTable 返回空 → 表不存在 - when(deviceMessageMapper.showSTable()).thenReturn(""); - - // 调用 - service.defineDeviceMessageStable(); - - // 断言:触发 createSTable - verify(deviceMessageMapper, times(1)).createSTable(); - } - // ========== createDeviceLogAsync ========== @Test @@ -96,7 +74,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { // 断言:mapper.insert 接收到的 messageDO 已被填上 ts,值在 [before, after] 区间 ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); - verify(deviceMessageMapper).insert(captor.capture()); + verify(deviceMessageDAO).insert(captor.capture()); Long actualTs = captor.getValue().getTs(); assertNotNull(actualTs, "ts 不应为空"); assertTrue(actualTs >= before && actualTs <= after, @@ -114,7 +92,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { // 断言:mapper.insert 接收到的 messageDO 已补充 id ArgumentCaptor captor = ArgumentCaptor.forClass(IotDeviceMessageDO.class); - verify(deviceMessageMapper).insert(captor.capture()); + verify(deviceMessageDAO).insert(captor.capture()); assertNotNull(captor.getValue().getId(), "id 不应为空"); } @@ -122,11 +100,11 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { public void testCreateDeviceLogAsync_swallowMapperException() { // 准备:mapper.insert 抛异常,验证 @Async 方法内部 try/catch 兜底,不向上抛 IotDeviceMessage message = buildMessage(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod()); - doThrow(new RuntimeException("DB unavailable")).when(deviceMessageMapper).insert(any()); + doThrow(new RuntimeException("DB unavailable")).when(deviceMessageDAO).insert(any()); // 调用 & 断言 assertDoesNotThrow(() -> service.createDeviceLogAsync(message)); - verify(deviceMessageMapper).insert(any(IotDeviceMessageDO.class)); + verify(deviceMessageDAO).insert(any(IotDeviceMessageDO.class)); } // ========== sendDeviceMessage ========== @@ -170,49 +148,18 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { reqVO.setPageNo(1); reqVO.setPageSize(10); IotDeviceMessageDO record = new IotDeviceMessageDO().setId("msg-1"); - Page page = new Page<>(1, 10, 1L); - page.setRecords(Collections.singletonList(record)); - when(deviceMessageMapper.selectPage(any(), eq(reqVO))).thenReturn(page); + when(deviceMessageDAO.selectPage(same(reqVO))) + .thenReturn(new PageResult<>(Collections.singletonList(record), 1L)); // 调用 PageResult result = service.getDeviceMessagePage(reqVO); - // 断言 + // 断言:表不存在等存储差异,由 DAO 处理(见各 DAO 的单元测试) assertEquals(1L, result.getTotal()); assertEquals(1, result.getList().size()); assertEquals("msg-1", result.getList().get(0).getId()); } - @Test - public void testGetDeviceMessagePage_whenTableMissing_returnEmpty() { - // 准备:mapper 抛 "Table does not exist" → 视为表未创建,返回空结果 - IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); - reqVO.setPageNo(1); - reqVO.setPageSize(10); - when(deviceMessageMapper.selectPage(any(), any())) - .thenThrow(new RuntimeException("Table does not exist")); - - // 调用 - PageResult result = service.getDeviceMessagePage(reqVO); - - // 断言 - assertEquals(0L, result.getTotal()); - assertTrue(result.getList().isEmpty()); - } - - @Test - public void testGetDeviceMessagePage_otherException_rethrow() { - // 准备:mapper 抛非「表不存在」的异常 → 应向上抛 - IotDeviceMessagePageReqVO reqVO = new IotDeviceMessagePageReqVO(); - reqVO.setPageNo(1); - reqVO.setPageSize(10); - when(deviceMessageMapper.selectPage(any(), any())) - .thenThrow(new RuntimeException("Connection refused")); - - // 调用 & 断言 - assertThrows(RuntimeException.class, () -> service.getDeviceMessagePage(reqVO)); - } - // ========== getDeviceMessageListByRequestIdsAndReply ========== @Test @@ -222,7 +169,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { 1L, Collections.emptyList(), true); assertTrue(result.isEmpty()); - verify(deviceMessageMapper, never()).selectListByRequestIdsAndReply(any(), any(), any()); + verify(deviceMessageDAO, never()).selectListByRequestIdsAndReply(any(), any(), any()); } @Test @@ -230,7 +177,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { // 准备 List requestIds = Collections.singletonList("req-1"); IotDeviceMessageDO record = new IotDeviceMessageDO().setId("msg-1"); - when(deviceMessageMapper.selectListByRequestIdsAndReply(1L, requestIds, true)) + when(deviceMessageDAO.selectListByRequestIdsAndReply(1L, requestIds, true)) .thenReturn(Collections.singletonList(record)); // 调用 @@ -247,14 +194,14 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { @Test public void testGetDeviceMessageCount_whenCreateTimeNull_passNullToMapper() { // 准备 - when(deviceMessageMapper.selectCountByCreateTime(isNull())).thenReturn(123L); + when(deviceMessageDAO.selectCountByCreateTime(isNull())).thenReturn(123L); // 调用 Long count = service.getDeviceMessageCount(null); // 断言 assertEquals(123L, count); - verify(deviceMessageMapper).selectCountByCreateTime(isNull()); + verify(deviceMessageDAO).selectCountByCreateTime(isNull()); } @Test @@ -262,7 +209,7 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { // 准备:service 内部用 LocalDateTimeUtil.toEpochMilli 做转换,断言时也用同函数得到期望值 LocalDateTime createTime = LocalDateTime.of(2026, 1, 1, 0, 0); long expectedMs = LocalDateTimeUtil.toEpochMilli(createTime); - when(deviceMessageMapper.selectCountByCreateTime(expectedMs)).thenReturn(456L); + when(deviceMessageDAO.selectCountByCreateTime(expectedMs)).thenReturn(456L); // 调用 Long count = service.getDeviceMessageCount(createTime); @@ -271,6 +218,33 @@ public class IotDeviceMessageServiceImplTest extends BaseMockitoUnitTest { assertEquals(456L, count); } + // ========== getDeviceMessageSummaryByDate ========== + + @Test + public void testGetDeviceMessageSummaryByDate_success() { + // 准备参数:按天合并 2026-01-01 的小时统计 + IotStatisticsDeviceMessageReqVO reqVO = new IotStatisticsDeviceMessageReqVO(); + reqVO.setInterval(DateIntervalEnum.DAY.getInterval()); + LocalDateTime startTime = LocalDateTime.of(2026, 1, 1, 0, 0); + LocalDateTime endTime = LocalDateTime.of(2026, 1, 1, 23, 59, 59); + reqVO.setTimes(new LocalDateTime[]{startTime, endTime}); + + // mock 方法:DAO 统一返回 Timestamp 时间、Long 数量 + when(deviceMessageDAO.selectDeviceMessageCountGroupByDate( + LocalDateTimeUtil.toEpochMilli(startTime), LocalDateTimeUtil.toEpochMilli(endTime))) + .thenReturn(Arrays.asList( + IotDeviceMessageDAO.buildMessageCount(Timestamp.valueOf(startTime.withHour(1)), 3L, 1L), + IotDeviceMessageDAO.buildMessageCount(Timestamp.valueOf(startTime.withHour(2)), 2L, 0L))); + + // 调用 + List list = service.getDeviceMessageSummaryByDate(reqVO); + + // 断言 + assertEquals(1, list.size()); + assertEquals(5, list.get(0).getUpstreamCount()); + assertEquals(1, list.get(0).getDownstreamCount()); + } + // ========== 辅助方法 ========== /** 构造一条最简消息(指定 method 决定上下行分支) */ diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java index 67be85e..e5f97c3 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java @@ -1,6 +1,10 @@ package cn.iocoder.yudao.module.iot.service.device.property; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.map.MapUtil; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; @@ -9,10 +13,10 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO; -import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper; +import cn.iocoder.yudao.module.iot.dal.storage.IotDevicePropertyDAO; import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum; -import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; import org.junit.jupiter.api.Test; @@ -22,12 +26,14 @@ import org.mockito.Mock; import java.time.LocalDateTime; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DEVICE_PROPERTY_HISTORY_TIMES_INVALID; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.PRODUCT_NOT_EXISTS; import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -48,7 +54,9 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { @Mock private IotProductService productService; @Mock - private IotDevicePropertyMapper devicePropertyMapper; + private IotDeviceService deviceService; + @Mock + private IotDevicePropertyDAO devicePropertyDAO; @Mock private DevicePropertyRedisDAO deviceDataRedisDAO; @@ -97,7 +105,7 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { service.saveDeviceProperty(device, message); // 断言:没有合法属性,不会写入 TDengine 与 Redis - verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong()); + verify(devicePropertyDAO, never()).insert(any(), any(), anyLong(), anyLong()); verify(deviceDataRedisDAO, never()).putAll(anyLong(), any()); } @@ -119,7 +127,7 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { assertDoesNotThrow(() -> service.saveDeviceProperty(device, message)); // 断言:没有合法属性,不会写入 TDengine 与 Redis - verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong()); + verify(devicePropertyDAO, never()).insert(any(), any(), anyLong(), anyLong()); verify(deviceDataRedisDAO, never()).putAll(anyLong(), any()); } @@ -141,7 +149,7 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { // 断言:跳过空值,不会转换属性值,也不会写入 TDengine 与 Redis verify(thingModelService, never()).convertThingModelPropertyValue(any(), any()); - verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong()); + verify(devicePropertyDAO, never()).insert(any(), any(), anyLong(), anyLong()); verify(deviceDataRedisDAO, never()).putAll(anyLong(), any()); } @@ -170,31 +178,149 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { } @Test - public void testDefineDevicePropertyData_fieldNameToLowerCase() { - // 准备参数:全大写缩写和驼峰缩写都需要转换为 TDengine 实际的小写字段名 + public void testDefineDevicePropertyData_success() { + // 准备参数:只有属性类型的物模型,才需要定义存储结构 Long productId = 2L; IotProductDO product = IotProductDO.builder().id(productId).build(); - List thingModels = Arrays.asList( - buildThingModel("Ua", IotDataSpecsDataTypeEnum.FLOAT.getDataType()), - buildThingModel("PfT", IotDataSpecsDataTypeEnum.FLOAT.getDataType()), - buildThingModel("PT", IotDataSpecsDataTypeEnum.FLOAT.getDataType()), - buildThingModel("PA", IotDataSpecsDataTypeEnum.FLOAT.getDataType())); - thingModels.forEach(thingModel -> thingModel.setType(IotThingModelTypeEnum.PROPERTY.getType())); + IotThingModelDO temperature = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.FLOAT.getDataType()); + temperature.setType(IotThingModelTypeEnum.PROPERTY.getType()); + IotThingModelDO alarm = buildThingModel("Alarm", IotDataSpecsDataTypeEnum.INT.getDataType()); + alarm.setType(IotThingModelTypeEnum.EVENT.getType()); // mock 方法 when(productService.validateProductExists(productId)).thenReturn(product); - when(thingModelService.getThingModelListByProductId(productId)).thenReturn(thingModels); - when(devicePropertyMapper.getProductPropertySTableFieldList(productId)).thenReturn(Collections.emptyList()); + when(thingModelService.getThingModelListByProductId(productId)).thenReturn(Arrays.asList(temperature, alarm)); // 调用 service.defineDevicePropertyData(productId); - // 断言:字段名统一为小写下划线,避免 PT 和数据库中的 pt 被误判为不同字段 - ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); - verify(devicePropertyMapper).createProductPropertySTable(eq(productId), captor.capture()); - assertEquals(Arrays.asList("ua", "pf_t", "pt", "pa"), captor.getValue().stream() - .map(TDengineTableField::getField) - .collect(Collectors.toList())); + // 断言:只传入属性物模型,由存储 DAO 完成字段映射与差异比较 + verify(devicePropertyDAO).defineProductPropertyTable(eq(productId), eq(singletonList(temperature))); + } + + @Test + public void testDefineDevicePropertyData_productNotExists() { + // 准备参数 + Long productId = 2L; + + // mock 方法:产品不存在 + when(productService.validateProductExists(productId)) + .thenThrow(exception(PRODUCT_NOT_EXISTS)); + + // 调用,并断言 + assertServiceException(() -> service.defineDevicePropertyData(productId), PRODUCT_NOT_EXISTS); + verify(devicePropertyDAO, never()).defineProductPropertyTable(any(), any()); + } + + @Test + public void testSaveDeviceProperty_success() { + // 准备参数 + IotDeviceDO device = buildDevice(); + IotThingModelDO thingModel = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.INT.getDataType()); + Map params = new HashMap<>(); + params.put("Temperature", 36); + IotDeviceMessage message = buildMessage(params); + + // mock 方法 + when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) + .thenReturn(singletonList(thingModel)); + when(thingModelService.convertThingModelPropertyValue(thingModel, 36)).thenReturn(36); + + // 调用 + long before = System.currentTimeMillis(); + service.saveDeviceProperty(device, message); + long after = System.currentTimeMillis(); + + // 断言:ts 为存储时间,reportTime 为上报时间 + ArgumentCaptor tsCaptor = ArgumentCaptor.forClass(Long.class); + verify(devicePropertyDAO).insert(eq(device), eq(MapUtil.of("Temperature", 36)), tsCaptor.capture(), + eq(LocalDateTimeUtil.toEpochMilli(message.getReportTime()))); + assertTrue(tsCaptor.getValue() >= before && tsCaptor.getValue() <= after); + } + + // ========== getHistoryDevicePropertyList ========== + + @Test + public void testGetHistoryDevicePropertyList_success() { + // 准备参数 + IotDevicePropertyHistoryListReqVO reqVO = buildHistoryReqVO("Temperature", + LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 2, 0, 0)); + IotDevicePropertyRespVO respVO = new IotDevicePropertyRespVO(); + respVO.setValue(36); + respVO.setUpdateTime(1L); + + // mock 方法 + when(deviceService.getDevice(eq(1L))).thenReturn(buildDevice()); + when(devicePropertyDAO.selectHistoryList(eq(2L), same(reqVO))).thenReturn(singletonList(respVO)); + + // 调用 + List list = service.getHistoryDevicePropertyList(reqVO); + + // 断言:按设备的产品编号查询时序库;使用按租户、逻辑删除过滤的 getDevice,而不是忽略租户的缓存查询 + assertEquals(singletonList(respVO), list); + verify(deviceService, never()).getDeviceFromCache(anyLong()); + } + + @Test + public void testGetHistoryDevicePropertyList_timesNull() { + // 准备参数 + IotDevicePropertyHistoryListReqVO reqVO = buildHistoryReqVO("Temperature"); + reqVO.setTimes(null); + + // 调用,并断言 + assertServiceException(() -> service.getHistoryDevicePropertyList(reqVO), DEVICE_PROPERTY_HISTORY_TIMES_INVALID); + verifyNoInteractions(deviceService, devicePropertyDAO); + } + + @Test + public void testGetHistoryDevicePropertyList_timesLengthInvalid() { + // 准备参数 + IotDevicePropertyHistoryListReqVO reqVO = buildHistoryReqVO("Temperature", LocalDateTime.now()); + + // 调用,并断言 + assertServiceException(() -> service.getHistoryDevicePropertyList(reqVO), DEVICE_PROPERTY_HISTORY_TIMES_INVALID); + verifyNoInteractions(deviceService, devicePropertyDAO); + } + + @Test + public void testGetHistoryDevicePropertyList_timeNull() { + // 准备参数:开始时间为空、结束时间为空 + IotDevicePropertyHistoryListReqVO reqVO1 = buildHistoryReqVO("Temperature", null, LocalDateTime.now()); + IotDevicePropertyHistoryListReqVO reqVO2 = buildHistoryReqVO("Temperature", LocalDateTime.now(), null); + + // 调用,并断言:返回空,不查询设备与时序库 + assertTrue(service.getHistoryDevicePropertyList(reqVO1).isEmpty()); + assertTrue(service.getHistoryDevicePropertyList(reqVO2).isEmpty()); + verifyNoInteractions(deviceService, devicePropertyDAO); + } + + @Test + public void testGetHistoryDevicePropertyList_identifierInvalid() { + // 准备参数:保留字段、非法格式 + LocalDateTime now = LocalDateTime.now(); + IotDevicePropertyHistoryListReqVO reqVO1 = buildHistoryReqVO("reportTime", now, now); + IotDevicePropertyHistoryListReqVO reqVO2 = buildHistoryReqVO("ts", now, now); + IotDevicePropertyHistoryListReqVO reqVO3 = buildHistoryReqVO("a` OR 1=1", now, now); + + // 调用,并断言:返回空,不查询设备与时序库 + assertTrue(service.getHistoryDevicePropertyList(reqVO1).isEmpty()); + assertTrue(service.getHistoryDevicePropertyList(reqVO2).isEmpty()); + assertTrue(service.getHistoryDevicePropertyList(reqVO3).isEmpty()); + verifyNoInteractions(deviceService, devicePropertyDAO); + } + + @Test + public void testGetHistoryDevicePropertyList_deviceNotExists() { + // 准备参数 + LocalDateTime now = LocalDateTime.now(); + IotDevicePropertyHistoryListReqVO reqVO = buildHistoryReqVO("Temperature", now, now); + + // mock 方法:设备不存在、已删除,或者不属于当前租户 + when(deviceService.getDevice(eq(1L))).thenReturn(null); + + // 调用,并断言:返回空,不查询时序库 + assertTrue(service.getHistoryDevicePropertyList(reqVO).isEmpty()); + verifyNoInteractions(devicePropertyDAO); } // ========== 辅助方法 ========== @@ -228,12 +354,23 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { } /** - * 抓取 mapper.insert 的 properties 入参 + * 构造属性历史查询条件,设备编号固定为 1 + */ + private IotDevicePropertyHistoryListReqVO buildHistoryReqVO(String identifier, LocalDateTime... times) { + IotDevicePropertyHistoryListReqVO reqVO = new IotDevicePropertyHistoryListReqVO(); + reqVO.setDeviceId(1L); + reqVO.setIdentifier(identifier); + reqVO.setTimes(times); + return reqVO; + } + + /** + * 抓取 DAO.insert 的 properties 入参 */ @SuppressWarnings("unchecked") private Map captureMapperInsertProperties() { ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); - verify(devicePropertyMapper).insert(any(IotDeviceDO.class), captor.capture(), anyLong(), anyLong()); + verify(devicePropertyDAO).insert(any(IotDeviceDO.class), captor.capture(), anyLong(), anyLong()); return captor.getValue(); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImplTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImplTest.java index 2668c43..46f1a27 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImplTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImplTest.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.service.thingmodel; import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; @@ -15,11 +16,14 @@ import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.*; /** @@ -162,6 +166,51 @@ public class IotThingModelServiceImplTest extends BaseDbUnitTest { assertEquals(JsonUtils.toJsonString(value), result); } + @Test + public void testConvertThingModelPropertyValue_jsonMaxLength() { + // 准备参数:JSON 恰好 LENGTH_JSON 字节,包含中文、emoji 等多字节字符 + Map structValue = buildJsonValue(ThingModelProperty.LENGTH_JSON); + List arrayValue = singletonList(buildJsonValue(ThingModelProperty.LENGTH_JSON - 2)); + String structJson = JsonUtils.toJsonString(structValue); + String arrayJson = JsonUtils.toJsonString(arrayValue); + assertEquals(ThingModelProperty.LENGTH_JSON, structJson.getBytes(StandardCharsets.UTF_8).length); + assertEquals(ThingModelProperty.LENGTH_JSON, arrayJson.getBytes(StandardCharsets.UTF_8).length); + assertTrue(structJson.length() < ThingModelProperty.LENGTH_JSON); // 按字节而不是字符计算 + + // 调用,并断言:STRUCT、ARRAY 都可以写入 + assertEquals(structJson, thingModelService.convertThingModelPropertyValue( + buildThingModel("Location", IotDataSpecsDataTypeEnum.STRUCT.getDataType()), structValue)); + assertEquals(arrayJson, thingModelService.convertThingModelPropertyValue( + buildThingModel("Points", IotDataSpecsDataTypeEnum.ARRAY.getDataType()), arrayValue)); + } + + @Test + public void testConvertThingModelPropertyValue_jsonOverLength() { + // 准备参数:JSON 超过 LENGTH_JSON 1 个字节,包含中文、emoji 等多字节字符 + Map structValue = buildJsonValue(ThingModelProperty.LENGTH_JSON + 1); + List arrayValue = singletonList(buildJsonValue(ThingModelProperty.LENGTH_JSON - 1)); + assertEquals(ThingModelProperty.LENGTH_JSON + 1, JsonUtils.toJsonString(structValue).getBytes(StandardCharsets.UTF_8).length); + assertEquals(ThingModelProperty.LENGTH_JSON + 1, JsonUtils.toJsonString(arrayValue).getBytes(StandardCharsets.UTF_8).length); + + // 调用,并断言:STRUCT、ARRAY 都返回 null,不写入 + assertNull(thingModelService.convertThingModelPropertyValue( + buildThingModel("Location", IotDataSpecsDataTypeEnum.STRUCT.getDataType()), structValue)); + assertNull(thingModelService.convertThingModelPropertyValue( + buildThingModel("Points", IotDataSpecsDataTypeEnum.ARRAY.getDataType()), arrayValue)); + } + + /** + * 构建 JSON 序列化后恰好 jsonBytes 字节(UTF-8)的结构体,包含中文、emoji 等多字节字符 + */ + private static Map buildJsonValue(int jsonBytes) { + String prefix = "中文😀"; + Map value = new HashMap<>(); + value.put("name", prefix); + int padding = jsonBytes - JsonUtils.toJsonString(value).getBytes(StandardCharsets.UTF_8).length; + value.put("name", prefix + StrUtil.repeat('x', padding)); + return value; + } + private void assertBoolValueConvertedToByte(Object reportedValue, byte expected) { IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType()); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql index 8cf6c9d..b1cc3bd 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/resources/sql/create_tables.sql @@ -43,20 +43,23 @@ CREATE TABLE IF NOT EXISTS "iot_product" ( CREATE TABLE IF NOT EXISTS "iot_device" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "device_name" varchar(255) NOT NULL DEFAULT '', - "product_id" bigint NOT NULL, - "device_key" varchar(100) NOT NULL DEFAULT '', - "device_secret" varchar(100) NOT NULL DEFAULT '', "nickname" varchar(255) DEFAULT NULL, - "status" tinyint NOT NULL DEFAULT '0', - "status_last_update_time" timestamp DEFAULT NULL, - "last_online_time" timestamp DEFAULT NULL, - "last_offline_time" timestamp DEFAULT NULL, - "active_time" timestamp DEFAULT NULL, - "ip" varchar(50) DEFAULT NULL, - "firmware_version" varchar(50) DEFAULT NULL, - "device_type" tinyint NOT NULL DEFAULT '0', + "serial_number" varchar(255) DEFAULT NULL, + "pic_url" varchar(255) DEFAULT NULL, + "group_ids" varchar(255) DEFAULT NULL, + "product_id" bigint NOT NULL, + "product_key" varchar(100) DEFAULT NULL, + "device_type" tinyint DEFAULT NULL, "gateway_id" bigint DEFAULT NULL, - "sub_device_count" int NOT NULL DEFAULT '0', + "state" tinyint DEFAULT NULL, + "online_time" timestamp DEFAULT NULL, + "offline_time" timestamp DEFAULT NULL, + "active_time" timestamp DEFAULT NULL, + "firmware_id" bigint DEFAULT NULL, + "device_secret" varchar(100) DEFAULT NULL, + "latitude" decimal(10, 6) DEFAULT NULL, + "longitude" decimal(10, 6) DEFAULT NULL, + "config" varchar(4096) DEFAULT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, "updater" varchar(64) DEFAULT '', diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index ce4d787..a78ae76 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -55,6 +55,24 @@ spring: url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 username: root password: 123456 +# tdengine: # IoT 数据库(需要 IoT 物联网,且 yudao.iot.storage.type 为 tdengine 再开启噢!) +# lazy: true # 开启懒加载,保证启动速度 +# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true&enableAutoReconnect=true # 参见:https://t.zsxq.com/qaX9c,开启 TDengine WebSocket 自动重连 +# driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver +# username: root +# password: taosdata +# druid: +# validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL +# iotdb: # IoT 数据库(需要 IoT 物联网,且 yudao.iot.storage.type 为 iotdb 再开启噢!) +# lazy: true # 开启懒加载,保证启动速度 +# type: com.zaxxer.hikari.HikariDataSource # IoTDB 不使用 Druid 连接池 +# url: jdbc:iotdb://127.0.0.1:6667/ruoyi_vue_pro?sql_dialect=table # 表模型;数据库需要预先创建,且时间精度为 ms +# driver-class-name: org.apache.iotdb.jdbc.IoTDBDriver +# username: root +# password: root +# hikari: +# maximum-pool-size: 8 +# connection-test-query: SELECT 1 # IoTDB 数据源的有效性检查 SQL # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 data: diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index c8d4244..e5fbbd9 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -71,7 +71,7 @@ spring: url: jdbc:mysql://127.0.0.1:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true username: root password: 123456 -# tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) +# tdengine: # IoT 数据库(需要 IoT 物联网,且 yudao.iot.storage.type 为 tdengine 再开启噢!) # lazy: true # 开启懒加载,保证启动速度 # url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true&enableAutoReconnect=true # 参见:https://t.zsxq.com/qaX9c,开启 TDengine WebSocket 自动重连 # driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver @@ -79,6 +79,16 @@ spring: # password: taosdata # druid: # validation-query: SELECT SERVER_STATUS() # TDengine 数据源的有效性检查 SQL +# iotdb: # IoT 数据库(需要 IoT 物联网,且 yudao.iot.storage.type 为 iotdb 再开启噢!) +# lazy: true # 开启懒加载,保证启动速度 +# type: com.zaxxer.hikari.HikariDataSource # IoTDB 不使用 Druid 连接池 +# url: jdbc:iotdb://127.0.0.1:6667/ruoyi_vue_pro?sql_dialect=table # 表模型;数据库需要预先创建,且时间精度为 ms +# driver-class-name: org.apache.iotdb.jdbc.IoTDBDriver +# username: root +# password: root +# hikari: +# maximum-pool-size: 8 +# connection-test-query: SELECT 1 # IoTDB 数据源的有效性检查 SQL # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 data: diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index 6a64a10..3f49e16 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -368,5 +368,7 @@ yudao: iot: message-bus: type: redis # 消息总线的类型 + storage: + type: tdengine # 时序存储的类型:tdengine、iotdb,需要配置同名的 spring.datasource.dynamic.datasource 数据源 debug: false -- libgit2 0.21.4