2026-06-24 f4bd1f3c89d906131495a0aca5aaf82966378510
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
package cn.iocoder.yudao.module.iot.service.device.property;
 
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO;
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO;
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDateOrTextDataSpecs;
import cn.iocoder.yudao.module.iot.dal.redis.device.DevicePropertyRedisDAO;
import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceReportTimeRedisDAO;
import cn.iocoder.yudao.module.iot.dal.redis.device.DeviceServerIdRedisDAO;
import cn.iocoder.yudao.module.iot.dal.tdengine.IotDevicePropertyMapper;
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum;
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField;
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
 
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
 
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.getBigDecimal;
 
/**
 * IoT 设备【属性】数据 Service 实现类
 *
 * @author 芋道源码
 */
@Service
@Slf4j
public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
 
    /**
     * 物模型的数据类型,与 TDengine 数据类型的映射关系
     *
     * @see <a href="https://docs.taosdata.com/reference/taos-sql/data-type/">TDEngine 数据类型</a>
     */
    private static final Map<String, String> TYPE_MAPPING = MapUtil.<String, String>builder()
            .put(IotDataSpecsDataTypeEnum.INT.getDataType(), TDengineTableField.TYPE_INT)
            .put(IotDataSpecsDataTypeEnum.FLOAT.getDataType(), TDengineTableField.TYPE_FLOAT)
            .put(IotDataSpecsDataTypeEnum.DOUBLE.getDataType(), TDengineTableField.TYPE_DOUBLE)
            .put(IotDataSpecsDataTypeEnum.ENUM.getDataType(), TDengineTableField.TYPE_TINYINT)
            .put(IotDataSpecsDataTypeEnum.BOOL.getDataType(), TDengineTableField.TYPE_TINYINT)
            .put(IotDataSpecsDataTypeEnum.TEXT.getDataType(), TDengineTableField.TYPE_VARCHAR)
            .put(IotDataSpecsDataTypeEnum.DATE.getDataType(), TDengineTableField.TYPE_TIMESTAMP)
            .put(IotDataSpecsDataTypeEnum.STRUCT.getDataType(), TDengineTableField.TYPE_VARCHAR)
            .put(IotDataSpecsDataTypeEnum.ARRAY.getDataType(), TDengineTableField.TYPE_VARCHAR)
            .build();
 
    @Resource
    private IotThingModelService thingModelService;
    @Resource
    @Lazy  // 延迟加载,解决循环依赖
    private IotProductService productService;
    @Resource
    @Lazy  // 延迟加载,解决循环依赖
    private IotDeviceService deviceService;
 
    @Resource
    private DevicePropertyRedisDAO deviceDataRedisDAO;
    @Resource
    private DeviceReportTimeRedisDAO deviceReportTimeRedisDAO;
    @Resource
    private DeviceServerIdRedisDAO deviceServerIdRedisDAO;
 
    @Resource
    private IotDevicePropertyMapper devicePropertyMapper;
 
    // ========== 设备属性相关操作 ==========
 
    @Override
    public void defineDevicePropertyData(Long productId) {
        // 1.1 查询产品和物模型
        IotProductDO product = productService.validateProductExists(productId);
        List<IotThingModelDO> thingModels = filterList(thingModelService.getThingModelListByProductId(productId),
                thingModel -> IotThingModelTypeEnum.PROPERTY.getType().equals(thingModel.getType()));
        // 1.2 解析 DB 里的字段
        List<TDengineTableField> oldFields = new ArrayList<>();
        try {
            oldFields.addAll(devicePropertyMapper.getProductPropertySTableFieldList(product.getId()));
        } catch (Exception e) {
            if (!e.getMessage().contains("Table does not exist")) {
                throw e;
            }
        }
 
        // 2.1 情况一:如果是新增的时候,需要创建表
        List<TDengineTableField> newFields = buildTableFieldList(thingModels);
        if (CollUtil.isEmpty(oldFields)) {
            if (CollUtil.isEmpty(newFields)) {
                log.info("[defineDevicePropertyData][productId({}) 没有需要定义的属性]", productId);
                return;
            }
            devicePropertyMapper.createProductPropertySTable(product.getId(), newFields);
            return;
        }
        // 2.2 情况二:如果是修改的时候,需要更新表
        devicePropertyMapper.alterProductPropertySTable(product.getId(), oldFields, newFields);
    }
 
    private List<TDengineTableField> buildTableFieldList(List<IotThingModelDO> thingModels) {
        return convertList(thingModels, thingModel -> {
            TDengineTableField field = new TDengineTableField(
                    StrUtil.toUnderlineCase(thingModel.getIdentifier()), // TDengine 字段默认都是小写
                    TYPE_MAPPING.get(thingModel.getProperty().getDataType()));
            String dataType = thingModel.getProperty().getDataType();
            if (Objects.equals(dataType, IotDataSpecsDataTypeEnum.TEXT.getDataType())) {
                field.setLength(((ThingModelDateOrTextDataSpecs) thingModel.getProperty().getDataSpecs()).getLength());
            } else if (ObjectUtils.equalsAny(dataType, IotDataSpecsDataTypeEnum.STRUCT.getDataType(),
                    IotDataSpecsDataTypeEnum.ARRAY.getDataType())) {
                field.setLength(TDengineTableField.LENGTH_VARCHAR);
            }
            return field;
        });
    }
 
    @Override
    @SuppressWarnings("PatternVariableCanBeUsed")
    public void saveDeviceProperty(IotDeviceDO device, IotDeviceMessage message) {
        if (!(message.getParams() instanceof Map)) {
            log.error("[saveDeviceProperty][消息内容({}) 的 data 类型不正确]", message);
            return;
        }
        Map<?, ?> params = (Map<?, ?>) message.getParams();
        if (CollUtil.isEmpty(params)) {
            log.error("[saveDeviceProperty][消息内容({}) 的 data 为空]", message);
            return;
        }
 
        // 1. 根据物模型,拼接合法的属性
        // TODO @芋艿:【待定 004】赋能后,属性到底以 thingModel 为准(ik),还是 db 的表结构为准(tl)?
        List<IotThingModelDO> thingModels = thingModelService.getThingModelListByProductIdFromCache(device.getProductId());
        Map<String, Object> properties = new HashMap<>();
        params.forEach((key, value) -> {
            // 忽略大小写匹配物模型,避免设备上报的 key 与 identifier 大小写不一致导致丢失
            IotThingModelDO thingModel = CollUtil.findOne(thingModels,
                    o -> StrUtil.equalsIgnoreCase(o.getIdentifier(), (CharSequence) key));
            if (thingModel == null || thingModel.getProperty() == null) {
                log.error("[saveDeviceProperty][消息({}) 的属性({}) 不存在]", message, key);
                return;
            }
            String identifier = thingModel.getIdentifier(); // 统一以物模型 identifier 作为 key,避免大小写问题
            String dataType = thingModel.getProperty().getDataType();
            if (ObjectUtils.equalsAny(dataType,
                    IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) {
                // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储
                properties.put(identifier, JsonUtils.toJsonString(value));
            } else if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) {
                properties.put(identifier, Convert.toInt(value));
            } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(dataType)) {
                properties.put(identifier, Convert.toFloat(value));
            } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(dataType)) {
                properties.put(identifier, Convert.toDouble(value));
            } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) {
                properties.put(identifier, Convert.toBool(value, false) ? (byte) 1 : (byte) 0);
            } else {
                properties.put(identifier, value);
            }
        });
        if (CollUtil.isEmpty(properties)) {
            log.error("[saveDeviceProperty][消息({}) 没有合法的属性]", message);
        } else {
            // 2.1 保存设备属性【数据】
            devicePropertyMapper.insert(device, properties,
                    System.currentTimeMillis(),
                    LocalDateTimeUtil.toEpochMilli(message.getReportTime()));
 
            // 2.2 保存设备属性【日志】
            Map<String, IotDevicePropertyDO> properties2 = convertMap(properties.entrySet(), Map.Entry::getKey, entry ->
                    IotDevicePropertyDO.builder().value(entry.getValue()).updateTime(message.getReportTime()).build());
            deviceDataRedisDAO.putAll(device.getId(), properties2);
        }
 
        // 2.3 提取 GeoLocation 并更新设备定位
        // 为什么 properties 为空,也要执行定位更新?因为可能上报的属性里,没有合法属性,但是包含 GeoLocation 定位属性
        extractAndUpdateDeviceLocation(device, (Map<?, ?>) message.getParams());
    }
 
    @Override
    public Map<String, IotDevicePropertyDO> getLatestDeviceProperties(Long deviceId) {
        return deviceDataRedisDAO.get(deviceId);
    }
 
    @Override
    public List<IotDevicePropertyRespVO> getHistoryDevicePropertyList(IotDevicePropertyHistoryListReqVO listReqVO) {
        try {
            return devicePropertyMapper.selectListByHistory(listReqVO);
        } catch (Exception exception) {
            if (exception.getMessage().contains("Table does not exist")) {
                return Collections.emptyList();
            }
            throw exception;
        }
    }
 
    // ========== 设备时间相关操作 ==========
 
    @Override
    public Set<Long> getDeviceIdListByReportTime(LocalDateTime maxReportTime) {
        return deviceReportTimeRedisDAO.range(maxReportTime);
    }
 
    @Override
    @Async
    public void updateDeviceReportTimeAsync(Long id, LocalDateTime reportTime) {
        deviceReportTimeRedisDAO.update(id, reportTime);
    }
 
    @Override
    public void updateDeviceServerIdAsync(Long id, String serverId) {
        if (StrUtil.isEmpty(serverId)) {
            return;
        }
        deviceServerIdRedisDAO.update(id, serverId);
    }
 
    @Override
    public String getDeviceServerId(Long id) {
        return deviceServerIdRedisDAO.get(id);
    }
 
    // ========== 设备定位相关操作 ==========
 
    /**
     * 从属性中提取 GeoLocation 并更新设备定位
     *
     * @see <a href="https://help.aliyun.com/zh/iot/user-guide/device-geolocation">阿里云规范</a>
     * GeoLocation 结构体包含:Longitude, Latitude, Altitude, CoordinateSystem
     */
    private void extractAndUpdateDeviceLocation(IotDeviceDO device, Map<?, ?> params) {
        // 1. 解析 GeoLocation 经纬度坐标
        BigDecimal[] location = parseGeoLocation(params);
        if (location == null) {
            return;
        }
 
        // 2. 更新设备定位
        deviceService.updateDeviceLocation(device, location[0], location[1]);
        log.info("[extractAndUpdateGeoLocation][设备({}) 定位更新: lng={}, lat={}]",
                device.getId(), location[0], location[1]);
    }
 
    /**
     * 从属性参数中解析 GeoLocation,返回经纬度坐标数组 [longitude, latitude]
     *
     * @param params 属性参数
     * @return [经度, 纬度],解析失败返回 null
     */
    @SuppressWarnings("unchecked")
    private BigDecimal[] parseGeoLocation(Map<?, ?> params) {
        if (params == null) {
            return null;
        }
        // 1. 查找 GeoLocation 属性(标识符为 GeoLocation 或 geoLocation)
        Object geoValue = params.get("GeoLocation");
        if (geoValue == null) {
            geoValue = params.get("geoLocation");
        }
        if (geoValue == null) {
            return null;
        }
 
        // 2. 转换为 Map
        Map<String, Object> geoLocation = null;
        if (geoValue instanceof Map) {
            geoLocation = (Map<String, Object>) geoValue;
        } else if (geoValue instanceof String) {
            geoLocation = JsonUtils.parseObject((String) geoValue, Map.class);
        }
        if (geoLocation == null) {
            return null;
        }
 
        // 3. 提取经纬度(支持阿里云命名规范:首字母大写)
        BigDecimal longitude = getBigDecimal(geoLocation, "Longitude");
        if (longitude == null) {
            longitude = getBigDecimal(geoLocation, "longitude");
        }
        BigDecimal latitude = getBigDecimal(geoLocation, "Latitude");
        if (latitude == null) {
            latitude = getBigDecimal(geoLocation, "latitude");
        }
        if (longitude == null || latitude == null) {
            return null;
        }
        // 校验经纬度范围:经度 -180 到 180,纬度 -90 到 90
        if (longitude.compareTo(BigDecimal.valueOf(-180)) < 0 || longitude.compareTo(BigDecimal.valueOf(180)) > 0
                || latitude.compareTo(BigDecimal.valueOf(-90)) < 0 || latitude.compareTo(BigDecimal.valueOf(90)) > 0) {
            log.warn("[parseGeoLocation][经纬度超出有效范围: lng={}, lat={}]", longitude, latitude);
            return null;
        }
        return new BigDecimal[]{longitude, latitude};
    }
 
}