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

46
protocol-jsatl12/pom.xml Normal file
View File

@@ -0,0 +1,46 @@
<?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>protocol-jsatl12</artifactId>
<name>protocol-jsatl12</name>
<description>
苏标JSATL12主动安全报警附件上传协议。独立 TCP 端口(默认 7612
与 JT808 使用相同的转义规则,但数据帧带 01cd 魔数前缀,用于大文件分块上传。
</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>protocol-jt808</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-archive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,80 @@
package com.lingniu.ingest.protocol.jsatl12.codec;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
/**
* 苏标报警附件协议分帧。
*
* <p>两种帧并存:
* <ol>
* <li>标准 JT/T 808 帧:以 {@code 0x7e} 为分界,承载 0x1210 / 0x1211 / 0x9212 等信令
* <li>数据帧:以 {@code 0x30 0x31 0x63 0x64}(即 ASCII "01cd")为魔数,后续 4 字节大端长度 + 数据
* </ol>
*
* <p>本 decoder 合并处理:根据第一个字节决定分帧策略,输出带类型标记的 {@link Jsatl12RawFrame}。
*/
public class Jsatl12FrameDecoder extends ByteToMessageDecoder {
private static final byte[] DATA_MAGIC = {0x30, 0x31, 0x63, 0x64};
private static final int MAX_FRAME = 1024 * 65;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
while (in.readableBytes() >= 1) {
byte first = in.getByte(in.readerIndex());
if (first == 0x7e) {
if (!decodeJtFrame(in, out)) return;
} else if (first == DATA_MAGIC[0]) {
if (!decodeDataFrame(in, out)) return;
} else {
in.skipBytes(1);
}
}
}
private boolean decodeJtFrame(ByteBuf in, List<Object> out) {
if (in.readableBytes() < 2) return false;
int endIdx = in.indexOf(in.readerIndex() + 1, in.writerIndex(), (byte) 0x7e);
if (endIdx < 0) {
if (in.readableBytes() > MAX_FRAME) in.skipBytes(in.readableBytes() - 1);
return false;
}
int len = endIdx - (in.readerIndex() + 1);
if (len <= 0) {
in.skipBytes(1);
return true;
}
byte[] raw = new byte[len];
in.skipBytes(1);
in.readBytes(raw);
in.skipBytes(1);
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.JT_MESSAGE, raw));
return true;
}
private boolean decodeDataFrame(ByteBuf in, List<Object> out) {
if (in.readableBytes() < DATA_MAGIC.length + 4) return false;
for (int i = 0; i < DATA_MAGIC.length; i++) {
if (in.getByte(in.readerIndex() + i) != DATA_MAGIC[i]) {
in.skipBytes(1);
return true;
}
}
// 4B length (big-endian) 之后是负载
long dataLen = in.getUnsignedInt(in.readerIndex() + DATA_MAGIC.length);
if (dataLen > MAX_FRAME) {
in.skipBytes(1);
return true;
}
int totalFrameLen = DATA_MAGIC.length + 4 + (int) dataLen;
if (in.readableBytes() < totalFrameLen) return false;
byte[] frame = new byte[totalFrameLen];
in.readBytes(frame);
out.add(new Jsatl12RawFrame(Jsatl12RawFrame.Type.DATA_CHUNK, frame));
return true;
}
}

View File

@@ -0,0 +1,8 @@
package com.lingniu.ingest.protocol.jsatl12.codec;
/**
* JSATL12 原始帧:区分信令帧与数据帧。
*/
public record Jsatl12RawFrame(Type type, byte[] bytes) {
public enum Type { JT_MESSAGE, DATA_CHUNK }
}

View File

@@ -0,0 +1,23 @@
package com.lingniu.ingest.protocol.jsatl12.config;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
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(Jsatl12Properties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.jsatl12", name = "enabled", havingValue = "true")
@ConditionalOnBean(ArchiveStore.class)
public class Jsatl12AutoConfiguration {
@Bean
@ConditionalOnMissingBean
public Jsatl12NettyServer jsatl12NettyServer(Jsatl12Properties props, ArchiveStore archive) {
return new Jsatl12NettyServer(props.getPort(), archive);
}
}

View File

@@ -0,0 +1,21 @@
package com.lingniu.ingest.protocol.jsatl12.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* JSATL12 配置。实现尚在 TODO 状态,当前只做 Bean 占位避免空依赖。
*/
@ConfigurationProperties(prefix = "lingniu.ingest.jsatl12")
public class Jsatl12Properties {
private boolean enabled = false;
private int port = 7612;
/** 附件落盘根目录,支持 file:// / s3:// / oss:// 形式,由 sink-archive 解析。 */
private String fileStore = "file:///var/lingniu/alarm-files";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public String getFileStore() { return fileStore; }
public void setFileStore(String fileStore) { this.fileStore = fileStore; }
}

View File

@@ -0,0 +1,89 @@
package com.lingniu.ingest.protocol.jsatl12.inbound;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12RawFrame;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* JSATL12 入站处理器。
*
* <p>当前实现:把 DATA_CHUNK 帧写入 {@link ArchiveStore},文件名按日期 + 对端 IP +
* 时间戳生成JT_MESSAGE 帧暂仅打日志(后续批次可接入 jt808 的 BodyParserRegistry 做完整解析)。
*
* <p>路径模板:{@code yyyy/MM/dd/<peerIp>/<epochMillis>.bin}。
*/
public class Jsatl12ChannelHandler extends SimpleChannelInboundHandler<Jsatl12RawFrame> {
private static final Logger log = LoggerFactory.getLogger(Jsatl12ChannelHandler.class);
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.systemDefault());
/** 为每个 Channel 持久化一个文件 key保证同一次传输追加到同一个文件。 */
private static final Cache<ChannelHandlerContext, String> KEYS = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(30))
.maximumSize(10_000)
.build();
private final ArchiveStore archive;
public Jsatl12ChannelHandler(ArchiveStore archive) {
this.archive = archive;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, Jsatl12RawFrame frame) {
switch (frame.type()) {
case JT_MESSAGE -> {
// 信令帧:打印摘要。完整解析留到 protocol-jt808 BodyParserRegistry 接入后完成。
log.info("[jsatl12] signaling frame peer={} len={}", addr(ctx), frame.bytes().length);
}
case DATA_CHUNK -> {
String key = KEYS.get(ctx, k -> {
String day = DAY_FMT.format(Instant.now());
return "jsatl12/" + day + "/" + addr(ctx).replace(':', '_')
+ "/" + Instant.now().toEpochMilli() + ".bin";
});
try {
// 去除 4B 魔数 + 4B 长度的 header仅写真实数据
byte[] all = frame.bytes();
int payloadOffset = 8;
int payloadLen = all.length - payloadOffset;
if (payloadLen > 0) {
byte[] payload = new byte[payloadLen];
System.arraycopy(all, payloadOffset, payload, 0, payloadLen);
archive.append(key, payload);
}
} catch (IOException e) {
log.warn("[jsatl12] write chunk failed key={}", key, e);
}
}
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
String key = KEYS.getIfPresent(ctx);
if (key != null) {
log.info("[jsatl12] channel closed, archived key={}", key);
KEYS.invalidate(ctx);
}
}
private static String addr(ChannelHandlerContext ctx) {
if (ctx.channel().remoteAddress() instanceof InetSocketAddress i) {
return i.getAddress().getHostAddress() + ":" + i.getPort();
}
return "unknown";
}
}

View File

@@ -0,0 +1,76 @@
package com.lingniu.ingest.protocol.jsatl12.inbound;
import com.lingniu.ingest.protocol.jsatl12.codec.Jsatl12FrameDecoder;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import java.util.concurrent.ThreadFactory;
/**
* 苏标报警附件 Netty 服务端。独立端口(默认 7612
*/
public final class Jsatl12NettyServer implements InitializingBean, DisposableBean {
private static final Logger log = LoggerFactory.getLogger(Jsatl12NettyServer.class);
private final int port;
private final ArchiveStore archive;
private EventLoopGroup boss;
private EventLoopGroup workers;
private Channel serverChannel;
public Jsatl12NettyServer(int port, ArchiveStore archive) {
this.port = port;
this.archive = archive;
}
@Override
public void afterPropertiesSet() throws Exception {
ThreadFactory bossTf = r -> new Thread(r, "jsatl12-boss");
ThreadFactory wkTf = r -> new Thread(r, "jsatl12-worker");
this.boss = new NioEventLoopGroup(1, bossTf);
this.workers = new NioEventLoopGroup(Runtime.getRuntime().availableProcessors(), wkTf);
ServerBootstrap b = new ServerBootstrap();
b.group(boss, workers)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 256)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast("frame", new Jsatl12FrameDecoder())
.addLast("dispatch", new Jsatl12ChannelHandler(archive));
}
});
this.serverChannel = b.bind(port).sync().channel();
log.info("jsatl12 Netty server listening on :{}", port);
}
@Override
public void destroy() {
try {
if (serverChannel != null) serverChannel.close().sync();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (boss != null) boss.shutdownGracefully();
if (workers != null) workers.shutdownGracefully();
log.info("jsatl12 Netty server stopped");
}
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration