Commit 7e35f1dab9c7bb49d6637cb929c85a42acd26c30

Authored by YunaiV
1 parent 9705ce11

fix(iot): 统一 TDengine 设备属性字段名规范化

- 新增 TDengineTableField.buildFieldName,统一转换为小写下划线字段名
- 建表、写入、历史查询统一使用同一字段名生成规则
- 修复 PT/PA 等全大写 identifier 与 TDengine 小写字段比较不一致的问题
- 补充字段名转换契约测试和设备属性表结构同步测试
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 lombok.AllArgsConstructor; 4 import lombok.AllArgsConstructor;
4 import lombok.Data; 5 import lombok.Data;
5 import lombok.NoArgsConstructor; 6 import lombok.NoArgsConstructor;
6 7
  8 +import java.util.Locale;
  9 +
7 /** 10 /**
8 * TDEngine 表字段 11 * TDEngine 表字段
9 */ 12 */
@@ -61,4 +64,15 @@ public class TDengineTableField { @@ -61,4 +64,15 @@ public class TDengineTableField {
61 this.type = type; 64 this.type = type;
62 } 65 }
63 66
  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 +
64 } 78 }
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java
@@ -118,7 +118,7 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { @@ -118,7 +118,7 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
118 private List<TDengineTableField> buildTableFieldList(List<IotThingModelDO> thingModels) { 118 private List<TDengineTableField> buildTableFieldList(List<IotThingModelDO> thingModels) {
119 return convertList(thingModels, thingModel -> { 119 return convertList(thingModels, thingModel -> {
120 TDengineTableField field = new TDengineTableField( 120 TDengineTableField field = new TDengineTableField(
121 - StrUtil.toUnderlineCase(thingModel.getIdentifier()), // TDengine 字段默认都是小写 121 + TDengineTableField.buildFieldName(thingModel.getIdentifier()),
122 TYPE_MAPPING.get(thingModel.getProperty().getDataType())); 122 TYPE_MAPPING.get(thingModel.getProperty().getDataType()));
123 String dataType = thingModel.getProperty().getDataType(); 123 String dataType = thingModel.getProperty().getDataType();
124 if (Objects.equals(dataType, IotDataSpecsDataTypeEnum.TEXT.getDataType())) { 124 if (Objects.equals(dataType, IotDataSpecsDataTypeEnum.TEXT.getDataType())) {
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.hutool.core.util.StrUtil@toUnderlineCase(key)} 54 + ${@cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField@buildFieldName(key)}
55 </foreach> 55 </foreach>
56 ) 56 )
57 VALUES 57 VALUES
@@ -68,12 +68,12 @@ @@ -68,12 +68,12 @@
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.hutool.core.util.StrUtil@toUnderlineCase(reqVO.identifier)} AS `value`, ts AS update_time 71 + SELECT ${@cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField@buildFieldName(reqVO.identifier)} AS `value`, ts AS update_time
72 FROM device_property_${reqVO.deviceId} 72 FROM device_property_${reqVO.deviceId}
73 - WHERE ${@cn.hutool.core.util.StrUtil@toUnderlineCase(reqVO.identifier)} IS NOT NULL 73 + WHERE ${@cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField@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
77 </select> 77 </select>
78 78
79 -</mapper>  
80 \ No newline at end of file 79 \ No newline at end of file
  80 +</mapper>
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImplTest.java
@@ -5,11 +5,15 @@ import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; @@ -5,11 +5,15 @@ import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 5 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
6 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; 6 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
7 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; 7 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
  8 +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
8 import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; 9 import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
9 import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty; 10 import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty;
10 import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO; 11 import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO;
11 import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper; 12 import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper;
12 import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum; 13 import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum;
  14 +import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
  15 +import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField;
  16 +import cn.iocoder.yudao.module.iot.service.product.IotProductService;
13 import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; 17 import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
14 import org.junit.jupiter.api.Test; 18 import org.junit.jupiter.api.Test;
15 import org.mockito.ArgumentCaptor; 19 import org.mockito.ArgumentCaptor;
@@ -17,8 +21,12 @@ import org.mockito.InjectMocks; @@ -17,8 +21,12 @@ import org.mockito.InjectMocks;
17 import org.mockito.Mock; 21 import org.mockito.Mock;
18 22
19 import java.time.LocalDateTime; 23 import java.time.LocalDateTime;
  24 +import java.util.Arrays;
  25 +import java.util.Collections;
20 import java.util.HashMap; 26 import java.util.HashMap;
  27 +import java.util.List;
21 import java.util.Map; 28 import java.util.Map;
  29 +import java.util.stream.Collectors;
22 30
23 import static java.util.Collections.singletonList; 31 import static java.util.Collections.singletonList;
24 import static org.junit.jupiter.api.Assertions.*; 32 import static org.junit.jupiter.api.Assertions.*;
@@ -38,6 +46,8 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { @@ -38,6 +46,8 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
38 @Mock 46 @Mock
39 private IotThingModelService thingModelService; 47 private IotThingModelService thingModelService;
40 @Mock 48 @Mock
  49 + private IotProductService productService;
  50 + @Mock
41 private IotDevicePropertyMapper devicePropertyMapper; 51 private IotDevicePropertyMapper devicePropertyMapper;
42 @Mock 52 @Mock
43 private DevicePropertyRedisDAO deviceDataRedisDAO; 53 private DevicePropertyRedisDAO deviceDataRedisDAO;
@@ -100,12 +110,15 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { @@ -100,12 +110,15 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
100 params.put("Temperature", "abc"); 110 params.put("Temperature", "abc");
101 IotDeviceMessage message = buildMessage(params); 111 IotDeviceMessage message = buildMessage(params);
102 112
  113 + // mock 方法
103 when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) 114 when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
104 .thenReturn(singletonList(temperature)); 115 .thenReturn(singletonList(temperature));
105 when(thingModelService.convertThingModelPropertyValue(temperature, "abc")).thenReturn(null); 116 when(thingModelService.convertThingModelPropertyValue(temperature, "abc")).thenReturn(null);
106 117
  118 + // 调用,并断言:不会抛出异常
107 assertDoesNotThrow(() -> service.saveDeviceProperty(device, message)); 119 assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
108 120
  121 + // 断言:没有合法属性,不会写入 TDengine 与 Redis
109 verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong()); 122 verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
110 verify(deviceDataRedisDAO, never()).putAll(anyLong(), any()); 123 verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
111 } 124 }
@@ -119,11 +132,14 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { @@ -119,11 +132,14 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
119 params.put("Temperature", null); 132 params.put("Temperature", null);
120 IotDeviceMessage message = buildMessage(params); 133 IotDeviceMessage message = buildMessage(params);
121 134
  135 + // mock 方法
122 when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) 136 when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
123 .thenReturn(singletonList(thingModel)); 137 .thenReturn(singletonList(thingModel));
124 138
  139 + // 调用,并断言:不会抛出异常
125 assertDoesNotThrow(() -> service.saveDeviceProperty(device, message)); 140 assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
126 141
  142 + // 断言:跳过空值,不会转换属性值,也不会写入 TDengine 与 Redis
127 verify(thingModelService, never()).convertThingModelPropertyValue(any(), any()); 143 verify(thingModelService, never()).convertThingModelPropertyValue(any(), any());
128 verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong()); 144 verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
129 verify(deviceDataRedisDAO, never()).putAll(anyLong(), any()); 145 verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
@@ -139,17 +155,48 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest { @@ -139,17 +155,48 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
139 params.put("PowerSwitch", true); 155 params.put("PowerSwitch", true);
140 IotDeviceMessage message = buildMessage(params); 156 IotDeviceMessage message = buildMessage(params);
141 157
  158 + // mock 方法
142 when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId())) 159 when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
143 .thenReturn(singletonList(thingModel)); 160 .thenReturn(singletonList(thingModel));
144 when(thingModelService.convertThingModelPropertyValue(thingModel, true)).thenReturn((byte) 1); 161 when(thingModelService.convertThingModelPropertyValue(thingModel, true)).thenReturn((byte) 1);
145 162
  163 + // 调用,并断言:非字符串 key 不影响其它合法属性
146 assertDoesNotThrow(() -> service.saveDeviceProperty(device, message)); 164 assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
147 165
  166 + // 断言:只写入合法属性
148 Map<String, Object> dbProperties = captureMapperInsertProperties(); 167 Map<String, Object> dbProperties = captureMapperInsertProperties();
149 assertEquals(1, dbProperties.size()); 168 assertEquals(1, dbProperties.size());
150 assertEquals((byte) 1, dbProperties.get("PowerSwitch")); 169 assertEquals((byte) 1, dbProperties.get("PowerSwitch"));
151 } 170 }
152 171
  172 + @Test
  173 + public void testDefineDevicePropertyData_fieldNameToLowerCase() {
  174 + // 准备参数:全大写缩写和驼峰缩写都需要转换为 TDengine 实际的小写字段名
  175 + Long productId = 2L;
  176 + 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()));
  183 +
  184 + // mock 方法
  185 + when(productService.validateProductExists(productId)).thenReturn(product);
  186 + when(thingModelService.getThingModelListByProductId(productId)).thenReturn(thingModels);
  187 + when(devicePropertyMapper.getProductPropertySTableFieldList(productId)).thenReturn(Collections.emptyList());
  188 +
  189 + // 调用
  190 + service.defineDevicePropertyData(productId);
  191 +
  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()));
  198 + }
  199 +
153 // ========== 辅助方法 ========== 200 // ========== 辅助方法 ==========
154 201
155 /** 202 /**