feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,235 @@
spring:
application:
name: lingniu-vehicle-ingest
threads:
virtual:
enabled: true
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /v3/api-docs
server:
port: 20000
lingniu:
ingest:
gb32960:
enabled: true
port: ${GB32960_PORT:32960}
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:
# 现代 (XIANDAI)
- username: ${GB32960_PLATFORM_USER_1:lingniu}
password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#}
allowed-ips: # 可选:限制来源 IP为空不限制
description: 外部下级平台 - 现代 (原旧服务客户端)
# 现代平台真实 GB32960 接入账号
- username: ${GB32960_PLATFORM_USER_HYUNDAI:Hyundai}
password: ${GB32960_PLATFORM_PWD_HYUNDAI:f2e3445d7cda409fb4f278f6fb890734}
allowed-ips:
- ${GB32960_PLATFORM_IP_HYUNDAI:8.134.95.166}
description: 外部下级平台 - Hyundai
# 飞驰 (FEICHI)
- username: ${GB32960_PLATFORM_USER_2:ln@feichi}
password: ${GB32960_PLATFORM_PWD_2:Lingniu.2024#}
allowed-ips:
description: 外部下级平台 - 飞驰
# 海珀特 (HAIPOTE)
- username: ${GB32960_PLATFORM_USER_3:hbt@lingniu}
password: ${GB32960_PLATFORM_PWD_3:sgmi88b@&9}
allowed-ips:
description: 外部下级平台 - 海珀特
# 跃进 (YUEJIN)
- username: ${GB32960_PLATFORM_USER_4:yj01@lingniu}
password: ${GB32960_PLATFORM_PWD_4:01234567890123456789}
allowed-ips:
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
# ====================================================================
# 厂商扩展协议路由vendor extensions
# 命中规则按 list 顺序首匹first-match-wins命中后用对应套件的 parser 集合
# 在标准 0x01..0x09 之上叠加。已支持的套件:
# - guangdong-fc广东燃料电池汽车示范规范 0x30~0x34、0x80
# 来源reference/广东燃料电池汽车示范应用城市群综合监管平台-...pdf
# ====================================================================
vendor-extensions:
- name: guangdong-fc
match:
# 平台账号匹配精确匹配case-insensitive。这里和上面 auth.platforms
# 中的 username 保持一致,确保 lingniu 账号登入后所有 0x02 帧都走广东 profile。
platform-accounts:
- lingniu
- Hyundai
- ln@feichi
# VIN 前缀匹配startsWith空 list = 不参与匹配
vin-prefixes: []
# VIN 精确匹配
vins: []
# 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成
# InfoBlock.Raw 后继续解析下一个块,不再整帧丢弃。仅在需要严格失败语义
# (灰度回滚或抓虫)时设为 false。
parse:
lenient-block-failure: true
diagnostics:
max-logged-frame-keys-per-channel: 128
jt808:
# 1078 信令和 JSATL12 嵌入 JT808 信令都依赖本模块;一体化启动默认开启,
# 需要只跑 GB32960 时可用 JT808_ENABLED=false 显式关闭。
enabled: ${JT808_ENABLED:true}
port: ${JT808_PORT:10808}
max-frame-length: ${JT808_MAX_FRAME_LENGTH:1048}
jt1078:
# 1078 媒体流归档可独立启用1078 信令事件复用 JT808 mapper
# 只有同时启用 jt808.enabled=true 时才会装配信令桥接 handler。
enabled: ${JT1078_ENABLED:true}
media-stream:
enabled: ${JT1078_MEDIA_STREAM_ENABLED:true}
tcp-enabled: ${JT1078_MEDIA_STREAM_TCP_ENABLED:true}
udp-enabled: ${JT1078_MEDIA_STREAM_UDP_ENABLED:false}
port: ${JT1078_MEDIA_STREAM_PORT:11078}
udp-port: ${JT1078_MEDIA_STREAM_UDP_PORT:11078}
worker-threads: ${JT1078_MEDIA_STREAM_WORKERS:0}
max-packet-length: ${JT1078_MEDIA_STREAM_MAX_PACKET_LENGTH:1048576}
segment-seconds: ${JT1078_MEDIA_STREAM_SEGMENT_SECONDS:300}
jsatl12:
# JSATL12 附件流依赖 ArchiveStore嵌入的 JT808 信令解析依赖
# jt808.enabled=true。缺任一依赖时不会装配附件接入 server。
enabled: ${JSATL12_ENABLED:true}
port: ${JSATL12_PORT:7612}
worker-threads: ${JSATL12_WORKERS:0}
max-frame-length: ${JSATL12_MAX_FRAME_LENGTH:65536}
file-store: ${JSATL12_FILE_STORE:file:///var/lingniu/alarm-files}
mqtt:
enabled: ${MQTT_ENABLED:false}
endpoints:
- name: yutong
uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883}
topic: ${MQTT_TOPIC:/ytforward/shln/+}
qos: 2
clean-session: false
keep-alive-seconds: 20
connection-timeout-seconds: 10
client-id: ${MQTT_CLIENT_ID:shlnClientId}
username: ${MQTT_USER:shln}
password: ${MQTT_PWD:}
tls:
ca-pem: ${MQTT_CA_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/ca.crt}
client-pem: ${MQTT_CLIENT_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/client.crt}
client-key: ${MQTT_CLIENT_KEY:/opt/lingniuServices/certificate/yutong/vehicledatareception/client-key-pkcs8.pem}
xinda-push:
enabled: ${XINDA_PUSH_ENABLED:false}
host: ${XINDA_HOST:}
port: ${XINDA_PORT:10100}
username: ${XINDA_USER:}
password: ${XINDA_PWD:}
subscribe-msg-ids: ${XINDA_SUBSCRIBE_MSG_IDS:0200,0300,0401}
client-desc: ${XINDA_CLIENT_DESC:lingniu-ingest}
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:
# memory | redis。生产默认 Redis会话索引用于多实例/重启后的下行命令解析。
store: ${SESSION_STORE:redis}
ttl: ${SESSION_TTL:30m}
identity:
# memory 适合本地开发file 是生产最低可用持久化后端,后续可替换 DB/配置中心实现。
store: ${VEHICLE_IDENTITY_STORE:file}
file:
path: ${VEHICLE_IDENTITY_FILE:./data/vehicle-identity.jsonl}
sink:
mq:
# MQ Sink 总开关。true=装配 Kafka Producer 并投递事件false=完全不装配(适合本地开发或 Kafka 不可达)。
enabled: ${KAFKA_ENABLED:false}
type: kafka # 后端选择kafka | (rocketmq | pulsar 预留)
bootstrap-servers: ${KAFKA_BROKERS:114.55.58.251: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
consumer:
# true=启动服务侧 Kafka Envelope 消费 worker默认绑定 event-history/state/stat 三类 processor。
enabled: ${KAFKA_CONSUMER_ENABLED:false}
auto-startup: true
client-id-prefix: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX:lingniu-envelope-consumer}
poll-timeout-millis: ${KAFKA_CONSUMER_POLL_TIMEOUT_MILLIS:1000}
loop-backoff-millis: ${KAFKA_CONSUMER_LOOP_BACKOFF_MILLIS:1000}
auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET:earliest}
max-poll-records: ${KAFKA_CONSUMER_MAX_POLL_RECORDS:500}
archive:
enabled: true
type: local # local | s3 | oss
path: ./archive/
event-file-store:
enabled: ${EVENT_FILE_STORE_ENABLED:true}
path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500}
flush-interval-millis: ${EVENT_FILE_STORE_FLUSH_INTERVAL_MILLIS:1000}
event-history:
enabled: ${EVENT_HISTORY_ENABLED:true}
vehicle-state:
enabled: ${VEHICLE_STATE_ENABLED:false}
vehicle-stat:
enabled: ${VEHICLE_STAT_ENABLED:true}
file-path: ${VEHICLE_STAT_FILE_PATH:./target/vehicle-stat/}
zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}
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

View File

@@ -0,0 +1,134 @@
package com.lingniu.ingest.bootstrap;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.core.io.ClassPathResource;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
class BootstrapApplicationDefaultsTest {
@Test
void defaultConfigEnablesJt808WhenDependentProtocolsAreEnabledByDefault() {
Properties props = loadApplicationProperties();
assertThat(defaultEnabled(props.getProperty("lingniu.ingest.jsatl12.enabled"))).isTrue();
assertThat(defaultEnabled(props.getProperty("lingniu.ingest.jt1078.enabled"))).isTrue();
assertThat(defaultEnabled(props.getProperty("lingniu.ingest.jt808.enabled")))
.as("JSATL12 and JT1078 signaling depend on JT808 decoder/mapper, so bootstrap defaults must not disable JT808")
.isTrue();
}
@Test
void productionProtocolEntrypointsAreControlledByEnvironmentFlags() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("lingniu.ingest.jt808.enabled")).isEqualTo("${JT808_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.jt1078.enabled")).isEqualTo("${JT1078_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.jsatl12.enabled")).isEqualTo("${JSATL12_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.mqtt.enabled")).isEqualTo("${MQTT_ENABLED:false}");
assertThat(props.getProperty("lingniu.ingest.xinda-push.enabled")).isEqualTo("${XINDA_PUSH_ENABLED:false}");
}
@Test
void defaultConfigKeepsLegacyProductionIngressPortsAndYutongMqttShape() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("lingniu.ingest.gb32960.port")).isEqualTo("${GB32960_PORT:32960}");
assertThat(props.getProperty("lingniu.ingest.jt808.port")).isEqualTo("${JT808_PORT:10808}");
assertThat(props.getProperty("lingniu.ingest.jsatl12.port")).isEqualTo("${JSATL12_PORT:7612}");
assertThat(props.getProperty("lingniu.ingest.jt1078.media-stream.port"))
.isEqualTo("${JT1078_MEDIA_STREAM_PORT:11078}");
assertThat(props.getProperty("lingniu.ingest.jt1078.media-stream.udp-port"))
.isEqualTo("${JT1078_MEDIA_STREAM_UDP_PORT:11078}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].name")).isEqualTo("yutong");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].uri"))
.isEqualTo("${MQTT_URI:ssl://cpxlm.axxc.cn:38883}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].topic"))
.isEqualTo("${MQTT_TOPIC:/ytforward/shln/+}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].qos")).isEqualTo("2");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].clean-session")).isEqualTo("false");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].keep-alive-seconds")).isEqualTo("20");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].connection-timeout-seconds")).isEqualTo("10");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].client-id"))
.isEqualTo("${MQTT_CLIENT_ID:shlnClientId}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].username"))
.isEqualTo("${MQTT_USER:shln}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].password"))
.isEqualTo("${MQTT_PWD:}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].tls.ca-pem"))
.isEqualTo("${MQTT_CA_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/ca.crt}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].tls.client-pem"))
.isEqualTo("${MQTT_CLIENT_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/client.crt}");
assertThat(props.getProperty("lingniu.ingest.mqtt.endpoints[0].tls.client-key"))
.isEqualTo("${MQTT_CLIENT_KEY:/opt/lingniuServices/certificate/yutong/vehicledatareception/client-key-pkcs8.pem}");
assertThat(props.getProperty("lingniu.ingest.sink.mq.bootstrap-servers"))
.isEqualTo("${KAFKA_BROKERS:114.55.58.251:9092}");
assertThat(props.getProperty("lingniu.ingest.session.store")).isEqualTo("${SESSION_STORE:redis}");
assertThat(props.getProperty("lingniu.ingest.session.ttl")).isEqualTo("${SESSION_TTL:30m}");
}
@Test
void productionEventHistoryQueryApiIsEnabledByDefault() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("lingniu.ingest.event-history.enabled"))
.isEqualTo("${EVENT_HISTORY_ENABLED:true}");
}
@Test
void productionVehicleStatisticsAreEnabledByDefault() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("lingniu.ingest.vehicle-stat.enabled"))
.isEqualTo("${VEHICLE_STAT_ENABLED:true}");
assertThat(props.getProperty("lingniu.ingest.vehicle-stat.file-path"))
.isEqualTo("${VEHICLE_STAT_FILE_PATH:./target/vehicle-stat/}");
assertThat(props.getProperty("lingniu.ingest.vehicle-stat.zone-id"))
.isEqualTo("${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}");
}
@Test
void swaggerUiAndOpenApiDocsUseStableDefaultPaths() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("springdoc.swagger-ui.path")).isEqualTo("/swagger-ui.html");
assertThat(props.getProperty("springdoc.api-docs.path")).isEqualTo("/v3/api-docs");
}
@Test
void hyundaiPlatformUsesGuangdongFuelCellVendorProfileByDefault() {
Properties props = loadApplicationProperties();
assertThat(props.getProperty("lingniu.ingest.gb32960.vendor-extensions[0].name"))
.isEqualTo("guangdong-fc");
assertThat(props.getProperty("lingniu.ingest.gb32960.vendor-extensions[0].match.platform-accounts[1]"))
.isEqualTo("Hyundai");
}
private static Properties loadApplicationProperties() {
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ClassPathResource("application.yml"));
Properties props = yaml.getObject();
assertThat(props).isNotNull();
return props;
}
private static boolean defaultEnabled(String value) {
if (value == null) {
return false;
}
String normalized = value.trim();
if ("true".equalsIgnoreCase(normalized)) {
return true;
}
if ("false".equalsIgnoreCase(normalized)) {
return false;
}
return normalized.matches("\\$\\{[^:}]+:true}");
}
}

View File

@@ -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 sinkTestEventSink 成为唯一 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));
}
}

View File

@@ -0,0 +1,73 @@
package com.lingniu.ingest.bootstrap;
import com.lingniu.ingest.core.config.IngestCoreAutoConfiguration;
import com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration;
import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager;
import com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration;
import com.lingniu.ingest.inbound.xinda.XindaPushClient;
import com.lingniu.ingest.inbound.xinda.config.XindaPushAutoConfiguration;
import com.lingniu.ingest.protocol.jsatl12.config.Jsatl12AutoConfiguration;
import com.lingniu.ingest.protocol.jsatl12.inbound.Jsatl12NettyServer;
import com.lingniu.ingest.protocol.jt1078.config.Jt1078AutoConfiguration;
import com.lingniu.ingest.protocol.jt1078.config.Jt1078SignalAutoConfiguration;
import com.lingniu.ingest.protocol.jt1078.handler.Jt1078MediaSignalHandler;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaArchiveService;
import com.lingniu.ingest.protocol.jt1078.media.Jt1078MediaStreamServer;
import com.lingniu.ingest.protocol.jt808.config.Jt808AutoConfiguration;
import com.lingniu.ingest.protocol.jt808.inbound.Jt808NettyServer;
import com.lingniu.ingest.session.config.SessionCoreAutoConfiguration;
import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ProtocolAutoConfigurationCompositionTest {
@TempDir
Path archiveRoot;
@Test
void nonGb32960ProtocolAdaptersCanBeComposedWithoutExternalConnections() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
IngestCoreAutoConfiguration.class,
SessionCoreAutoConfiguration.class,
VehicleIdentityAutoConfiguration.class,
SinkArchiveAutoConfiguration.class,
Jt1078AutoConfiguration.class,
Jt808AutoConfiguration.class,
Jt1078SignalAutoConfiguration.class,
Jsatl12AutoConfiguration.class,
MqttInboundAutoConfiguration.class,
XindaPushAutoConfiguration.class))
.withPropertyValues(
"lingniu.ingest.sink.archive.enabled=true",
"lingniu.ingest.sink.archive.type=local",
"lingniu.ingest.sink.archive.path=" + archiveRoot,
"lingniu.ingest.jt808.enabled=true",
"lingniu.ingest.jt808.port=0",
"lingniu.ingest.jt1078.enabled=true",
"lingniu.ingest.jt1078.media-stream.enabled=true",
"lingniu.ingest.jt1078.media-stream.tcp-enabled=true",
"lingniu.ingest.jt1078.media-stream.udp-enabled=false",
"lingniu.ingest.jt1078.media-stream.port=0",
"lingniu.ingest.jsatl12.enabled=true",
"lingniu.ingest.jsatl12.port=0",
"lingniu.ingest.mqtt.enabled=true",
"lingniu.ingest.xinda-push.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(Jt808NettyServer.class);
assertThat(context).hasSingleBean(Jt1078MediaSignalHandler.class);
assertThat(context).hasSingleBean(Jt1078MediaArchiveService.class);
assertThat(context).hasSingleBean(Jt1078MediaStreamServer.class);
assertThat(context).hasSingleBean(Jsatl12NettyServer.class);
assertThat(context).hasSingleBean(MqttEndpointManager.class);
assertThat(context).hasSingleBean(XindaPushClient.class);
});
}
}

View File

@@ -0,0 +1,174 @@
package com.lingniu.ingest.bootstrap;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960NettyServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.ByteArrayOutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.Instant;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 端到端验证原始报文冷存链路:
*
* <pre>
* TCP → Gb32960 Netty → Dispatcher 发 RawArchive 事件
* → Disruptor 虚拟线程 → ArchiveEventSink.publish
* → LocalArchiveStore.put → 文件系统落盘
* </pre>
*
* <p>与 {@link IngestEndToEndTest} 互补:前者验证业务事件路径;这里验证 raw archive
* 路径。两个测试跑在独立 Spring 上下文里archive 是否启用的配置不同)。
*/
@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",
"lingniu.ingest.sink.mq.enabled=false",
// 启用 archive sink路径由 @DynamicPropertySource 注入
"lingniu.ingest.sink.archive.enabled=true",
"lingniu.ingest.sink.archive.type=local"
})
class RawArchiveEndToEndTest {
@TempDir
static Path archiveRoot;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("lingniu.ingest.sink.archive.path", archiveRoot::toString);
}
@Autowired
private Gb32960NettyServer server;
@Test
void gb32960FrameIsArchivedToDisk() throws Exception {
int port = server.getBoundPort();
assertThat(port).isGreaterThan(0);
String vin = "LTEST0000ARCH0001";
byte[] frame = buildRealtimeFrame(vin);
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.getOutputStream().write(frame);
socket.getOutputStream().flush();
}
// 预期落盘目录:<root>/yyyy/MM/dd/GB32960/<vin>/
// ingestTime 在 Dispatcher 里用 receivedAt = Instant.now(),跨天边界理论上今天/昨天两个候选都可能命中,
// 为避免边界脆弱,扫描 root 下所有 .bin 文件匹配 vin。
Path file = awaitArchivedFile(archiveRoot, vin, 3000);
assertThat(file)
.as("expected an archived frame for vin=%s under %s within 3s", vin, archiveRoot)
.isNotNull();
assertThat(Files.readAllBytes(file)).containsExactly(frame);
// 额外key 的日期分片应是 ingestTime 的 UTC 当天(允许跨天时两者都可接受)
String today = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC).format(Instant.now());
String yesterday = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC)
.format(Instant.now().minusSeconds(86400));
String rel = archiveRoot.relativize(file).toString().replace('\\', '/');
assertThat(rel).satisfiesAnyOf(
r -> assertThat(r).startsWith(today + "/GB32960/" + vin + "/"),
r -> assertThat(r).startsWith(yesterday + "/GB32960/" + vin + "/"));
assertThat(rel).endsWith(".bin");
}
private static Path awaitArchivedFile(Path root, String vin, long timeoutMs) throws Exception {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
Path hit = findFirst(root, vin);
if (hit != null) return hit;
Thread.sleep(50);
}
return null;
}
private static Path findFirst(Path root, String vin) throws Exception {
if (!Files.exists(root)) return null;
try (Stream<Path> walk = Files.walk(root)) {
List<Path> hits = walk
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".bin"))
.filter(p -> p.toString().contains("/" + vin + "/") || p.toString().contains("\\" + vin + "\\"))
.toList();
return hits.isEmpty() ? null : hits.get(0);
}
}
// ===== frame builder (inlined copy of IngestEndToEndTest#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);
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);
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));
}
}