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:
107
bootstrap-all/pom.xml
Normal file
107
bootstrap-all/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
bootstrap-all/src/main/resources/application.yml
Normal file
130
bootstrap-all/src/main/resources/application.yml
Normal 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
|
||||
@@ -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 sink:TestEventSink 成为唯一 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user