Commit 8ff336369fc6d12445e7e672a062bed9a01b7822

Authored by YunaiV
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
  1 +/**
  2 + * iot 模块的时序数据存储 DAO 接口:设备属性、设备消息的统一契约,与具体的时序库无关
  3 + *
  4 + * 1. Service 只依赖本包的 DAO 接口
  5 + * 2. 实现类分别在 dal/tdengine、dal/iotdb 包下,由 yudao.iot.storage.type 统一选择其中一个
  6 + */
  7 +package cn.iocoder.yudao.module.iot.dal.storage;
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
  1 +/**
  2 + * iot 模块的 IoTDB 拓展封装
  3 + */
  4 +package cn.iocoder.yudao.module.iot.framework.iotdb;
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
  1 +/**
  2 + * iot 模块的时序数据存储封装:通过 yudao.iot.storage.type 选择 TDengine 或 IoTDB
  3 + */
  4 +package cn.iocoder.yudao.module.iot.framework.storage;
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 &gt;= #{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 &gt;= #{startTime}
  121 + </if>
  122 + <if test="endTime != null">
  123 + AND time &lt;= #{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 &gt;= #{startTime}
  66 + AND time &lt;= #{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 &quot;iot_product&quot; ( @@ -43,20 +43,23 @@ CREATE TABLE IF NOT EXISTS &quot;iot_product&quot; (
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