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