feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

View 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>

View File

@@ -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());
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
};
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}

View File

@@ -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];
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration

View File

@@ -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();
}
}
}

View File

@@ -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");
});
}
}

View File

@@ -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-----
""";
}

View File

@@ -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");
});
}
}

View File

@@ -0,0 +1,94 @@
<?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-xinda-push</artifactId>
<name>inbound-xinda-push</name>
<description>
信达平台推送接入。基于私仓 org.lingniu:gps-push-client:1.0 封装的
com.gps31.push.netty.PushClient 重写,保留信达真实帧格式,
登录/心跳/订阅/位置/报警 全程对接成功后把事件直接投到 Kafka。
</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>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 信达私仓 Push 客户端。com.gps31.push.netty.PushClient 封装了真实信达帧格式。 -->
<dependency>
<groupId>org.lingniu</groupId>
<artifactId>gps-push-client</artifactId>
<version>1.0</version>
</dependency>
<!-- gps-push-client 依赖 fastjson未在其 pom 里声明,这里补齐) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<!-- commons-collections4PushClient 内部用到 ListOrderedSet -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>
<!-- commons-lang3PushClient.setSubMsgIds / channelActive 里调用 StringUtils.isBlank / isNotBlank -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- commons-codecTcpClientHandler 用到 Hex 做字节日志打印) -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!-- commons-loggingPushClient 使用 JCL由 Spring Boot 的 jcl-over-slf4j 桥接到 logback无需额外处理 -->
<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>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,336 @@
package com.lingniu.ingest.inbound.xinda;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.gps31.push.netty.PushClient;
import com.gps31.push.netty.PushMsg;
import com.gps31.push.netty.client.TcpClient;
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.xinda.config.XindaPushProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
/**
* 信达 Push 接入客户端。基于 {@code org.lingniu:gps-push-client:1.0} 的 {@link PushClient}
* 实现,完全使用信达真实协议帧格式(帧头 / 长度 / cmd / json / 帧尾 + 序列号 + 心跳)。
*
* <p>行为:
* <ul>
* <li>{@link #start()} 调用基类 {@code TcpClient.start()} 建立连接并自动重连
* <li>基类在 {@code channelActive} 自动发送 {@code 0x0001 登录} 帧,携带 userName / pwd / csTag / desc
* <li>登录成功收到应答后(由基类内部处理)会自动订阅 {@code subMsgIds}(默认空,需显式调用 setSubMsgIds
* <li>{@link #messageReceived(TcpClient, PushMsg)} 为业务入口cmd 字符串区分消息类型
* <li>位置 {@code 0200} / 报警 {@code 0300} / 透传 {@code 0401} → RawFrame → Dispatcher
* </ul>
*
* <p>类继承基类 {@code PushClient},不再需要手工处理帧编解码、心跳、重连、订阅等机制。
*/
public class XindaPushClient extends PushClient {
private static final Logger log = LoggerFactory.getLogger(XindaPushClient.class);
private final XindaPushProperties props;
private final VehicleIdentityResolver identityResolver;
private final Dispatcher dispatcher;
public XindaPushClient(XindaPushProperties props, Dispatcher dispatcher) {
this(props, new InMemoryVehicleIdentityService(), dispatcher);
}
public XindaPushClient(XindaPushProperties props,
VehicleIdentityResolver identityResolver,
Dispatcher dispatcher) {
super();
this.props = props;
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
this.dispatcher = dispatcher;
this.isLog = false;
}
/**
* 启动客户端:填入 host/port/user/pwd/desc配置订阅消息 ID交给基类建立连接。
*/
public void start() {
if (props.getHost() == null || props.getHost().isBlank()) {
publishOperationalError("startup", "host not configured");
return;
}
if (props.getUsername() == null || props.getPassword() == null) {
publishOperationalError("startup", "credentials missing");
return;
}
setHost(props.getHost());
setPort(props.getPort());
setUserName(props.getUsername());
setPwd(props.getPassword());
setDesc(props.getClientDesc());
// 订阅消息 ID信达基类 PushClient.setSubMsgIds 用管道符 "|" 作为分隔符
// (内部调用 StringUtil.splitStr(s, "|")
if (props.getSubscribeMsgIds() != null && !props.getSubscribeMsgIds().isEmpty()) {
setSubMsgIds(String.join("|", props.getSubscribeMsgIds()));
}
// 基类 PushClient 构造器默认 isLog=false / isLogBytes=false
// 这里尊重默认值,不再覆盖为 true避免 <<<Log发送 / >>>Log接收 每条帧都被 ERROR 级打印。
// 真要打开 wire-level 诊断时,临时改为 setLog(true) 即可。
try {
log.info("[xinda-push] starting host={} port={} user={} subMsgIds={}",
getHost(), getPort(), getUserName(), getSubMsgIds());
super.start();
} catch (Exception e) {
log.error("[xinda-push] start failed", e);
publishOperationalError("startup", e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
}
}
/**
* Spring 生命周期停止钩子。
*/
public void stop() {
try {
destroy();
log.info("[xinda-push] stopped");
} catch (Exception e) {
log.warn("[xinda-push] stop error", e);
}
}
// ========================================================================
// 业务回调:收到完整的 PushMsg 后分派事件
// ========================================================================
@Override
public void messageReceived(TcpClient client, PushMsg msg) throws Exception {
super.messageReceived(client, msg);
String cmd = msg.getCmd();
if (cmd == null) return;
try {
switch (cmd) {
case "8001" -> handleLoginAck(msg);
case "8002" -> log.debug("[xinda-push] heartbeat ack");
case "8003" -> log.info("[xinda-push] subscribe ack json={}", msg.getJson());
case "0200", "0300", "0401" -> dispatchBusinessMessage(msg, false);
default -> {
log.debug("[xinda-push] rx unknown business cmd={} json={}", cmd, msg.getJson());
dispatchBusinessMessage(msg, true);
}
}
} catch (Exception e) {
log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e);
publishMessageError(msg, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
}
}
/**
* 收到 8001 登录应答后处理:若 {@code rspResult=0} 则主动发送 0003 订阅命令。
*
* <p>信达订阅命令的 body 格式(从旧 {@code PushClientHandler} 和 8003 错误提示反推得到):
* <pre>{@code
* {
* "seq": "1", // 序列号
* "action": "add", // 操作: add 订阅 / del 取消
* "msgIds": "[\"0200\",\"0300\",\"0401\"]" // JSON 数组字符串(注意是字符串,不是数组)
* }
* }</pre>
* <p>
* 其中 {@code msgIds} 的值来自基类 {@link PushClient#getSubCmdSet()},内容由
* {@link PushClient#setSubMsgIds(String)} 填充(分隔符是 {@code |})。
*/
private void handleLoginAck(PushMsg msg) throws Exception {
String json = msg.getJson() == null ? "" : msg.getJson();
log.info("[xinda-push] login ack json={}", json);
JSONObject parsed;
try {
parsed = JSON.parseObject(json);
} catch (Exception e) {
publishOperationalError("login", "login ack parse failed: "
+ (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
return;
}
String rsp = parsed == null ? null : parsed.getString("rspResult");
if (!"0".equals(rsp)) {
log.error("[xinda-push] login REJECTED rspResult={}", rsp);
publishOperationalError("login", "login rejected rspResult=" + (rsp == null ? "" : rsp));
return;
}
Map<String, Object> body = new java.util.HashMap<>();
body.put("seq", "1");
body.put("action", "add");
body.put("msgIds", JSON.toJSONString(getSubCmdSet()));
PushMsg subscribe = getInstance("0003", body);
sendMsg(subscribe);
log.info("[xinda-push] subscribe sent action=add msgIds={}", getSubCmdSet());
}
@Override
public void channelActive(TcpClient client) throws Exception {
super.channelActive(client);
log.info("[xinda-push] channel active, login frame sent");
}
@Override
public void channelInactive(TcpClient client) throws Exception {
super.channelInactive(client);
log.warn("[xinda-push] channel inactive, will auto-reconnect");
}
private void dispatchBusinessMessage(PushMsg msg, boolean unknownCommand) {
String json = msg.getJson() == null ? "" : msg.getJson();
XindaPushMessage payload = new XindaPushMessage(msg.getCmd(), json);
byte[] raw = json.getBytes(StandardCharsets.UTF_8);
JsonParseResult parsed = parseForMetadata(json);
JSONObject identityFields = parsed.json();
IdentityResolution identity = resolveIdentity(identityFields);
Map<String, String> meta = new HashMap<>();
meta.put("source", "xinda-push");
meta.put("cmd", payload.command());
meta.put("vin", identity.identity().vin());
meta.put("externalVin", firstNonBlank(identityFields, "vin"));
meta.put("phone", firstNonBlank(identityFields, "sim", "phone", "mobile"));
meta.put("deviceId", firstNonBlank(identityFields, "deviceId", "terminalId", "imei"));
meta.put("plateNo", firstNonBlank(identityFields, "plateNo", "carNo"));
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());
}
if (unknownCommand) {
meta.put("unknownCommand", "true");
}
if (payload.malformedCommand()) {
meta.put("malformedCommand", "true");
}
if (parsed.parseError()) {
meta.put("parseError", "true");
meta.put("parseErrorMessage", parsed.errorMessage());
}
dispatcher.dispatch(new RawFrame(
ProtocolId.XINDA_PUSH,
payload.commandCode(),
0,
payload,
raw,
meta,
Instant.now()));
}
private void publishOperationalError(String phase, String reason) {
log.error("[xinda-push] operational error phase={} reason={}", phase, reason);
JSONObject json = new JSONObject();
json.put("operationalError", true);
json.put("phase", phase == null ? "" : phase);
json.put("reason", reason == null ? "" : reason);
XindaPushMessage payload = new XindaPushMessage("0000", json.toJSONString());
byte[] raw = payload.json().getBytes(StandardCharsets.UTF_8);
Map<String, String> meta = new HashMap<>();
meta.put("source", "xinda-push");
meta.put("cmd", payload.command());
meta.put("vin", "unknown");
meta.put("identityResolved", "false");
meta.put("identitySource", "UNKNOWN");
meta.put("operationalError", "true");
meta.put("phase", phase == null ? "" : phase);
meta.put("reason", reason == null ? "" : reason);
dispatcher.dispatch(new RawFrame(
ProtocolId.XINDA_PUSH,
payload.commandCode(),
0,
payload,
raw,
meta,
Instant.now()));
}
private void publishMessageError(PushMsg msg, String reason) {
String cmd = msg == null || msg.getCmd() == null ? "0000" : msg.getCmd();
String json = msg == null || msg.getJson() == null ? "" : msg.getJson();
XindaPushMessage payload = new XindaPushMessage(cmd, json);
byte[] raw = json.getBytes(StandardCharsets.UTF_8);
Map<String, String> meta = new HashMap<>();
meta.put("source", "xinda-push");
meta.put("cmd", cmd);
meta.put("vin", "unknown");
meta.put("identityResolved", "false");
meta.put("identitySource", "UNKNOWN");
meta.put("operationalError", "true");
meta.put("phase", "message");
meta.put("reason", reason == null ? "" : reason);
dispatcher.dispatch(new RawFrame(
ProtocolId.XINDA_PUSH,
0,
0,
payload,
raw,
meta,
Instant.now()));
}
// ========================================================================
// JSON helpers
// ========================================================================
private static String firstNonBlank(JSONObject node, String... keys) {
if (node == null) return "";
for (String k : keys) {
Object v = node.get(k);
if (v != null && !v.toString().isBlank()) return v.toString();
}
return "";
}
private IdentityResolution resolveIdentity(JSONObject json) {
try {
return new IdentityResolution(identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.XINDA_PUSH,
firstNonBlank(json, "vin"),
firstNonBlank(json, "sim", "phone", "mobile"),
firstNonBlank(json, "deviceId", "terminalId", "imei"),
firstNonBlank(json, "plateNo", "carNo"))), null);
} catch (RuntimeException e) {
return new IdentityResolution(
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
e.getMessage() == null ? "" : e.getMessage());
}
}
private static JSONObject parseQuietly(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
return new JSONObject();
}
try {
JSONObject parsed = JSON.parseObject(rawJson);
return parsed == null ? new JSONObject() : parsed;
} catch (Exception ignored) {
return new JSONObject();
}
}
private static JsonParseResult parseForMetadata(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
return new JsonParseResult(new JSONObject(), false, "");
}
try {
JSONObject parsed = JSON.parseObject(rawJson);
return new JsonParseResult(parsed == null ? new JSONObject() : parsed, false, "");
} catch (Exception e) {
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
return new JsonParseResult(new JSONObject(), true, message);
}
}
private record JsonParseResult(JSONObject json, boolean parseError, String errorMessage) {}
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
}

View File

@@ -0,0 +1,353 @@
package com.lingniu.ingest.inbound.xinda;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.AlarmPayload;
import com.lingniu.ingest.api.event.LocationPayload;
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 java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public final class XindaPushEventMapper implements EventMapper<XindaPushMessage> {
private final VehicleIdentityResolver identityResolver;
public XindaPushEventMapper() {
this(new InMemoryVehicleIdentityService());
}
public XindaPushEventMapper(VehicleIdentityResolver identityResolver) {
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
}
@Override
public List<VehicleEvent> toEvents(XindaPushMessage message) {
if (message == null || message.command().isBlank()) {
return List.of();
}
JSONObject json;
try {
json = parse(message.json());
} catch (Exception e) {
return List.of(passthrough(message, new JSONObject(), true, false,
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
if (json.getBooleanValue("operationalError")) {
return List.of(passthrough(message, json, false, false));
}
return switch (message.command()) {
case "0200" -> List.of(location(message, json));
case "0300" -> List.of(alarm(message, json));
case "0401" -> List.of(passthrough(message, json, false, false));
default -> List.of(passthrough(message, json, false, true));
};
}
private VehicleEvent.Location location(XindaPushMessage message, JSONObject json) {
IdentityResolution identity = resolveIdentity(json);
double lon = normalizeCoord(asDouble(json, "lng", "lon", "longitude"));
double lat = normalizeCoord(asDouble(json, "lat", "latitude"));
double speed = asDouble(json, "speed");
Double totalMileageKm = optionalDouble(json,
"totalMileage", "totalMileageKm", "mileage", "mileageKm", "total_mileage");
double direction = asDouble(json, "drct", "direction");
long alarmFlag = (long) asDouble(json, "alarmStts", "alarmFlag");
long statusFlag = (long) asDouble(json, "statusStts", "statusFlag");
Instant now = Instant.now();
return new VehicleEvent.Location(
UUID.randomUUID().toString(),
identity.vin(),
ProtocolId.XINDA_PUSH,
now,
now,
null,
metadata(message, identity, false, false),
new LocationPayload(lon, lat, 0.0, speed, direction, alarmFlag, statusFlag, totalMileageKm));
}
private VehicleEvent.Alarm alarm(XindaPushMessage message, JSONObject json) {
IdentityResolution identity = resolveIdentity(json);
double lon = normalizeCoord(asDouble(json, "lng", "lon", "longitude"));
double lat = normalizeCoord(asDouble(json, "lat", "latitude"));
String alarmType = firstNonBlank(json, "alarmType", "alarmCode", "type", "warnType");
String alarmName = firstNonBlank(json, "alarmName", "alarmDesc", "alarmContent", "content", "warnName");
int alarmTypeCode = alarmTypeCode(alarmType);
boolean hydrogenLeak = isHydrogenLeak(alarmType, alarmName, firstNonBlank(json, "safetyCategory"));
AlarmPayload.AlarmLevel level = alarmLevel(asInt(json, "alarmLevel", "level"), hydrogenLeak);
Instant now = Instant.now();
return new VehicleEvent.Alarm(
UUID.randomUUID().toString(),
identity.vin(),
ProtocolId.XINDA_PUSH,
now,
now,
null,
alarmMetadata(message, identity, hydrogenLeak),
new AlarmPayload(
level,
alarmTypeCode,
alarmType.isBlank() ? alarmName : alarmType,
faultCodes(json, alarmType, alarmName),
activeBits(json, alarmType, alarmName, hydrogenLeak),
lon,
lat,
hydrogenLeak ? AlarmPayload.SafetyCategory.HYDROGEN_LEAK : AlarmPayload.SafetyCategory.GENERAL,
hydrogenLeak,
hydrogenLeak ? AlarmPayload.HydrogenLeakLevel.CRITICAL : AlarmPayload.HydrogenLeakLevel.NONE,
hydrogenLeak));
}
private VehicleEvent.Passthrough passthrough(XindaPushMessage message,
JSONObject json,
boolean parseError,
boolean unknownCommand) {
return passthrough(message, json, parseError, unknownCommand, "");
}
private VehicleEvent.Passthrough passthrough(XindaPushMessage message,
JSONObject json,
boolean parseError,
boolean unknownCommand,
String parseErrorMessage) {
IdentityResolution identity = resolveIdentity(json);
byte[] raw = message.json().getBytes(StandardCharsets.UTF_8);
Instant now = Instant.now();
return new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
identity.vin(),
ProtocolId.XINDA_PUSH,
now,
now,
null,
metadata(message, identity, parseError, unknownCommand, parseErrorMessage),
message.commandCode(),
raw);
}
private IdentityResolution resolveIdentity(JSONObject json) {
try {
return IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.XINDA_PUSH,
firstNonBlank(json, "vin"),
firstNonBlank(json, "sim", "phone", "mobile"),
firstNonBlank(json, "deviceId", "terminalId", "imei"),
firstNonBlank(json, "plateNo", "carNo"))));
} catch (RuntimeException e) {
return new IdentityResolution(
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
e.getMessage() == null ? "" : e.getMessage());
}
}
private static Map<String, String> metadata(XindaPushMessage message,
IdentityResolution identity,
boolean parseError,
boolean unknownCommand) {
return metadata(message, identity, parseError, unknownCommand, "");
}
private static Map<String, String> metadata(XindaPushMessage message,
IdentityResolution identity,
boolean parseError,
boolean unknownCommand,
String parseErrorMessage) {
JSONObject json = parseQuietly(message.json());
Map<String, String> out = new java.util.LinkedHashMap<>();
out.put("source", "xinda-push");
out.put("cmd", message.command());
out.put("rawJson", message.json());
out.put("vin", normalizedVin(identity));
out.put("externalVin", firstNonBlank(json, "vin"));
out.put("phone", firstNonBlank(json, "sim", "phone", "mobile"));
out.put("deviceId", firstNonBlank(json, "deviceId", "terminalId", "imei"));
out.put("plateNo", firstNonBlank(json, "plateNo", "carNo"));
out.put("identityResolved", Boolean.toString(identity.identity().resolved()));
out.put("identitySource", identity.identity().source().name());
if (identity.errorMessage() != null) {
out.put("identityError", "true");
out.put("identityErrorMessage", identity.errorMessage());
}
out.put("parseError", Boolean.toString(parseError));
if (parseError) {
out.put("parseErrorMessage", parseErrorMessage == null ? "" : parseErrorMessage);
}
out.put("unknownCommand", Boolean.toString(unknownCommand));
out.put("malformedCommand", Boolean.toString(message.malformedCommand()));
if (json.getBooleanValue("operationalError")) {
out.put("operationalError", "true");
out.put("phase", firstNonBlank(json, "phase"));
out.put("reason", firstNonBlank(json, "reason"));
}
return Map.copyOf(out);
}
private static String normalizedVin(IdentityResolution identity) {
if (identity == null || identity.vin() == null || identity.vin().isBlank()) {
return "unknown";
}
return identity.vin();
}
private static Map<String, String> alarmMetadata(XindaPushMessage message,
IdentityResolution identity,
boolean hydrogenLeak) {
JSONObject json = parseQuietly(message.json());
Map<String, String> out = new java.util.LinkedHashMap<>(metadata(message, identity, false, false));
out.put("alarmType", firstNonBlank(json, "alarmType", "alarmCode", "type", "warnType"));
out.put("alarmName", firstNonBlank(json, "alarmName", "alarmDesc", "alarmContent", "content", "warnName"));
out.put("alarmLevel", firstNonBlank(json, "alarmLevel", "level"));
out.put("hydrogenLeakDetected", Boolean.toString(hydrogenLeak));
if (hydrogenLeak) {
out.put("safetyCategory", AlarmPayload.SafetyCategory.HYDROGEN_LEAK.name());
out.put("hydrogenLeakLevel", AlarmPayload.HydrogenLeakLevel.CRITICAL.name());
out.put("hydrogenLeakActionRequired", "true");
}
return Map.copyOf(out);
}
private static JSONObject parse(String raw) {
if (raw == null || raw.isBlank()) {
return new JSONObject();
}
return JSON.parseObject(raw);
}
private static JSONObject parseQuietly(String raw) {
try {
return parse(raw);
} catch (Exception ignored) {
return new JSONObject();
}
}
private static String firstNonBlank(JSONObject node, String... keys) {
if (node == null) return "";
for (String k : keys) {
Object v = node.get(k);
if (v != null && !v.toString().isBlank()) return v.toString();
}
return "";
}
private static double asDouble(JSONObject node, String... keys) {
Double value = optionalDouble(node, keys);
return value == null ? 0.0 : value;
}
private static Double optionalDouble(JSONObject node, String... keys) {
if (node == null) return null;
for (String k : keys) {
Object v = node.get(k);
if (v instanceof Number n) return n.doubleValue();
if (v != null) {
try {
return Double.parseDouble(v.toString());
} catch (NumberFormatException ignored) {
}
}
}
return null;
}
private static int asInt(JSONObject node, String... keys) {
return (int) asDouble(node, keys);
}
private static double normalizeCoord(double value) {
if (Math.abs(value) > 1000) {
return value / 1_000_000.0;
}
return value;
}
private static boolean isHydrogenLeak(String... values) {
for (String value : values) {
if (value == null || value.isBlank()) {
continue;
}
String v = value.trim().toLowerCase(java.util.Locale.ROOT);
if (v.contains("hydrogen_leak")
|| v.contains("h2_leak")
|| v.contains("hydrogenleak")
|| v.contains("氢气泄")
|| v.contains("氢泄")
|| v.contains("漏氢")) {
return true;
}
}
return false;
}
private static AlarmPayload.AlarmLevel alarmLevel(int code, boolean hydrogenLeak) {
if (hydrogenLeak || code >= 3) {
return AlarmPayload.AlarmLevel.CRITICAL;
}
if (code == 2) {
return AlarmPayload.AlarmLevel.MAJOR;
}
if (code == 1) {
return AlarmPayload.AlarmLevel.MINOR;
}
return AlarmPayload.AlarmLevel.INFO;
}
private static int alarmTypeCode(String alarmType) {
if (alarmType == null || alarmType.isBlank()) {
return 0;
}
try {
return Integer.parseInt(alarmType.trim());
} catch (NumberFormatException ignored) {
return Math.abs(alarmType.trim().hashCode());
}
}
private static List<String> faultCodes(JSONObject json, String alarmType, String alarmName) {
LinkedHashSet<String> codes = new LinkedHashSet<>();
addIfPresent(codes, alarmType);
addIfPresent(codes, firstNonBlank(json, "faultCode", "faultCodes", "alarmCode"));
addIfPresent(codes, alarmName);
return List.copyOf(codes);
}
private static java.util.Set<String> activeBits(JSONObject json,
String alarmType,
String alarmName,
boolean hydrogenLeak) {
LinkedHashSet<String> bits = new LinkedHashSet<>();
addIfPresent(bits, alarmType);
addIfPresent(bits, alarmName);
if (hydrogenLeak) {
bits.add("HYDROGEN_LEAK");
}
return java.util.Set.copyOf(bits);
}
private static void addIfPresent(java.util.Set<String> out, String value) {
if (value != null && !value.isBlank()) {
out.add(value.trim());
}
}
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {
private static IdentityResolution ok(VehicleIdentity identity) {
return new IdentityResolution(identity, null);
}
private String vin() {
return identity.vin();
}
}
}

View File

@@ -0,0 +1,32 @@
package com.lingniu.ingest.inbound.xinda;
public record XindaPushMessage(String command, String json) {
public XindaPushMessage {
command = command == null ? "" : command.trim();
json = json == null ? "" : json;
}
public int commandCode() {
if (command.isBlank()) {
return 0;
}
try {
return Integer.parseInt(command, 16);
} catch (NumberFormatException e) {
return 0;
}
}
public boolean malformedCommand() {
if (command.isBlank()) {
return false;
}
try {
Integer.parseInt(command, 16);
return false;
} catch (NumberFormatException e) {
return true;
}
}
}

View File

@@ -0,0 +1,37 @@
package com.lingniu.ingest.inbound.xinda;
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.event.VehicleEvent;
import java.util.List;
@ProtocolHandler(protocol = ProtocolId.XINDA_PUSH)
public final class XindaPushRealtimeHandler {
private final XindaPushEventMapper mapper;
public XindaPushRealtimeHandler(XindaPushEventMapper mapper) {
this.mapper = mapper;
}
@MessageMapping(command = 0x0200, desc = "信达定位")
@EventEmit(VehicleEvent.Location.class)
public List<VehicleEvent> onLocation(XindaPushMessage message) {
return mapper.toEvents(message);
}
@MessageMapping(command = 0x0300, desc = "信达报警")
@EventEmit(VehicleEvent.Alarm.class)
public List<VehicleEvent> onAlarm(XindaPushMessage message) {
return mapper.toEvents(message);
}
@MessageMapping(desc = "信达透传/未知业务消息兜底")
@EventEmit(VehicleEvent.Passthrough.class)
public List<VehicleEvent> onPassthrough(XindaPushMessage message) {
return mapper.toEvents(message);
}
}

View File

@@ -0,0 +1,50 @@
package com.lingniu.ingest.inbound.xinda.config;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.inbound.xinda.XindaPushClient;
import com.lingniu.ingest.inbound.xinda.XindaPushEventMapper;
import com.lingniu.ingest.inbound.xinda.XindaPushRealtimeHandler;
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;
@AutoConfiguration
@EnableConfigurationProperties(XindaPushProperties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.xinda-push", name = "enabled", havingValue = "true")
public class XindaPushAutoConfiguration {
@Bean(destroyMethod = "stop")
@ConditionalOnMissingBean
public XindaPushClient xindaPushClient(XindaPushProperties props,
VehicleIdentityResolver identityResolver,
Dispatcher dispatcher) {
return new XindaPushClient(props, identityResolver, dispatcher);
}
@Bean
@ConditionalOnMissingBean
public XindaPushEventMapper xindaPushEventMapper(VehicleIdentityResolver identityResolver) {
return new XindaPushEventMapper(identityResolver);
}
@Bean
@ConditionalOnMissingBean
public XindaPushRealtimeHandler xindaPushRealtimeHandler(XindaPushEventMapper mapper) {
return new XindaPushRealtimeHandler(mapper);
}
@Bean
public ApplicationRunner xindaPushStartupRunner(XindaPushClient client) {
return new ApplicationRunner() {
@Override
public void run(ApplicationArguments args) {
client.start();
}
};
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.inbound.xinda.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。
*/
@ConfigurationProperties(prefix = "lingniu.ingest.xinda-push")
public class XindaPushProperties {
private boolean enabled = false;
private String host;
private int port = 10100;
private String username;
private String password;
private String clientDesc = "lingniu-ingest";
private List<String> subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401"));
/** 断线重连间隔(秒)。 */
private int reconnectIntervalSec = 5;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
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 String getClientDesc() { return clientDesc; }
public void setClientDesc(String clientDesc) { this.clientDesc = clientDesc; }
public List<String> getSubscribeMsgIds() { return subscribeMsgIds; }
public void setSubscribeMsgIds(List<String> subscribeMsgIds) { this.subscribeMsgIds = subscribeMsgIds; }
public int getReconnectIntervalSec() { return reconnectIntervalSec; }
public void setReconnectIntervalSec(int reconnectIntervalSec) { this.reconnectIntervalSec = reconnectIntervalSec; }
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.inbound.xinda.config.XindaPushAutoConfiguration

View File

@@ -0,0 +1,242 @@
package com.lingniu.ingest.inbound.xinda;
import com.gps31.push.netty.PushMsg;
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.VehicleIdentityBinding;
import com.lingniu.ingest.inbound.xinda.config.XindaPushProperties;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class XindaPushClientTest {
@Test
void missingHostIsDispatchedAsOperationalPassthroughCandidate() {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushProperties props = new XindaPushProperties();
props.setUsername("user");
props.setPassword("pwd");
XindaPushClient client = new XindaPushClient(props, dispatcher);
client.start();
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isZero();
assertThat(frame.payload()).isInstanceOf(XindaPushMessage.class);
assertThat(frame.rawBytes()).asString(StandardCharsets.UTF_8)
.contains("\"operationalError\":true")
.contains("\"phase\":\"startup\"")
.contains("\"reason\":\"host not configured\"");
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("operationalError", "true")
.containsEntry("phase", "startup")
.containsEntry("reason", "host not configured")
.containsEntry("vin", "unknown")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN");
}
@Test
void unknownBusinessCommandIsDispatchedForArchiveAndPassthrough() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher);
PushMsg msg = new PushMsg();
msg.init("0500", "{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isEqualTo(0x0500);
assertThat(frame.payload()).isEqualTo(new XindaPushMessage("0500",
"{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}"));
assertThat(frame.rawBytes()).containsExactly(
"{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}".getBytes(StandardCharsets.UTF_8));
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("cmd", "0500")
.containsEntry("vin", "LNVIN000000000303")
.containsEntry("unknownCommand", "true");
}
@Test
void businessMessageRawFrameUsesResolvedVehicleIdentity() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService();
identities.bind(new VehicleIdentityBinding(
ProtocolId.XINDA_PUSH,
"LNVIN000000XINDA1",
"",
"XD-DEVICE-001",
"粤B12345"));
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), identities, dispatcher);
PushMsg msg = new PushMsg();
msg.init("0200", "{\"deviceId\":\"XD-DEVICE-001\",\"plateNo\":\"粤B12345\",\"lng\":113.1,\"lat\":23.1}");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.sourceMeta())
.containsEntry("vin", "LNVIN000000XINDA1")
.containsEntry("deviceId", "XD-DEVICE-001")
.containsEntry("plateNo", "粤B12345")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_DEVICE_ID");
}
@Test
void malformedCommandIsStillDispatchedForArchiveAndPassthrough() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher);
PushMsg msg = new PushMsg();
msg.init("bad-cmd", "{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isZero();
assertThat(frame.payload()).isEqualTo(new XindaPushMessage("bad-cmd",
"{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}"));
assertThat(frame.rawBytes()).containsExactly(
"{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}".getBytes(StandardCharsets.UTF_8));
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("cmd", "bad-cmd")
.containsEntry("vin", "LNVIN000000000404")
.containsEntry("unknownCommand", "true")
.containsEntry("malformedCommand", "true");
}
@Test
void malformedJsonBusinessMessageMarksRawFrameForArchiveDiagnostics() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher);
PushMsg msg = new PushMsg();
msg.init("0200", "{bad-json");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isEqualTo(0x0200);
assertThat(frame.rawBytes()).containsExactly("{bad-json".getBytes(StandardCharsets.UTF_8));
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("cmd", "0200")
.containsEntry("vin", "unknown")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN")
.containsEntry("parseError", "true")
.containsKey("parseErrorMessage");
assertThat(frame.sourceMeta().get("parseErrorMessage")).contains("expect");
}
@Test
void identityResolverFailureStillDispatchesBusinessMessageWithUnknownIdentity() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushClient client = new XindaPushClient(
new XindaPushProperties(),
lookup -> {
throw new IllegalStateException("identity backend unavailable");
},
dispatcher);
PushMsg msg = new PushMsg();
msg.init("0200", "{\"deviceId\":\"XD-DEVICE-001\",\"lng\":113.1,\"lat\":23.1}");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isEqualTo(0x0200);
assertThat(frame.rawBytes()).containsExactly(
"{\"deviceId\":\"XD-DEVICE-001\",\"lng\":113.1,\"lat\":23.1}".getBytes(StandardCharsets.UTF_8));
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("cmd", "0200")
.containsEntry("vin", "unknown")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN")
.containsEntry("identityError", "true")
.containsEntry("identityErrorMessage", "identity backend unavailable");
assertThat(frame.sourceMeta()).doesNotContainKeys("operationalError", "phase", "reason");
}
@Test
void rejectedLoginAckIsDispatchedAsOperationalPassthroughCandidate() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher);
PushMsg msg = new PushMsg();
msg.init("8001", "{\"rspResult\":\"1\",\"message\":\"bad credentials\"}");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isZero();
assertThat(frame.rawBytes()).asString(StandardCharsets.UTF_8)
.contains("\"operationalError\":true")
.contains("\"phase\":\"login\"")
.contains("login rejected rspResult=1");
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("operationalError", "true")
.containsEntry("phase", "login")
.containsEntry("vin", "unknown")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN");
assertThat(frame.sourceMeta().get("reason")).contains("login rejected rspResult=1");
}
@Test
void malformedLoginAckIsDispatchedAsOperationalPassthroughCandidate() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), dispatcher);
PushMsg msg = new PushMsg();
msg.init("8001", "{bad-json");
client.messageReceived(null, msg);
ArgumentCaptor<RawFrame> captor = ArgumentCaptor.forClass(RawFrame.class);
verify(dispatcher).dispatch(captor.capture());
RawFrame frame = captor.getValue();
assertThat(frame.protocolId()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(frame.command()).isZero();
assertThat(frame.rawBytes()).asString(StandardCharsets.UTF_8)
.contains("\"operationalError\":true")
.contains("\"phase\":\"login\"")
.contains("login ack parse failed");
assertThat(frame.sourceMeta())
.containsEntry("source", "xinda-push")
.containsEntry("operationalError", "true")
.containsEntry("phase", "login")
.containsEntry("vin", "unknown")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN");
assertThat(frame.sourceMeta().get("reason")).contains("login ack parse failed");
}
}

View File

@@ -0,0 +1,215 @@
package com.lingniu.ingest.inbound.xinda;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.AlarmPayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
import com.lingniu.ingest.identity.VehicleIdentityBinding;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class XindaPushEventMapperTest {
private final InMemoryVehicleIdentityService identity = new InMemoryVehicleIdentityService();
private final XindaPushEventMapper mapper = new XindaPushEventMapper(identity);
@Test
void maps0200ToLocationEvent() {
XindaPushMessage message = new XindaPushMessage("0200",
"{\"vin\":\"LNVIN000000000101\",\"sim\":\"13800138000\",\"deviceId\":\"XD-101\",\"plateNo\":\"粤B10101\",\"lng\":116397128,\"lat\":39916527,\"speed\":52.3,\"drct\":90}");
List<VehicleEvent> events = mapper.toEvents(message);
assertThat(events).hasSize(1);
assertThat(events.get(0)).isInstanceOf(VehicleEvent.Location.class);
VehicleEvent.Location loc = (VehicleEvent.Location) events.get(0);
assertThat(loc.source()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(loc.vin()).isEqualTo("LNVIN000000000101");
assertThat(loc.payload().longitude()).isEqualTo(116.397128);
assertThat(loc.payload().latitude()).isEqualTo(39.916527);
assertThat(loc.metadata())
.containsEntry("vin", "LNVIN000000000101")
.containsEntry("externalVin", "LNVIN000000000101")
.containsEntry("phone", "13800138000")
.containsEntry("deviceId", "XD-101")
.containsEntry("plateNo", "粤B10101");
}
@Test
void maps0200MileageToInternalLocationPayload() {
XindaPushMessage message = new XindaPushMessage("0200",
"{\"vin\":\"LNVIN000000000101\",\"lng\":116397128,\"lat\":39916527,\"totalMileage\":12345.6}");
VehicleEvent.Location loc = (VehicleEvent.Location) mapper.toEvents(message).getFirst();
assertThat(loc.payload().totalMileageKm()).isEqualTo(12345.6);
}
@Test
void mapsAlarmAndPassthroughEvents() {
assertThat(mapper.toEvents(new XindaPushMessage("0300", "{\"plateNo\":\"粤B12345\"}")))
.singleElement()
.isInstanceOf(VehicleEvent.Alarm.class)
.extracting(VehicleEvent::source)
.isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(mapper.toEvents(new XindaPushMessage("0401", "{\"plateNo\":\"粤B12345\"}")))
.singleElement()
.isInstanceOf(VehicleEvent.Passthrough.class);
}
@Test
void mapsHydrogenLeakAlarmToCriticalAlarmEvent() {
identity.bind(new VehicleIdentityBinding(
ProtocolId.XINDA_PUSH, "LNVIN000000H2LEAK", "", "XD-H2-001", ""));
XindaPushMessage message = new XindaPushMessage("0300", """
{
"deviceId": "XD-H2-001",
"alarmType": "HYDROGEN_LEAK",
"alarmName": "氢气泄漏",
"alarmLevel": 3,
"lng": 116397128,
"lat": 39916527
}
""");
List<VehicleEvent> events = mapper.toEvents(message);
assertThat(events).singleElement().satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Alarm.class);
VehicleEvent.Alarm alarm = (VehicleEvent.Alarm) event;
assertThat(alarm.vin()).isEqualTo("LNVIN000000H2LEAK");
assertThat(alarm.payload().level()).isEqualTo(AlarmPayload.AlarmLevel.CRITICAL);
assertThat(alarm.payload().safetyCategory()).isEqualTo(AlarmPayload.SafetyCategory.HYDROGEN_LEAK);
assertThat(alarm.payload().hydrogenLeakDetected()).isTrue();
assertThat(alarm.payload().hydrogenLeakLevel()).isEqualTo(AlarmPayload.HydrogenLeakLevel.CRITICAL);
assertThat(alarm.payload().hydrogenLeakActionRequired()).isTrue();
assertThat(alarm.payload().longitude()).isEqualTo(116.397128);
assertThat(alarm.payload().latitude()).isEqualTo(39.916527);
assertThat(alarm.metadata())
.containsEntry("alarmType", "HYDROGEN_LEAK")
.containsEntry("alarmName", "氢气泄漏")
.containsEntry("hydrogenLeakDetected", "true");
});
}
@Test
void resolvesVehicleIdentityByPlate() {
identity.bind(new VehicleIdentityBinding(
ProtocolId.XINDA_PUSH, "LNVIN000000000202", "", "", "粤B12345"));
List<VehicleEvent> events = mapper.toEvents(new XindaPushMessage("0200",
"{\"plateNo\":\"粤B12345\",\"lng\":116397128,\"lat\":39916527}"));
assertThat(events).singleElement()
.satisfies(event -> {
assertThat(event.vin()).isEqualTo("LNVIN000000000202");
assertThat(event.metadata())
.containsEntry("vin", "LNVIN000000000202")
.containsEntry("identityResolved", "true");
});
}
@Test
void identityResolverFailureStillProducesLocationEventWithUnknownVin() {
XindaPushEventMapper mapperWithFailingIdentity = new XindaPushEventMapper(lookup -> {
throw new IllegalStateException("identity backend unavailable");
});
List<VehicleEvent> events = mapperWithFailingIdentity.toEvents(new XindaPushMessage("0200",
"{\"deviceId\":\"XD-DEVICE-001\",\"lng\":116397128,\"lat\":39916527,\"speed\":52.3}"));
assertThat(events).singleElement()
.satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Location.class);
assertThat(event.vin()).isEqualTo("unknown");
assertThat(event.metadata())
.containsEntry("vin", "unknown")
.containsEntry("deviceId", "XD-DEVICE-001")
.containsEntry("identityResolved", "false")
.containsEntry("identitySource", "UNKNOWN")
.containsEntry("identityError", "true")
.containsEntry("identityErrorMessage", "identity backend unavailable");
});
}
@Test
void malformedJsonFallsBackToPassthroughEvent() {
List<VehicleEvent> events = mapper.toEvents(new XindaPushMessage("0200", "{bad-json"));
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("vin", "unknown")
.containsEntry("parseError", "true")
.containsKey("parseErrorMessage");
assertThat(event.metadata().get("parseErrorMessage")).contains("expect");
});
}
@Test
void unknownBusinessCommandFallsBackToPassthroughEvent() {
List<VehicleEvent> events = mapper.toEvents(new XindaPushMessage("0500",
"{\"vin\":\"LNVIN000000000303\",\"vendor\":\"extension\"}"));
assertThat(events).singleElement()
.satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
assertThat(event.source()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(event.vin()).isEqualTo("LNVIN000000000303");
assertThat(event.metadata())
.containsEntry("vin", "LNVIN000000000303")
.containsEntry("cmd", "0500")
.containsEntry("unknownCommand", "true")
.containsEntry("parseError", "false")
.containsEntry("externalVin", "LNVIN000000000303");
});
}
@Test
void malformedCommandFallsBackToPassthroughEventWithMetadata() {
List<VehicleEvent> events = mapper.toEvents(new XindaPushMessage("bad-cmd",
"{\"vin\":\"LNVIN000000000404\",\"vendor\":\"broken\"}"));
assertThat(events).singleElement()
.satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
assertThat(event.source()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(event.vin()).isEqualTo("LNVIN000000000404");
assertThat(((VehicleEvent.Passthrough) event).passthroughType()).isZero();
assertThat(event.metadata())
.containsEntry("vin", "LNVIN000000000404")
.containsEntry("cmd", "bad-cmd")
.containsEntry("unknownCommand", "true")
.containsEntry("malformedCommand", "true")
.containsEntry("parseError", "false");
});
}
@Test
void operationalFailureMessageBecomesPassthroughEventWithFailureMetadata() {
List<VehicleEvent> events = mapper.toEvents(new XindaPushMessage("0000",
"{\"operationalError\":true,\"phase\":\"startup\",\"reason\":\"host not configured\"}"));
assertThat(events).singleElement()
.satisfies(event -> {
assertThat(event).isInstanceOf(VehicleEvent.Passthrough.class);
assertThat(event.source()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(event.vin()).isEqualTo("unknown");
assertThat(event.metadata())
.containsEntry("vin", "unknown")
.containsEntry("cmd", "0000")
.containsEntry("operationalError", "true")
.containsEntry("phase", "startup")
.containsEntry("reason", "host not configured")
.containsEntry("unknownCommand", "false")
.containsEntry("parseError", "false");
});
}
}

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.inbound.xinda;
import com.lingniu.ingest.api.annotation.EventEmit;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.event.VehicleEvent;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class XindaPushRealtimeHandlerTest {
@Test
void declares0300AsAlarmHandler() throws Exception {
Method method = XindaPushRealtimeHandler.class.getMethod("onAlarm", XindaPushMessage.class);
assertThat(method.getAnnotation(MessageMapping.class).command()).containsExactly(0x0300);
assertThat(method.getAnnotation(EventEmit.class).value()).containsExactly(VehicleEvent.Alarm.class);
}
@Test
void alarmHandlerEmitsAlarmEvent() {
XindaPushRealtimeHandler handler = new XindaPushRealtimeHandler(new XindaPushEventMapper());
assertThat(handler.onAlarm(new XindaPushMessage("0300", "{\"alarmType\":\"HYDROGEN_LEAK\"}")))
.singleElement()
.isInstanceOf(VehicleEvent.Alarm.class);
}
}