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

38
sink-archive/pom.xml Normal file
View File

@@ -0,0 +1,38 @@
<?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>sink-archive</artifactId>
<name>sink-archive</name>
<description>原始报文冷存:本地文件系统实现 + S3/OSS 扩展点。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</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>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,32 @@
package com.lingniu.ingest.sink.archive;
import java.io.IOException;
import java.io.InputStream;
/**
* 冷存后端 SPI。
*
* <p>当前提供本地文件系统实现 {@link LocalArchiveStore},未来可加 S3/OSS 实现。
* 同一接口既用于 JSATL12 报警附件的分块写入,也用于 Envelope 的原始报文存档。
*/
public interface ArchiveStore {
/**
* 写入一段字节流。
*
* @param key 逻辑路径,例如 {@code 2026/04/13/LTEST000.../session-abc/file.bin}
* @param data 数据流(调用方负责关闭)
* @return 可回传给下游的引用 URI{@code file://...} 或 {@code s3://...}
*/
String put(String key, InputStream data, long length) throws IOException;
/** 追加写用于分块上传场景。key 应在同一文件上稳定。 */
String append(String key, byte[] chunk) throws IOException;
/** 读取(用于重放 / 回放测试)。 */
InputStream get(String key) throws IOException;
boolean exists(String key);
long size(String key) throws IOException;
}

View File

@@ -0,0 +1,83 @@
package com.lingniu.ingest.sink.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* 本地文件系统冷存实现。根路径支持 {@code file:///abs/path} 或相对路径。
*/
public final class LocalArchiveStore implements ArchiveStore {
private static final Logger log = LoggerFactory.getLogger(LocalArchiveStore.class);
private final Path root;
public LocalArchiveStore(String rootUri) {
Path path;
if (rootUri == null || rootUri.isBlank()) {
path = Paths.get(System.getProperty("java.io.tmpdir"), "lingniu-archive");
} else if (rootUri.startsWith("file://")) {
path = Paths.get(rootUri.substring("file://".length()));
} else {
path = Paths.get(rootUri);
}
this.root = path.toAbsolutePath();
try {
Files.createDirectories(this.root);
} catch (IOException e) {
throw new IllegalStateException("archive root init failed: " + this.root, e);
}
log.info("local archive store initialized root={}", this.root);
}
@Override
public String put(String key, InputStream data, long length) throws IOException {
Path target = resolve(key);
Files.createDirectories(target.getParent());
try (InputStream in = data) {
Files.copy(in, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
return "file://" + target;
}
@Override
public String append(String key, byte[] chunk) throws IOException {
Path target = resolve(key);
Files.createDirectories(target.getParent());
Files.write(target, chunk,
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE);
return "file://" + target;
}
@Override
public InputStream get(String key) throws IOException {
return Files.newInputStream(resolve(key));
}
@Override
public boolean exists(String key) {
return Files.exists(resolve(key));
}
@Override
public long size(String key) throws IOException {
return Files.size(resolve(key));
}
public Path root() {
return root;
}
private Path resolve(String key) {
// 防目录穿越
String normalized = key.replace("..", "_").replaceAll("^/+", "");
return root.resolve(normalized);
}
}

View File

@@ -0,0 +1,26 @@
package com.lingniu.ingest.sink.archive.config;
import com.lingniu.ingest.sink.archive.ArchiveStore;
import com.lingniu.ingest.sink.archive.LocalArchiveStore;
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(SinkArchiveProperties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.sink.archive", name = "enabled", havingValue = "true", matchIfMissing = true)
public class SinkArchiveAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public ArchiveStore archiveStore(SinkArchiveProperties props) {
return switch (props.getType() == null ? "local" : props.getType().toLowerCase()) {
case "local" -> new LocalArchiveStore(props.getPath());
default -> throw new IllegalStateException(
"archive type not supported yet: " + props.getType()
+ " (only 'local' is implemented; s3/oss TBD)");
};
}
}

View File

@@ -0,0 +1,20 @@
package com.lingniu.ingest.sink.archive.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.sink.archive")
public class SinkArchiveProperties {
private boolean enabled = true;
/** local / s3 / oss (仅 local 有实现,其余待补) */
private String type = "local";
/** 根路径URI 或绝对路径)。 */
private String path = System.getProperty("java.io.tmpdir") + "/lingniu-archive";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration

View File

@@ -0,0 +1,38 @@
package com.lingniu.ingest.sink.archive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayInputStream;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LocalArchiveStoreTest {
@Test
void putThenGet(@TempDir Path tmp) throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tmp.toString());
byte[] data = "hello-raw".getBytes();
String ref = store.put("2026/04/13/sample.bin", new ByteArrayInputStream(data), data.length);
assertThat(ref).startsWith("file://");
assertThat(store.exists("2026/04/13/sample.bin")).isTrue();
assertThat(store.size("2026/04/13/sample.bin")).isEqualTo(data.length);
}
@Test
void appendChunksAccumulate(@TempDir Path tmp) throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tmp.toString());
store.append("chunks/a.bin", new byte[]{1, 2, 3});
store.append("chunks/a.bin", new byte[]{4, 5});
assertThat(store.size("chunks/a.bin")).isEqualTo(5);
}
@Test
void rejectsPathTraversal(@TempDir Path tmp) throws Exception {
LocalArchiveStore store = new LocalArchiveStore(tmp.toString());
store.append("../../evil.bin", new byte[]{1});
// 目录穿越:每个 .. 被替换为 _
assertThat(store.exists("_/_/evil.bin")).isTrue();
}
}