chore: initial import of lingniu-vehicle-ingest

Multi-module Spring Boot ingest service for vehicle telemetry. Modules:

- ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher,
  Disruptor event bus, BCC/BCD codec helpers
- protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser
  packages v2016/v2025), platform login auth, VIN whitelist, idle handler
- protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound
- inbound-mqtt / inbound-xinda-push: alternative ingest channels
- session-core: per-channel session state
- sink-archive / sink-mq: persistence sinks (local file / Kafka)
- command-gateway: terminal control command gateway
- bootstrap-all: aggregator Spring Boot app
- observability: Micrometer / Actuator wiring

Includes hex-dump golden samples under protocol-gb32960/src/test/resources
and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lingniu-dev
2026-04-15 16:08:57 +08:00
commit 064ecc479c
220 changed files with 11874 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
<?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>
</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>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无需额外处理 -->
</dependencies>
</project>

View File

@@ -0,0 +1,267 @@
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.LocationPayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
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.Map;
import java.util.UUID;
/**
* 信达 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} → {@link VehicleEvent.Location}
* <li>报警 {@code 0300} → {@link VehicleEvent.Alarm}(转为 Passthrough 保留原始 json 供下游分析)
* <li>透传 {@code 0401} → {@link VehicleEvent.Passthrough}
* </ul>
*
* <p>类继承基类 {@code PushClient},不再需要手工处理帧编解码、心跳、重连、订阅等机制。
*/
public class XindaPushClient extends PushClient {
private static final Logger log = LoggerFactory.getLogger(XindaPushClient.class);
private final XindaPushProperties props;
private final Dispatcher dispatcher;
private final DisruptorEventBus eventBus;
public XindaPushClient(XindaPushProperties props, Dispatcher dispatcher, DisruptorEventBus eventBus) {
super();
this.props = props;
this.dispatcher = dispatcher;
this.eventBus = eventBus;
this.isLog = false;
}
/**
* 启动客户端:填入 host/port/user/pwd/desc配置订阅消息 ID交给基类建立连接。
*/
public void start() {
if (props.getHost() == null || props.getHost().isBlank()) {
log.error("[xinda-push] host not configured, module stays idle");
return;
}
if (props.getUsername() == null || props.getPassword() == null) {
log.error("[xinda-push] credentials missing, module stays idle "
+ "(set lingniu.ingest.xinda-push.username/password)");
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);
}
}
/**
* 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" -> handleLocation(msg);
case "0300" -> handleAlarm(msg);
case "0401" -> handlePassthrough(msg);
default -> log.debug("[xinda-push] rx cmd={} json={}", cmd, msg.getJson());
}
} catch (Exception e) {
log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e);
}
}
/**
* 收到 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 = JSON.parseObject(json);
String rsp = parsed == null ? null : parsed.getString("rspResult");
if (!"0".equals(rsp)) {
log.error("[xinda-push] login REJECTED rspResult={}", 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");
}
// ========================================================================
// 业务处理0200/0300/0401 → VehicleEvent
// ========================================================================
/**
* 0200 定位消息 → {@link VehicleEvent.Location}。
* <p>信达 0200 常见字段(不同版本可能略有差异):
* <pre>
* plateNo 车牌号
* lat/lng WGS84 度 * 1e6 (整数)
* speed km/h
* drct 方向
* height 海拔 m
* alarmStts 报警状态位
* statusStts 状态位
* time yyyyMMddHHmmss
* </pre>
*/
private void handleLocation(PushMsg msg) {
JSONObject json = JSON.parseObject(msg.getJson());
String vin = firstNonBlank(json, "vin", "plateNo", "carNo");
double lon = asDouble(json, "lng", "lon", "longitude") / 1_000_000.0;
double lat = asDouble(json, "lat", "latitude") / 1_000_000.0;
// 有的实现已经是十进制度,做一次保护:如果 |lon| > 1000 显然是 1e6 单位再除一次
if (Math.abs(lon) > 1000) lon /= 1.0; // already divided above
double speed = asDouble(json, "speed");
double direction = asDouble(json, "drct", "direction");
long alarmFlag = (long) asDouble(json, "alarmStts", "alarmFlag");
long statusFlag = (long) asDouble(json, "statusStts", "statusFlag");
VehicleEvent.Location loc = new VehicleEvent.Location(
UUID.randomUUID().toString(),
vin.isBlank() ? "unknown" : vin,
ProtocolId.XINDA_PUSH,
Instant.now(), Instant.now(),
null,
Map.of("source", "xinda-push", "cmd", "0200"),
new LocationPayload(lon, lat, 0.0, speed, direction, alarmFlag, statusFlag));
eventBus.publish(loc);
log.debug("[xinda-push] 0200 location vin={} lon={} lat={} speed={}", vin, lon, lat, speed);
}
private void handleAlarm(PushMsg msg) {
emitPassthrough(0x0300, msg);
log.info("[xinda-push] 0300 alarm json={}", msg.getJson());
}
private void handlePassthrough(PushMsg msg) {
emitPassthrough(0x0401, msg);
log.debug("[xinda-push] 0401 passthrough json={}", msg.getJson());
}
private void emitPassthrough(int type, PushMsg msg) {
JSONObject json = JSON.parseObject(msg.getJson());
String vin = firstNonBlank(json, "vin", "plateNo", "carNo");
byte[] raw = msg.getJson() == null
? new byte[0] : msg.getJson().getBytes(StandardCharsets.UTF_8);
VehicleEvent.Passthrough pt = new VehicleEvent.Passthrough(
UUID.randomUUID().toString(),
vin.isBlank() ? "unknown" : vin,
ProtocolId.XINDA_PUSH,
Instant.now(), Instant.now(),
null,
Map.of("source", "xinda-push", "cmd", String.format("%04x", type),
"rawJson", msg.getJson() == null ? "" : msg.getJson()),
type, raw);
eventBus.publish(pt);
}
// ========================================================================
// 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 static double asDouble(JSONObject node, String... keys) {
if (node == null) return 0.0;
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 0.0;
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.inbound.xinda.config;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.inbound.xinda.XindaPushClient;
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,
Dispatcher dispatcher,
DisruptorEventBus eventBus) {
return new XindaPushClient(props, dispatcher, eventBus);
}
@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