|
|
|
|
@ -0,0 +1,323 @@
|
|
|
|
|
package cc.iotkit.openapi.sync;
|
|
|
|
|
|
|
|
|
|
import cc.iotkit.common.constant.Constants;
|
|
|
|
|
import cc.iotkit.common.thing.ThingModelMessage;
|
|
|
|
|
import cc.iotkit.data.manager.IDeviceInfoData;
|
|
|
|
|
import cc.iotkit.manager.config.DeviceSyncConfig;
|
|
|
|
|
import cc.iotkit.model.device.DeviceInfo;
|
|
|
|
|
import cc.iotkit.mq.ConsumerHandler;
|
|
|
|
|
import cc.iotkit.mq.MqConsumer;
|
|
|
|
|
import cn.hutool.core.util.ObjectUtil;
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
import lombok.AllArgsConstructor;
|
|
|
|
|
import lombok.Data;
|
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
import okhttp3.MediaType;
|
|
|
|
|
import okhttp3.OkHttpClient;
|
|
|
|
|
import okhttp3.Request;
|
|
|
|
|
import okhttp3.RequestBody;
|
|
|
|
|
import okhttp3.Response;
|
|
|
|
|
import org.apache.commons.lang3.StringUtils;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
import org.springframework.beans.factory.annotation.Qualifier;
|
|
|
|
|
import org.springframework.stereotype.Component;
|
|
|
|
|
|
|
|
|
|
import javax.annotation.PostConstruct;
|
|
|
|
|
import javax.annotation.PreDestroy;
|
|
|
|
|
import java.util.ArrayList;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
|
import java.util.concurrent.ExecutorService;
|
|
|
|
|
import java.util.concurrent.Executors;
|
|
|
|
|
import java.util.concurrent.LinkedBlockingQueue;
|
|
|
|
|
import java.util.concurrent.ScheduledExecutorService;
|
|
|
|
|
import java.util.concurrent.ThreadPoolExecutor;
|
|
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 实时数据转发器
|
|
|
|
|
* <p>
|
|
|
|
|
* 订阅物模型消息总线(THING_MODEL_MESSAGE_TOPIC),将设备上报/告警/故障/恢复/上下线
|
|
|
|
|
* 按协议翻译后推送至盐城 dockingData 与南京 deviceInfo/receive。
|
|
|
|
|
* 心跳按固定周期(默认 10 秒)定时推送,事件类消息即时推送并做简单去重。
|
|
|
|
|
* <p>
|
|
|
|
|
* 默认开关关闭,联调确认细节后再开启。
|
|
|
|
|
*
|
|
|
|
|
* @author codex
|
|
|
|
|
*/
|
|
|
|
|
@Slf4j
|
|
|
|
|
@Component
|
|
|
|
|
public class RealtimeDataForwarder implements ConsumerHandler<ThingModelMessage>, Runnable {
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
@Qualifier("deviceInfoDataCache")
|
|
|
|
|
private IDeviceInfoData deviceInfoData;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private MqConsumer<ThingModelMessage> thingModelMessageConsumer;
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
private DeviceSyncConfig config;
|
|
|
|
|
|
|
|
|
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
|
|
|
|
|
|
|
private final OkHttpClient httpClient = new OkHttpClient.Builder()
|
|
|
|
|
.connectTimeout(5, TimeUnit.SECONDS)
|
|
|
|
|
.readTimeout(10, TimeUnit.SECONDS)
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 设备最近一次上报数据缓存:deviceId -> 数据
|
|
|
|
|
*/
|
|
|
|
|
private final Map<String, DeviceLatestData> latestDataCache = new ConcurrentHashMap<>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 事件去重:deviceName:dedupKey -> 上次推送时间
|
|
|
|
|
*/
|
|
|
|
|
private final Map<String, Long> dedupCache = new ConcurrentHashMap<>();
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 推送线程池:与消息总线消费解耦,避免阻塞规则引擎
|
|
|
|
|
*/
|
|
|
|
|
private final ExecutorService pushPool = new ThreadPoolExecutor(2, 8, 60L, TimeUnit.SECONDS,
|
|
|
|
|
new LinkedBlockingQueue<>(200), new ThreadPoolExecutor.CallerRunsPolicy());
|
|
|
|
|
|
|
|
|
|
private ScheduledExecutorService heartbeatScheduler;
|
|
|
|
|
|
|
|
|
|
@PostConstruct
|
|
|
|
|
public void init() {
|
|
|
|
|
thingModelMessageConsumer.consume(Constants.THING_MODEL_MESSAGE_TOPIC, this);
|
|
|
|
|
heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
|
|
|
|
Thread t = new Thread(r, "realtime-data-heartbeat");
|
|
|
|
|
t.setDaemon(true);
|
|
|
|
|
return t;
|
|
|
|
|
});
|
|
|
|
|
int interval = Math.max(1, config.getHeartbeatIntervalSeconds());
|
|
|
|
|
heartbeatScheduler.scheduleAtFixedRate(this, interval, interval, TimeUnit.SECONDS);
|
|
|
|
|
log.info("实时数据转发器已启动:heartbeatInterval={}s, yanchengRealtime={}, nanjing={}",
|
|
|
|
|
interval, config.isYanchengRealtimeEnable(), config.isNanjingEnable());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@PreDestroy
|
|
|
|
|
public void destroy() {
|
|
|
|
|
if (heartbeatScheduler != null) {
|
|
|
|
|
heartbeatScheduler.shutdown();
|
|
|
|
|
}
|
|
|
|
|
pushPool.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public void handler(ThingModelMessage msg) {
|
|
|
|
|
try {
|
|
|
|
|
if (!config.isYanchengRealtimeEnable() && !config.isNanjingEnable()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (msg == null || StringUtils.isBlank(msg.getDeviceName())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
DeviceInfo device = deviceInfoData.findByDeviceName(msg.getDeviceName());
|
|
|
|
|
if (device == null || !inSyncProducts(device.getProductKey())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 设备上下线
|
|
|
|
|
if (ThingModelMessage.TYPE_STATE.equals(msg.getType())) {
|
|
|
|
|
if (ThingModelMessage.ID_OFFLINE.equals(msg.getIdentifier())) {
|
|
|
|
|
latestDataCache.remove(device.getDeviceId());
|
|
|
|
|
pushOffline(device);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 仅处理属性上报
|
|
|
|
|
if (!ThingModelMessage.TYPE_PROPERTY.equals(msg.getType())
|
|
|
|
|
|| !ThingModelMessage.ID_PROPERTY_REPORT.equals(msg.getIdentifier())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!(msg.getData() instanceof Map)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
|
Map<String, Object> data = (Map<String, Object>) msg.getData();
|
|
|
|
|
long time = msg.getTime() != null ? msg.getTime() : System.currentTimeMillis();
|
|
|
|
|
latestDataCache.put(device.getDeviceId(), new DeviceLatestData(time, data, device));
|
|
|
|
|
|
|
|
|
|
RealtimeProtocolMapper.ClassifiedEvent event = RealtimeProtocolMapper.classify(data);
|
|
|
|
|
if (event == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 事件类消息去重,心跳由定时任务负责
|
|
|
|
|
if (!tryAcquireDedup(device.getDeviceName(), event.getDedupKey())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pushEvent(event, device, data, time);
|
|
|
|
|
} catch (Throwable e) {
|
|
|
|
|
log.error("实时数据转发处理异常:deviceName={}",
|
|
|
|
|
msg != null ? msg.getDeviceName() : null, e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 定时心跳:按固定周期扫描最近有上报的设备,推送盐城 102 / 南京 01
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
public void run() {
|
|
|
|
|
try {
|
|
|
|
|
if (!config.isYanchengRealtimeEnable() && !config.isNanjingEnable()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
long now = System.currentTimeMillis();
|
|
|
|
|
long staleAfter = Math.max(1, config.getHeartbeatIntervalSeconds()) * 3L * 1000L;
|
|
|
|
|
List<String> staleKeys = new ArrayList<>();
|
|
|
|
|
for (Map.Entry<String, DeviceLatestData> entry : latestDataCache.entrySet()) {
|
|
|
|
|
DeviceLatestData latest = entry.getValue();
|
|
|
|
|
try {
|
|
|
|
|
if (now - latest.getTime() > staleAfter) {
|
|
|
|
|
staleKeys.add(entry.getKey());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
RealtimeProtocolMapper.ClassifiedEvent event = buildHeartbeatEvent(latest.getData());
|
|
|
|
|
pushEvent(event, latest.getDevice(), latest.getData(), latest.getTime());
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.error("定时心跳推送异常:deviceName={}", latest.getDevice().getDeviceName(), e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
staleKeys.forEach(latestDataCache::remove);
|
|
|
|
|
} catch (Throwable e) {
|
|
|
|
|
log.error("定时心跳推送异常", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private RealtimeProtocolMapper.ClassifiedEvent buildHeartbeatEvent(Map<String, Object> data) {
|
|
|
|
|
RealtimeProtocolMapper.ClassifiedEvent event = RealtimeProtocolMapper.classify(data);
|
|
|
|
|
if (event == null) {
|
|
|
|
|
event = new RealtimeProtocolMapper.ClassifiedEvent();
|
|
|
|
|
event.setNodeId(RealtimeProtocolMapper.resolveNodeId(data));
|
|
|
|
|
}
|
|
|
|
|
event.setKind(RealtimeProtocolMapper.EVENT_HEARTBEAT);
|
|
|
|
|
event.setGasSensorState(0);
|
|
|
|
|
event.setErrorCode(0);
|
|
|
|
|
event.setContent("正常");
|
|
|
|
|
event.setDedupKey("heartbeat");
|
|
|
|
|
return event;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void pushOffline(DeviceInfo device) {
|
|
|
|
|
RealtimeProtocolMapper.ClassifiedEvent event = new RealtimeProtocolMapper.ClassifiedEvent();
|
|
|
|
|
event.setKind(RealtimeProtocolMapper.EVENT_OFFLINE);
|
|
|
|
|
event.setGasSensorState(0);
|
|
|
|
|
event.setErrorCode(10);
|
|
|
|
|
event.setContent("离线");
|
|
|
|
|
event.setDedupKey("offline");
|
|
|
|
|
if (!tryAcquireDedup(device.getDeviceName(), event.getDedupKey())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pushEvent(event, device, null, System.currentTimeMillis());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void pushEvent(RealtimeProtocolMapper.ClassifiedEvent event, DeviceInfo device,
|
|
|
|
|
Map<String, Object> data, long time) {
|
|
|
|
|
if (config.isYanchengRealtimeEnable()) {
|
|
|
|
|
Map<String, Object> ycBody = RealtimeProtocolMapper.buildYanchengBody(
|
|
|
|
|
event, device, data, time, config.getDockingCompany());
|
|
|
|
|
sendYancheng(ycBody, device.getDeviceName());
|
|
|
|
|
}
|
|
|
|
|
if (config.isNanjingEnable()) {
|
|
|
|
|
Map<String, Object> njBody = RealtimeProtocolMapper.buildNanjingBody(
|
|
|
|
|
event, device, data, time, config.getDockingCompany());
|
|
|
|
|
sendNanjing(njBody, device.getDeviceName());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void sendYancheng(Map<String, Object> body, String deviceName) {
|
|
|
|
|
if (StringUtils.isBlank(config.getYanchengRealtimeUrl())) {
|
|
|
|
|
log.warn("盐城实时数据 URL 未配置,跳过:deviceName={}", deviceName);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pushPool.submit(() -> {
|
|
|
|
|
int maxRetry = Math.max(1, config.getRetry());
|
|
|
|
|
for (int i = 0; i < maxRetry; i++) {
|
|
|
|
|
try {
|
|
|
|
|
Request request = new Request.Builder()
|
|
|
|
|
.url(config.getYanchengRealtimeUrl())
|
|
|
|
|
.post(RequestBody.create(objectMapper.writeValueAsString(body),
|
|
|
|
|
MediaType.parse("application/json; charset=utf-8")))
|
|
|
|
|
.build();
|
|
|
|
|
try (Response response = httpClient.newCall(request).execute()) {
|
|
|
|
|
if (!response.isSuccessful()) {
|
|
|
|
|
throw new RuntimeException("HTTP " + response.code());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
log.info("盐城实时数据推送成功:deviceName={}, serviceType={}, body={}",
|
|
|
|
|
deviceName, body.get("serviceType"), objectMapper.writeValueAsString(body));
|
|
|
|
|
return;
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.warn("盐城实时数据推送失败,第{}次:deviceName={}, error={}",
|
|
|
|
|
i + 1, deviceName, e.getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void sendNanjing(Map<String, Object> body, String deviceName) {
|
|
|
|
|
if (StringUtils.isBlank(config.getNanjingReportUrl())) {
|
|
|
|
|
log.warn("南京数据上报 URL 未配置,跳过:deviceName={}", deviceName);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
pushPool.submit(() -> {
|
|
|
|
|
int maxRetry = Math.max(1, config.getRetry());
|
|
|
|
|
for (int i = 0; i < maxRetry; i++) {
|
|
|
|
|
try {
|
|
|
|
|
Request request = new Request.Builder()
|
|
|
|
|
.url(config.getNanjingReportUrl())
|
|
|
|
|
.post(RequestBody.create(objectMapper.writeValueAsString(body),
|
|
|
|
|
MediaType.parse("application/json; charset=utf-8")))
|
|
|
|
|
.build();
|
|
|
|
|
try (Response response = httpClient.newCall(request).execute()) {
|
|
|
|
|
if (!response.isSuccessful()) {
|
|
|
|
|
throw new RuntimeException("HTTP " + response.code());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
log.info("南京数据上报推送成功:deviceName={}, messageType={}, body={}",
|
|
|
|
|
deviceName, body.get("messageType"), objectMapper.writeValueAsString(body));
|
|
|
|
|
return;
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
log.warn("南京数据上报推送失败,第{}次:deviceName={}, error={}",
|
|
|
|
|
i + 1, deviceName, e.getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean inSyncProducts(String productKey) {
|
|
|
|
|
if (ObjectUtil.isEmpty(config.getSyncProductKeys())) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return config.getSyncProductKeys().contains(productKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean tryAcquireDedup(String deviceName, String dedupKey) {
|
|
|
|
|
if (StringUtils.isBlank(dedupKey)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
long now = System.currentTimeMillis();
|
|
|
|
|
long dedupMs = Math.max(1, config.getDedupSeconds()) * 1000L;
|
|
|
|
|
String key = deviceName + ":" + dedupKey;
|
|
|
|
|
Long last = dedupCache.get(key);
|
|
|
|
|
if (last != null && now - last < dedupMs) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
dedupCache.put(key, now);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Data
|
|
|
|
|
@AllArgsConstructor
|
|
|
|
|
private static class DeviceLatestData {
|
|
|
|
|
private long time;
|
|
|
|
|
private Map<String, Object> data;
|
|
|
|
|
private DeviceInfo device;
|
|
|
|
|
}
|
|
|
|
|
}
|