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

107
bootstrap-all/pom.xml Normal file
View File

@@ -0,0 +1,107 @@
<?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>bootstrap-all</artifactId>
<name>bootstrap-all</name>
<description>一体化启动:默认集成全部协议与 Sink。生产环境通过配置开关按需启停。</description>
<dependencies>
<!-- Core -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-core</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>session-core</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>observability</artifactId>
</dependency>
<!-- Sinks -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-mq</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-archive</artifactId>
</dependency>
<!-- Protocols -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-gb32960</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt808</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt1078</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jsatl12</artifactId>
</dependency>
<!-- Inbound -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>inbound-mqtt</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>inbound-xinda-push</artifactId>
</dependency>
<!-- Command Gateway (HTTP) -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>command-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>lingniu-vehicle-ingest</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- JDK 24+ 对所有调用 sun.misc.Unsafe 的库jctools、byte-buddy 等)
打弃用警告,每进程打一次,纯日志噪声。显式 allow 让 JVM 闭嘴,
等上游库迁移到 VarHandle 后即可去掉这个参数。 -->
<jvmArguments>--sun-misc-unsafe-memory-access=allow</jvmArguments>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.bootstrap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 一体化启动入口。
* 各协议 / Sink 模块通过 Spring Boot AutoConfiguration + @ConditionalOnProperty 按需加载。
*
* <p><b>启动失败兜底</b>:某个 Netty server 绑定端口失败时Spring 会尝试销毁已初始化的 Bean。
* 但如果 {@code destroy()} 过程中再次失败或卡住,残留的 Netty boss/worker 线程(非守护线程)
* 会让 JVM 永远不退出,表现为进程仍在监听端口,下次启动时出现 {@code Address already in use}。
* 这里 main 捕获 Throwable 后强制 {@link System#exit(int)},确保进程干净退出。
*/
@SpringBootApplication(scanBasePackages = "com.lingniu.ingest")
public class IngestApplication {
private static final Logger log = LoggerFactory.getLogger(IngestApplication.class);
public static void main(String[] args) {
try {
SpringApplication.run(IngestApplication.class, args);
} catch (Throwable t) {
log.error("Ingest application failed to start, forcing JVM exit", t);
System.exit(1);
}
}
}

View File

@@ -0,0 +1,130 @@
spring:
application:
name: lingniu-vehicle-ingest
threads:
virtual:
enabled: true
server:
port: 20000
lingniu:
ingest:
gb32960:
enabled: true
port: 9000
boss-threads: 1
worker-threads: 0 # 0 = Runtime.availableProcessors() * 2
# VIN 白名单认证内存配置。GB/T 32960 设备没有账号密码,身份由 VIN 决定。
auth:
enabled: ${GB32960_AUTH_ENABLED:false} # true 启用白名单false 放行所有 VIN
case-sensitive: false # 默认大小写不敏感
whitelist: # VIN 列表,未列入的 0x01 登入返回 0x04 VIN_NOT_EXIST
# - LTEST000000000001
# - LGWEF4A58RE123456
# 平台登入 0x05 账号列表GB/T 32960.3 表 29
# 列表为空时放行所有平台登入(兼容旧行为),否则 username/password 必须匹配,
# 失败返回 0x02 OTHER_ERROR 应答并关闭连接。
platforms:
- username: ${GB32960_PLATFORM_USER_1:lingniu}
password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#}
allowed-ips: # 可选:限制来源 IP为空不限制
description: 外部下级平台 (原旧服务客户端)
# TLS 双向认证(可选)。启用后 Netty pipeline 前置 SslHandler 并强制客户端证书。
tls:
enabled: ${GB32960_TLS_ENABLED:false}
cert-path: ${GB32960_TLS_CERT:} # 服务端证书 PEM 路径
key-path: ${GB32960_TLS_KEY:} # 服务端私钥 PEM 路径PKCS#8 未加密)
trust-cert-path: ${GB32960_TLS_CA:} # 受信 CA 证书 PEM用于校验客户端证书
require-client-auth: true # 强制双向false=单向 TLS
jt808:
enabled: false
port: 10808
max-frame-length: 1048
jt1078:
enabled: true
jsatl12:
enabled: true
port: 7612
file-store: file:///var/lingniu/alarm-files
mqtt:
enabled: false
endpoints:
- name: yutong
uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883}
topic: ${MQTT_TOPIC:/ytforward/shln/+}
qos: 1
client-id: ${MQTT_CLIENT_ID:shlnClientId}
username: ${MQTT_USER:shln}
password: ${MQTT_PWD:}
tls:
ca-pem: ${MQTT_CA_PEM:}
client-pem: ${MQTT_CLIENT_PEM:}
client-key: ${MQTT_CLIENT_KEY:}
xinda-push:
enabled: false
host: ${XINDA_HOST:115.231.168.135}
port: ${XINDA_PORT:10100}
username: ${XINDA_USER:浙江羚牛}
password: ${XINDA_PWD:xd123456+}
subscribe-msg-ids: 0200,0300,0401
client-desc: 羚牛客户端
pipeline:
disruptor:
ring-buffer-size: 131072 # 2^17
wait-strategy: yielding # blocking | sleeping | yielding | busy-spin
producer-type: multi
dedup:
enabled: true
cache-size: 200000
ttl-seconds: 600
rate-limit:
per-vin-qps: 50
session:
store: memory-and-redis
token-ttl-seconds: 86400
sink:
mq:
# MQ Sink 总开关。true=装配 Kafka Producer 并投递事件false=完全不装配(适合本地开发或 Kafka 不可达)。
enabled: ${KAFKA_ENABLED:false}
type: kafka # 后端选择kafka | (rocketmq | pulsar 预留)
bootstrap-servers: ${KAFKA_BROKERS:localhost:9092}
compression-type: zstd
linger-ms: 20
batch-size: 65536
acks: all
enable-idempotence: true
topics:
realtime: vehicle.realtime
location: vehicle.location
alarm: vehicle.alarm
session: vehicle.session
media-meta: vehicle.media.meta
raw-archive: vehicle.raw.archive
dlq: vehicle.dlq
archive:
enabled: true
type: local # local | s3 | oss
path: ./archive/
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,env
metrics:
tags:
application: lingniu-vehicle-ingest
logging:
level:
root: INFO
com.lingniu.ingest: INFO
com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler: INFO
com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder: INFO
com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser: INFO
# 信达 push 诊断:打开下面一行可以看到每一条收发的原始字节 hex dump
com.lingniu.ingest.inbound.xinda: INFO

View File

@@ -0,0 +1,166 @@
package com.lingniu.ingest.bootstrap;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import java.io.ByteArrayOutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 端到端集成测试:
* <pre>
* test client → TCP → Gb32960 Netty server → frame decoder → dispatcher
* → InterceptorChain → Handler → DisruptorEventBus → TestEventSink
* </pre>
*
* <p>关闭了所有非 gb32960 的协议与 Kafka sink只测核心链路连通性。
*/
@SpringBootTest(
classes = IngestApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"lingniu.ingest.gb32960.enabled=true",
"lingniu.ingest.gb32960.port=0",
"lingniu.ingest.jt808.enabled=false",
"lingniu.ingest.jt1078.enabled=false",
"lingniu.ingest.jsatl12.enabled=false",
"lingniu.ingest.mqtt.enabled=false",
"lingniu.ingest.xinda-push.enabled=false",
"lingniu.ingest.command-gateway.enabled=false",
// 总开关关闭 Kafka sinkTestEventSink 成为唯一 Sink
"lingniu.ingest.sink.mq.enabled=false",
"lingniu.ingest.sink.archive.enabled=false",
"spring.main.allow-bean-definition-overriding=true"
})
@Import(IngestEndToEndTest.TestConfig.class)
class IngestEndToEndTest {
@Autowired
private Gb32960NettyServer server;
@Autowired
private TestEventSink testSink;
@Test
void gb32960FrameReachesEventSink() throws Exception {
int port = server.getBoundPort();
assertThat(port).isGreaterThan(0);
byte[] frame = buildRealtimeFrame("LTEST0000000E2E01");
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.getOutputStream().write(frame);
socket.getOutputStream().flush();
}
// DisruptorEventBus + Virtual threads have some jitter; poll up to 3s.
VehicleEvent captured = null;
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
captured = testSink.queue.poll(100, TimeUnit.MILLISECONDS);
if (captured instanceof VehicleEvent.Realtime) break;
if (captured != null) continue;
}
assertThat(captured).isInstanceOf(VehicleEvent.Realtime.class);
assertThat(captured.vin()).isEqualTo("LTEST0000000E2E01");
}
// ===== test config =====
@TestConfiguration
static class TestConfig {
@Bean
TestEventSink testEventSink() {
return new TestEventSink();
}
}
/** 收集所有 publish 的事件。 */
static final class TestEventSink implements EventSink {
final BlockingQueue<VehicleEvent> queue = new LinkedBlockingQueue<>();
@Override public String name() { return "test"; }
@Override
public CompletableFuture<Void> publish(VehicleEvent event) {
queue.add(event);
return CompletableFuture.completedFuture(null);
}
@Override
public CompletableFuture<Void> publishBatch(List<VehicleEvent> batch) {
queue.addAll(batch);
return CompletableFuture.completedFuture(null);
}
}
// ===== frame builder (inlined copy of Gb32960DecoderTest#buildRealtimeFrame) =====
private static byte[] buildRealtimeFrame(String vin) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5);
// 0x01 整车 20 字节
body.write(0x01);
body.write(0x01); body.write(0x03); body.write(0x01);
writeU16(body, 523);
writeU32(body, 1234567);
writeU16(body, 6000);
writeU16(body, 1100);
body.write(70);
body.write(0x01);
body.write(0x0F);
writeU16(body, 500);
body.write(30);
body.write(0);
// 0x05 位置 9 字节
body.write(0x05);
body.write(0x00);
writeU32(body, 116_397_128L);
writeU32(body, 39_916_527L);
byte[] bodyBytes = body.toByteArray();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02);
out.write(0xFE);
byte[] vinBytes = vin.getBytes(StandardCharsets.US_ASCII);
out.write(vinBytes, 0, 17);
out.write(0x01);
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
out.write(bcc & 0xFF);
return out.toByteArray();
}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writeU32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
}