chore: remove xinda ingestion modules

This commit is contained in:
lingniu
2026-07-01 18:22:51 +08:00
parent f89ac1c23f
commit 4eaaa88e9c
34 changed files with 234 additions and 2130 deletions

View File

@@ -0,0 +1,48 @@
package com.lingniu.ingest.identity;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public final class InMemoryVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
private final List<VehicleIdentityBinding> bindings = new CopyOnWriteArrayList<>();
@Override
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
if (lookup == null) {
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
}
if (!lookup.vin().isBlank() && !"unknown".equalsIgnoreCase(lookup.vin())) {
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
}
for (VehicleIdentityBinding binding : bindings) {
if (binding.protocol() != lookup.protocol()) {
continue;
}
if (!lookup.phone().isBlank() && lookup.phone().equals(binding.phone())) {
return new VehicleIdentity(binding.vin(), true, VehicleIdentitySource.BOUND_PHONE);
}
if (!lookup.deviceId().isBlank() && lookup.deviceId().equals(binding.deviceId())) {
return new VehicleIdentity(binding.vin(), true, VehicleIdentitySource.BOUND_DEVICE_ID);
}
if (!lookup.plate().isBlank() && lookup.plate().equals(binding.plate())) {
return new VehicleIdentity(binding.vin(), true, VehicleIdentitySource.BOUND_PLATE);
}
}
if (!lookup.phone().isBlank()) {
return new VehicleIdentity("unknown", false, VehicleIdentitySource.FALLBACK_PHONE);
}
if (!lookup.deviceId().isBlank()) {
return new VehicleIdentity("unknown", false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
}
if (!lookup.plate().isBlank()) {
return new VehicleIdentity("unknown", false, VehicleIdentitySource.FALLBACK_PLATE);
}
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
}
@Override
public void bind(VehicleIdentityBinding binding) {
bindings.add(binding);
}
}

View File

@@ -1,118 +0,0 @@
<?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>io.netty</groupId>
<artifactId>netty-common</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</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

@@ -1,413 +0,0 @@
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.event.RawArchiveKeys;
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 {
// 信达只把业务消息派发到 Dispatcher登录/心跳/订阅 ACK 只影响连接状态和日志。
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);
// 推送回调异常也生成错误 RawFrame保证原始 JSON 不丢。
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<>();
// 保留外部字段和内部 VIN 的双轨元数据,方便定位绑定错误或第三方字段变更。
meta.put("source", "xinda-push");
meta.put("cmd", payload.command());
putRawFields(meta, identityFields);
meta.put("vin", identity.identity().vin());
meta.put("externalVin", firstNonBlank(identityFields, "vin"));
meta.put("phone", phone(identityFields));
meta.put("deviceId", deviceId(identityFields));
meta.put("plateNo", plateNo(identityFields));
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());
}
meta.put(RawArchiveKeys.META_PARSED_JSON, parsedJson(payload.command(), json, parsed));
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);
meta.put(RawArchiveKeys.META_PARSED_JSON,
parsedJson(payload.command(), payload.json(), new JsonParseResult(json, false, "")));
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);
JsonParseResult parsed = parseForMetadata(json);
meta.put(RawArchiveKeys.META_PARSED_JSON, parsedJson(cmd, json, parsed));
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"),
phone(json),
deviceId(json),
plateNo(json))), null);
} catch (RuntimeException e) {
return new IdentityResolution(
new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN),
e.getMessage() == null ? "" : e.getMessage());
}
}
private static String plateNo(JSONObject node) {
return firstNonBlank(node, "plateNo", "carNo", "carName");
}
private static String phone(JSONObject node) {
return firstNonBlank(node, "sim", "phone", "mobile", "simCode", "tmnKey");
}
private static String deviceId(JSONObject node) {
return firstNonBlank(node, "deviceId", "terminalId", "imei", "tmnKey", "tmnCode");
}
private static void putRawFields(Map<String, String> meta, JSONObject json) {
if (meta == null || json == null || json.isEmpty()) {
return;
}
for (Map.Entry<String, Object> entry : json.entrySet()) {
if (entry.getKey() == null || entry.getKey().isBlank() || entry.getValue() == null) {
continue;
}
Object value = entry.getValue();
if (value instanceof JSONObject object) {
for (Map.Entry<String, Object> nested : object.entrySet()) {
if (nested.getKey() != null && !nested.getKey().isBlank() && nested.getValue() != null) {
meta.put("raw." + entry.getKey() + "." + nested.getKey(), nested.getValue().toString());
}
}
} else {
String text = value.toString();
JSONObject nestedJson = parseObjectOrNull(text);
if (nestedJson != null) {
for (Map.Entry<String, Object> nested : nestedJson.entrySet()) {
if (nested.getKey() != null && !nested.getKey().isBlank() && nested.getValue() != null) {
meta.put("raw." + entry.getKey() + "." + nested.getKey(), nested.getValue().toString());
}
}
} else {
meta.put("raw." + entry.getKey(), text);
}
}
}
}
private static JSONObject parseObjectOrNull(String value) {
if (value == null || value.isBlank() || !value.trim().startsWith("{")) {
return null;
}
try {
return JSON.parseObject(value);
} catch (Exception ignored) {
return null;
}
}
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 static String parsedJson(String command, String rawJson, JsonParseResult parsed) {
JSONObject root = new JSONObject(true);
root.put("command", command == null ? "" : command);
root.put("parseError", parsed != null && parsed.parseError());
if (parsed != null && parsed.parseError()) {
root.put("parseErrorMessage", parsed.errorMessage() == null ? "" : parsed.errorMessage());
root.put("rawJson", rawJson == null ? "" : rawJson);
} else {
root.put("body", parsed == null || parsed.json() == null ? new JSONObject() : parsed.json());
}
return root.toJSONString();
}
private record JsonParseResult(JSONObject json, boolean parseError, String errorMessage) {}
private record IdentityResolution(VehicleIdentity identity, String errorMessage) {}
}

View File

@@ -1,413 +0,0 @@
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.VehicleIdentityRegistry;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import com.lingniu.ingest.identity.VehicleIdentitySource;
import com.lingniu.ingest.identity.VehicleRegistrationBinding;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 static final Logger log = LoggerFactory.getLogger(XindaPushEventMapper.class);
private static final VehicleIdentityRegistry NOOP_REGISTRY = binding -> {};
private final VehicleIdentityResolver identityResolver;
private final VehicleIdentityRegistry identityRegistry;
public XindaPushEventMapper() {
this(new InMemoryVehicleIdentityService());
}
public XindaPushEventMapper(VehicleIdentityResolver identityResolver) {
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
this.identityRegistry = this.identityResolver instanceof VehicleIdentityRegistry registry ? registry : NOOP_REGISTRY;
}
public XindaPushEventMapper(VehicleIdentityResolver identityResolver, VehicleIdentityRegistry identityRegistry) {
this.identityResolver = identityResolver == null ? new InMemoryVehicleIdentityService() : identityResolver;
this.identityRegistry = identityRegistry == null ? NOOP_REGISTRY : identityRegistry;
}
@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) {
// JSON 解析失败不丢弃,转 passthrough 保留 rawJson后续可通过历史库回查。
return List.of(passthrough(message, new JSONObject(), true, false,
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
}
if (json.getBooleanValue("operationalError")) {
// 接入侧运行错误也统一作为 passthrough 事件进入管线,便于监控和审计。
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 {
IdentityResolution resolution = IdentityResolution.ok(identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.XINDA_PUSH,
firstNonBlank(json, "vin"),
phone(json),
deviceId(json),
plateNo(json))));
registerIdentity(json, resolution);
return resolution;
} 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("vin", normalizedVin(identity));
out.put("externalVin", firstNonBlank(json, "vin"));
out.put("phone", phone(json));
out.put("deviceId", deviceId(json));
out.put("plateNo", plateNo(json));
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 String plateNo(JSONObject node) {
return firstNonBlank(node, "plateNo", "carNo", "carName");
}
private static String phone(JSONObject node) {
return firstNonBlank(node, "sim", "phone", "mobile", "simCode", "tmnKey");
}
private static String deviceId(JSONObject node) {
return firstNonBlank(node, "deviceId", "terminalId", "imei", "tmnKey", "tmnCode");
}
private void registerIdentity(JSONObject json, IdentityResolution identity) {
String phone = phone(json);
String deviceId = deviceId(json);
String plate = plateNo(json);
if (phone.isBlank() && deviceId.isBlank() && plate.isBlank()) {
return;
}
String vin = identity != null && identity.identity().resolved() ? identity.identity().vin() : "unknown";
try {
identityRegistry.register(new VehicleRegistrationBinding(
ProtocolId.XINDA_PUSH,
vin,
phone,
deviceId,
plate,
null,
null,
"",
"",
optionalInteger(json, "plateColor", "carColor")));
} catch (RuntimeException e) {
log.warn("[xinda-push] vehicle identity registration failed plate={} phone={} deviceId={}",
plate, phone, deviceId, e);
}
}
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 Integer optionalInteger(JSONObject node, String... keys) {
Double value = optionalDouble(node, keys);
return value == null ? null : value.intValue();
}
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

@@ -1,32 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -1,52 +0,0 @@
package com.lingniu.ingest.inbound.xinda.config;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
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,
VehicleIdentityRegistry identityRegistry) {
return new XindaPushEventMapper(identityResolver, identityRegistry);
}
@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

@@ -1,45 +0,0 @@
package com.lingniu.ingest.inbound.xinda.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。
*
* <p>该入口属于第三方推送协议,与 GB32960 TCP 接收链路并列。生产排查 32960 时,
* 应先确认本开关是否关闭,避免把信达推送事件误认为 32960 原始包。
*/
@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";
/** 默认订阅常见定位/轨迹类消息,新增业务消息需要先在 mapper 中确认字段映射。 */
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

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

View File

@@ -1,272 +0,0 @@
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 businessMessageRawFrameResolvesIdentityByCarNamePlateAlias() throws Exception {
Dispatcher dispatcher = mock(Dispatcher.class);
InMemoryVehicleIdentityService identities = new InMemoryVehicleIdentityService();
identities.bind(new VehicleIdentityBinding(
ProtocolId.XINDA_PUSH,
"LB9A32A24P0LS1270",
"",
"",
"粤BD12345"));
XindaPushClient client = new XindaPushClient(new XindaPushProperties(), identities, dispatcher);
PushMsg msg = new PushMsg();
msg.init("0200", "{\"carName\":\"粤BD12345\",\"tmnKey\":\"XD-TMN-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.sourceMeta())
.containsEntry("vin", "LB9A32A24P0LS1270")
.containsEntry("plateNo", "粤BD12345")
.containsEntry("deviceId", "XD-TMN-001")
.containsEntry("raw.carName", "粤BD12345")
.containsEntry("raw.tmnKey", "XD-TMN-001")
.containsEntry("raw.lng", "113.1")
.containsEntry("identityResolved", "true")
.containsEntry("identitySource", "BOUND_PLATE");
}
@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

@@ -1,295 +0,0 @@
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 com.lingniu.ingest.identity.VehicleIdentityRegistry;
import com.lingniu.ingest.identity.VehicleRegistrationBinding;
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")
.doesNotContainKey("rawJson");
}
@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 resolvesVehicleIdentityByCarNamePlateFromXindaPayload() {
identity.bind(new VehicleIdentityBinding(
ProtocolId.XINDA_PUSH, "LNVIN000000000203", "", "", "浙F00780F"));
List<VehicleEvent> events = mapper.toEvents(new XindaPushMessage("0200",
"{\"carName\":\"浙F00780F\",\"tmnKey\":\"13307811342\",\"simCode\":\"8986062582008805648\","
+ "\"lng\":121.075223,\"lat\":30.583753}"));
assertThat(events).singleElement()
.satisfies(event -> {
assertThat(event.vin()).isEqualTo("LNVIN000000000203");
assertThat(event.metadata())
.containsEntry("vin", "LNVIN000000000203")
.containsEntry("plateNo", "浙F00780F")
.containsEntry("phone", "8986062582008805648")
.containsEntry("deviceId", "13307811342")
.containsEntry("identityResolved", "true");
});
}
@Test
void registersXindaIdentityFromCarNamePayload() {
RecordingIdentityRegistry registry = new RecordingIdentityRegistry();
identity.bind(new VehicleIdentityBinding(
ProtocolId.XINDA_PUSH, "LA9GG68L8PBAF4776", "", "", "浙F00780F"));
XindaPushEventMapper mapper = new XindaPushEventMapper(identity, registry);
mapper.toEvents(new XindaPushMessage("0200",
"{\"carName\":\"浙F00780F\",\"tmnKey\":\"13307811342\",\"simCode\":\"8986062582008805648\","
+ "\"carColor\":\"93\",\"lng\":121.075223,\"lat\":30.583753}"));
assertThat(registry.registration)
.isNotNull()
.satisfies(registration -> {
assertThat(registration.protocol()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(registration.vin()).isEqualTo("LA9GG68L8PBAF4776");
assertThat(registration.phone()).isEqualTo("8986062582008805648");
assertThat(registration.deviceId()).isEqualTo("13307811342");
assertThat(registration.plate()).isEqualTo("浙F00780F");
assertThat(registration.plateColor()).isEqualTo(93);
});
}
@Test
void registersUnknownVinWhenXindaIdentityIsNotResolved() {
RecordingIdentityRegistry registry = new RecordingIdentityRegistry();
XindaPushEventMapper mapper = new XindaPushEventMapper(identity, registry);
mapper.toEvents(new XindaPushMessage("0200",
"{\"carName\":\"浙F00780F\",\"tmnKey\":\"13307811342\","
+ "\"lng\":121.075223,\"lat\":30.583753}"));
assertThat(registry.registration)
.isNotNull()
.satisfies(registration -> {
assertThat(registration.protocol()).isEqualTo(ProtocolId.XINDA_PUSH);
assertThat(registration.vin()).isEqualTo("unknown");
assertThat(registration.phone()).isEqualTo("13307811342");
assertThat(registration.deviceId()).isEqualTo("13307811342");
assertThat(registration.plate()).isEqualTo("浙F00780F");
});
}
@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");
});
}
private static final class RecordingIdentityRegistry implements VehicleIdentityRegistry {
private VehicleRegistrationBinding registration;
@Override
public void bind(VehicleIdentityBinding binding) {
}
@Override
public void register(VehicleRegistrationBinding registration) {
this.registration = registration;
}
}
}

View File

@@ -1,30 +0,0 @@
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);
}
}