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:
38
sink-archive/pom.xml
Normal file
38
sink-archive/pom.xml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user