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:
46
protocol-jsatl12/pom.xml
Normal file
46
protocol-jsatl12/pom.xml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration
|
||||
Reference in New Issue
Block a user