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:
74
inbound-xinda-push/pom.xml
Normal file
74
inbound-xinda-push/pom.xml
Normal 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-collections4(PushClient 内部用到 ListOrderedSet) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-collections4</artifactId>
|
||||
<version>4.4</version>
|
||||
</dependency>
|
||||
<!-- commons-lang3(PushClient.setSubMsgIds / channelActive 里调用 StringUtils.isBlank / isNotBlank) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<!-- commons-codec(TcpClientHandler 用到 Hex 做字节日志打印) -->
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
<!-- commons-logging:PushClient 使用 JCL;由 Spring Boot 的 jcl-over-slf4j 桥接到 logback,无需额外处理 -->
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.inbound.xinda.config.XindaPushAutoConfiguration
|
||||
Reference in New Issue
Block a user