feat: make gb32960 archive history query production ready
This commit is contained in:
55
modules/inbound/inbound-mqtt/pom.xml
Normal file
55
modules/inbound/inbound-mqtt/pom.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>inbound-mqtt</artifactId>
|
||||
<name>inbound-mqtt</name>
|
||||
<description>MQTT 接入模块(支持多 endpoint,宇通等),HiveMQ MQTT Client + 双向 TLS。</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>vehicle-identity</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.hivemq</groupId>
|
||||
<artifactId>hivemq-mqtt-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.client;
|
||||
|
||||
import com.hivemq.client.mqtt.datatypes.MqttQos;
|
||||
import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient;
|
||||
import com.hivemq.client.mqtt.mqtt3.Mqtt3Client;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentity;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityLookup;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.identity.VehicleIdentitySource;
|
||||
import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* MQTT 多 endpoint 生命周期管理。
|
||||
*
|
||||
* <p>为每个 endpoint 创建一个 HiveMQ 异步客户端 → 连接 → 订阅 → 注册回调 →
|
||||
* 回调里解析 payload → {@link Dispatcher#dispatch(RawFrame)}。
|
||||
*
|
||||
* <p>Netty EventLoop 回调不做业务处理,虚拟线程由 Dispatcher 侧的 Disruptor 承接。
|
||||
*/
|
||||
public final class MqttEndpointManager implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MqttEndpointManager.class);
|
||||
|
||||
private final MqttInboundProperties props;
|
||||
private final MqttProfileRegistry profileRegistry;
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
private final Dispatcher dispatcher;
|
||||
private final List<Mqtt3AsyncClient> clients = new ArrayList<>();
|
||||
|
||||
public MqttEndpointManager(MqttInboundProperties props,
|
||||
MqttProfileRegistry profileRegistry,
|
||||
Dispatcher dispatcher) {
|
||||
this(props, profileRegistry, new InMemoryVehicleIdentityService(), dispatcher);
|
||||
}
|
||||
|
||||
public MqttEndpointManager(MqttInboundProperties props,
|
||||
MqttProfileRegistry profileRegistry,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Dispatcher dispatcher) {
|
||||
this.props = props;
|
||||
this.profileRegistry = profileRegistry;
|
||||
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) {
|
||||
try {
|
||||
Mqtt3AsyncClient client = buildClient(ep);
|
||||
clients.add(client);
|
||||
connectAndSubscribe(client, ep);
|
||||
} catch (Exception e) {
|
||||
log.error("mqtt endpoint [{}] init failed, skipped", ep.getName(), e);
|
||||
publishOperationalError(ep, "init", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Mqtt3AsyncClient buildClient(MqttInboundProperties.Endpoint ep) {
|
||||
URI uri = URI.create(ep.getUri());
|
||||
int port = uri.getPort() > 0 ? uri.getPort() : defaultPort(uri.getScheme());
|
||||
var builder = Mqtt3Client.builder()
|
||||
.identifier(ep.getClientId() == null || ep.getClientId().isBlank()
|
||||
? "lingniu-" + UUID.randomUUID() : ep.getClientId())
|
||||
.serverHost(uri.getHost())
|
||||
.serverPort(port)
|
||||
.automaticReconnectWithDefaultConfig();
|
||||
if (ep.getConnectionTimeoutSeconds() > 0) {
|
||||
builder = builder.transportConfig()
|
||||
.serverHost(uri.getHost())
|
||||
.serverPort(port)
|
||||
.socketConnectTimeout(ep.getConnectionTimeoutSeconds(), TimeUnit.SECONDS)
|
||||
.mqttConnectTimeout(ep.getConnectionTimeoutSeconds(), TimeUnit.SECONDS)
|
||||
.applyTransportConfig();
|
||||
}
|
||||
|
||||
if ("ssl".equalsIgnoreCase(uri.getScheme()) || "tls".equalsIgnoreCase(uri.getScheme())
|
||||
|| "mqtts".equalsIgnoreCase(uri.getScheme())) {
|
||||
if (hasCustomTls(ep.getTls())) {
|
||||
builder = builder.sslConfig(MqttTlsSupport.build(ep.getTls()));
|
||||
} else {
|
||||
builder = builder.sslWithDefaultConfig();
|
||||
}
|
||||
}
|
||||
return builder.buildAsync();
|
||||
}
|
||||
|
||||
private void connectAndSubscribe(Mqtt3AsyncClient client, MqttInboundProperties.Endpoint ep) {
|
||||
var connectBuilder = client.connectWith()
|
||||
.cleanSession(ep.isCleanSession());
|
||||
if (ep.getKeepAliveSeconds() > 0) {
|
||||
connectBuilder = connectBuilder.keepAlive(ep.getKeepAliveSeconds());
|
||||
}
|
||||
if (ep.getUsername() != null && !ep.getUsername().isBlank()) {
|
||||
connectBuilder = connectBuilder.simpleAuth()
|
||||
.username(ep.getUsername())
|
||||
.password(ep.getPassword() == null ? new byte[0] : ep.getPassword().getBytes())
|
||||
.applySimpleAuth();
|
||||
}
|
||||
connectBuilder.send().whenComplete((ack, err) -> {
|
||||
if (err != null) {
|
||||
log.error("mqtt endpoint [{}] connect failed", ep.getName(), err);
|
||||
publishOperationalError(ep, "connect", err.getMessage() == null ? err.getClass().getSimpleName() : err.getMessage());
|
||||
return;
|
||||
}
|
||||
log.info("mqtt endpoint [{}] connected code={}", ep.getName(), ack.getReturnCode());
|
||||
client.subscribeWith()
|
||||
.topicFilter(ep.getTopic())
|
||||
.qos(mapQos(ep.getQos()))
|
||||
.callback(publish -> onMessage(ep, publish.getTopic().toString(), publish.getPayloadAsBytes()))
|
||||
.send()
|
||||
.whenComplete((subAck, subErr) -> {
|
||||
if (subErr != null) {
|
||||
log.error("mqtt endpoint [{}] subscribe failed topic={}", ep.getName(), ep.getTopic(), subErr);
|
||||
publishOperationalError(ep, "subscribe",
|
||||
subErr.getMessage() == null ? subErr.getClass().getSimpleName() : subErr.getMessage());
|
||||
} else {
|
||||
log.info("mqtt endpoint [{}] subscribed topic={} qos={}",
|
||||
ep.getName(), ep.getTopic(), ep.getQos());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void onMessage(MqttInboundProperties.Endpoint ep, String topic, byte[] payload) {
|
||||
try {
|
||||
MqttPayload parsed = profileRegistry.parse(ep.getName(), ep.getProfile(), topic, payload);
|
||||
String externalDeviceId = firstNonBlank(parsed.deviceId(), parsed.vin());
|
||||
IdentityResolution identity = resolveIdentity(parsed);
|
||||
Map<String, String> meta = new HashMap<>(4);
|
||||
meta.put("vin", identity.identity().vin());
|
||||
meta.put("externalVin", parsed.externalVin() == null ? "" : parsed.externalVin());
|
||||
meta.put("phone", parsed.phone() == null ? "" : parsed.phone());
|
||||
meta.put("mqttDeviceId", externalDeviceId);
|
||||
meta.put("plateNo", parsed.plateNo() == null ? "" : parsed.plateNo());
|
||||
meta.put("identityResolved", Boolean.toString(identity.identity().resolved()));
|
||||
meta.put("identitySource", identity.identity().source().name());
|
||||
if (identity.errorMessage() != null) {
|
||||
meta.put("identityError", "true");
|
||||
meta.put("identityErrorMessage", identity.errorMessage());
|
||||
}
|
||||
meta.put("endpoint", ep.getName());
|
||||
meta.put("topic", topic);
|
||||
meta.put("profile", parsed.profile() == null ? "" : parsed.profile());
|
||||
if (parsed.data() != null && parsed.data().path("_parseError").asBoolean(false)) {
|
||||
meta.put("parseError", "true");
|
||||
meta.put("parseErrorMessage", parsed.data().path("_parseErrorMessage").asText(""));
|
||||
meta.put("unsupportedProfile",
|
||||
Boolean.toString(parsed.data().path("_unsupportedProfile").asBoolean(false)));
|
||||
}
|
||||
RawFrame rf = new RawFrame(
|
||||
ProtocolId.MQTT_YUTONG, 0, 0,
|
||||
parsed, payload, meta, Instant.now());
|
||||
dispatcher.dispatch(rf);
|
||||
} catch (Exception e) {
|
||||
log.warn("mqtt endpoint [{}] message handling failed topic={} len={}",
|
||||
ep.getName(), topic, payload.length, e);
|
||||
publishMessageError(ep, topic, payload, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private IdentityResolution resolveIdentity(MqttPayload payload) {
|
||||
String externalDeviceId = firstNonBlank(payload.deviceId(), payload.vin());
|
||||
String externalVin = payload.externalVin() == null ? "" : payload.externalVin();
|
||||
String phone = payload.phone() == null ? "" : payload.phone();
|
||||
String plateNo = payload.plateNo() == null ? "" : payload.plateNo();
|
||||
try {
|
||||
VehicleIdentity bound = identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
externalVin,
|
||||
phone,
|
||||
externalDeviceId,
|
||||
plateNo));
|
||||
if (bound.resolved() || !looksLikeVin(externalDeviceId)) {
|
||||
return new IdentityResolution(bound, null);
|
||||
}
|
||||
return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
externalDeviceId,
|
||||
phone,
|
||||
externalDeviceId,
|
||||
plateNo)), null);
|
||||
} catch (RuntimeException e) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
|
||||
e.getMessage() == null ? "" : e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private void publishOperationalError(MqttInboundProperties.Endpoint ep, String phase, String reason) {
|
||||
String endpointName = ep == null ? "" : ep.getName();
|
||||
String profile = ep == null ? "" : ep.getProfile();
|
||||
String topic = ep == null ? "" : ep.getTopic();
|
||||
String json = "{\"operationalError\":true"
|
||||
+ ",\"endpoint\":\"" + escape(endpointName) + "\""
|
||||
+ ",\"profile\":\"" + escape(profile) + "\""
|
||||
+ ",\"topic\":\"" + escape(topic) + "\""
|
||||
+ ",\"phase\":\"" + escape(phase) + "\""
|
||||
+ ",\"reason\":\"" + escape(reason) + "\"}";
|
||||
byte[] raw = json.getBytes(StandardCharsets.UTF_8);
|
||||
MqttPayload payload = MqttProfileRegistry.operationalErrorPayload(
|
||||
endpointName, profile, topic, raw, phase, reason);
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
meta.put("vin", "unknown");
|
||||
meta.put("identityResolved", "false");
|
||||
meta.put("identitySource", "UNKNOWN");
|
||||
meta.put("endpoint", endpointName == null ? "" : endpointName);
|
||||
meta.put("topic", topic == null ? "" : topic);
|
||||
meta.put("profile", profile == null ? "" : profile);
|
||||
meta.put("operationalError", "true");
|
||||
meta.put("phase", phase == null ? "" : phase);
|
||||
meta.put("reason", reason == null ? "" : reason);
|
||||
dispatcher.dispatch(new RawFrame(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
0,
|
||||
0,
|
||||
payload,
|
||||
raw,
|
||||
meta,
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
private void publishMessageError(MqttInboundProperties.Endpoint ep, String topic, byte[] raw, String reason) {
|
||||
String endpointName = ep == null ? "" : ep.getName();
|
||||
String profile = ep == null ? "" : ep.getProfile();
|
||||
byte[] payloadBytes = raw == null ? new byte[0] : raw;
|
||||
MqttPayload payload = MqttProfileRegistry.operationalErrorPayload(
|
||||
endpointName,
|
||||
profile,
|
||||
topic,
|
||||
payloadBytes,
|
||||
"message",
|
||||
reason);
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
meta.put("vin", "unknown");
|
||||
meta.put("identityResolved", "false");
|
||||
meta.put("identitySource", "UNKNOWN");
|
||||
meta.put("endpoint", endpointName == null ? "" : endpointName);
|
||||
meta.put("topic", topic == null ? "" : topic);
|
||||
meta.put("profile", profile == null ? "" : profile);
|
||||
meta.put("operationalError", "true");
|
||||
meta.put("phase", "message");
|
||||
meta.put("reason", reason == null ? "" : reason);
|
||||
dispatcher.dispatch(new RawFrame(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
0,
|
||||
0,
|
||||
payload,
|
||||
payloadBytes,
|
||||
meta,
|
||||
Instant.now()));
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static MqttQos mapQos(int level) {
|
||||
return switch (level) {
|
||||
case 0 -> MqttQos.AT_MOST_ONCE;
|
||||
case 2 -> MqttQos.EXACTLY_ONCE;
|
||||
default -> MqttQos.AT_LEAST_ONCE;
|
||||
};
|
||||
}
|
||||
|
||||
private static int defaultPort(String scheme) {
|
||||
if (scheme == null) return 1883;
|
||||
return switch (scheme.toLowerCase()) {
|
||||
case "ssl", "tls", "mqtts" -> 8883;
|
||||
default -> 1883;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean looksLikeVin(String value) {
|
||||
return value != null && value.trim().length() == 17;
|
||||
}
|
||||
|
||||
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
|
||||
|
||||
private static boolean hasCustomTls(MqttInboundProperties.Tls tls) {
|
||||
return tls != null && (hasText(tls.getCaPem())
|
||||
|| (hasText(tls.getClientPem()) && hasText(tls.getClientKey())));
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (Mqtt3AsyncClient c : clients) {
|
||||
try {
|
||||
c.disconnect();
|
||||
} catch (Exception ignored) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
log.info("mqtt endpoint manager stopped, clients={}", clients.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.client;
|
||||
|
||||
import com.hivemq.client.mqtt.MqttClientSslConfig;
|
||||
import com.hivemq.client.mqtt.MqttClientSslConfigBuilder;
|
||||
import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties;
|
||||
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public final class MqttTlsSupport {
|
||||
|
||||
private MqttTlsSupport() {}
|
||||
|
||||
public static MqttClientSslConfig build(MqttInboundProperties.Tls tls) {
|
||||
try {
|
||||
MqttClientSslConfigBuilder builder = MqttClientSslConfig.builder();
|
||||
if (tls != null && hasText(tls.getCaPem())) {
|
||||
builder.trustManagerFactory(trustManagerFactory(Path.of(tls.getCaPem())));
|
||||
}
|
||||
if (tls != null && hasText(tls.getClientPem()) && hasText(tls.getClientKey())) {
|
||||
builder.keyManagerFactory(keyManagerFactory(Path.of(tls.getClientPem()), Path.of(tls.getClientKey())));
|
||||
}
|
||||
return builder.build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("mqtt tls config build failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static TrustManagerFactory trustManagerFactory(Path caPem) throws Exception {
|
||||
KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
trustStore.load(null, null);
|
||||
int i = 0;
|
||||
for (Certificate cert : readCertificates(caPem)) {
|
||||
trustStore.setCertificateEntry("ca-" + i++, cert);
|
||||
}
|
||||
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init(trustStore);
|
||||
return tmf;
|
||||
}
|
||||
|
||||
private static KeyManagerFactory keyManagerFactory(Path clientPem, Path clientKey) throws Exception {
|
||||
List<Certificate> chain = readCertificates(clientPem);
|
||||
PrivateKey key = readPrivateKey(clientKey);
|
||||
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
keyStore.load(null, null);
|
||||
char[] password = new char[0];
|
||||
keyStore.setKeyEntry("mqtt-client", key, password, chain.toArray(Certificate[]::new));
|
||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
kmf.init(keyStore, password);
|
||||
return kmf;
|
||||
}
|
||||
|
||||
private static List<Certificate> readCertificates(Path pem) throws Exception {
|
||||
String text = Files.readString(pem, StandardCharsets.UTF_8);
|
||||
List<Certificate> certificates = new ArrayList<>();
|
||||
CertificateFactory factory = CertificateFactory.getInstance("X.509");
|
||||
for (String block : pemBlocks(text, "CERTIFICATE")) {
|
||||
try (ByteArrayInputStream in = new ByteArrayInputStream(Base64.getMimeDecoder().decode(block))) {
|
||||
certificates.add(factory.generateCertificate(in));
|
||||
}
|
||||
}
|
||||
if (certificates.isEmpty()) {
|
||||
throw new IllegalArgumentException("no certificate found in " + pem);
|
||||
}
|
||||
return certificates;
|
||||
}
|
||||
|
||||
private static PrivateKey readPrivateKey(Path pem) throws Exception {
|
||||
String text = Files.readString(pem, StandardCharsets.UTF_8);
|
||||
List<String> blocks = pemBlocks(text, "PRIVATE KEY");
|
||||
if (blocks.isEmpty()) {
|
||||
throw new IllegalArgumentException("no PKCS#8 private key found in " + pem);
|
||||
}
|
||||
byte[] der = Base64.getMimeDecoder().decode(blocks.getFirst());
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der);
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
|
||||
private static List<String> pemBlocks(String text, String type) {
|
||||
String begin = "-----BEGIN " + type + "-----";
|
||||
String end = "-----END " + type + "-----";
|
||||
List<String> blocks = new ArrayList<>();
|
||||
int pos = 0;
|
||||
while (true) {
|
||||
int start = text.indexOf(begin, pos);
|
||||
if (start < 0) break;
|
||||
int contentStart = start + begin.length();
|
||||
int stop = text.indexOf(end, contentStart);
|
||||
if (stop < 0) break;
|
||||
blocks.add(text.substring(contentStart, stop).replaceAll("\\s+", ""));
|
||||
pos = stop + end.length();
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager;
|
||||
import com.lingniu.ingest.inbound.mqtt.handler.MqttRealtimeHandler;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.MqttProfile;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.YutongMqttProfile;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(MqttInboundProperties.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.mqtt", name = "enabled", havingValue = "true")
|
||||
public class MqttInboundAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public ObjectMapper mqttObjectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MqttPayloadParser mqttPayloadParser(ObjectMapper objectMapper) {
|
||||
return new MqttPayloadParser(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public YutongEventMapper yutongEventMapper(VehicleIdentityResolver identityResolver) {
|
||||
return new YutongEventMapper(identityResolver);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public YutongMqttProfile yutongMqttProfile(MqttPayloadParser parser, YutongEventMapper mapper) {
|
||||
return new YutongMqttProfile(parser, mapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MqttProfileRegistry mqttProfileRegistry(List<MqttProfile> profiles) {
|
||||
return new MqttProfileRegistry(profiles);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MqttRealtimeHandler mqttRealtimeHandler(MqttProfileRegistry profileRegistry) {
|
||||
return new MqttRealtimeHandler(profileRegistry);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnMissingBean
|
||||
public MqttEndpointManager mqttEndpointManager(MqttInboundProperties props,
|
||||
MqttProfileRegistry profileRegistry,
|
||||
VehicleIdentityResolver identityResolver,
|
||||
Dispatcher dispatcher) {
|
||||
return new MqttEndpointManager(props, profileRegistry, identityResolver, dispatcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Boot 启动完成后触发 MQTT 连接,避免与 Netty / Kafka 启动竞争。
|
||||
*/
|
||||
@Bean
|
||||
public ApplicationRunner mqttStartupRunner(MqttEndpointManager manager) {
|
||||
return new ApplicationRunner() {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
manager.start();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MQTT 接入配置。支持多 endpoint(每个 endpoint 可对应一个车厂 / 一个 broker)。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.mqtt")
|
||||
public class MqttInboundProperties {
|
||||
|
||||
private boolean enabled = false;
|
||||
private List<Endpoint> endpoints = new ArrayList<>();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public List<Endpoint> getEndpoints() { return endpoints; }
|
||||
public void setEndpoints(List<Endpoint> endpoints) { this.endpoints = endpoints; }
|
||||
|
||||
public static class Endpoint {
|
||||
/** 逻辑名称,用于指标与日志。 */
|
||||
private String name;
|
||||
/** 对端 URI。形如 ssl://host:port 或 tcp://host:port。 */
|
||||
private String uri;
|
||||
/** 订阅 topic,支持 + / # 通配符。 */
|
||||
private String topic;
|
||||
/** QoS 0/1/2。 */
|
||||
private int qos = 1;
|
||||
private String clientId;
|
||||
private String username;
|
||||
private String password;
|
||||
private boolean cleanSession = false;
|
||||
private int keepAliveSeconds = 20;
|
||||
private int connectionTimeoutSeconds = 10;
|
||||
/** JSON 字段适配器 profile。默认 yutong。未来可支持 haipote / others。 */
|
||||
private String profile = "yutong";
|
||||
private Tls tls = new Tls();
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getUri() { return uri; }
|
||||
public void setUri(String uri) { this.uri = uri; }
|
||||
public String getTopic() { return topic; }
|
||||
public void setTopic(String topic) { this.topic = topic; }
|
||||
public int getQos() { return qos; }
|
||||
public void setQos(int qos) { this.qos = qos; }
|
||||
public String getClientId() { return clientId; }
|
||||
public void setClientId(String clientId) { this.clientId = clientId; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public boolean isCleanSession() { return cleanSession; }
|
||||
public void setCleanSession(boolean cleanSession) { this.cleanSession = cleanSession; }
|
||||
public int getKeepAliveSeconds() { return keepAliveSeconds; }
|
||||
public void setKeepAliveSeconds(int keepAliveSeconds) { this.keepAliveSeconds = keepAliveSeconds; }
|
||||
public int getConnectionTimeoutSeconds() { return connectionTimeoutSeconds; }
|
||||
public void setConnectionTimeoutSeconds(int connectionTimeoutSeconds) { this.connectionTimeoutSeconds = connectionTimeoutSeconds; }
|
||||
public String getProfile() { return profile; }
|
||||
public void setProfile(String profile) { this.profile = profile; }
|
||||
public Tls getTls() { return tls; }
|
||||
public void setTls(Tls tls) { this.tls = tls; }
|
||||
}
|
||||
|
||||
public static class Tls {
|
||||
private String caPem;
|
||||
private String clientPem;
|
||||
private String clientKey;
|
||||
public String getCaPem() { return caPem; }
|
||||
public void setCaPem(String caPem) { this.caPem = caPem; }
|
||||
public String getClientPem() { return clientPem; }
|
||||
public void setClientPem(String clientPem) { this.clientPem = clientPem; }
|
||||
public String getClientKey() { return clientKey; }
|
||||
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.annotation.EventEmit;
|
||||
import com.lingniu.ingest.api.annotation.MessageMapping;
|
||||
import com.lingniu.ingest.api.annotation.ProtocolHandler;
|
||||
import com.lingniu.ingest.api.annotation.RateLimited;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MQTT 接入 Handler。
|
||||
*
|
||||
* <p>MQTT 没有"命令字"概念,使用默认的 wildcard 匹配({@code command} 为空)。
|
||||
* 同一 Dispatcher 仍能正确路由,因为协议 ID 做了隔离。
|
||||
*/
|
||||
@ProtocolHandler(protocol = ProtocolId.MQTT_YUTONG)
|
||||
public class MqttRealtimeHandler {
|
||||
|
||||
private final MqttProfileRegistry profileRegistry;
|
||||
|
||||
public MqttRealtimeHandler(MqttProfileRegistry profileRegistry) {
|
||||
this.profileRegistry = profileRegistry;
|
||||
}
|
||||
|
||||
@MessageMapping(desc = "MQTT profile 实时数据")
|
||||
@RateLimited(perVin = 20)
|
||||
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class, VehicleEvent.Passthrough.class})
|
||||
public List<VehicleEvent> onMqtt(MqttPayload payload) {
|
||||
return profileRegistry.toEvents(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.mapper;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.LocationPayload;
|
||||
import com.lingniu.ingest.api.event.RealtimePayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.spi.EventMapper;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentity;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityLookup;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import com.lingniu.ingest.identity.VehicleIdentitySource;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 宇通 MQTT payload → 领域事件。
|
||||
*
|
||||
* <p>字段名取自旧项目的 {@code ReceptionDataVO}(大写 + 下划线),对字段映射一致性做出显式断言,
|
||||
* 便于与旧服务双跑对账。
|
||||
*/
|
||||
public final class YutongEventMapper implements EventMapper<MqttPayload> {
|
||||
|
||||
private final VehicleIdentityResolver identityResolver;
|
||||
|
||||
public YutongEventMapper() {
|
||||
this(new InMemoryVehicleIdentityService());
|
||||
}
|
||||
|
||||
public YutongEventMapper(VehicleIdentityResolver identityResolver) {
|
||||
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VehicleEvent> toEvents(MqttPayload payload) {
|
||||
JsonNode d = payload.data();
|
||||
if (d == null || !d.isObject()) return List.of();
|
||||
|
||||
Instant ingestTime = Instant.now();
|
||||
String externalDeviceId = firstNonBlank(payload.deviceId(), payload.vin());
|
||||
IdentityResolution identity = resolveIdentity(payload);
|
||||
String vin = identity.vin();
|
||||
Map<String, String> meta = metadata(payload, externalDeviceId, vin, identity);
|
||||
|
||||
if (d.path("_parseError").asBoolean(false)) {
|
||||
return List.of(new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin == null || vin.isBlank() ? "unknown" : vin,
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
payload.deviceTime(),
|
||||
ingestTime,
|
||||
null,
|
||||
withMeta(meta,
|
||||
"parseError", "true",
|
||||
"parseErrorMessage", d.path("_parseErrorMessage").asText("")),
|
||||
0,
|
||||
rawBytes(d.path("_rawBase64").asText(""))));
|
||||
}
|
||||
|
||||
Double speed = optDouble(d, "METER_SPEED");
|
||||
Double mileage = optDouble(d, "TOTAL_MILEAGE");
|
||||
Double soc = optDouble(d, "BATTERY_CAPACITY_SOC");
|
||||
Double fcVoltage = optDouble(d, "VOLTAGE_OF_FC");
|
||||
Double fcCurrent = optDouble(d, "CURRENT_OF_FC");
|
||||
Double fcTemp = optDouble(d, "TEMPT_OF_FC");
|
||||
Double leftH2 = optDouble(d, "LEFT_HYDROGEN");
|
||||
Double lowH2Pressure = optDouble(d, "HYDROGEN_LOW_PRESSURE");
|
||||
Double longitude = optCoord(d, "LONGITUDE");
|
||||
Double latitude = optCoord(d, "LATITUDE");
|
||||
Double direction = optDouble(d, "GPSDirection");
|
||||
Double acc = optDouble(d, "ACC_PEDAL_APT");
|
||||
Double brake = optDouble(d, "BRAKEPEDALAPT");
|
||||
Integer gear = optInt(d, "ON_GEAR");
|
||||
|
||||
RealtimePayload realtime = new RealtimePayload(
|
||||
speed, mileage, soc, null, null,
|
||||
fcVoltage, fcCurrent, fcTemp, leftH2, null, lowH2Pressure,
|
||||
null, null, null, gear, acc, brake,
|
||||
longitude, latitude, null, direction,
|
||||
null, null);
|
||||
|
||||
List<VehicleEvent> out = new ArrayList<>(2);
|
||||
out.add(new VehicleEvent.Realtime(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime,
|
||||
null, meta, realtime));
|
||||
|
||||
if (longitude != null && latitude != null) {
|
||||
LocationPayload loc = new LocationPayload(
|
||||
longitude, latitude, 0.0,
|
||||
speed == null ? 0.0 : speed,
|
||||
direction == null ? 0.0 : direction,
|
||||
0, 0);
|
||||
out.add(new VehicleEvent.Location(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime,
|
||||
null, meta, loc));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private IdentityResolution resolveIdentity(MqttPayload payload) {
|
||||
String externalDeviceId = firstNonBlank(payload.deviceId(), payload.vin());
|
||||
String externalVin = payload.externalVin() == null ? "" : payload.externalVin();
|
||||
String phone = payload.phone() == null ? "" : payload.phone();
|
||||
String plateNo = payload.plateNo() == null ? "" : payload.plateNo();
|
||||
try {
|
||||
if (externalDeviceId == null
|
||||
|| externalDeviceId.isBlank()
|
||||
|| "unknown".equalsIgnoreCase(externalDeviceId.trim())) {
|
||||
if (!externalVin.isBlank() || !phone.isBlank() || !plateNo.isBlank()) {
|
||||
return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
externalVin,
|
||||
phone,
|
||||
"",
|
||||
plateNo)));
|
||||
}
|
||||
return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN));
|
||||
}
|
||||
VehicleIdentity bound = identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
externalVin,
|
||||
phone,
|
||||
externalDeviceId,
|
||||
plateNo));
|
||||
if (bound.resolved() || !looksLikeVin(externalDeviceId)) {
|
||||
return IdentityResolution.ok(bound);
|
||||
}
|
||||
return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
externalDeviceId,
|
||||
phone,
|
||||
externalDeviceId,
|
||||
plateNo)));
|
||||
} catch (RuntimeException e) {
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
|
||||
e.getMessage() == null ? "" : e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static boolean looksLikeVin(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() == 17;
|
||||
}
|
||||
|
||||
private static Double optDouble(JsonNode n, String field) {
|
||||
JsonNode v = n.get(field);
|
||||
if (v == null || v.isNull() || !v.isNumber()) return null;
|
||||
return v.asDouble();
|
||||
}
|
||||
|
||||
private static Integer optInt(JsonNode n, String field) {
|
||||
JsonNode v = n.get(field);
|
||||
if (v == null || v.isNull() || !v.isNumber()) return null;
|
||||
return v.asInt();
|
||||
}
|
||||
|
||||
/** 宇通坐标约定:1e6 整数 或 已经是小数。同时过滤无效值 999999 / 0。 */
|
||||
private static Double optCoord(JsonNode n, String field) {
|
||||
Double v = optDouble(n, field);
|
||||
if (v == null) return null;
|
||||
if (v.intValue() == 999_999 || v == 0.0) return null;
|
||||
if (Math.abs(v) > 1000) return v / 1_000_000.0;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static Map<String, String> withMeta(Map<String, String> meta, String key, String value) {
|
||||
java.util.HashMap<String, String> copy = new java.util.HashMap<>(meta);
|
||||
copy.put(key, value == null ? "" : value);
|
||||
return Map.copyOf(copy);
|
||||
}
|
||||
|
||||
private static Map<String, String> metadata(MqttPayload payload,
|
||||
String externalDeviceId,
|
||||
String vin,
|
||||
IdentityResolution identity) {
|
||||
java.util.HashMap<String, String> meta = new java.util.HashMap<>();
|
||||
meta.put("endpoint", payload.endpointName() == null ? "" : payload.endpointName());
|
||||
meta.put("topic", payload.topic() == null ? "" : payload.topic());
|
||||
meta.put("profile", payload.profile() == null ? "" : payload.profile());
|
||||
meta.put("externalVin", payload.externalVin() == null ? "" : payload.externalVin());
|
||||
meta.put("phone", payload.phone() == null ? "" : payload.phone());
|
||||
meta.put("mqttDeviceId", externalDeviceId == null ? "" : externalDeviceId);
|
||||
meta.put("plateNo", payload.plateNo() == null ? "" : payload.plateNo());
|
||||
meta.put("vin", vin == null || vin.isBlank() ? "unknown" : vin);
|
||||
meta.put("identityResolved", Boolean.toString(identity.identity().resolved()));
|
||||
meta.put("identitySource", identity.identity().source().name());
|
||||
if (identity.errorMessage() != null) {
|
||||
meta.put("identityError", "true");
|
||||
meta.put("identityErrorMessage", identity.errorMessage());
|
||||
}
|
||||
return Map.copyOf(meta);
|
||||
}
|
||||
|
||||
private static Map<String, String> withMeta(Map<String, String> meta,
|
||||
String firstKey,
|
||||
String firstValue,
|
||||
String secondKey,
|
||||
String secondValue) {
|
||||
java.util.HashMap<String, String> copy = new java.util.HashMap<>(meta);
|
||||
copy.put(firstKey, firstValue == null ? "" : firstValue);
|
||||
copy.put(secondKey, secondValue == null ? "" : secondValue);
|
||||
return Map.copyOf(copy);
|
||||
}
|
||||
|
||||
private static byte[] rawBytes(String base64) {
|
||||
if (base64 == null || base64.isBlank()) {
|
||||
return new byte[0];
|
||||
}
|
||||
try {
|
||||
return Base64.getDecoder().decode(base64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
|
||||
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {
|
||||
private static IdentityResolution ok(VehicleIdentity identity) {
|
||||
return new IdentityResolution(identity, null);
|
||||
}
|
||||
|
||||
private String vin() {
|
||||
return identity.vin();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* MQTT 解析后的统一消息体:{@code Dispatcher} 收到的 RawFrame payload 就是这个。
|
||||
*
|
||||
* @param endpointName 来源 endpoint 逻辑名
|
||||
* @param profile 字段映射 profile(如 yutong / haipote)
|
||||
* @param topic 原始 topic
|
||||
* @param vin VIN(解析自 payload 的 device / vin 字段,保留兼容旧调用)
|
||||
* @param externalVin 外部 VIN 字段
|
||||
* @param phone 外部手机号 / SIM 字段
|
||||
* @param deviceId 外部设备号 / 终端 ID / IMEI 字段
|
||||
* @param plateNo 外部车牌字段
|
||||
* @param deviceTime 设备上报时刻
|
||||
* @param data 原始 JSON 树,供 Mapper 按 profile 提取字段
|
||||
*/
|
||||
public record MqttPayload(
|
||||
String endpointName,
|
||||
String profile,
|
||||
String topic,
|
||||
String vin,
|
||||
String externalVin,
|
||||
String phone,
|
||||
String deviceId,
|
||||
String plateNo,
|
||||
Instant deviceTime,
|
||||
JsonNode data
|
||||
) {
|
||||
public MqttPayload(String endpointName,
|
||||
String profile,
|
||||
String topic,
|
||||
String vin,
|
||||
Instant deviceTime,
|
||||
JsonNode data) {
|
||||
this(endpointName, profile, topic, vin, "", "", vin, "", deviceTime, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.parser;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 宇通 MQTT JSON 消息基础解析器(对标旧 {@code VehicleDataReceptionVO} + {@code ReceptionDataVO})。
|
||||
*
|
||||
* <p>约定宇通 MQTT 的 payload JSON 形如:
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "id": "xxx",
|
||||
* "device": "LTEST000000000001", // VIN
|
||||
* "code": "C001",
|
||||
* "time": "20260413100000", // yyyyMMddHHmmss
|
||||
* "data": { "METER_SPEED": 52, "LATITUDE": 39916527, ... }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>其它厂商只需新增 {@code MqttProfile} 实现,公共 MQTT endpoint 生命周期不需要修改。
|
||||
*/
|
||||
public final class MqttPayloadParser {
|
||||
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public MqttPayloadParser(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public MqttPayload parse(String endpointName, String profile, String topic, byte[] bytes) {
|
||||
try {
|
||||
JsonNode root = mapper.readTree(bytes);
|
||||
String externalVin = text(root, "vin", "VIN");
|
||||
String deviceId = text(root, "device", "deviceId", "terminalId", "imei", "IMEI");
|
||||
String phone = text(root, "sim", "phone", "mobile");
|
||||
String plateNo = text(root, "plateNo", "carNo", "plate");
|
||||
String vin = !externalVin.isBlank() ? externalVin : deviceId;
|
||||
Instant deviceTime = parseTime(text(root, "time"));
|
||||
JsonNode data = root.has("data") ? root.get("data") : root;
|
||||
return new MqttPayload(endpointName, profile, topic,
|
||||
vin, externalVin, phone, deviceId, plateNo, deviceTime, data);
|
||||
} catch (Exception e) {
|
||||
throw new DecodeException("mqtt payload parse failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode root, String... keys) {
|
||||
for (String k : keys) {
|
||||
JsonNode n = root.get(k);
|
||||
if (n != null && !n.isNull()) return n.asText();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static Instant parseTime(String raw) {
|
||||
if (raw == null || raw.isEmpty()) return Instant.now();
|
||||
try {
|
||||
return LocalDateTime.parse(raw, TIME_FMT).atZone(ZONE).toInstant();
|
||||
} catch (Exception e) {
|
||||
return Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.profile;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MQTT 厂商 profile 边界。
|
||||
*
|
||||
* <p>endpoint 只关心 profile 名称,具体 payload 字段结构、VIN/时间提取和事件映射都收敛在 profile 内。
|
||||
*/
|
||||
public interface MqttProfile {
|
||||
|
||||
String name();
|
||||
|
||||
MqttPayload parse(String endpointName, String topic, byte[] bytes);
|
||||
|
||||
List<VehicleEvent> toEvents(MqttPayload payload);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.profile;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class MqttProfileRegistry {
|
||||
|
||||
private final Map<String, MqttProfile> profiles;
|
||||
|
||||
public MqttProfileRegistry(Collection<MqttProfile> profiles) {
|
||||
Map<String, MqttProfile> indexed = new LinkedHashMap<>();
|
||||
for (MqttProfile profile : profiles) {
|
||||
String key = normalize(profile.name());
|
||||
if (key.isEmpty()) {
|
||||
throw new IllegalArgumentException("mqtt profile name must not be blank");
|
||||
}
|
||||
MqttProfile old = indexed.putIfAbsent(key, profile);
|
||||
if (old != null) {
|
||||
throw new IllegalArgumentException("duplicated mqtt profile: " + key);
|
||||
}
|
||||
}
|
||||
this.profiles = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
public MqttPayload parse(String endpointName, String profileName, String topic, byte[] bytes) {
|
||||
MqttProfile profile;
|
||||
try {
|
||||
profile = resolve(profileName);
|
||||
} catch (DecodeException e) {
|
||||
return parseErrorPayload(endpointName, profileName, topic, bytes, e.getMessage(), true);
|
||||
}
|
||||
try {
|
||||
return profile.parse(endpointName, topic, bytes);
|
||||
} catch (DecodeException e) {
|
||||
return parseErrorPayload(endpointName, profile.name(), topic, bytes, e.getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
public List<VehicleEvent> toEvents(MqttPayload payload) {
|
||||
if (payload != null && payload.data() != null
|
||||
&& payload.data().path("_operationalError").asBoolean(false)) {
|
||||
return List.of(operationalErrorEvent(payload));
|
||||
}
|
||||
if (payload != null && payload.data() != null
|
||||
&& payload.data().path("_unsupportedProfile").asBoolean(false)) {
|
||||
return List.of(parseErrorEvent(payload));
|
||||
}
|
||||
return resolve(payload.profile()).toEvents(payload);
|
||||
}
|
||||
|
||||
public MqttProfile resolve(String profileName) {
|
||||
String key = normalize(profileName);
|
||||
MqttProfile profile = profiles.get(key);
|
||||
if (profile == null) {
|
||||
throw new DecodeException("unsupported mqtt profile: " + profileName);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static String normalize(String raw) {
|
||||
return raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static MqttPayload parseErrorPayload(String endpointName,
|
||||
String profileName,
|
||||
String topic,
|
||||
byte[] bytes,
|
||||
String message,
|
||||
boolean unsupportedProfile) {
|
||||
ObjectNode data = JsonNodeFactory.instance.objectNode();
|
||||
data.put("_parseError", true);
|
||||
data.put("_parseErrorMessage", message == null ? "" : message);
|
||||
data.put("_unsupportedProfile", unsupportedProfile);
|
||||
data.put("_rawBase64", Base64.getEncoder().encodeToString(bytes == null ? new byte[0] : bytes));
|
||||
return new MqttPayload(endpointName, normalize(profileName), topic, "unknown", Instant.now(), data);
|
||||
}
|
||||
|
||||
public static MqttPayload operationalErrorPayload(String endpointName,
|
||||
String profileName,
|
||||
String topic,
|
||||
byte[] bytes,
|
||||
String phase,
|
||||
String reason) {
|
||||
ObjectNode data = JsonNodeFactory.instance.objectNode();
|
||||
data.put("_operationalError", true);
|
||||
data.put("_phase", phase == null ? "" : phase);
|
||||
data.put("_reason", reason == null ? "" : reason);
|
||||
data.put("_rawBase64", Base64.getEncoder().encodeToString(bytes == null ? new byte[0] : bytes));
|
||||
return new MqttPayload(endpointName, normalize(profileName), topic, "unknown", Instant.now(), data);
|
||||
}
|
||||
|
||||
private static VehicleEvent.Passthrough parseErrorEvent(MqttPayload payload) {
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
metadata.put("endpoint", payload.endpointName() == null ? "" : payload.endpointName());
|
||||
metadata.put("topic", payload.topic() == null ? "" : payload.topic());
|
||||
metadata.put("profile", payload.profile() == null ? "" : payload.profile());
|
||||
metadata.put("parseError", "true");
|
||||
metadata.put("unsupportedProfile", Boolean.toString(payload.data().path("_unsupportedProfile").asBoolean(false)));
|
||||
metadata.put("parseErrorMessage", payload.data().path("_parseErrorMessage").asText(""));
|
||||
addUnknownIdentity(metadata);
|
||||
return new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
"unknown",
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
payload.deviceTime(),
|
||||
Instant.now(),
|
||||
null,
|
||||
Map.copyOf(metadata),
|
||||
0,
|
||||
rawBytes(payload.data().path("_rawBase64").asText("")));
|
||||
}
|
||||
|
||||
private static VehicleEvent.Passthrough operationalErrorEvent(MqttPayload payload) {
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
metadata.put("endpoint", payload.endpointName() == null ? "" : payload.endpointName());
|
||||
metadata.put("topic", payload.topic() == null ? "" : payload.topic());
|
||||
metadata.put("profile", payload.profile() == null ? "" : payload.profile());
|
||||
metadata.put("operationalError", "true");
|
||||
metadata.put("phase", payload.data().path("_phase").asText(""));
|
||||
metadata.put("reason", payload.data().path("_reason").asText(""));
|
||||
addUnknownIdentity(metadata);
|
||||
return new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
"unknown",
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
payload.deviceTime(),
|
||||
Instant.now(),
|
||||
null,
|
||||
Map.copyOf(metadata),
|
||||
0,
|
||||
rawBytes(payload.data().path("_rawBase64").asText("")));
|
||||
}
|
||||
|
||||
private static void addUnknownIdentity(Map<String, String> metadata) {
|
||||
metadata.put("vin", "unknown");
|
||||
metadata.put("identityResolved", "false");
|
||||
metadata.put("identitySource", "UNKNOWN");
|
||||
}
|
||||
|
||||
private static byte[] rawBytes(String base64) {
|
||||
if (base64 == null || base64.isBlank()) {
|
||||
return new byte[0];
|
||||
}
|
||||
try {
|
||||
return Base64.getDecoder().decode(base64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.profile;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class YutongMqttProfile implements MqttProfile {
|
||||
|
||||
public static final String NAME = "yutong";
|
||||
|
||||
private final MqttPayloadParser parser;
|
||||
private final YutongEventMapper mapper;
|
||||
|
||||
public YutongMqttProfile(MqttPayloadParser parser, YutongEventMapper mapper) {
|
||||
this.parser = parser;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MqttPayload parse(String endpointName, String topic, byte[] bytes) {
|
||||
return parser.parse(endpointName, NAME, topic, bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VehicleEvent> toEvents(MqttPayload payload) {
|
||||
return mapper.toEvents(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.lingniu.ingest.inbound.mqtt;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
|
||||
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
|
||||
import com.lingniu.ingest.core.pipeline.InterceptorChain;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager;
|
||||
import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties;
|
||||
import com.lingniu.ingest.inbound.mqtt.handler.MqttRealtimeHandler;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.YutongMqttProfile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MqttEndpointManagerTest {
|
||||
|
||||
@Test
|
||||
void inboundMessageRawArchiveUsesResolvedVehicleIdentity() throws Exception {
|
||||
MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint();
|
||||
endpoint.setName("endpoint-a");
|
||||
endpoint.setTopic("/ytforward/#");
|
||||
endpoint.setProfile("yutong");
|
||||
byte[] payload = """
|
||||
{"device":"YT-DEVICE-001","time":"20260622150000","data":{"METER_SPEED":31,"TOTAL_MILEAGE":1024}}
|
||||
""".trim().getBytes(StandardCharsets.UTF_8);
|
||||
InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService();
|
||||
identities.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
"LNVIN000000MQTT01",
|
||||
"",
|
||||
"YT-DEVICE-001",
|
||||
""));
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of(
|
||||
new YutongMqttProfile(
|
||||
new MqttPayloadParser(new ObjectMapper()),
|
||||
new YutongEventMapper(identities))));
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registry(profileRegistry),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
MqttEndpointManager manager = new MqttEndpointManager(
|
||||
new MqttInboundProperties(), profileRegistry, identities, dispatcher);
|
||||
|
||||
invokeOnMessage(manager, endpoint, "/ytforward/vehicle/YT-DEVICE-001", payload);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.vin()).isEqualTo("LNVIN000000MQTT01");
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("vin", "LNVIN000000MQTT01")
|
||||
.containsEntry("mqttDeviceId", "YT-DEVICE-001")
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_DEVICE_ID");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedInboundMessageArchivesParseFailureMetadataAndPassthrough() throws Exception {
|
||||
MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint();
|
||||
endpoint.setName("endpoint-a");
|
||||
endpoint.setTopic("/ytforward/#");
|
||||
endpoint.setProfile("yutong");
|
||||
byte[] badPayload = "{bad-json".getBytes(StandardCharsets.UTF_8);
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of(
|
||||
new YutongMqttProfile(
|
||||
new MqttPayloadParser(new ObjectMapper()),
|
||||
new YutongEventMapper())));
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registry(profileRegistry),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
MqttEndpointManager manager = new MqttEndpointManager(
|
||||
new MqttInboundProperties(), profileRegistry, dispatcher);
|
||||
|
||||
invokeOnMessage(manager, endpoint, "/ytforward/bad", badPayload);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(archive.rawBytes()).containsExactly(badPayload);
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("endpoint", "endpoint-a")
|
||||
.containsEntry("topic", "/ytforward/bad")
|
||||
.containsEntry("profile", "yutong")
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("vin", "unknown");
|
||||
assertThat(archive.metadata().get("parseErrorMessage"))
|
||||
.contains("mqtt payload parse failed");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(passthrough.data()).containsExactly(badPayload);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("endpoint", "endpoint-a")
|
||||
.containsEntry("profile", "yutong");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointInitializationFailureIsArchivedAndDispatchedAsPassthrough() throws Exception {
|
||||
MqttInboundProperties props = new MqttInboundProperties();
|
||||
MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint();
|
||||
endpoint.setName("bad-endpoint");
|
||||
endpoint.setUri("://bad-uri");
|
||||
endpoint.setTopic("/vehicles/#");
|
||||
endpoint.setProfile("yutong");
|
||||
props.setEndpoints(List.of(endpoint));
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of());
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registry(profileRegistry),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
MqttEndpointManager manager = new MqttEndpointManager(props, profileRegistry, dispatcher);
|
||||
|
||||
manager.start();
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("endpoint", "bad-endpoint")
|
||||
.containsEntry("profile", "yutong")
|
||||
.containsEntry("operationalError", "true")
|
||||
.containsEntry("phase", "init")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("operationalError", "true")
|
||||
.containsEntry("endpoint", "bad-endpoint")
|
||||
.containsEntry("profile", "yutong")
|
||||
.containsEntry("phase", "init");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileRuntimeFailureIsArchivedAndDispatchedAsPassthrough() throws Exception {
|
||||
MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint();
|
||||
endpoint.setName("endpoint-a");
|
||||
endpoint.setTopic("/vehicles/#");
|
||||
endpoint.setProfile("broken");
|
||||
byte[] payload = "{\"device\":\"YT-DEVICE-001\"}".getBytes(StandardCharsets.UTF_8);
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of(new BrokenProfile()));
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registry(profileRegistry),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
MqttEndpointManager manager = new MqttEndpointManager(
|
||||
new MqttInboundProperties(), profileRegistry, dispatcher);
|
||||
|
||||
invokeOnMessage(manager, endpoint, "/vehicles/broken", payload);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(archive.rawBytes()).containsExactly(payload);
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("endpoint", "endpoint-a")
|
||||
.containsEntry("topic", "/vehicles/broken")
|
||||
.containsEntry("profile", "broken")
|
||||
.containsEntry("operationalError", "true")
|
||||
.containsEntry("phase", "message")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
assertThat(archive.metadata().get("reason")).contains("profile blew up");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(passthrough.data()).containsExactly(payload);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("operationalError", "true")
|
||||
.containsEntry("endpoint", "endpoint-a")
|
||||
.containsEntry("profile", "broken")
|
||||
.containsEntry("phase", "message");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillArchivesAndDispatchesRealtimeWithUnknownIdentity() throws Exception {
|
||||
MqttInboundProperties.Endpoint endpoint = new MqttInboundProperties.Endpoint();
|
||||
endpoint.setName("endpoint-a");
|
||||
endpoint.setTopic("/ytforward/#");
|
||||
endpoint.setProfile("yutong");
|
||||
byte[] payload = """
|
||||
{"device":"YT-DEVICE-001","time":"20260622150000","data":{"METER_SPEED":31,"TOTAL_MILEAGE":1024}}
|
||||
""".trim().getBytes(StandardCharsets.UTF_8);
|
||||
var failingIdentity = (com.lingniu.ingest.identity.VehicleIdentityResolver) lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
};
|
||||
RecordingSink sink = new RecordingSink(2);
|
||||
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
|
||||
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
|
||||
MqttProfileRegistry profileRegistry = new MqttProfileRegistry(List.of(
|
||||
new YutongMqttProfile(
|
||||
new MqttPayloadParser(new ObjectMapper()),
|
||||
new YutongEventMapper(failingIdentity))));
|
||||
Dispatcher dispatcher = new Dispatcher(
|
||||
registry(profileRegistry),
|
||||
new InterceptorChain(List.of()),
|
||||
new HandlerInvoker(),
|
||||
eventBus,
|
||||
batchExecutor);
|
||||
MqttEndpointManager manager = new MqttEndpointManager(
|
||||
new MqttInboundProperties(), profileRegistry, failingIdentity, dispatcher);
|
||||
|
||||
invokeOnMessage(manager, endpoint, "/ytforward/vehicle/YT-DEVICE-001", payload);
|
||||
|
||||
assertThat(sink.await()).isTrue();
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.RawArchive.class);
|
||||
VehicleEvent.RawArchive archive = (VehicleEvent.RawArchive) event;
|
||||
assertThat(archive.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(archive.vin()).isEqualTo("unknown");
|
||||
assertThat(archive.rawBytes()).containsExactly(payload);
|
||||
assertThat(archive.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("mqttDeviceId", "YT-DEVICE-001")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
assertThat(sink.events).anySatisfy(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Realtime.class);
|
||||
assertThat(event.vin()).isEqualTo("unknown");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("mqttDeviceId", "YT-DEVICE-001")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
|
||||
batchExecutor.close();
|
||||
eventBus.close();
|
||||
}
|
||||
|
||||
private static HandlerRegistry registry(MqttProfileRegistry profileRegistry) throws NoSuchMethodException {
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
MqttRealtimeHandler handler = new MqttRealtimeHandler(profileRegistry);
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
0,
|
||||
0,
|
||||
"test mqtt",
|
||||
handler,
|
||||
MqttRealtimeHandler.class.getMethod("onMqtt", MqttPayload.class),
|
||||
MqttPayload.class,
|
||||
null,
|
||||
null,
|
||||
null));
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static void invokeOnMessage(MqttEndpointManager manager,
|
||||
MqttInboundProperties.Endpoint endpoint,
|
||||
String topic,
|
||||
byte[] payload) throws Exception {
|
||||
Method method = MqttEndpointManager.class.getDeclaredMethod(
|
||||
"onMessage", MqttInboundProperties.Endpoint.class, String.class, byte[].class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(manager, endpoint, topic, payload);
|
||||
}
|
||||
|
||||
private static final class RecordingSink implements EventSink {
|
||||
private final List<VehicleEvent> events = new CopyOnWriteArrayList<>();
|
||||
private final CountDownLatch latch;
|
||||
|
||||
private RecordingSink(int expectedEvents) {
|
||||
this.latch = new CountDownLatch(expectedEvents);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "recording";
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||
events.add(event);
|
||||
latch.countDown();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
private boolean await() throws InterruptedException {
|
||||
return latch.await(3, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BrokenProfile implements com.lingniu.ingest.inbound.mqtt.profile.MqttProfile {
|
||||
@Override
|
||||
public String name() {
|
||||
return "broken";
|
||||
}
|
||||
|
||||
@Override
|
||||
public MqttPayload parse(String endpointName, String topic, byte[] bytes) {
|
||||
throw new IllegalStateException("profile blew up");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<VehicleEvent> toEvents(MqttPayload payload) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.lingniu.ingest.inbound.mqtt;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.MqttProfileRegistry;
|
||||
import com.lingniu.ingest.inbound.mqtt.profile.YutongMqttProfile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MqttProfileRegistryTest {
|
||||
|
||||
private final MqttProfileRegistry registry = new MqttProfileRegistry(List.of(
|
||||
new YutongMqttProfile(new MqttPayloadParser(new ObjectMapper()), new YutongEventMapper())));
|
||||
|
||||
@Test
|
||||
void resolvesProfileCaseInsensitiveAndRoutesParseAndMapping() {
|
||||
byte[] payload = """
|
||||
{
|
||||
"device": "LTEST000000000001",
|
||||
"time": "20260413100000",
|
||||
"data": {
|
||||
"METER_SPEED": 52.3,
|
||||
"TOTAL_MILEAGE": 123456.7,
|
||||
"BATTERY_CAPACITY_SOC": 70,
|
||||
"LONGITUDE": 116397128,
|
||||
"LATITUDE": 39916527
|
||||
}
|
||||
}
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
var parsed = registry.parse("endpoint-a", "YuTong", "/ytforward/shln/dev1", payload);
|
||||
|
||||
assertThat(parsed.profile()).isEqualTo("yutong");
|
||||
assertThat(parsed.vin()).isEqualTo("LTEST000000000001");
|
||||
assertThat(registry.toEvents(parsed))
|
||||
.extracting(VehicleEvent::vin)
|
||||
.containsOnly("LTEST000000000001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void parserKeepsCommonExternalIdentityFieldsForColdStorageAndMapping() {
|
||||
byte[] payload = """
|
||||
{
|
||||
"vin": "LVIN0000000000001",
|
||||
"terminalId": "terminal-001",
|
||||
"sim": "13800138000",
|
||||
"plateNo": "粤B12345",
|
||||
"time": "20260413100000",
|
||||
"data": { "METER_SPEED": 12.3 }
|
||||
}
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
var parsed = registry.parse("endpoint-a", "yutong", "/ytforward/shln/terminal-001", payload);
|
||||
|
||||
assertThat(parsed.externalVin()).isEqualTo("LVIN0000000000001");
|
||||
assertThat(parsed.deviceId()).isEqualTo("terminal-001");
|
||||
assertThat(parsed.phone()).isEqualTo("13800138000");
|
||||
assertThat(parsed.plateNo()).isEqualTo("粤B12345");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsupportedProfileBecomesPassthroughEventForOperationalTraceability() {
|
||||
byte[] payload = "{\"device\":\"D1\"}".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
var parsed = registry.parse("endpoint-a", "unknown", "/x", payload);
|
||||
|
||||
assertThat(parsed.profile()).isEqualTo("unknown");
|
||||
assertThat(parsed.data().path("_parseError").asBoolean()).isTrue();
|
||||
assertThat(parsed.data().path("_unsupportedProfile").asBoolean()).isTrue();
|
||||
assertThat(registry.toEvents(parsed)).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.data()).containsExactly(payload);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("unsupportedProfile", "true")
|
||||
.containsEntry("profile", "unknown")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedPayloadBecomesPassthroughEventForOperationalTraceability() {
|
||||
byte[] payload = "{bad-json".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
var parsed = registry.parse("endpoint-a", "yutong", "/ytforward/bad", payload);
|
||||
|
||||
assertThat(parsed.vin()).isEqualTo("unknown");
|
||||
assertThat(parsed.data().path("_parseError").asBoolean()).isTrue();
|
||||
assertThat(registry.toEvents(parsed)).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(com.lingniu.ingest.api.ProtocolId.MQTT_YUTONG);
|
||||
assertThat(passthrough.data()).containsExactly(payload);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void operationalPayloadBecomesPassthroughEventWithFailureMetadata() {
|
||||
byte[] payload = """
|
||||
{"operationalError":true,"endpoint":"endpoint-a","phase":"init","reason":"bad uri"}
|
||||
""".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
var parsed = MqttProfileRegistry.operationalErrorPayload(
|
||||
"endpoint-a", "yutong", "/operational", payload, "init", "bad uri");
|
||||
|
||||
assertThat(registry.toEvents(parsed)).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.source()).isEqualTo(com.lingniu.ingest.api.ProtocolId.MQTT_YUTONG);
|
||||
assertThat(passthrough.vin()).isEqualTo("unknown");
|
||||
assertThat(passthrough.data()).containsExactly(payload);
|
||||
assertThat(passthrough.metadata())
|
||||
.containsEntry("operationalError", "true")
|
||||
.containsEntry("endpoint", "endpoint-a")
|
||||
.containsEntry("profile", "yutong")
|
||||
.containsEntry("phase", "init")
|
||||
.containsEntry("reason", "bad uri")
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.lingniu.ingest.inbound.mqtt;
|
||||
|
||||
import com.hivemq.client.mqtt.MqttClientSslConfig;
|
||||
import com.lingniu.ingest.inbound.mqtt.client.MqttTlsSupport;
|
||||
import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MqttTlsSupportTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void buildsTrustManagerFromCaPem() throws Exception {
|
||||
Path ca = tempDir.resolve("ca.pem");
|
||||
Files.writeString(ca, CERT);
|
||||
MqttInboundProperties.Tls tls = new MqttInboundProperties.Tls();
|
||||
tls.setCaPem(ca.toString());
|
||||
|
||||
MqttClientSslConfig config = MqttTlsSupport.build(tls);
|
||||
|
||||
assertThat(config.getTrustManagerFactory()).isPresent();
|
||||
assertThat(config.getKeyManagerFactory()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsMutualTlsFromClientCertificateAndKey() throws Exception {
|
||||
Path ca = tempDir.resolve("ca.pem");
|
||||
Path cert = tempDir.resolve("client.pem");
|
||||
Path key = tempDir.resolve("client.key");
|
||||
Files.writeString(ca, CERT);
|
||||
Files.writeString(cert, CERT);
|
||||
Files.writeString(key, KEY);
|
||||
MqttInboundProperties.Tls tls = new MqttInboundProperties.Tls();
|
||||
tls.setCaPem(ca.toString());
|
||||
tls.setClientPem(cert.toString());
|
||||
tls.setClientKey(key.toString());
|
||||
|
||||
MqttClientSslConfig config = MqttTlsSupport.build(tls);
|
||||
|
||||
assertThat(config.getTrustManagerFactory()).isPresent();
|
||||
assertThat(config.getKeyManagerFactory()).isPresent();
|
||||
}
|
||||
|
||||
private static final String CERT = """
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC/zCCAeegAwIBAgIUPg+j3PAaXVWYykrese0Ssb/blk4wDQYJKoZIhvcNAQEL
|
||||
BQAwDzENMAsGA1UEAwwEdGVzdDAeFw0yNjA2MjIwNTA0MzNaFw0yNjA2MjMwNTA0
|
||||
MzNaMA8xDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQChuMSubmmNY13b4PUGBoUO+EIy074ZfbRMPHCGJhfSnZVWdETBflOe3kNy
|
||||
ouejxN2eT3Xoqf7slqTPySJo8hZQjukORNtudMPWQkiBrvaWPCttlYtCxpxhxRw0
|
||||
VnIo/jYP2Fmwt2buHV//Kn039amDDr3auZWVXEIGLcafVpd/3v7c+1NLbMPR/uK8
|
||||
62Yk6UG1rlvOYBIttNp2Y9fGZLkMLA5fE3xsPbg8VeWuN9/xjPtZGZ5qUYHaQn3Z
|
||||
DzmZ8U9q5TGCbcpoVEIooRSb2lIILAqw2FRAR7ashCcJf6QzPVdhoZ8QIovJoViB
|
||||
nzuFP0I4VYp9fT1pRmBYjcZtIsSfAgMBAAGjUzBRMB0GA1UdDgQWBBSzG5+aGFJr
|
||||
qgnVlOmtdz8iGzU5RjAfBgNVHSMEGDAWgBSzG5+aGFJrqgnVlOmtdz8iGzU5RjAP
|
||||
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB7/6Q1e+acJobo/wnW
|
||||
9DiGzNaownFRs4yz8EgIYVcc7iROjBrVFrcORJAcFrg1uh0Q4rFcslMmEqFM8ryV
|
||||
IJ1/L6PcQUp4QlKGbbc9LarhalEdFxhX8IAIp2QCNaG8h9/WczZ5/TczwODfy1tW
|
||||
BFoLG19/dXd5HXLqxiLIpez2f2a/bbUkTu1Yj9QQUFUX2r6SO6LOWmZQsfboq040
|
||||
oeJhDzhL218ze1t7qhBk4rbju8NLFwtdhWZ8M7JBzlj5PfACBewd0eosFtb1Tz3N
|
||||
eAZn4M/GkzBh4t1M55Jt+GwYBD2A7trDRUCUQxtJ1QVzr2gbIB/FfoGvP0qnE4gk
|
||||
R3PW
|
||||
-----END CERTIFICATE-----
|
||||
""";
|
||||
|
||||
private static final String KEY = """
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChuMSubmmNY13b
|
||||
4PUGBoUO+EIy074ZfbRMPHCGJhfSnZVWdETBflOe3kNyouejxN2eT3Xoqf7slqTP
|
||||
ySJo8hZQjukORNtudMPWQkiBrvaWPCttlYtCxpxhxRw0VnIo/jYP2Fmwt2buHV//
|
||||
Kn039amDDr3auZWVXEIGLcafVpd/3v7c+1NLbMPR/uK862Yk6UG1rlvOYBIttNp2
|
||||
Y9fGZLkMLA5fE3xsPbg8VeWuN9/xjPtZGZ5qUYHaQn3ZDzmZ8U9q5TGCbcpoVEIo
|
||||
oRSb2lIILAqw2FRAR7ashCcJf6QzPVdhoZ8QIovJoViBnzuFP0I4VYp9fT1pRmBY
|
||||
jcZtIsSfAgMBAAECggEAQu+cbIwjoRM3NnpqQAezzAniMHJmlNtsJD/B3SxoINL7
|
||||
jDCMgr/cMX3SUeDuWmDxz4QZC+dMrbT+W0hnNyO4K7iy6qaCYjnvEsAVjaOSyYT2
|
||||
/qDuZoGZGXiBn4IGN0RcsPs9yEBo2HaNFKqL8Hz8H9QarayxpoPsie0pcCrhgtlr
|
||||
fsVN2AMhbEU15SpD0nkjWkqG51m1EY36seSzAwQod2UasD+eNdZ89a2o849cfuA3
|
||||
1F1TyghVIm0RmAdC1yWuud8YY4xvtpfy1+3RjShW0jgA0nVHfP2UgYfBAJ8+TFGy
|
||||
+hVCgnnt/eMQ1fCszwLkq5spRcqNr4oOVcm+nR718QKBgQDSvEXaaQgqhARfyN/c
|
||||
/1qFgOBT34a7pEJXotEC6aDV3YxivAt2jDB4TC0NVS5IGP9NYm4oi0Fiw1v0V5eZ
|
||||
n8nmq0gkfBjW1y37U4mP74QyKY9rwyjyjjsYqr/aTS8e2ZY4QWmWxm9y5a3Q0Ifu
|
||||
UwzLD5eqiXNLNvvNZoggo+dVsQKBgQDEdWAntCdTZEYO3fp4/ygCJisPeFgy/0Gw
|
||||
GsgHCkWR4ZyzmBmFSvf3Es3MmcH3QgkfIROCOMaZ0YcGCLzr/RMdYkgpa9DvhJnG
|
||||
WZDtumxQAW3G3moAYu2r2wXfOjgsMBDlRBH0mBWTBjpovbWBs1NMFfxsetQYY7zG
|
||||
aQGLDPJDTwKBgQCD6QQUrlBFRLP0PSocDN9d2AkTl0SgKja44prQputdU7vvhePr
|
||||
Bd/FPXGp+drpmHQevXFVAa4hI0ZpEXc822+naynSZLerq7AFtQnTxkrKl4dGHjiA
|
||||
dBV74E4NWOkY93x3pEJy9a2Hj0uY/R9JSEUmypDWWAmKWFWQAhFN1SsWUQKBgD7e
|
||||
xTfPimpAg78MQLTqCvatGkioHamsUGw4Fd1S5zKpPcmnmjsy46nZBa09Y3pqUpr4
|
||||
rdKVstDU4d4He9YVtkFIC4nd7A5KpB962EuLxk/QNT5YPRoEjsTZocZvTjyt4SpN
|
||||
n2VkKjtT2etdErIAHl8SBib9I9TuTiI8xnamXP03AoGATMdUyehOBqG3+YlS31c8
|
||||
z9vcIn2egPOjFCWK64FQIuwpmVU5sx6aYIRDb3/jxYqYCaMRcnMSSp/Mrn2yBnui
|
||||
oRry31MaC/aTSumn/r6WMz7FEYrbsfr9//bYr+tiTrtimWAe4yTwrqFve1Bw2bOQ
|
||||
P16PTZ2/SMJDmiLR1MIkCns=
|
||||
-----END PRIVATE KEY-----
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.lingniu.ingest.inbound.mqtt;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityBinding;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class YutongEventMapperTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final MqttPayloadParser parser = new MqttPayloadParser(objectMapper);
|
||||
private final YutongEventMapper mapper = new YutongEventMapper();
|
||||
|
||||
@Test
|
||||
void parsesYutongSamplePayload() {
|
||||
String json = """
|
||||
{
|
||||
"id": "abc",
|
||||
"device": "LTEST000000000001",
|
||||
"code": "C1",
|
||||
"time": "20260413100000",
|
||||
"data": {
|
||||
"METER_SPEED": 52.3,
|
||||
"TOTAL_MILEAGE": 123456.7,
|
||||
"BATTERY_CAPACITY_SOC": 70,
|
||||
"LONGITUDE": 116397128,
|
||||
"LATITUDE": 39916527,
|
||||
"GPSDirection": 90,
|
||||
"LEFT_HYDROGEN": 5.5,
|
||||
"VOLTAGE_OF_FC": 600.0,
|
||||
"CURRENT_OF_FC": 120.0,
|
||||
"TEMPT_OF_FC": 65.0,
|
||||
"ON_GEAR": 3,
|
||||
"ACC_PEDAL_APT": 25,
|
||||
"BRAKEPEDALAPT": 0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
MqttPayload payload = parser.parse("yutong", "yutong", "/ytforward/shln/dev1", json.getBytes());
|
||||
assertThat(payload.vin()).isEqualTo("LTEST000000000001");
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(payload);
|
||||
assertThat(events).hasSize(2);
|
||||
|
||||
VehicleEvent.Realtime rt = (VehicleEvent.Realtime) events.stream()
|
||||
.filter(e -> e instanceof VehicleEvent.Realtime).findFirst().orElseThrow();
|
||||
assertThat(rt.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(rt.vin()).isEqualTo("LTEST000000000001");
|
||||
assertThat(rt.payload().speedKmh()).isEqualTo(52.3);
|
||||
assertThat(rt.payload().batterySoc()).isEqualTo(70.0);
|
||||
assertThat(rt.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(rt.payload().latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(rt.payload().hydrogenRemainingKg()).isEqualTo(5.5);
|
||||
|
||||
VehicleEvent.Location loc = (VehicleEvent.Location) events.stream()
|
||||
.filter(e -> e instanceof VehicleEvent.Location).findFirst().orElseThrow();
|
||||
assertThat(loc.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesVinThroughSharedIdentityServiceWhenDeviceIsAnExternalId() throws Exception {
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
"VIN-INTERNAL-001",
|
||||
"",
|
||||
"mqtt-device-001",
|
||||
""));
|
||||
YutongEventMapper mapperWithIdentity = new YutongEventMapper(identity);
|
||||
|
||||
MqttPayload payload = new MqttPayload(
|
||||
"yutong",
|
||||
"yutong",
|
||||
"/ytforward/shln/mqtt-device-001",
|
||||
"mqtt-device-001",
|
||||
java.time.Instant.parse("2026-06-22T08:00:00Z"),
|
||||
objectMapper.readTree("""
|
||||
{
|
||||
"METER_SPEED": 18.5,
|
||||
"TOTAL_MILEAGE": 99.1
|
||||
}
|
||||
"""));
|
||||
|
||||
List<VehicleEvent> events = mapperWithIdentity.toEvents(payload);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event.vin()).isEqualTo("VIN-INTERNAL-001");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_DEVICE_ID")
|
||||
.containsEntry("mqttDeviceId", "mqtt-device-001");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesVinThroughSharedIdentityServiceWhenPayloadUsesTerminalId() {
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
"VIN-INTERNAL-TID",
|
||||
"",
|
||||
"terminal-001",
|
||||
""));
|
||||
YutongEventMapper mapperWithIdentity = new YutongEventMapper(identity);
|
||||
String json = """
|
||||
{
|
||||
"terminalId": "terminal-001",
|
||||
"time": "20260413100000",
|
||||
"data": {
|
||||
"METER_SPEED": 18.5,
|
||||
"TOTAL_MILEAGE": 99.1
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
MqttPayload payload = parser.parse(
|
||||
"yutong", "yutong", "/ytforward/shln/terminal-001", json.getBytes());
|
||||
List<VehicleEvent> events = mapperWithIdentity.toEvents(payload);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event.vin()).isEqualTo("VIN-INTERNAL-TID");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("identityResolved", "true")
|
||||
.containsEntry("identitySource", "BOUND_DEVICE_ID")
|
||||
.containsEntry("mqttDeviceId", "terminal-001");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersBoundDeviceIdentityOverVinLengthGuess() throws Exception {
|
||||
InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
|
||||
identity.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
"VIN-INTERNAL-017",
|
||||
"",
|
||||
"DEVICEID000000000",
|
||||
""));
|
||||
YutongEventMapper mapperWithIdentity = new YutongEventMapper(identity);
|
||||
|
||||
MqttPayload payload = new MqttPayload(
|
||||
"yutong",
|
||||
"yutong",
|
||||
"/ytforward/shln/DEVICEID000000000",
|
||||
"DEVICEID000000000",
|
||||
java.time.Instant.parse("2026-06-22T08:00:00Z"),
|
||||
objectMapper.readTree("{\"METER_SPEED\":18.5}"));
|
||||
|
||||
List<VehicleEvent> events = mapperWithIdentity.toEvents(payload);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event.vin()).isEqualTo("VIN-INTERNAL-017");
|
||||
assertThat(event.metadata()).containsEntry("identitySource", "BOUND_DEVICE_ID");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityResolverFailureStillProducesRealtimeEventWithUnknownVin() throws Exception {
|
||||
YutongEventMapper mapperWithFailingIdentity = new YutongEventMapper(lookup -> {
|
||||
throw new IllegalStateException("identity backend unavailable");
|
||||
});
|
||||
MqttPayload payload = new MqttPayload(
|
||||
"yutong",
|
||||
"yutong",
|
||||
"/ytforward/shln/mqtt-device-001",
|
||||
"mqtt-device-001",
|
||||
java.time.Instant.parse("2026-06-22T08:00:00Z"),
|
||||
objectMapper.readTree("""
|
||||
{
|
||||
"METER_SPEED": 18.5,
|
||||
"TOTAL_MILEAGE": 99.1
|
||||
}
|
||||
"""));
|
||||
|
||||
List<VehicleEvent> events = mapperWithFailingIdentity.toEvents(payload);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Realtime.class);
|
||||
assertThat(event.vin()).isEqualTo("unknown");
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("vin", "unknown")
|
||||
.containsEntry("mqttDeviceId", "mqtt-device-001")
|
||||
.containsEntry("identityResolved", "false")
|
||||
.containsEntry("identitySource", "UNKNOWN")
|
||||
.containsEntry("identityError", "true")
|
||||
.containsEntry("identityErrorMessage", "identity backend unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseErrorPayloadKeepsDiagnosticMessageOnPassthroughEvent() throws Exception {
|
||||
MqttPayload payload = new MqttPayload(
|
||||
"endpoint-a",
|
||||
"yutong",
|
||||
"/ytforward/shln/bad-device",
|
||||
"unknown",
|
||||
java.time.Instant.parse("2026-06-22T08:00:00Z"),
|
||||
objectMapper.readTree("""
|
||||
{
|
||||
"_parseError": true,
|
||||
"_parseErrorMessage": "Unexpected character at byte 1",
|
||||
"_rawBase64": "e2JhZC1qc29u"
|
||||
}
|
||||
"""));
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(payload);
|
||||
|
||||
assertThat(events).singleElement().satisfies(event -> {
|
||||
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
|
||||
VehicleEvent.Passthrough passthrough = (VehicleEvent.Passthrough) event;
|
||||
assertThat(passthrough.data()).containsExactly("{bad-json".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
assertThat(event.metadata())
|
||||
.containsEntry("parseError", "true")
|
||||
.containsEntry("parseErrorMessage", "Unexpected character at byte 1");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user