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,128 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>bootstrap-all</artifactId>
<name>bootstrap-all</name>
<description>一体化启动:默认集成全部协议与 Sink。生产环境通过配置开关按需启停。</description>
<dependencies>
<!-- Core -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-core</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>session-core</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>observability</artifactId>
</dependency>
<!-- Sinks -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-mq</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-archive</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>event-file-store</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>event-history-service</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>vehicle-state-service</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>vehicle-stat-service</artifactId>
</dependency>
<!-- Protocols -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-gb32960</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt808</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt1078</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jsatl12</artifactId>
</dependency>
<!-- Inbound -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>inbound-mqtt</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>inbound-xinda-push</artifactId>
</dependency>
<!-- Command Gateway (HTTP) -->
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>command-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>lingniu-vehicle-ingest</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- JDK 24+ 对所有调用 sun.misc.Unsafe 的库jctools、byte-buddy 等)
打弃用警告,每进程打一次,纯日志噪声。显式 allow 让 JVM 闭嘴,
等上游库迁移到 VarHandle 后即可去掉这个参数。 -->
<jvmArguments>--sun-misc-unsafe-memory-access=allow</jvmArguments>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

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

View File

@@ -0,0 +1,53 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>command-gateway</artifactId>
<name>command-gateway</name>
<description>
下行命令网关HTTP → 设备。复用 session-core 的 CommandDispatcher
覆盖原 JT808Controller / JT1078Controller 的 43 个端点PoC 阶段先落核心子集)。
</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>session-core</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt808</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt1078</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</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,14 @@
package com.lingniu.ingest.gateway;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.DispatcherServlet;
@AutoConfiguration
@ConditionalOnClass(DispatcherServlet.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true)
@ComponentScan(basePackageClasses = TerminalCommandController.class)
public class CommandGatewayAutoConfiguration {
}

View File

@@ -0,0 +1,24 @@
package com.lingniu.ingest.gateway;
/**
* 统一命令响应 envelope。
*
* @param success 是否成功
* @param code 业务状态码0=成功,其它=失败原因)
* @param message 人类可读消息
* @param data 可选负载设备应答对象、JSON 节点等)
*/
public record CommandResult(boolean success, int code, String message, Object data) {
public static CommandResult ok(Object data) {
return new CommandResult(true, 0, "ok", data);
}
public static CommandResult notImplemented(String hint) {
return new CommandResult(false, 501, "not_implemented: " + hint, null);
}
public static CommandResult failure(int code, String message) {
return new CommandResult(false, code, message, null);
}
}

View File

@@ -0,0 +1,278 @@
package com.lingniu.ingest.gateway;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.protocol.jt1078.downlink.Jt1078Commands;
import com.lingniu.ingest.session.CommandDispatcher;
import com.lingniu.ingest.session.DeviceSession;
import com.lingniu.ingest.session.SessionStore;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.stream.Collectors;
/**
* 下行命令 REST 入口。命令通过 {@link CommandDispatcher} 发往协议模块,
* 由 protocol-jt808 的 {@code Jt808CommandDispatcher} 实际发送到终端。
*
* <p>路径参数 {@code clientId} 可以是 sessionId / phone / vin
* 由 {@link SessionStore} 三索引解析。
*/
@RestController
@RequestMapping("/terminal")
public class TerminalCommandController {
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(20);
private final SessionStore sessions;
private final CommandDispatcher dispatcher;
public TerminalCommandController(SessionStore sessions, CommandDispatcher dispatcher) {
this.sessions = sessions;
this.dispatcher = dispatcher;
}
@GetMapping("/session")
public CommandResult session(@RequestParam String clientId) {
Optional<DeviceSession> s = resolveSession(clientId);
return s.<CommandResult>map(CommandResult::ok)
.orElseGet(() -> CommandResult.failure(404, "session not found: " + clientId));
}
@GetMapping("/location")
public CommandResult queryLocation(@RequestParam String clientId) {
return awaitSync(clientId, Jt808Commands.queryLocation(), "query-location");
}
@GetMapping("/parameters")
public CommandResult queryParameters(@RequestParam String clientId) {
return awaitSync(clientId, Jt808Commands.queryParameters(), "query-parameters");
}
@PutMapping("/parameters")
public CommandResult setParameters(@RequestParam String clientId,
@RequestBody Map<String, String> body) {
List<Jt808Commands.ParamItem> items = body.entrySet().stream()
.map(e -> new Jt808Commands.ParamItem(
parseId(e.getKey()),
e.getValue() == null ? new byte[0] : e.getValue().getBytes(StandardCharsets.UTF_8)))
.collect(Collectors.toList());
return awaitSync(clientId, Jt808Commands.setParameters(items), "set-parameters");
}
@PostMapping("/control")
public CommandResult terminalControl(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
int word = Integer.parseInt(body.getOrDefault("command", "0").toString());
String params = body.getOrDefault("params", "").toString();
return awaitSync(clientId, Jt808Commands.terminalControl(word, params), "terminal-control");
}
@PostMapping("/ack")
public CommandResult ack(@RequestParam String clientId, @RequestBody Map<String, Integer> body) {
int ackSerial = body.getOrDefault("ackSerial", 0);
int ackMsgId = body.getOrDefault("ackMsgId", 0);
int result = body.getOrDefault("result", 0);
return fireAndForget(clientId, Jt808Commands.platformAck(ackSerial, ackMsgId, result), "platform-ack");
}
@GetMapping("/attributes")
public CommandResult attributes(@RequestParam String clientId) {
return awaitSync(clientId, Jt808Commands.queryAttributes(), "query-attributes");
}
@DeleteMapping("/area")
public CommandResult deleteArea(@RequestParam String clientId, @RequestParam String ids) {
return awaitSync(clientId, Jt808Commands.deleteArea(parseIds(ids)), "delete-area");
}
@PostMapping("/alarm_ack")
public CommandResult alarmAck(@RequestParam String clientId, @RequestBody Map<String, Object> body) {
int responseSerialNo = Integer.parseInt(body.getOrDefault("responseSerialNo", "0").toString());
long type = parseLong(body.getOrDefault("type", "0").toString());
return fireAndForget(clientId, Jt808Commands.alarmAck(responseSerialNo, type), "alarm-ack");
}
@GetMapping("/jt1078/attributes")
public CommandResult jt1078Attributes(@RequestParam String clientId) {
return awaitSync(clientId, Jt1078Commands.queryMediaAttributes(), "jt1078-query-media-attributes");
}
@PostMapping("/jt1078/realtime")
public CommandResult jt1078RealtimePlay(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.realtimePlay(
stringValue(body, "ip", ""),
intValue(body, "tcpPort", 0),
intValue(body, "udpPort", 0),
intValue(body, "channelNo", 0),
intValue(body, "mediaType", 0),
intValue(body, "streamType", 0)), "jt1078-realtime-play");
}
@PostMapping("/jt1078/realtime_control")
public CommandResult jt1078RealtimeControl(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.realtimeControl(
intValue(body, "channelNo", 0),
intValue(body, "command", 0),
intValue(body, "closeType", 0),
intValue(body, "streamType", 0)), "jt1078-realtime-control");
}
@PostMapping("/jt1078/history")
public CommandResult jt1078HistoryPlay(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.historyPlay(
stringValue(body, "ip", ""),
intValue(body, "tcpPort", 0),
intValue(body, "udpPort", 0),
intValue(body, "channelNo", 0),
intValue(body, "mediaType", 0),
intValue(body, "streamType", 0),
intValue(body, "storageType", 0),
intValue(body, "playbackMode", 0),
intValue(body, "playbackSpeed", 0),
stringValue(body, "startTime", ""),
stringValue(body, "endTime", "")), "jt1078-history-play");
}
@PostMapping("/jt1078/history_control")
public CommandResult jt1078HistoryControl(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.historyControl(
intValue(body, "channelNo", 0),
intValue(body, "playbackMode", 0),
intValue(body, "playbackSpeed", 0),
stringValue(body, "playbackTime", "")), "jt1078-history-control");
}
@PostMapping("/jt1078/resource_search")
public CommandResult jt1078ResourceSearch(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.resourceSearch(
intValue(body, "channelNo", 0),
stringValue(body, "startTime", ""),
stringValue(body, "endTime", ""),
longValue(body, "warnBit1", 0),
longValue(body, "warnBit2", 0),
intValue(body, "mediaType", 0),
intValue(body, "streamType", 0),
intValue(body, "storageType", 0)), "jt1078-resource-search");
}
@PostMapping("/jt1078/file_upload")
public CommandResult jt1078FileUpload(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.fileUpload(
stringValue(body, "ip", ""),
intValue(body, "port", 0),
stringValue(body, "username", ""),
stringValue(body, "password", ""),
stringValue(body, "path", ""),
intValue(body, "channelNo", 0),
stringValue(body, "startTime", ""),
stringValue(body, "endTime", ""),
longValue(body, "warnBit1", 0),
longValue(body, "warnBit2", 0),
intValue(body, "mediaType", 0),
intValue(body, "streamType", 0),
intValue(body, "storageType", 0),
intValue(body, "condition", 0)), "jt1078-file-upload");
}
@PostMapping("/jt1078/file_upload_control")
public CommandResult jt1078FileUploadControl(@RequestParam String clientId,
@RequestBody Map<String, Object> body) {
return awaitSync(clientId, Jt1078Commands.fileUploadControl(
intValue(body, "responseSerialNo", 0),
intValue(body, "command", 0)), "jt1078-file-upload-control");
}
// ===== helpers =====
private Optional<DeviceSession> resolveSession(String clientId) {
return sessions.findBySessionId(clientId)
.or(() -> sessions.findByPhone(clientId))
.or(() -> sessions.findByVin(clientId));
}
private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
CompletableFuture<Jt808Message> future =
dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT);
try {
Jt808Message resp = future.join();
return CommandResult.ok(Map.of(
"messageId", "0x" + Integer.toHexString(resp.header().messageId()),
"serial", resp.header().serialNo(),
"phone", resp.header().phone()));
} catch (CompletionException e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
return CommandResult.failure(500, op + " failed: " + cause.getMessage());
} catch (Exception e) {
return CommandResult.failure(500, op + " failed: " + e.getMessage());
}
}
private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
try {
dispatcher.notify(clientId, cmd).join();
return CommandResult.ok(Map.of("op", op));
} catch (Exception e) {
return CommandResult.failure(500, op + " failed: " + e.getMessage());
}
}
private static long parseId(String key) {
if (key == null || key.isBlank()) return 0;
String k = key.startsWith("0x") ? key.substring(2) : key;
return Long.parseLong(k, 16);
}
private static List<Long> parseIds(String ids) {
if (ids == null || ids.isBlank()) {
return List.of();
}
return java.util.Arrays.stream(ids.split(","))
.map(String::trim)
.filter(s -> !s.isBlank())
.map(TerminalCommandController::parseLong)
.toList();
}
private static long parseLong(String value) {
if (value == null || value.isBlank()) return 0;
String v = value.startsWith("0x") || value.startsWith("0X") ? value.substring(2) : value;
return value.startsWith("0x") || value.startsWith("0X")
? Long.parseLong(v, 16)
: Long.parseLong(v);
}
private static String stringValue(Map<String, Object> body, String key, String defaultValue) {
Object value = body.get(key);
return value == null ? defaultValue : value.toString();
}
private static int intValue(Map<String, Object> body, String key, int defaultValue) {
Object value = body.get(key);
return value == null ? defaultValue : (int) parseLong(value.toString());
}
private static long longValue(Map<String, Object> body, String key, long defaultValue) {
Object value = body.get(key);
return value == null ? defaultValue : parseLong(value.toString());
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.gateway.CommandGatewayAutoConfiguration

View File

@@ -0,0 +1,147 @@
package com.lingniu.ingest.gateway;
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
import com.lingniu.ingest.protocol.jt808.model.Jt808Header;
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
import com.lingniu.ingest.session.CommandDispatcher;
import com.lingniu.ingest.session.DeviceSession;
import com.lingniu.ingest.session.SessionStore;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.UnaryOperator;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalCommandControllerTest {
@Test
void queryAttributesDispatchesJt8088107InsteadOfNotImplemented() {
RecordingDispatcher dispatcher = new RecordingDispatcher();
TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher);
CommandResult result = controller.attributes("13800138000");
assertThat(result.success()).isTrue();
assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x8107);
assertThat(dispatcher.lastRequestCommand.body()).isEmpty();
}
@Test
void alarmAckDispatchesJt8088203WithResponseSerialAndAlarmType() {
RecordingDispatcher dispatcher = new RecordingDispatcher();
TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher);
CommandResult result = controller.alarmAck("13800138000", Map.of(
"responseSerialNo", 0x1234,
"type", 0x0000_0001));
assertThat(result.success()).isTrue();
assertThat(dispatcher.lastNotifyCommand.messageId()).isEqualTo(0x8203);
assertThat(dispatcher.lastNotifyCommand.body()).containsExactly(
0x12, 0x34,
0x00, 0x00, 0x00, 0x01);
}
@Test
void deleteAreaDispatchesJt8088601WithCommaSeparatedAreaIds() {
RecordingDispatcher dispatcher = new RecordingDispatcher();
TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher);
CommandResult result = controller.deleteArea("13800138000", "1,0x01020304");
assertThat(result.success()).isTrue();
assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x8601);
assertThat(dispatcher.lastRequestCommand.body()).containsExactly(
0x02,
0x00, 0x00, 0x00, 0x01,
0x01, 0x02, 0x03, 0x04);
}
@Test
void jt1078RealtimePlayDispatches9101ThroughSharedDispatcher() {
RecordingDispatcher dispatcher = new RecordingDispatcher();
TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher);
CommandResult result = controller.jt1078RealtimePlay("13800138000", Map.of(
"ip", "10.1.2.3",
"tcpPort", 11078,
"udpPort", 11079,
"channelNo", 2,
"mediaType", 1,
"streamType", 0));
assertThat(result.success()).isTrue();
assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x9101);
assertThat(dispatcher.lastRequestCommand.body()).containsExactly(
0x08, '1', '0', '.', '1', '.', '2', '.', '3',
0x2B, 0x46,
0x2B, 0x47,
0x02,
0x01,
0x00);
}
@Test
void jt1078ResourceSearchDispatches9205AndAwaitsFileListResponse() {
RecordingDispatcher dispatcher = new RecordingDispatcher();
TerminalCommandController controller = new TerminalCommandController(new EmptySessionStore(), dispatcher);
CommandResult result = controller.jt1078ResourceSearch("13800138000", Map.of(
"channelNo", 3,
"startTime", "2026-06-22 08:09:10",
"endTime", "260622180000",
"warnBit1", "0x01020304",
"warnBit2", "0x05060708",
"mediaType", 3,
"streamType", 2,
"storageType", 1));
assertThat(result.success()).isTrue();
assertThat(dispatcher.lastRequestCommand.messageId()).isEqualTo(0x9205);
assertThat(dispatcher.lastRequestCommand.body()).containsExactly(
0x03,
0x26, 0x06, 0x22, 0x08, 0x09, 0x10,
0x26, 0x06, 0x22, 0x18, 0x00, 0x00,
0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08,
0x03,
0x02,
0x01);
}
private static final class RecordingDispatcher implements CommandDispatcher {
private Jt808Commands.DownlinkCommand lastNotifyCommand;
private Jt808Commands.DownlinkCommand lastRequestCommand;
@Override
public CompletableFuture<Void> notify(String sessionId, Object command) {
this.lastNotifyCommand = (Jt808Commands.DownlinkCommand) command;
return CompletableFuture.completedFuture(null);
}
@Override
@SuppressWarnings("unchecked")
public <T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout) {
this.lastRequestCommand = (Jt808Commands.DownlinkCommand) command;
Jt808Message response = new Jt808Message(
new Jt808Header(0x0107, 0, 0, false, Jt808Header.ProtocolVersion.V2013,
"13800138000", 1, 0, 0),
new com.lingniu.ingest.protocol.jt808.model.Jt808Body.Raw(0x0107, new byte[0]));
return CompletableFuture.completedFuture((T) response);
}
}
private static final class EmptySessionStore implements SessionStore {
@Override public void put(DeviceSession session) {}
@Override public Optional<DeviceSession> findBySessionId(String sessionId) { return Optional.empty(); }
@Override public Optional<DeviceSession> findByVin(String vin) { return Optional.empty(); }
@Override public Optional<DeviceSession> findByPhone(String phone) { return Optional.empty(); }
@Override public Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater) { return Optional.empty(); }
@Override public void remove(String sessionId) {}
@Override public int size() { return 0; }
}
}

View File

@@ -0,0 +1,30 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>ingest-api</artifactId>
<name>ingest-api</name>
<description>SPI、sealed 领域事件、注解定义。零 Spring 依赖。</description>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</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,15 @@
package com.lingniu.ingest.api;
/**
* 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。
* 新增协议需要在此处登记,避免散落的字符串常量。
*/
public enum ProtocolId {
UNKNOWN,
GB32960,
JT808,
JT1078,
JSATL12,
MQTT_YUTONG,
XINDA_PUSH
}

View File

@@ -0,0 +1,22 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
*
* <p>处理方法签名需为 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>}。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AsyncBatch {
int size() default 1000;
long waitMs() default 500;
int poolSize() default 2;
}

View File

@@ -0,0 +1,17 @@
package com.lingniu.ingest.api.annotation;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 声明当前 Handler 方法产出的事件类型。用于文档化和静态校验。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface EventEmit {
Class<? extends VehicleEvent>[] value();
}

View File

@@ -0,0 +1,17 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
*
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IdempotentKey {
String value();
}

View File

@@ -0,0 +1,27 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 方法级路由注解:声明此方法处理哪些协议消息。
*
* <p>{@link #command} 和 {@link #infoType} 的含义由各协议自行解释Dispatcher 只负责匹配:
* <ul>
* <li>GB/T 32960{@code command} = 0x01 车辆登入 / 0x02 实时上报 / ...{@code infoType} = 信息体 ID
* <li>JT/T 808{@code command} = 消息 ID0x0100 / 0x0200 / ...{@code infoType} 留空
* <li>MQTT{@code command} 可为 topic hash 或忽略
* </ul>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MessageMapping {
int[] command() default {};
int[] infoType() default {};
String desc() default "";
}

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.api.annotation;
import com.lingniu.ingest.api.ProtocolId;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标记一个类为协议 Handler Bean由 {@code AnnotationHandlerProcessor} 自动扫描注册。
*
* <p>典型用法:
* <pre>{@code
* @ProtocolHandler(protocol = ProtocolId.GB32960)
* public class Gb32960RealtimeHandler {
* @MessageMapping(command = 0x02)
* public VehicleEvent.Realtime handle(Gb32960Message msg) { ... }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ProtocolHandler {
ProtocolId protocol();
/** 可选:子协议版本(如 32960 的 2011 / 2017。 */
String version() default "";
}

View File

@@ -0,0 +1,20 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimited {
/** 单 VIN 每秒最大消息数。 */
int perVin() default 50;
/** 全局 QPS 上限;<=0 表示不设限。 */
int global() default -1;
}

View File

@@ -0,0 +1,15 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 为 Handler 方法开启 OpenTelemetry span。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TraceSpan {
String value();
}

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.api.consumer;
import java.time.Instant;
import java.util.EnumSet;
import java.util.Set;
public final class EnvelopeConsumerProcessor {
private static final Set<EnvelopeIngestResult.Status> DEAD_LETTER_STATUSES = EnumSet.of(
EnvelopeIngestResult.Status.SKIPPED,
EnvelopeIngestResult.Status.INVALID_ENVELOPE,
EnvelopeIngestResult.Status.FAILED
);
private final String service;
private final EnvelopeIngestor ingestor;
private final EnvelopeDeadLetterSink deadLetterSink;
public EnvelopeConsumerProcessor(String service, EnvelopeIngestor ingestor, EnvelopeDeadLetterSink deadLetterSink) {
if (ingestor == null) {
throw new IllegalArgumentException("ingestor must not be null");
}
if (deadLetterSink == null) {
throw new IllegalArgumentException("deadLetterSink must not be null");
}
this.service = service == null || service.isBlank() ? "unknown" : service;
this.ingestor = ingestor;
this.deadLetterSink = deadLetterSink;
}
public EnvelopeIngestResult process(EnvelopeConsumerRecord record) {
if (record == null) {
throw new IllegalArgumentException("record must not be null");
}
EnvelopeIngestResult result = ingestor.tryIngest(record.payload());
if (DEAD_LETTER_STATUSES.contains(result.status())) {
deadLetterSink.publish(toDeadLetter(record, result));
}
return result;
}
private EnvelopeDeadLetterRecord toDeadLetter(EnvelopeConsumerRecord record, EnvelopeIngestResult result) {
return new EnvelopeDeadLetterRecord(
service,
record.topic(),
record.partition(),
record.offset(),
record.key(),
result.status(),
result.eventId(),
result.vin(),
result.message(),
record.payload(),
Instant.now());
}
}

View File

@@ -0,0 +1,20 @@
package com.lingniu.ingest.api.consumer;
public record EnvelopeConsumerRecord(
String topic,
int partition,
long offset,
String key,
byte[] payload
) {
public EnvelopeConsumerRecord {
topic = topic == null ? "" : topic;
key = key == null ? "" : key;
payload = payload == null ? new byte[0] : payload.clone();
}
@Override
public byte[] payload() {
return payload.clone();
}
}

View File

@@ -0,0 +1,36 @@
package com.lingniu.ingest.api.consumer;
import java.time.Instant;
public record EnvelopeDeadLetterRecord(
String service,
String topic,
int partition,
long offset,
String key,
EnvelopeIngestResult.Status status,
String eventId,
String vin,
String message,
byte[] payload,
Instant createdAt
) {
public EnvelopeDeadLetterRecord {
service = service == null ? "" : service;
topic = topic == null ? "" : topic;
key = key == null ? "" : key;
if (status == null) {
throw new IllegalArgumentException("status must not be null");
}
eventId = eventId == null ? "" : eventId;
vin = vin == null ? "" : vin;
message = message == null ? "" : message;
payload = payload == null ? new byte[0] : payload.clone();
createdAt = createdAt == null ? Instant.now() : createdAt;
}
@Override
public byte[] payload() {
return payload.clone();
}
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.api.consumer;
@FunctionalInterface
public interface EnvelopeDeadLetterSink {
void publish(EnvelopeDeadLetterRecord record);
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.api.consumer;
/**
* Non-throwing result for Kafka envelope consumers. It lets production
* listeners isolate bad records without blocking the partition.
*/
public record EnvelopeIngestResult(Status status, String eventId, String vin, String message) {
public enum Status {
STORED,
PROCESSED,
SKIPPED,
INVALID_ENVELOPE,
FAILED
}
public EnvelopeIngestResult {
if (status == null) {
throw new IllegalArgumentException("status must not be null");
}
eventId = eventId == null ? "" : eventId;
vin = vin == null ? "" : vin;
message = message == null ? "" : message;
}
public static EnvelopeIngestResult stored(String eventId, String vin) {
return new EnvelopeIngestResult(Status.STORED, eventId, vin, "");
}
public static EnvelopeIngestResult processed(String eventId, String vin) {
return new EnvelopeIngestResult(Status.PROCESSED, eventId, vin, "");
}
public static EnvelopeIngestResult skipped(String eventId, String vin, String message) {
return new EnvelopeIngestResult(Status.SKIPPED, eventId, vin, message);
}
public static EnvelopeIngestResult invalid(String message) {
return new EnvelopeIngestResult(Status.INVALID_ENVELOPE, "", "", message);
}
public static EnvelopeIngestResult failed(String eventId, String vin, String message) {
return new EnvelopeIngestResult(Status.FAILED, eventId, vin, message);
}
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.api.consumer;
@FunctionalInterface
public interface EnvelopeIngestor {
EnvelopeIngestResult tryIngest(byte[] kafkaValue);
}

View File

@@ -0,0 +1,65 @@
package com.lingniu.ingest.api.event;
import java.util.List;
import java.util.Set;
/**
* 报警事件载荷,跨协议归一。
*
* @param level 报警最高等级
* @param alarmTypeCode 协议原始 alarm type 编码(按协议自定义),可用于联查协议规范
* @param alarmTypeName 报警类型名称,如 {@code GB32960_ALARM}
* @param faultCodes 故障代码列表(协议私有编码字符串化,便于下游去重/聚合)
* @param activeBits 结构化的通用报警位集合2016 版0~152025 版0~27对照 GB/T 32960.3 表 24
* @param longitude 经度(可选,若事件伴随位置)
* @param latitude 纬度(可选)
* @param safetyCategory 内部安全分类。氢气泄露独立成类,不与普通故障混用
* @param hydrogenLeakDetected 是否检测到氢气泄露
* @param hydrogenLeakLevel 氢气泄露等级
* @param hydrogenLeakActionRequired 是否需要立即处置
*/
public record AlarmPayload(
AlarmLevel level,
int alarmTypeCode,
String alarmTypeName,
List<String> faultCodes,
Set<String> activeBits,
Double longitude,
Double latitude,
SafetyCategory safetyCategory,
boolean hydrogenLeakDetected,
HydrogenLeakLevel hydrogenLeakLevel,
boolean hydrogenLeakActionRequired
) {
/**
* 报警等级(归一化的跨协议分类)。
*
* <ul>
* <li>{@link #INFO}:提示级(对应 GB/T 32960.3 0/无故障)
* <li>{@link #MINOR}1 级 —— 不影响车辆正常行驶
* <li>{@link #MAJOR}2 级 —— 影响车辆性能,需驾驶员限制行驶
* <li>{@link #CRITICAL}3 级(驾驶员应立即停车)或 4 级(热事件最高级)
* </ul>
*/
public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL }
/**
* 面向运营统计的内部安全分类。
*/
public enum SafetyCategory {
GENERAL,
TANK_PRESSURE,
TANK_TEMPERATURE,
HYDROGEN_LEAK
}
/**
* 氢气泄露内部等级。明确泄露信号一律视为 CRITICAL。
*/
public enum HydrogenLeakLevel {
NONE,
WARNING,
CRITICAL,
UNKNOWN
}
}

View File

@@ -0,0 +1,23 @@
package com.lingniu.ingest.api.event;
/** 位置事件载荷。坐标已统一转换为 WGS84 十进制度。 */
public record LocationPayload(
double longitude,
double latitude,
double altitudeM,
double speedKmh,
double directionDeg,
long alarmFlag,
long statusFlag,
Double totalMileageKm
) {
public LocationPayload(double longitude,
double latitude,
double altitudeM,
double speedKmh,
double directionDeg,
long alarmFlag,
long statusFlag) {
this(longitude, latitude, altitudeM, speedKmh, directionDeg, alarmFlag, statusFlag, null);
}
}

View File

@@ -0,0 +1,53 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
/**
* Shared raw-archive key conventions used by Dispatcher, archive sink, and query services.
*/
public final class RawArchiveKeys {
public static final String META_EVENT_ID = "rawArchiveEventId";
public static final String META_KEY = "rawArchiveKey";
public static final String META_URI = "rawArchiveUri";
private static final DateTimeFormatter DATE_KEY =
DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.of("Asia/Shanghai"));
private RawArchiveKeys() {
}
public static String key(Instant ingestTime, ProtocolId source, String vin, String eventId) {
String dateKey = DATE_KEY.format(ingestTime == null ? Instant.EPOCH : ingestTime);
String safeVin = vin == null || vin.isBlank() ? "unknown-vin" : vin;
String safeEventId = eventId == null || eventId.isBlank()
? Long.toHexString(System.nanoTime()) : eventId;
String safeSource = source == null ? "UNKNOWN" : source.name();
return dateKey + "/" + safeSource + "/" + safeVin + "/" + safeEventId + ".bin";
}
public static String key(VehicleEvent.RawArchive raw) {
String key = metadataValue(raw.metadata(), META_KEY);
if (!key.isBlank()) {
return key;
}
return key(raw.ingestTime(), raw.source(), raw.vin(), raw.eventId());
}
public static String logicalUri(String key) {
return key == null || key.isBlank() ? "" : "archive://" + key;
}
private static String metadataValue(Map<String, String> metadata, String key) {
if (metadata == null || key == null) {
return "";
}
String value = metadata.get(key);
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,49 @@
package com.lingniu.ingest.api.event;
/**
* 实时数据载荷(扁平 record字段用 {@code Double} / {@code Integer} 允许 null表达"未上报")。
*
* <p>不再复刻旧服务 {@code VehicleDataActual} 的 273 字段巨型类 —— 协议特有细节通过
* {@link VehicleEvent#metadata()} 传递。此处只保留跨协议通用字段。
*/
public record RealtimePayload(
// ===== 动力 & 能源 =====
Double speedKmh,
Double totalMileageKm,
Double batterySoc,
Double batteryVoltageV,
Double batteryCurrentA,
// ===== 燃料电池 / 氢能 =====
Double fcVoltageV,
Double fcCurrentA,
Double fcTempC,
Double hydrogenRemainingKg,
Double hydrogenHighPressureMpa,
Double hydrogenLowPressureMpa,
// ===== 车辆状态 =====
VehicleState vehicleState,
ChargingState chargingState,
RunningMode runningMode,
Integer gearLevel,
Double acceleratorPedal,
Double brakePedal,
// ===== 位置(冗余一份便于单主题消费)=====
Double longitude,
Double latitude,
Double altitudeM,
Double directionDeg,
// ===== 环境 =====
Double ambientTempC,
Double coolantTempC
) {
public enum VehicleState { STARTED, SHUTDOWN, OTHER, INVALID }
public enum ChargingState { UNCHARGED, PARKED_CHARGING, DRIVING_CHARGING, CHARGED, OTHER, INVALID }
public enum RunningMode { ELECTRIC, HYBRID, FUEL, OTHER, INVALID }
}

View File

@@ -0,0 +1,74 @@
package com.lingniu.ingest.api.event;
/**
* One normalized telemetry field value.
*
* <p>Protocol mappers use stable internal field keys so Kafka, Parquet, Redis,
* and statistics do not depend on protocol-specific names.
*/
public record TelemetryFieldValue(
String key,
ValueType valueType,
String value,
String unit,
Quality quality,
String sourcePath
) {
public TelemetryFieldValue {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("key must not be blank");
}
if (valueType == null) {
throw new IllegalArgumentException("valueType must not be null");
}
value = value == null ? "" : value;
unit = unit == null ? "" : unit;
quality = quality == null ? Quality.GOOD : quality;
sourcePath = sourcePath == null ? "" : sourcePath;
}
public static TelemetryFieldValue stringValue(String key, String value, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.STRING, value, unit, Quality.GOOD, sourcePath);
}
public static TelemetryFieldValue doubleValue(String key, double value, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.DOUBLE, Double.toString(value), unit, Quality.GOOD, sourcePath);
}
public static TelemetryFieldValue longValue(String key, long value, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.LONG, Long.toString(value), unit, Quality.GOOD, sourcePath);
}
public static TelemetryFieldValue booleanValue(String key, boolean value, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.BOOLEAN, Boolean.toString(value), unit, Quality.GOOD, sourcePath);
}
public static TelemetryFieldValue instantValue(String key, String value, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.INSTANT, value, unit, Quality.GOOD, sourcePath);
}
public static TelemetryFieldValue jsonValue(String key, String value, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.JSON, value, unit, Quality.GOOD, sourcePath);
}
public static TelemetryFieldValue missing(String key, String unit, String sourcePath) {
return new TelemetryFieldValue(key, ValueType.STRING, "", unit, Quality.MISSING, sourcePath);
}
public enum ValueType {
STRING,
DOUBLE,
LONG,
BOOLEAN,
INSTANT,
JSON
}
public enum Quality {
GOOD,
ESTIMATED,
INVALID,
MISSING
}
}

View File

@@ -0,0 +1,144 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Normalized full-field telemetry snapshot for one parsed vehicle event.
*
* <p>This is the shared contract for Kafka, Parquet history, Redis hot state,
* and statistics. It intentionally lives in {@code ingest-api} and has no
* Spring, Kafka, Redis, or DuckDB dependency.
*/
public record TelemetrySnapshot(
String eventId,
String vin,
ProtocolId protocol,
String eventType,
Instant eventTime,
Instant ingestTime,
String rawArchiveUri,
Map<String, String> metadata,
List<TelemetryFieldValue> fields
) {
public TelemetrySnapshot {
if (eventId == null || eventId.isBlank()) {
throw new IllegalArgumentException("eventId must not be blank");
}
if (vin == null || vin.isBlank()) {
throw new IllegalArgumentException("vin must not be blank");
}
if (protocol == null) {
throw new IllegalArgumentException("protocol must not be null");
}
if (eventType == null || eventType.isBlank()) {
throw new IllegalArgumentException("eventType must not be blank");
}
if (eventTime == null) {
throw new IllegalArgumentException("eventTime must not be null");
}
if (ingestTime == null) {
throw new IllegalArgumentException("ingestTime must not be null");
}
rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri;
metadata = Map.copyOf(metadata == null ? Map.of() : metadata);
fields = List.copyOf(fields == null ? List.of() : fields);
validateUniqueKeys(fields);
}
public Optional<TelemetryFieldValue> field(String key) {
if (key == null || key.isBlank()) {
return Optional.empty();
}
for (TelemetryFieldValue field : fields) {
if (field.key().equals(key)) {
return Optional.of(field);
}
}
return Optional.empty();
}
public Map<String, TelemetryFieldValue> fieldsByKey() {
Map<String, TelemetryFieldValue> index = new LinkedHashMap<>();
for (TelemetryFieldValue field : fields) {
index.put(field.key(), field);
}
return Map.copyOf(index);
}
public static Builder builder(String eventId,
String vin,
ProtocolId protocol,
String eventType,
Instant eventTime,
Instant ingestTime) {
return new Builder(eventId, vin, protocol, eventType, eventTime, ingestTime);
}
private static void validateUniqueKeys(List<TelemetryFieldValue> fields) {
Map<String, TelemetryFieldValue> seen = new LinkedHashMap<>();
for (TelemetryFieldValue field : fields) {
if (field == null) {
throw new IllegalArgumentException("field must not be null");
}
TelemetryFieldValue previous = seen.put(field.key(), field);
if (previous != null) {
throw new IllegalArgumentException("duplicate telemetry field key: " + field.key());
}
}
}
public static final class Builder {
private final String eventId;
private final String vin;
private final ProtocolId protocol;
private final String eventType;
private final Instant eventTime;
private final Instant ingestTime;
private String rawArchiveUri = "";
private Map<String, String> metadata = Map.of();
private final List<TelemetryFieldValue> fields = new ArrayList<>();
private Builder(String eventId,
String vin,
ProtocolId protocol,
String eventType,
Instant eventTime,
Instant ingestTime) {
this.eventId = eventId;
this.vin = vin;
this.protocol = protocol;
this.eventType = eventType;
this.eventTime = eventTime;
this.ingestTime = ingestTime;
}
public Builder rawArchiveUri(String rawArchiveUri) {
this.rawArchiveUri = rawArchiveUri;
return this;
}
public Builder metadata(Map<String, String> metadata) {
this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
return this;
}
public Builder addField(TelemetryFieldValue field) {
this.fields.add(field);
return this;
}
public TelemetrySnapshot build() {
return new TelemetrySnapshot(
eventId, vin, protocol, eventType, eventTime, ingestTime,
rawArchiveUri, metadata, fields);
}
}
}

View File

@@ -0,0 +1,166 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 领域事件根类型。所有协议归一到这里。
*
* <p>使用 sealed + record 表达有界闭合的事件集合,下游消费方可用 {@code switch} 模式匹配穷尽处理。
*
* <p>字段约定:
* <ul>
* <li>{@link #vin} 作为 Kafka 分区 key保证单车顺序
* <li>{@link #eventTime} 设备上报时间(而非服务器接收时间)
* <li>{@link #ingestTime} 服务器接收时间,用于延迟监控
* <li>{@link #traceId} W3C traceparent 上下文
* </ul>
*/
public sealed interface VehicleEvent
permits VehicleEvent.Realtime,
VehicleEvent.Location,
VehicleEvent.Alarm,
VehicleEvent.Login,
VehicleEvent.Logout,
VehicleEvent.Heartbeat,
VehicleEvent.MediaMeta,
VehicleEvent.Passthrough,
VehicleEvent.RawArchive {
String eventId();
String vin();
ProtocolId source();
Instant eventTime();
Instant ingestTime();
String traceId();
/** 通用元数据:协议版本、终端手机号、网关 IP 等。 */
Map<String, String> metadata();
// ===== 具体事件类型 =====
/** 整车实时数据32960 实时上报 / MQTT 宇通 / JT808 位置扩展等都归一到这里)。 */
record Realtime(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
RealtimePayload payload
) implements VehicleEvent {}
/** 纯位置事件808 的 0x0200 / Xinda 0200 映射到此)。 */
record Location(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
LocationPayload payload
) implements VehicleEvent {}
/** 报警事件。 */
record Alarm(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
AlarmPayload payload
) implements VehicleEvent {}
/** 设备登入 / 车辆登入。 */
record Login(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
String iccid,
String protocolVersion
) implements VehicleEvent {}
/** 设备登出。 */
record Logout(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata
) implements VehicleEvent {}
/** 心跳 / 链路保活。 */
record Heartbeat(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata
) implements VehicleEvent {}
/** 多媒体元数据(大文件本体走对象存储,通过 archiveRef 引用)。 */
record MediaMeta(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
String mediaId,
String mediaType,
long sizeBytes,
String archiveRef
) implements VehicleEvent {}
/** 透传 / 自定义扩展消息。 */
record Passthrough(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
int passthroughType,
byte[] data
) implements VehicleEvent {}
/**
* 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节交
* {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型
* (见 {@code KafkaEventSink.accepts})。
*
* <p>key 组装建议:{@code yyyy/MM/dd/<source>/<vin>/<eventId>.bin},具体由 sink 实现决定。
*
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
* @param infoType 协议子类型(可为 0同上
* @param rawBytes 原始入站字节,不可为 null
*/
record RawArchive(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
int command,
int infoType,
byte[] rawBytes
) implements VehicleEvent {}
}

View File

@@ -0,0 +1,206 @@
package com.lingniu.ingest.api.event;
import java.util.Map;
import java.util.Optional;
/**
* Converts current {@link VehicleEvent} variants into normalized full-field
* telemetry snapshots.
*
* <p>Protocol-specific mappers can add richer fields later, but all downstream
* modules should consume {@link TelemetrySnapshot} instead of protocol objects.
*/
public final class VehicleEventTelemetrySnapshotMapper {
private VehicleEventTelemetrySnapshotMapper() {
}
public static Optional<TelemetrySnapshot> toSnapshot(VehicleEvent event) {
if (event == null || event instanceof VehicleEvent.RawArchive) {
return Optional.empty();
}
TelemetrySnapshot.Builder builder = TelemetrySnapshot.builder(
event.eventId(),
event.vin(),
event.source(),
eventType(event),
event.eventTime(),
event.ingestTime())
.metadata(event.metadata())
.rawArchiveUri(rawArchiveUri(event));
switch (event) {
case VehicleEvent.Realtime realtime -> addRealtimeFields(builder, realtime.payload());
case VehicleEvent.Location location -> addLocationFields(builder, location.payload());
case VehicleEvent.Alarm alarm -> addAlarmFields(builder, alarm.payload());
case VehicleEvent.Login login -> {
addString(builder, "iccid", login.iccid(), "", "event.login.iccid");
addString(builder, "protocol_version", login.protocolVersion(), "", "event.login.protocolVersion");
}
case VehicleEvent.Logout ignored -> {
}
case VehicleEvent.Heartbeat ignored -> {
}
case VehicleEvent.MediaMeta media -> {
addString(builder, "media_id", media.mediaId(), "", "event.media.mediaId");
addString(builder, "media_type", media.mediaType(), "", "event.media.mediaType");
builder.addField(TelemetryFieldValue.longValue(
"media_size_bytes", media.sizeBytes(), "bytes", "event.media.sizeBytes"));
addString(builder, "media_archive_ref", media.archiveRef(), "", "event.media.archiveRef");
}
case VehicleEvent.Passthrough passthrough -> {
builder.addField(TelemetryFieldValue.longValue(
"passthrough_type", passthrough.passthroughType(), "", "event.passthrough.type"));
builder.addField(TelemetryFieldValue.longValue(
"passthrough_size_bytes",
passthrough.data() == null ? 0 : passthrough.data().length,
"bytes",
"event.passthrough.data"));
}
case VehicleEvent.RawArchive ignored -> {
return Optional.empty();
}
}
return Optional.of(builder.build());
}
private static void addRealtimeFields(TelemetrySnapshot.Builder builder, RealtimePayload p) {
if (p == null) return;
addDouble(builder, "speed_kmh", p.speedKmh(), "km/h", "event.realtime.speedKmh");
addDouble(builder, "total_mileage_km", p.totalMileageKm(), "km", "event.realtime.totalMileageKm");
addDouble(builder, "battery_soc", p.batterySoc(), "%", "event.realtime.batterySoc");
addDouble(builder, "battery_voltage_v", p.batteryVoltageV(), "V", "event.realtime.batteryVoltageV");
addDouble(builder, "battery_current_a", p.batteryCurrentA(), "A", "event.realtime.batteryCurrentA");
addDouble(builder, "fc_voltage_v", p.fcVoltageV(), "V", "event.realtime.fcVoltageV");
addDouble(builder, "fc_current_a", p.fcCurrentA(), "A", "event.realtime.fcCurrentA");
addDouble(builder, "fc_temp_c", p.fcTempC(), "C", "event.realtime.fcTempC");
addDouble(builder, "hydrogen_remaining_kg", p.hydrogenRemainingKg(), "kg", "event.realtime.hydrogenRemainingKg");
addDouble(builder, "hydrogen_high_pressure_mpa", p.hydrogenHighPressureMpa(), "MPa", "event.realtime.hydrogenHighPressureMpa");
addDouble(builder, "hydrogen_low_pressure_mpa", p.hydrogenLowPressureMpa(), "MPa", "event.realtime.hydrogenLowPressureMpa");
addEnum(builder, "vehicle_state", p.vehicleState(), "", "event.realtime.vehicleState");
addEnum(builder, "charging_state", p.chargingState(), "", "event.realtime.chargingState");
addEnum(builder, "running_mode", p.runningMode(), "", "event.realtime.runningMode");
addLong(builder, "gear_level", p.gearLevel(), "", "event.realtime.gearLevel");
addDouble(builder, "accelerator_pedal", p.acceleratorPedal(), "%", "event.realtime.acceleratorPedal");
addDouble(builder, "brake_pedal", p.brakePedal(), "%", "event.realtime.brakePedal");
addDouble(builder, "longitude", p.longitude(), "deg", "event.realtime.longitude");
addDouble(builder, "latitude", p.latitude(), "deg", "event.realtime.latitude");
addDouble(builder, "altitude_m", p.altitudeM(), "m", "event.realtime.altitudeM");
addDouble(builder, "direction_deg", p.directionDeg(), "deg", "event.realtime.directionDeg");
addDouble(builder, "ambient_temp_c", p.ambientTempC(), "C", "event.realtime.ambientTempC");
addDouble(builder, "coolant_temp_c", p.coolantTempC(), "C", "event.realtime.coolantTempC");
}
private static void addLocationFields(TelemetrySnapshot.Builder builder, LocationPayload p) {
if (p == null) return;
builder.addField(TelemetryFieldValue.doubleValue("longitude", p.longitude(), "deg", "event.location.longitude"));
builder.addField(TelemetryFieldValue.doubleValue("latitude", p.latitude(), "deg", "event.location.latitude"));
builder.addField(TelemetryFieldValue.doubleValue("altitude_m", p.altitudeM(), "m", "event.location.altitudeM"));
builder.addField(TelemetryFieldValue.doubleValue("speed_kmh", p.speedKmh(), "km/h", "event.location.speedKmh"));
addDouble(builder, "total_mileage_km", p.totalMileageKm(), "km", "event.location.totalMileageKm");
builder.addField(TelemetryFieldValue.doubleValue("direction_deg", p.directionDeg(), "deg", "event.location.directionDeg"));
builder.addField(TelemetryFieldValue.longValue("location_alarm_flag", p.alarmFlag(), "", "event.location.alarmFlag"));
builder.addField(TelemetryFieldValue.longValue("location_status_raw", p.statusFlag(), "", "event.location.statusFlag"));
}
private static void addAlarmFields(TelemetrySnapshot.Builder builder, AlarmPayload p) {
if (p == null) return;
addEnum(builder, "alarm_level", p.level(), "", "event.alarm.level");
builder.addField(TelemetryFieldValue.longValue("alarm_type_code", p.alarmTypeCode(), "", "event.alarm.alarmTypeCode"));
addString(builder, "alarm_type_name", p.alarmTypeName(), "", "event.alarm.alarmTypeName");
if (p.faultCodes() != null && !p.faultCodes().isEmpty()) {
builder.addField(TelemetryFieldValue.stringValue(
"fault_codes", String.join(",", p.faultCodes()), "", "event.alarm.faultCodes"));
}
if (p.activeBits() != null && !p.activeBits().isEmpty()) {
builder.addField(TelemetryFieldValue.stringValue(
"active_alarm_bits", String.join(",", p.activeBits()), "", "event.alarm.activeBits"));
}
addDouble(builder, "longitude", p.longitude(), "deg", "event.alarm.longitude");
addDouble(builder, "latitude", p.latitude(), "deg", "event.alarm.latitude");
addEnum(builder, "safety_category", p.safetyCategory(), "", "event.alarm.safetyCategory");
builder.addField(TelemetryFieldValue.booleanValue(
"hydrogen_leak_detected", p.hydrogenLeakDetected(), "", "event.alarm.hydrogenLeakDetected"));
addEnum(builder, "hydrogen_leak_level", p.hydrogenLeakLevel(), "", "event.alarm.hydrogenLeakLevel");
builder.addField(TelemetryFieldValue.booleanValue(
"hydrogen_leak_action_required",
p.hydrogenLeakActionRequired(),
"",
"event.alarm.hydrogenLeakActionRequired"));
}
private static String eventType(VehicleEvent event) {
if (event instanceof VehicleEvent.Realtime) return "REALTIME";
if (event instanceof VehicleEvent.Location) return "LOCATION";
if (event instanceof VehicleEvent.Alarm) return "ALARM";
if (event instanceof VehicleEvent.Login) return "LOGIN";
if (event instanceof VehicleEvent.Logout) return "LOGOUT";
if (event instanceof VehicleEvent.Heartbeat) return "HEARTBEAT";
if (event instanceof VehicleEvent.MediaMeta) return "MEDIA_META";
if (event instanceof VehicleEvent.Passthrough) return "PASSTHROUGH";
return "UNKNOWN";
}
private static String rawArchiveUri(VehicleEvent event) {
Map<String, String> metadata = event.metadata();
if (metadata == null) {
return mediaArchiveRef(event);
}
String uri = metadata.getOrDefault(RawArchiveKeys.META_URI, "");
if (uri != null && !uri.isBlank()) {
return uri;
}
String key = metadata.getOrDefault(RawArchiveKeys.META_KEY, "");
if (key != null && !key.isBlank()) {
return RawArchiveKeys.logicalUri(key);
}
return mediaArchiveRef(event);
}
private static String mediaArchiveRef(VehicleEvent event) {
if (event instanceof VehicleEvent.MediaMeta media && media.archiveRef() != null) {
return media.archiveRef();
}
return "";
}
private static void addDouble(TelemetrySnapshot.Builder builder,
String key,
Double value,
String unit,
String sourcePath) {
if (value != null) {
builder.addField(TelemetryFieldValue.doubleValue(key, value, unit, sourcePath));
}
}
private static void addLong(TelemetrySnapshot.Builder builder,
String key,
Integer value,
String unit,
String sourcePath) {
if (value != null) {
builder.addField(TelemetryFieldValue.longValue(key, value, unit, sourcePath));
}
}
private static void addString(TelemetrySnapshot.Builder builder,
String key,
String value,
String unit,
String sourcePath) {
if (value != null && !value.isBlank()) {
builder.addField(TelemetryFieldValue.stringValue(key, value, unit, sourcePath));
}
}
private static void addEnum(TelemetrySnapshot.Builder builder,
String key,
Enum<?> value,
String unit,
String sourcePath) {
if (value != null) {
builder.addField(TelemetryFieldValue.stringValue(key, value.name(), unit, sourcePath));
}
}
}

View File

@@ -0,0 +1,13 @@
/**
* lingniu-vehicle-ingest 公共 API 模块。零 Spring 依赖,只定义 SPI、sealed 领域事件、注解。
*
* <p>分包:
* <ul>
* <li>{@code annotation} - Handler 注解体系
* <li>{@code event} - sealed {@code VehicleEvent} + payload records
* <li>{@code pipeline} - RawFrame / IngestContext / IngestInterceptor
* <li>{@code sink} - EventSink SPI
* <li>{@code spi} - ProtocolPlugin / FrameDecoder / FrameEncoder / EventMapper
* </ul>
*/
package com.lingniu.ingest.api;

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.api.pipeline;
import java.util.HashMap;
import java.util.Map;
/**
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享不需要同步。
*/
public final class IngestContext {
private final String traceId;
private final Map<String, Object> attributes = new HashMap<>(8);
private volatile boolean aborted;
private volatile String abortReason;
public IngestContext(String traceId) {
this.traceId = traceId;
}
public String traceId() {
return traceId;
}
public <T> T attr(String key) {
@SuppressWarnings("unchecked")
T v = (T) attributes.get(key);
return v;
}
public void attr(String key, Object value) {
attributes.put(key, value);
}
public void abort(String reason) {
this.aborted = true;
this.abortReason = reason;
}
public boolean aborted() {
return aborted;
}
public String abortReason() {
return abortReason;
}
}

View File

@@ -0,0 +1,21 @@
package com.lingniu.ingest.api.pipeline;
import com.lingniu.ingest.api.event.VehicleEvent;
/**
* 拦截器 SPI。内置实现Auth / Dedup / RateLimit / CoordTransform / Tracing。
* 实现 {@link org.springframework.core.Ordered}(或使用 {@code @Order})控制顺序。
*/
public interface IngestInterceptor {
/** 解码后、分发到 Handler 之前触发。返回 false 表示终止后续处理。 */
default boolean before(RawFrame frame, IngestContext ctx) {
return true;
}
/** Handler 成功产出事件后触发,允许修饰事件或取消投递。 */
default void after(VehicleEvent event, IngestContext ctx) {}
/** 处理链任一环节抛出异常时触发。 */
default void onError(Throwable error, IngestContext ctx) {}
}

View File

@@ -0,0 +1,27 @@
package com.lingniu.ingest.api.pipeline;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 进入 Pipeline 的原始帧描述。
*
* @param protocolId 来源协议
* @param command 协议主命令 / 消息 ID由各协议自行定义
* @param infoType 可选子类型(如 32960 的信息体 ID
* @param payload 已经被 {@link com.lingniu.ingest.api.spi.FrameDecoder} 解析出的协议对象
* @param rawBytes 原始字节,用于冷存与排障(可能为 null按配置开关
* @param sourceMeta 来源元数据peer ip、端口、session id、topic 等
* @param receivedAt 服务器接收时刻
*/
public record RawFrame(
ProtocolId protocolId,
int command,
int infoType,
Object payload,
byte[] rawBytes,
Map<String, String> sourceMeta,
Instant receivedAt
) {}

View File

@@ -0,0 +1,28 @@
package com.lingniu.ingest.api.sink;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 事件出口 SPI。Kafka、本地归档、指标打点都是一种 Sink。
*
* <p>实现应当线程安全、非阻塞(内部异步 IO。由 Disruptor EventBus 扇出调用。
*/
public interface EventSink {
String name();
CompletableFuture<Void> publish(VehicleEvent event);
default CompletableFuture<Void> publishBatch(List<VehicleEvent> batch) {
CompletableFuture<?>[] all = batch.stream().map(this::publish).toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(all);
}
/** 是否接受此事件类型。默认全部接受。 */
default boolean accepts(VehicleEvent event) {
return true;
}
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.api.spi;
/** 解码阶段异常,指示数据体本身违反协议。应被 Dispatcher 路由到 DLQ。 */
public class DecodeException extends RuntimeException {
public DecodeException(String message) {
super(message);
}
public DecodeException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,16 @@
package com.lingniu.ingest.api.spi;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.util.List;
/**
* 协议消息 → 领域事件的映射器Adapter 模式)。
*
* <p>实现类负责把"我这个协议特有的 Java 对象"翻译成统一的 {@link VehicleEvent}。
* 允许一条协议消息展开为多条领域事件(例如 32960 的一次实时上报同时产出 Realtime + Location
*/
@FunctionalInterface
public interface EventMapper<I> {
List<VehicleEvent> toEvents(I message);
}

View File

@@ -0,0 +1,13 @@
package com.lingniu.ingest.api.spi;
import java.nio.ByteBuffer;
/**
* 协议帧解码器:字节流 → 协议消息对象。
*
* <p>实现类应是无状态的(拆包/粘包由上游 Netty 的帧解码器处理),此处只负责一条完整帧的解析。
*/
@FunctionalInterface
public interface FrameDecoder<I> {
I decode(ByteBuffer buffer) throws DecodeException;
}

View File

@@ -0,0 +1,9 @@
package com.lingniu.ingest.api.spi;
import java.nio.ByteBuffer;
/** 下行帧编码器。无下行的协议返回 null 即可。 */
@FunctionalInterface
public interface FrameEncoder<O> {
ByteBuffer encode(O message);
}

View File

@@ -0,0 +1,16 @@
package com.lingniu.ingest.api.spi;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.util.List;
/**
* 编程式 Handler 接口。推荐使用 {@code @ProtocolHandler + @MessageMapping} 注解方式代替。
* 此接口保留是为了 SPI 场景下的脚本式扩展(例如动态加载的 groovy handler
*/
public interface MessageHandler<I> {
boolean supports(I message);
List<VehicleEvent> handle(I message);
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.api.spi;
import com.lingniu.ingest.api.ProtocolId;
import java.util.List;
/**
* 协议插件 SPI。每个协议模块提供一个实现由 {@code ProtocolPluginRegistry} 通过 ServiceLoader
* 或 Spring Bean 方式发现并装配。
*
* @param <I> 协议解码后的消息类型
* @param <O> 下行消息类型(无下行的协议用 {@link Void}
*/
public interface ProtocolPlugin<I, O> {
ProtocolId id();
FrameDecoder<I> decoder();
FrameEncoder<O> encoder();
EventMapper<I> eventMapper();
/**
* 插件自带的协议特定 Handler 列表(可选)。
* 通常推荐使用 {@code @ProtocolHandler} + Spring Bean 的方式注册,此处返回空列表。
*/
default List<MessageHandler<I>> builtinHandlers() {
return List.of();
}
}

View File

@@ -0,0 +1,55 @@
package com.lingniu.ingest.api.consumer;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class EnvelopeConsumerProcessorTest {
@Test
void invalidEnvelopePublishesDeadLetterRecordWithKafkaPosition() {
CapturingDeadLetterSink deadLetters = new CapturingDeadLetterSink();
EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor(
"event-history", ignored -> EnvelopeIngestResult.invalid("VehicleEnvelope bytes are invalid"), deadLetters);
EnvelopeIngestResult result = processor.process(
new EnvelopeConsumerRecord("vehicle.realtime", 2, 42L, "VIN001", new byte[]{0x01, 0x02}));
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
assertThat(deadLetters.records).singleElement().satisfies(record -> {
assertThat(record.service()).isEqualTo("event-history");
assertThat(record.topic()).isEqualTo("vehicle.realtime");
assertThat(record.partition()).isEqualTo(2);
assertThat(record.offset()).isEqualTo(42L);
assertThat(record.key()).isEqualTo("VIN001");
assertThat(record.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
assertThat(record.message()).contains("VehicleEnvelope");
assertThat(record.payload()).containsExactly(0x01, 0x02);
});
}
@Test
void processedEnvelopeDoesNotPublishDeadLetter() {
CapturingDeadLetterSink deadLetters = new CapturingDeadLetterSink();
EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor(
"vehicle-state", ignored -> EnvelopeIngestResult.processed("event-1", "VIN001"), deadLetters);
EnvelopeIngestResult result = processor.process(
new EnvelopeConsumerRecord("vehicle.realtime", 0, 7L, "VIN001", new byte[]{0x0a}));
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.PROCESSED);
assertThat(deadLetters.records).isEmpty();
}
private static final class CapturingDeadLetterSink implements EnvelopeDeadLetterSink {
private final List<EnvelopeDeadLetterRecord> records = new ArrayList<>();
@Override
public void publish(EnvelopeDeadLetterRecord record) {
records.add(record);
}
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class RawArchiveKeysTest {
@Test
void keyDateUsesShanghaiBusinessDayInsteadOfUtcDay() {
String key = RawArchiveKeys.key(
Instant.parse("2026-06-22T16:30:00Z"),
ProtocolId.GB32960,
"VIN001",
"event-1");
assertThat(key).isEqualTo("2026/06/23/GB32960/VIN001/event-1.bin");
}
@Test
void keyForRawArchiveUsesPrecomputedMetadataKey() {
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
"event-1",
"VIN001",
ProtocolId.GB32960,
Instant.parse("2026-06-22T16:30:00Z"),
Instant.parse("2026-06-22T16:30:00Z"),
"trace-1",
Map.of(RawArchiveKeys.META_KEY, "precomputed/key.bin"),
0x02,
0,
new byte[]{0x23, 0x23});
assertThat(RawArchiveKeys.key(raw)).isEqualTo("precomputed/key.bin");
}
}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.api.event;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TelemetryFieldValueTest {
@Test
void createsTypedDoubleFieldWithStableMetadata() {
TelemetryFieldValue field = TelemetryFieldValue.doubleValue(
"total_mileage_km", 12345.6, "km", "gb32960.vehicle.totalMileageKm");
assertThat(field.key()).isEqualTo("total_mileage_km");
assertThat(field.valueType()).isEqualTo(TelemetryFieldValue.ValueType.DOUBLE);
assertThat(field.value()).isEqualTo("12345.6");
assertThat(field.unit()).isEqualTo("km");
assertThat(field.quality()).isEqualTo(TelemetryFieldValue.Quality.GOOD);
assertThat(field.sourcePath()).isEqualTo("gb32960.vehicle.totalMileageKm");
}
@Test
void rejectsBlankFieldKey() {
assertThatThrownBy(() -> TelemetryFieldValue.stringValue(" ", "STARTED", "", "gb32960.vehicle.state"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("key");
}
@Test
void marksMissingValueExplicitly() {
TelemetryFieldValue field = TelemetryFieldValue.missing(
"hydrogen_remaining_kg", "kg", "gb32960.vendor.hydrogenRemaining");
assertThat(field.valueType()).isEqualTo(TelemetryFieldValue.ValueType.STRING);
assertThat(field.value()).isEmpty();
assertThat(field.quality()).isEqualTo(TelemetryFieldValue.Quality.MISSING);
}
}

View File

@@ -0,0 +1,58 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TelemetrySnapshotTest {
@Test
void indexesFieldsByInternalKey() {
TelemetrySnapshot snapshot = new TelemetrySnapshot(
"event-1",
"VIN001",
ProtocolId.GB32960,
"REALTIME",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"archive://raw/event-1.bin",
Map.of("protocolVersion", "V2016"),
List.of(
TelemetryFieldValue.doubleValue("total_mileage_km", 120.5, "km", "gb32960.vehicle.totalMileageKm"),
TelemetryFieldValue.booleanValue("hydrogen_leak_detected", true, "", "gb32960.alarm.HYDROGEN_LEAK")
)
);
assertThat(snapshot.field("total_mileage_km")).isPresent();
assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("120.5");
assertThat(snapshot.field("hydrogen_leak_detected").orElseThrow().value()).isEqualTo("true");
assertThat(snapshot.field("unknown")).isEmpty();
assertThat(snapshot.metadata()).containsEntry("protocolVersion", "V2016");
}
@Test
void rejectsDuplicateFieldKeys() {
assertThatThrownBy(() -> new TelemetrySnapshot(
"event-1",
"VIN001",
ProtocolId.GB32960,
"REALTIME",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"",
Map.of(),
List.of(
TelemetryFieldValue.doubleValue("speed_kmh", 1.0, "km/h", "a"),
TelemetryFieldValue.doubleValue("speed_kmh", 2.0, "km/h", "b")
)
))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate");
}
}

View File

@@ -0,0 +1,175 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class VehicleEventTelemetrySnapshotMapperTest {
@Test
void mapsRealtimeEventToInternalTelemetryFields() {
VehicleEvent.Realtime event = new VehicleEvent.Realtime(
"event-1",
"VIN001",
ProtocolId.GB32960,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-1",
Map.of("protocolVersion", "V2016", "rawArchiveUri", "archive://raw/event-1.bin"),
new RealtimePayload(
80.5,
12345.6,
55.0,
610.0,
-20.5,
220.0,
100.0,
65.0,
8.2,
35.0,
1.1,
RealtimePayload.VehicleState.STARTED,
RealtimePayload.ChargingState.UNCHARGED,
RealtimePayload.RunningMode.FUEL,
3,
20.0,
0.0,
113.12,
23.45,
10.0,
180.0,
30.0,
70.0
)
);
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
assertThat(snapshot.eventId()).isEqualTo("event-1");
assertThat(snapshot.rawArchiveUri()).isEqualTo("archive://raw/event-1.bin");
assertThat(snapshot.field("speed_kmh").orElseThrow().value()).isEqualTo("80.5");
assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("12345.6");
assertThat(snapshot.field("vehicle_state").orElseThrow().value()).isEqualTo("STARTED");
assertThat(snapshot.field("hydrogen_high_pressure_mpa").orElseThrow().value()).isEqualTo("35.0");
assertThat(snapshot.field("longitude").orElseThrow().value()).isEqualTo("113.12");
}
@Test
void mapsHydrogenLeakAlarmToInternalSafetyFields() {
VehicleEvent.Alarm event = new VehicleEvent.Alarm(
"event-2",
"VIN001",
ProtocolId.GB32960,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-2",
Map.of("protocolVersion", "V2016"),
new AlarmPayload(
AlarmPayload.AlarmLevel.CRITICAL,
1,
"GB32960_ALARM",
java.util.List.of("OTH-1"),
java.util.Set.of("HYDROGEN_LEAK"),
113.12,
23.45,
AlarmPayload.SafetyCategory.HYDROGEN_LEAK,
true,
AlarmPayload.HydrogenLeakLevel.CRITICAL,
true
)
);
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
assertThat(snapshot.eventType()).isEqualTo("ALARM");
assertThat(snapshot.field("hydrogen_leak_detected").orElseThrow().value()).isEqualTo("true");
assertThat(snapshot.field("hydrogen_leak_level").orElseThrow().value()).isEqualTo("CRITICAL");
assertThat(snapshot.field("hydrogen_leak_action_required").orElseThrow().value()).isEqualTo("true");
assertThat(snapshot.field("safety_category").orElseThrow().value()).isEqualTo("HYDROGEN_LEAK");
}
@Test
void mapsLocationMileageToInternalTelemetryField() {
VehicleEvent.Location event = new VehicleEvent.Location(
"event-location-1",
"VIN001",
ProtocolId.JT808,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-location-1",
Map.of("messageId", "0x200"),
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1, 1234.5)
);
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("1234.5");
assertThat(snapshot.field("total_mileage_km").orElseThrow().sourcePath())
.isEqualTo("event.location.totalMileageKm");
}
@Test
void mediaMetaUsesArchiveRefAsQueryableRawArchiveUriFallback() {
VehicleEvent.MediaMeta event = new VehicleEvent.MediaMeta(
"media-1",
"VIN001",
ProtocolId.JT1078,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-4",
Map.of("channel", "1"),
"media-001",
"jt1078-video-h264-channel-1",
1024,
"file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp"
);
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
assertThat(snapshot.eventType()).isEqualTo("MEDIA_META");
assertThat(snapshot.rawArchiveUri())
.isEqualTo("file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp");
assertThat(snapshot.field("media_archive_ref").orElseThrow().value())
.isEqualTo("file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp");
}
@Test
void derivesRawArchiveUriFromRawArchiveKeyMetadata() {
VehicleEvent.Heartbeat event = new VehicleEvent.Heartbeat(
"heartbeat-1",
"VIN001",
ProtocolId.JT808,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-5",
Map.of("rawArchiveKey", "2026/06/22/JT808/VIN001/raw-1.bin")
);
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
assertThat(snapshot.rawArchiveUri())
.isEqualTo("archive://2026/06/22/JT808/VIN001/raw-1.bin");
}
@Test
void skipsRawArchiveBecauseItIsStoredByArchiveSink() {
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
"raw-1",
"VIN001",
ProtocolId.GB32960,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-3",
Map.of(),
0x02,
0,
new byte[]{0x23, 0x23}
);
assertThat(VehicleEventTelemetrySnapshotMapper.toSnapshot(raw)).isEmpty();
}
}

View File

@@ -0,0 +1,30 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>ingest-codec-common</artifactId>
<name>ingest-codec-common</name>
<description>BCD / CRC / BCC / bit utils 等公共编解码工具。</description>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</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,19 @@
package com.lingniu.ingest.codec;
/** BCC异或校验。GB/T 32960 与 JT/T 808 都使用此方式做帧尾校验。 */
public final class BccChecksum {
private BccChecksum() {}
public static byte compute(byte[] data, int offset, int length) {
byte sum = 0;
for (int i = offset; i < offset + length; i++) {
sum ^= data[i];
}
return sum;
}
public static boolean verify(byte[] data, int offset, int length, byte expected) {
return compute(data, offset, length) == expected;
}
}

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.codec;
/** BCD (Binary-Coded Decimal) 编解码。JT/T 808 的手机号、时间戳常用。 */
public final class BcdCodec {
private BcdCodec() {}
public static byte[] encode(String decimal) {
int len = (decimal.length() + 1) / 2;
byte[] out = new byte[len];
int idx = decimal.length() % 2;
if (idx == 1) {
out[0] = (byte) Character.digit(decimal.charAt(0), 10);
}
for (int i = idx; i < decimal.length(); i += 2) {
int hi = Character.digit(decimal.charAt(i), 10);
int lo = Character.digit(decimal.charAt(i + 1), 10);
out[(i + idx) / 2] = (byte) ((hi << 4) | lo);
}
return out;
}
public static String decode(byte[] bcd) {
StringBuilder sb = new StringBuilder(bcd.length * 2);
for (byte b : bcd) {
sb.append((b >> 4) & 0x0F).append(b & 0x0F);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.codec;
/** 位运算工具:从字节数组读写无符号整数。 */
public final class BitUtils {
private BitUtils() {}
public static int readUint8(byte[] buf, int offset) {
return buf[offset] & 0xFF;
}
public static int readUint16BE(byte[] buf, int offset) {
return ((buf[offset] & 0xFF) << 8) | (buf[offset + 1] & 0xFF);
}
public static long readUint32BE(byte[] buf, int offset) {
return ((long) (buf[offset] & 0xFF) << 24)
| ((long) (buf[offset + 1] & 0xFF) << 16)
| ((long) (buf[offset + 2] & 0xFF) << 8)
| (buf[offset + 3] & 0xFF);
}
public static void writeUint16BE(byte[] buf, int offset, int value) {
buf[offset] = (byte) ((value >> 8) & 0xFF);
buf[offset + 1] = (byte) (value & 0xFF);
}
public static void writeUint32BE(byte[] buf, int offset, long value) {
buf[offset] = (byte) ((value >> 24) & 0xFF);
buf[offset + 1] = (byte) ((value >> 16) & 0xFF);
buf[offset + 2] = (byte) ((value >> 8) & 0xFF);
buf[offset + 3] = (byte) (value & 0xFF);
}
}

View File

@@ -0,0 +1,28 @@
package com.lingniu.ingest.codec;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BcdCodecTest {
@Test
void roundTripEvenLength() {
// 偶数位直接等值
byte[] bcd = BcdCodec.encode("1380013800");
assertThat(BcdCodec.decode(bcd)).isEqualTo("1380013800");
}
@Test
void roundTripOddLengthPadsLeadingZero() {
// 奇数位会在高位补 0
byte[] bcd = BcdCodec.encode("13800138000");
assertThat(BcdCodec.decode(bcd)).isEqualTo("013800138000");
}
@Test
void checksumMatches() {
byte[] data = {0x01, 0x02, 0x03, 0x04};
assertThat(BccChecksum.compute(data, 0, data.length)).isEqualTo((byte) 0x04);
}
}

View File

@@ -0,0 +1,62 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>ingest-core</artifactId>
<name>ingest-core</name>
<description>Dispatcher / Interceptor Chain / Disruptor Event Bus / 注解扫描与自动注册。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-codec-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.lmax</groupId>
<artifactId>disruptor</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-circuitbreaker</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,203 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
/**
* {@link AsyncBatch} 注解的执行器:把 Handler 方法从"单条调用"变成"批量调用"。
*
* <p>每个 Handler 方法对应一个 {@link Batcher},背后是一个固定容量的 {@link BlockingQueue}
* 和 {@link AsyncBatch#poolSize()} 条守护虚拟线程。批量触发条件:
* <ul>
* <li>累积 {@link AsyncBatch#size()} 条立即 flush
* <li>从第一条入队起等待 {@link AsyncBatch#waitMs()} 毫秒后强制 flush
* </ul>
*
* <p>目标 Handler 方法签名约定:
* <pre>{@code
* @MessageMapping(...)
* @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
* public List<VehicleEvent> onBatch(List<PayloadType> batch) { ... }
* }</pre>
*
* <p>flush 产出的事件由构造器注入的 {@code eventPublisher} 异步投递(通常是
* {@link DisruptorEventBus#publish}),不阻塞 Netty EventLoop。
*/
public final class AsyncBatchExecutor implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(AsyncBatchExecutor.class);
private final Consumer<VehicleEvent> eventPublisher;
private final ConcurrentMap<Method, Batcher> batchers = new ConcurrentHashMap<>();
public AsyncBatchExecutor(Consumer<VehicleEvent> eventPublisher) {
this.eventPublisher = eventPublisher;
}
/** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */
public void submit(HandlerDefinition def, Object message) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, UnaryOperator.identity(), false));
}
public void submit(HandlerDefinition def, Object message, UnaryOperator<VehicleEvent> eventTransformer) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, eventTransformer == null ? UnaryOperator.identity() : eventTransformer, true));
}
@Override
public void close() {
batchers.values().forEach(Batcher::close);
batchers.clear();
log.info("AsyncBatchExecutor closed");
}
// ===== internals =====
private static final class Batcher implements AutoCloseable {
private final HandlerDefinition def;
private final Consumer<VehicleEvent> publisher;
private final int batchSize;
private final long waitMs;
private final BlockingQueue<BatchItem> queue;
private final Thread[] workers;
private volatile boolean running = true;
Batcher(HandlerDefinition def, Consumer<VehicleEvent> publisher) {
AsyncBatch cfg = def.asyncBatch();
this.def = def;
this.publisher = publisher;
this.batchSize = Math.max(1, cfg.size());
this.waitMs = Math.max(1, cfg.waitMs());
this.queue = new ArrayBlockingQueue<>(Math.max(batchSize * 4, 16));
int pool = Math.max(1, cfg.poolSize());
this.workers = new Thread[pool];
for (int i = 0; i < pool; i++) {
Thread t = Thread.ofVirtual()
.name("batcher-" + def.method().getName() + "-" + i)
.unstarted(this::loop);
workers[i] = t;
t.start();
}
log.info("batcher started method={} size={} waitMs={} pool={}",
def.method().getName(), batchSize, waitMs, pool);
}
void offer(BatchItem item) {
try {
if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) {
log.warn("batcher queue full, dropping item for {}", def.method().getName());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void loop() {
while (running) {
try {
BatchItem first = queue.poll(200, TimeUnit.MILLISECONDS);
if (first == null) continue;
List<BatchItem> buf = new ArrayList<>(batchSize);
buf.add(first);
long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L;
while (buf.size() < batchSize) {
long remain = deadlineNanos - System.nanoTime();
if (remain <= 0) break;
BatchItem next = queue.poll(remain, TimeUnit.NANOSECONDS);
if (next == null) break;
buf.add(next);
}
flush(buf);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
log.error("batcher loop error method={}", def.method().getName(), e);
}
}
}
private void flush(List<BatchItem> buf) {
if (requiresPerItemTransform(buf)) {
for (BatchItem item : buf) {
flushTransformedItem(item);
}
return;
}
publishEvents(invoke(buf.stream().map(BatchItem::message).toList()), UnaryOperator.identity());
}
private boolean requiresPerItemTransform(List<BatchItem> buf) {
for (BatchItem item : buf) {
if (item.transformRequired()) {
return true;
}
}
return false;
}
private void flushTransformedItem(BatchItem item) {
publishEvents(invoke(List.of(item.message())), item.eventTransformer());
}
@SuppressWarnings("unchecked")
private List<VehicleEvent> invoke(List<Object> messages) {
List<VehicleEvent> events;
try {
Object result = def.method().invoke(def.bean(), messages);
events = switch (result) {
case null -> List.of();
case List<?> list -> (List<VehicleEvent>) list;
case VehicleEvent e -> List.of(e);
default -> throw new IllegalStateException(
"@AsyncBatch method must return List<VehicleEvent>: " + def.method());
};
} catch (InvocationTargetException e) {
log.error("batcher invoke failed method={}", def.method().getName(), e.getCause());
return List.of();
} catch (IllegalAccessException e) {
log.error("batcher access denied method={}", def.method().getName(), e);
return List.of();
}
return events;
}
private void publishEvents(List<VehicleEvent> events, UnaryOperator<VehicleEvent> eventTransformer) {
for (VehicleEvent e : events) {
try {
publisher.accept(eventTransformer.apply(e));
} catch (Exception ex) {
log.warn("batcher publish failed eventId={}", e.eventId(), ex);
}
}
}
@Override
public void close() {
running = false;
for (Thread t : workers) t.interrupt();
}
}
private record BatchItem(Object message,
UnaryOperator<VehicleEvent> eventTransformer,
boolean transformRequired) {
}
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
import com.lmax.disruptor.BlockingWaitStrategy;
import com.lmax.disruptor.BusySpinWaitStrategy;
import com.lmax.disruptor.EventHandler;
import com.lmax.disruptor.SleepingWaitStrategy;
import com.lmax.disruptor.WaitStrategy;
import com.lmax.disruptor.YieldingWaitStrategy;
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
/**
* 基于 Disruptor 的事件总线Dispatcher 投递事件 → RingBuffer → Sink 扇出。
*
* <p>使用虚拟线程作为 Sink 消费线程,避免阻塞 IO 占用平台线程。
* 单 VIN 有序性由上游 Netty + 分区投递保证RingBuffer 不做 per-vin 排序。
*/
public final class DisruptorEventBus implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(DisruptorEventBus.class);
private final Disruptor<VehicleEventSlot> disruptor;
private final AtomicLong published = new AtomicLong();
private final AtomicLong failed = new AtomicLong();
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
this.disruptor = new Disruptor<>(
VehicleEventSlot::new,
ringBufferSize,
tf,
ProducerType.MULTI,
waitStrategy(waitStrategyName));
EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
.map(this::toHandler)
.toArray(EventHandler[]::new);
disruptor.handleEventsWith(handlers);
disruptor.start();
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
ringBufferSize, waitStrategyName, sinks.size());
}
public void publish(VehicleEvent event) {
disruptor.getRingBuffer().publishEvent((slot, seq, e) -> slot.event = e, event);
published.incrementAndGet();
}
@Override
public void close() {
disruptor.shutdown();
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
}
private EventHandler<VehicleEventSlot> toHandler(EventSink sink) {
return (slot, seq, endOfBatch) -> {
VehicleEvent e = slot.event;
if (e == null || !sink.accepts(e)) return;
try {
sink.publish(e).exceptionally(ex -> {
failed.incrementAndGet();
log.warn("sink {} publish failed", sink.name(), ex);
return null;
});
} finally {
if (endOfBatch) slot.clear();
}
};
}
private static WaitStrategy waitStrategy(String name) {
return switch (name == null ? "yielding" : name.toLowerCase()) {
case "blocking" -> new BlockingWaitStrategy();
case "sleeping" -> new SleepingWaitStrategy();
case "busy-spin" -> new BusySpinWaitStrategy();
default -> new YieldingWaitStrategy();
};
}
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.event.VehicleEvent;
/** Disruptor RingBuffer 槽位:持有可变引用,避免每次发布都分配新对象。 */
public final class VehicleEventSlot {
public VehicleEvent event;
public void clear() {
this.event = null;
}
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.core.config;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import com.lingniu.ingest.core.pipeline.builtin.DedupInterceptor;
import com.lingniu.ingest.core.pipeline.builtin.RateLimitInterceptor;
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;
import java.util.List;
/**
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
*/
@AutoConfiguration
@EnableConfigurationProperties(IngestCoreProperties.class)
public class IngestCoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public HandlerRegistry handlerRegistry() {
return new HandlerRegistry();
}
@Bean
@ConditionalOnMissingBean
public HandlerInvoker handlerInvoker() {
return new HandlerInvoker();
}
@Bean
public AnnotationHandlerBeanPostProcessor annotationHandlerBeanPostProcessor(HandlerRegistry registry) {
return new AnnotationHandlerBeanPostProcessor(registry);
}
@Bean
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
}
@Bean
public RateLimitInterceptor rateLimitInterceptor(IngestCoreProperties props) {
return new RateLimitInterceptor(props.getRateLimit().getPerVinQps(), props.getRateLimit().getMaxVins());
}
@Bean
@ConditionalOnMissingBean
public InterceptorChain interceptorChain(List<IngestInterceptor> interceptors) {
return new InterceptorChain(interceptors);
}
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public DisruptorEventBus disruptorEventBus(IngestCoreProperties props, List<EventSink> sinks) {
return new DisruptorEventBus(
props.getDisruptor().getRingBufferSize(),
props.getDisruptor().getWaitStrategy(),
sinks);
}
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public AsyncBatchExecutor asyncBatchExecutor(DisruptorEventBus eventBus) {
return new AsyncBatchExecutor(eventBus::publish);
}
@Bean
@ConditionalOnMissingBean
public Dispatcher dispatcher(HandlerRegistry registry,
InterceptorChain chain,
HandlerInvoker invoker,
DisruptorEventBus eventBus,
AsyncBatchExecutor batchExecutor) {
return new Dispatcher(registry, chain, invoker, eventBus, batchExecutor);
}
}

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.core.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.pipeline")
public class IngestCoreProperties {
private Disruptor disruptor = new Disruptor();
private Dedup dedup = new Dedup();
private RateLimit rateLimit = new RateLimit();
public Disruptor getDisruptor() { return disruptor; }
public void setDisruptor(Disruptor disruptor) { this.disruptor = disruptor; }
public Dedup getDedup() { return dedup; }
public void setDedup(Dedup dedup) { this.dedup = dedup; }
public RateLimit getRateLimit() { return rateLimit; }
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
public static class Disruptor {
private int ringBufferSize = 131072;
private String waitStrategy = "yielding";
private String producerType = "multi";
public int getRingBufferSize() { return ringBufferSize; }
public void setRingBufferSize(int ringBufferSize) { this.ringBufferSize = ringBufferSize; }
public String getWaitStrategy() { return waitStrategy; }
public void setWaitStrategy(String waitStrategy) { this.waitStrategy = waitStrategy; }
public String getProducerType() { return producerType; }
public void setProducerType(String producerType) { this.producerType = producerType; }
}
public static class Dedup {
private boolean enabled = true;
private int cacheSize = 200000;
private long ttlSeconds = 600;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getCacheSize() { return cacheSize; }
public void setCacheSize(int cacheSize) { this.cacheSize = cacheSize; }
public long getTtlSeconds() { return ttlSeconds; }
public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
}
public static class RateLimit {
private int perVinQps = 50;
private int maxVins = 100000;
public int getPerVinQps() { return perVinQps; }
public void setPerVinQps(int perVinQps) { this.perVinQps = perVinQps; }
public int getMaxVins() { return maxVins; }
public void setMaxVins(int maxVins) { this.maxVins = maxVins; }
}
}

View File

@@ -0,0 +1,64 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.IdempotentKey;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.annotation.ProtocolHandler;
import com.lingniu.ingest.api.annotation.RateLimited;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotatedElementUtils;
import java.lang.reflect.Method;
/**
* Spring BeanPostProcessor扫描带 {@link ProtocolHandler} 注解的 Bean
* 把每个带 {@link MessageMapping} 的方法注册到 {@link HandlerRegistry}。
*
* <p>替代旧代码里遍布的 {@code if (msgId == 0x0100) ... else if (msgId == 0x0102) ...} 风格。
*/
public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerBeanPostProcessor.class);
private final HandlerRegistry registry;
public AnnotationHandlerBeanPostProcessor(HandlerRegistry registry) {
this.registry = registry;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Class<?> type = bean.getClass();
ProtocolHandler classAnno = AnnotatedElementUtils.findMergedAnnotation(type, ProtocolHandler.class);
if (classAnno == null) return bean;
for (Method method : type.getMethods()) {
MessageMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, MessageMapping.class);
if (mapping == null) continue;
Class<?> paramType = method.getParameterCount() > 0 ? method.getParameterTypes()[0] : Object.class;
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);
for (int cmd : commands) {
for (int info : infoTypes) {
HandlerDefinition def = new HandlerDefinition(
classAnno.protocol(), cmd, info, mapping.desc(),
bean, method, paramType, rl, ik, ab);
registry.register(def);
log.info("registered handler {} protocol={} command=0x{} info=0x{}",
type.getSimpleName() + "#" + method.getName(),
classAnno.protocol(), Integer.toHexString(cmd), Integer.toHexString(info));
}
}
}
return bean;
}
}

View File

@@ -0,0 +1,183 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.event.RawArchiveKeys;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
/**
* 核心分发器RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。
*
* <p>所有 Inbound AdapterNetty / MQTT / PushClient都调用 {@link #dispatch(RawFrame)}
* 协议差异在这里被彻底抹平。
*
* <p>对于带 {@code @AsyncBatch} 的 HandlerDispatcher 不直接反射调用,而是把消息交给
* {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。
*/
public final class Dispatcher {
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
private final HandlerRegistry registry;
private final InterceptorChain interceptors;
private final HandlerInvoker invoker;
private final DisruptorEventBus eventBus;
private final AsyncBatchExecutor batchExecutor;
public Dispatcher(HandlerRegistry registry,
InterceptorChain interceptors,
HandlerInvoker invoker,
DisruptorEventBus eventBus,
AsyncBatchExecutor batchExecutor) {
this.registry = registry;
this.interceptors = interceptors;
this.invoker = invoker;
this.eventBus = eventBus;
this.batchExecutor = batchExecutor;
}
public void dispatch(RawFrame frame) {
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
try {
// 在 interceptor 之前发 RawArchive保证原始字节被无条件落盘dedup/rate-limit
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 ArchiveEventSink 消费。
RawArchiveLookup rawArchive = emitRawArchive(frame, ctx);
if (!interceptors.before(frame, ctx)) {
log.debug("frame aborted: {}", ctx.abortReason());
return;
}
List<HandlerDefinition> handlers = registry.resolve(
frame.protocolId(), frame.command(), frame.infoType());
if (handlers.isEmpty()) {
log.debug("no handler for {} cmd=0x{} info=0x{}",
frame.protocolId(), Integer.toHexString(frame.command()),
Integer.toHexString(frame.infoType()));
return;
}
for (HandlerDefinition def : handlers) {
if (def.asyncBatch() != null) {
batchExecutor.submit(def, frame.payload(), event -> enrichWithRawArchive(event, rawArchive));
continue;
}
List<VehicleEvent> events = invoker.invoke(def, frame, ctx);
for (VehicleEvent e : events) {
e = enrichWithRawArchive(e, rawArchive);
interceptors.after(e, ctx);
eventBus.publish(e);
}
}
} catch (Throwable t) {
log.error("dispatch failure traceId={}", ctx.traceId(), t);
interceptors.onError(t, ctx);
}
}
/**
* 从 {@link RawFrame} 构造一条 {@link VehicleEvent.RawArchive} 发到 EventBus。
* 仅在 {@code rawBytes} 非空时发——有些入站适配器(未来)可能只传解析后的对象。
*
* <p>VIN 取自 sourceMeta 里的 {@code vin} key由各入站适配器负责填充缺失时
* 留空字符串archive sink 会用 "unknown-vin" 占位保证 key 可解析。
*/
private RawArchiveLookup emitRawArchive(RawFrame frame, IngestContext ctx) {
byte[] bytes = frame.rawBytes();
if (bytes == null || bytes.length == 0) return RawArchiveLookup.empty();
Map<String, String> meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta();
String vin = meta.getOrDefault("vin", "");
Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now();
String eventId = nextRawArchiveEventId(ingestTime);
String key = RawArchiveKeys.key(ingestTime, frame.protocolId(), vin, eventId);
Map<String, String> archiveMeta = addRawArchiveMetadata(meta, eventId, key);
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
eventId,
vin,
frame.protocolId(),
ingestTime,
ingestTime,
ctx.traceId(),
archiveMeta,
frame.command(),
frame.infoType(),
bytes);
eventBus.publish(raw);
return new RawArchiveLookup(eventId, key, RawArchiveKeys.logicalUri(key));
}
private static String nextRawArchiveEventId(Instant ingestTime) {
long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L;
long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base));
return Long.toString(next);
}
private static Map<String, String> addRawArchiveMetadata(Map<String, String> metadata,
String eventId,
String key) {
Map<String, String> out = new LinkedHashMap<>(metadata == null ? Map.of() : metadata);
out.putIfAbsent(RawArchiveKeys.META_EVENT_ID, eventId);
out.putIfAbsent(RawArchiveKeys.META_KEY, key);
out.putIfAbsent(RawArchiveKeys.META_URI, RawArchiveKeys.logicalUri(key));
return Map.copyOf(out);
}
private static VehicleEvent enrichWithRawArchive(VehicleEvent event, RawArchiveLookup rawArchive) {
if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) {
return event;
}
Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key());
return switch (event) {
case VehicleEvent.Realtime e -> new VehicleEvent.Realtime(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.payload());
case VehicleEvent.Location e -> new VehicleEvent.Location(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.payload());
case VehicleEvent.Alarm e -> new VehicleEvent.Alarm(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.payload());
case VehicleEvent.Login e -> new VehicleEvent.Login(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.iccid(), e.protocolVersion());
case VehicleEvent.Logout e -> new VehicleEvent.Logout(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata);
case VehicleEvent.Heartbeat e -> new VehicleEvent.Heartbeat(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata);
case VehicleEvent.MediaMeta e -> new VehicleEvent.MediaMeta(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.mediaId(), e.mediaType(), e.sizeBytes(), e.archiveRef());
case VehicleEvent.Passthrough e -> new VehicleEvent.Passthrough(
e.eventId(), e.vin(), e.source(), e.eventTime(), e.ingestTime(),
e.traceId(), metadata, e.passthroughType(), e.data());
case VehicleEvent.RawArchive e -> e;
};
}
private record RawArchiveLookup(String eventId, String key, String uri) {
private static RawArchiveLookup empty() {
return new RawArchiveLookup("", "", "");
}
private boolean isEmpty() {
return key == null || key.isBlank();
}
}
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.IdempotentKey;
import com.lingniu.ingest.api.annotation.RateLimited;
import java.lang.reflect.Method;
/**
* 一个被注解扫描到的 Handler 方法的静态描述。不可变。
*/
public record HandlerDefinition(
ProtocolId protocol,
int command,
int infoType,
String desc,
Object bean,
Method method,
Class<?> parameterType,
RateLimited rateLimited,
IdempotentKey idempotentKey,
AsyncBatch asyncBatch
) {
public boolean matches(ProtocolId p, int cmd, int info) {
if (p != protocol) return false;
if (command != 0 && command != cmd) return false;
if (infoType != 0 && infoType != info) return false;
return true;
}
}

View File

@@ -0,0 +1,33 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.List;
/**
* 反射调用 Handler。单独抽出是为了后续可插拔未来可替换为 MethodHandle 或字节码生成。
*/
public class HandlerInvoker {
@SuppressWarnings("unchecked")
public List<VehicleEvent> invoke(HandlerDefinition def, RawFrame frame, IngestContext ctx) {
try {
Object result = def.method().invoke(def.bean(), frame.payload());
return switch (result) {
case null -> Collections.emptyList();
case VehicleEvent e -> List.of(e);
case List<?> list -> (List<VehicleEvent>) list;
default -> throw new IllegalStateException(
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());
};
} catch (InvocationTargetException e) {
throw new RuntimeException("Handler threw exception: " + def.method(), e.getCause());
} catch (IllegalAccessException e) {
throw new RuntimeException("Handler not accessible: " + def.method(), e);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Handler 注册中心。按 {@code (ProtocolId, command)} 索引O(1) 查找;同 key 可注册多个 Handler
* {@link HandlerDefinition#matches} 进一步精确匹配 {@code infoType}。
*/
public final class HandlerRegistry {
private final Map<RoutingKey, List<HandlerDefinition>> byRoute = new ConcurrentHashMap<>();
private final List<HandlerDefinition> wildcardHandlers = Collections.synchronizedList(new ArrayList<>());
public void register(HandlerDefinition def) {
if (def.command() == 0) {
wildcardHandlers.add(def);
return;
}
byRoute.computeIfAbsent(new RoutingKey(def.protocol(), def.command()),
k -> Collections.synchronizedList(new ArrayList<>())).add(def);
}
public List<HandlerDefinition> resolve(ProtocolId protocol, int command, int infoType) {
List<HandlerDefinition> exact = byRoute.get(new RoutingKey(protocol, command));
List<HandlerDefinition> result = new ArrayList<>();
if (exact != null) {
for (HandlerDefinition d : exact) {
if (d.matches(protocol, command, infoType)) result.add(d);
}
}
if (!result.isEmpty()) {
return result;
}
for (HandlerDefinition d : wildcardHandlers) {
if (d.matches(protocol, command, infoType)) result.add(d);
}
return result;
}
public int size() {
return byRoute.values().stream().mapToInt(List::size).sum() + wildcardHandlers.size();
}
private record RoutingKey(ProtocolId protocol, int command) {}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.core.pipeline;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import java.util.ArrayList;
import java.util.List;
/**
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
*/
public final class InterceptorChain {
private final List<IngestInterceptor> interceptors;
public InterceptorChain(List<IngestInterceptor> interceptors) {
List<IngestInterceptor> sorted = new ArrayList<>(interceptors);
AnnotationAwareOrderComparator.sort(sorted);
this.interceptors = List.copyOf(sorted);
}
public boolean before(RawFrame frame, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
if (!i.before(frame, ctx) || ctx.aborted()) {
return false;
}
}
return true;
}
public void after(VehicleEvent event, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
i.after(event, ctx);
}
}
public void onError(Throwable error, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
i.onError(error, ctx);
}
}
}

View File

@@ -0,0 +1,57 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.Ordered;
import java.util.Arrays;
import java.time.Duration;
/**
* 基于 Caffeine 的本地幂等去重。
*
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性,
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。
*/
public class DedupInterceptor implements IngestInterceptor, Ordered {
private final Cache<String, Boolean> seen;
public DedupInterceptor(int maxSize, long ttlSeconds) {
this.seen = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(Duration.ofSeconds(ttlSeconds))
.build();
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
String seq = frame.sourceMeta().get("seq");
if (seq == null || seq.isBlank()) {
seq = rawFingerprint(frame);
}
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
ctx.abort("duplicate:" + key);
return false;
}
return true;
}
@Override
public int getOrder() {
return 100;
}
private static String rawFingerprint(RawFrame frame) {
byte[] rawBytes = frame.rawBytes();
if (rawBytes != null && rawBytes.length > 0) {
return "raw:" + Integer.toHexString(Arrays.hashCode(rawBytes));
}
return "receivedAt:" + frame.receivedAt();
}
}

View File

@@ -0,0 +1,49 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.core.Ordered;
import java.time.Duration;
/**
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter由 Caffeine 按 LRU 管理。
*/
public class RateLimitInterceptor implements IngestInterceptor, Ordered {
private final Cache<String, RateLimiter> limiters;
private final RateLimiterConfig defaultConfig;
public RateLimitInterceptor(int perVinQps, int maxVins) {
this.defaultConfig = RateLimiterConfig.custom()
.limitForPeriod(perVinQps)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ZERO)
.build();
this.limiters = Caffeine.newBuilder()
.maximumSize(maxVins)
.expireAfterAccess(Duration.ofMinutes(10))
.build();
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
if (!rl.acquirePermission()) {
ctx.abort("rate-limited:" + vin);
return false;
}
return true;
}
@Override
public int getOrder() {
return 200;
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.core.config.IngestCoreAutoConfiguration

View File

@@ -0,0 +1,218 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.annotation.ProtocolHandler;
import com.lingniu.ingest.api.event.LocationPayload;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import static org.assertj.core.api.Assertions.assertThat;
class DispatcherRawArchiveMetadataTest {
@Test
void enrichesBusinessEventsWithRawArchiveLookupMetadata() throws Exception {
CollectingSink sink = new CollectingSink();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
try {
Dispatcher dispatcher = new Dispatcher(
registryWithLocationHandler(),
new InterceptorChain(List.of()),
new HandlerInvoker(),
eventBus,
batchExecutor);
dispatcher.dispatch(new RawFrame(
ProtocolId.JT808,
0x0200,
0,
new Object(),
new byte[]{0x7e, 0x01, 0x7e},
Map.of("vin", "VIN001", "peer", "127.0.0.1:10001"),
Instant.parse("2026-06-22T08:00:00Z")));
VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class);
VehicleEvent.Location location = awaitEvent(sink, VehicleEvent.Location.class);
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
assertThat(rawArchiveKey).isNotBlank();
assertThat(raw.eventId()).containsOnlyDigits();
assertThat(rawArchiveKey).endsWith("/" + raw.eventId() + ".bin");
assertThat(location.metadata())
.containsEntry("rawArchiveEventId", raw.eventId())
.containsEntry("rawArchiveKey", rawArchiveKey)
.containsEntry("rawArchiveUri", "archive://" + rawArchiveKey);
} finally {
batchExecutor.close();
eventBus.close();
}
}
@Test
void enrichesAsyncBatchEventsWithTheirRawArchiveLookupMetadata() throws Exception {
CollectingSink sink = new CollectingSink();
DisruptorEventBus eventBus = new DisruptorEventBus(1024, "blocking", List.of(sink));
AsyncBatchExecutor batchExecutor = new AsyncBatchExecutor(eventBus::publish);
try {
Dispatcher dispatcher = new Dispatcher(
registryWithAsyncLocationHandler(),
new InterceptorChain(List.of()),
new HandlerInvoker(),
eventBus,
batchExecutor);
dispatcher.dispatch(new RawFrame(
ProtocolId.JT808,
0x0201,
0,
"payload-1",
new byte[]{0x7e, 0x02, 0x7e},
Map.of("vin", "VIN002"),
Instant.parse("2026-06-22T08:01:00Z")));
VehicleEvent.RawArchive raw = awaitEvent(sink, VehicleEvent.RawArchive.class);
VehicleEvent.Location location = awaitLocationEvent(sink, "async-location-1");
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
assertThat(location.metadata())
.containsEntry("rawArchiveEventId", raw.eventId())
.containsEntry("rawArchiveKey", rawArchiveKey)
.containsEntry("rawArchiveUri", "archive://" + rawArchiveKey);
} finally {
batchExecutor.close();
eventBus.close();
}
}
private static HandlerRegistry registryWithLocationHandler() throws NoSuchMethodException {
LocationHandler bean = new LocationHandler();
Method method = LocationHandler.class.getDeclaredMethod("onLocation", Object.class);
HandlerRegistry registry = new HandlerRegistry();
registry.register(new HandlerDefinition(
ProtocolId.JT808,
0x0200,
0,
"test-location",
bean,
method,
Object.class,
null,
null,
null));
return registry;
}
private static HandlerRegistry registryWithAsyncLocationHandler() throws NoSuchMethodException {
AsyncLocationHandler bean = new AsyncLocationHandler();
Method method = AsyncLocationHandler.class.getDeclaredMethod("onLocationBatch", List.class);
HandlerRegistry registry = new HandlerRegistry();
registry.register(new HandlerDefinition(
ProtocolId.JT808,
0x0201,
0,
"test-async-location",
bean,
method,
Object.class,
null,
null,
method.getAnnotation(AsyncBatch.class)));
return registry;
}
private static <T extends VehicleEvent> T awaitEvent(CollectingSink sink, Class<T> type) throws InterruptedException {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
synchronized (sink.events) {
for (VehicleEvent event : sink.events) {
if (type.isInstance(event)) {
return type.cast(event);
}
}
}
Thread.sleep(25);
}
throw new AssertionError("event not published: " + type.getSimpleName() + ", actual=" + sink.events);
}
private static VehicleEvent.Location awaitLocationEvent(CollectingSink sink, String eventId) throws InterruptedException {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
synchronized (sink.events) {
for (VehicleEvent event : sink.events) {
if (event instanceof VehicleEvent.Location location && location.eventId().equals(eventId)) {
return location;
}
}
}
Thread.sleep(25);
}
throw new AssertionError("location event not published: " + eventId + ", actual=" + sink.events);
}
@ProtocolHandler(protocol = ProtocolId.JT808)
static final class LocationHandler {
@MessageMapping(command = 0x0200)
VehicleEvent.Location onLocation(Object ignored) {
return new VehicleEvent.Location(
"location-1",
"VIN001",
ProtocolId.JT808,
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"trace-location",
Map.of(),
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1));
}
}
@ProtocolHandler(protocol = ProtocolId.JT808)
public static final class AsyncLocationHandler {
@MessageMapping(command = 0x0201)
@AsyncBatch(size = 2, waitMs = 10, poolSize = 1)
public List<VehicleEvent> onLocationBatch(List<Object> batch) {
return List.of(new VehicleEvent.Location(
"async-location-1",
"VIN002",
ProtocolId.JT808,
Instant.parse("2026-06-22T08:01:00Z"),
Instant.parse("2026-06-22T08:01:01Z"),
"trace-async-location",
Map.of(),
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1)));
}
}
private static final class CollectingSink implements EventSink {
private final List<VehicleEvent> events = new ArrayList<>();
@Override
public String name() {
return "collecting";
}
@Override
public CompletableFuture<Void> publish(VehicleEvent event) {
synchronized (events) {
events.add(event);
}
return CompletableFuture.completedFuture(null);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class HandlerRegistryTest {
@Test
void exactCommandMatchTakesPrecedenceOverWildcardHandlers() throws Exception {
HandlerRegistry registry = new HandlerRegistry();
Object bean = new SampleHandlers();
Method exact = SampleHandlers.class.getMethod("exact", Object.class);
Method wildcard = SampleHandlers.class.getMethod("wildcard", Object.class);
HandlerDefinition exactDef = definition(0x0200, "exact", bean, exact);
HandlerDefinition wildcardDef = definition(0, "wildcard", bean, wildcard);
registry.register(exactDef);
registry.register(wildcardDef);
assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0200, 0))
.containsExactly(exactDef);
assertThat(registry.resolve(ProtocolId.XINDA_PUSH, 0x0500, 0))
.containsExactly(wildcardDef);
}
private static HandlerDefinition definition(int command, String desc, Object bean, Method method) {
return new HandlerDefinition(
ProtocolId.XINDA_PUSH,
command,
0,
desc,
bean,
method,
Object.class,
null,
null,
null);
}
public static final class SampleHandlers {
public void exact(Object ignored) {
}
public void wildcard(Object ignored) {
}
}
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DedupInterceptorTest {
@Test
void usesRawFingerprintWhenSequenceIsMissing() {
DedupInterceptor interceptor = new DedupInterceptor(100, 60);
IngestContext ctx = new IngestContext("trace-1");
RawFrame first = frame(new byte[]{0x23, 0x23, 0x02, 0x01});
RawFrame differentPayload = frame(new byte[]{0x23, 0x23, 0x02, 0x02});
RawFrame duplicate = frame(new byte[]{0x23, 0x23, 0x02, 0x01});
assertThat(interceptor.before(first, ctx)).isTrue();
assertThat(interceptor.before(differentPayload, ctx)).isTrue();
assertThat(interceptor.before(duplicate, ctx)).isFalse();
assertThat(ctx.aborted()).isTrue();
}
private static RawFrame frame(byte[] rawBytes) {
return new RawFrame(
ProtocolId.GB32960,
0x02,
0,
new Object(),
rawBytes,
Map.of("vin", "TESTSTATVIN000001"),
Instant.parse("2026-06-22T13:00:00Z"));
}
}

View File

@@ -0,0 +1,32 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>observability</artifactId>
<name>observability</name>
<description>Micrometer + Prometheus + 业务指标封装事件计数、Dispatcher 耗时、DLQ 计数等)。</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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,84 @@
package com.lingniu.ingest.observability;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import org.springframework.core.Ordered;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 指标拦截器:自动以 {@link IngestInterceptor} 形式接入拦截链,无侵入采集核心流量指标。
*
* <p>暴露指标:
* <ul>
* <li>{@code ingest_frames_total{protocol}} - 入站帧计数
* <li>{@code ingest_events_total{protocol,type}} - 事件产出计数
* <li>{@code ingest_errors_total{protocol}} - 异常计数
* <li>{@code ingest_frame_duration_seconds{protocol}} - 单帧处理耗时
* </ul>
*/
public class IngestMetrics implements IngestInterceptor, Ordered {
private static final String ATTR_TIMER_SAMPLE = "metrics.timer.sample";
private final MeterRegistry registry;
private final ConcurrentMap<ProtocolId, Counter> frameCounters = new ConcurrentHashMap<>();
private final ConcurrentMap<ProtocolId, Counter> errorCounters = new ConcurrentHashMap<>();
private final ConcurrentMap<ProtocolId, Timer> frameTimers = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Counter> eventCounters = new ConcurrentHashMap<>();
public IngestMetrics(MeterRegistry registry) {
this.registry = registry;
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
ProtocolId p = frame.protocolId();
frameCounters.computeIfAbsent(p, k ->
Counter.builder("ingest_frames_total").tag("protocol", k.name()).register(registry)).increment();
Timer.Sample sample = Timer.start(registry);
ctx.attr(ATTR_TIMER_SAMPLE, sample);
return true;
}
@Override
public void after(VehicleEvent event, IngestContext ctx) {
String key = event.source().name() + ":" + event.getClass().getSimpleName();
eventCounters.computeIfAbsent(key, k -> {
String[] parts = k.split(":");
return Counter.builder("ingest_events_total")
.tags(Tags.of("protocol", parts[0], "type", parts[1]))
.register(registry);
}).increment();
Timer.Sample s = ctx.attr(ATTR_TIMER_SAMPLE);
if (s != null) {
Timer t = frameTimers.computeIfAbsent(event.source(), p ->
Timer.builder("ingest_frame_duration_seconds").tag("protocol", p.name()).register(registry));
s.stop(t);
ctx.attr(ATTR_TIMER_SAMPLE, null);
}
}
@Override
public void onError(Throwable error, IngestContext ctx) {
// 无协议上下文时归到 UNKNOWN
Counter c = errorCounters.computeIfAbsent(
ProtocolId.GB32960, // 默认桶:由上层拦截器设置具体协议更精确
p -> Counter.builder("ingest_errors_total").tag("protocol", "unknown").register(registry));
c.increment();
}
@Override
public int getOrder() {
return 10; // 尽早介入,晚于 Tracingorder=0
}
}

View File

@@ -0,0 +1,18 @@
package com.lingniu.ingest.observability;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
@ConditionalOnBean(MeterRegistry.class)
public class ObservabilityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public IngestMetrics ingestMetrics(MeterRegistry registry) {
return new IngestMetrics(registry);
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.observability.ObservabilityAutoConfiguration

View File

@@ -0,0 +1,56 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>session-core</artifactId>
<name>session-core</name>
<description>设备会话 + 鉴权 + Token + Redis 兜底。</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-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</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>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,23 @@
package com.lingniu.ingest.session;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* 下行命令调度器 SPI。
*
* <p>用于 command-gateway 把 HTTP 请求转为设备命令,等待设备应答。真正的实现需要与协议
* 模块(如 jt808的 Netty 通道绑定:按 sessionId 找到 Channel → 发送命令字节 → 用
* {@code CompletableFuture + 序列号 map} 实现同步请求/应答。
*
* <p>当前阶段提供接口 + 占位实现,具体 Netty 绑定在 command-gateway / protocol-jt808
* 的下一批次落地。
*/
public interface CommandDispatcher {
/** 异步下发命令,不等应答。 */
CompletableFuture<Void> notify(String sessionId, Object command);
/** 同步下发命令并等待应答。 */
<T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout);
}

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.session;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 设备会话领域模型。不可变;更新通过 {@link SessionStore#update} 原子替换。
*
* @param sessionId 会话 ID通常由 Netty channel id + 终端号生成)
* @param protocol 协议 ID
* @param vin 车架号(注册/鉴权后补齐,注册前为 null
* @param phone 终端手机号 / 设备号
* @param token 鉴权 Token平台下发
* @param peerAddress 对端 IP:port
* @param registeredAt 注册时间
* @param lastSeenAt 最后活跃时间
* @param attributes 协议特定属性(协议版本、型号等)
*/
public record DeviceSession(
String sessionId,
ProtocolId protocol,
String vin,
String phone,
String token,
String peerAddress,
Instant registeredAt,
Instant lastSeenAt,
Map<String, String> attributes
) {
public DeviceSession touch(Instant now) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, now, attributes);
}
public DeviceSession withVin(String vin) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, lastSeenAt, attributes);
}
public DeviceSession withToken(String token) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, lastSeenAt, attributes);
}
}

View File

@@ -0,0 +1,82 @@
package com.lingniu.ingest.session;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.UnaryOperator;
/**
* 纯内存会话存储。支持按 sessionId / vin / phone 三种 key 查询。
*
* <p>失活清理:基于 {@link Caffeine} 的 {@code expireAfterAccess 30 分钟}。
* 生产环境若需多节点一致性,可用 Redis 实现替换此 Bean。
*/
public final class InMemorySessionStore implements SessionStore {
private final Cache<String, DeviceSession> byId;
private final ConcurrentMap<String, String> vinToId = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> phoneToId = new ConcurrentHashMap<>();
public InMemorySessionStore() {
this.byId = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(30))
.removalListener((String sid, DeviceSession s, com.github.benmanes.caffeine.cache.RemovalCause c) -> {
if (s == null) return;
if (s.vin() != null) vinToId.remove(s.vin(), sid);
if (s.phone() != null) phoneToId.remove(s.phone(), sid);
})
.build();
}
@Override
public void put(DeviceSession session) {
byId.put(session.sessionId(), session);
if (session.vin() != null) vinToId.put(session.vin(), session.sessionId());
if (session.phone() != null) phoneToId.put(session.phone(), session.sessionId());
}
@Override
public Optional<DeviceSession> findBySessionId(String sessionId) {
return Optional.ofNullable(byId.getIfPresent(sessionId));
}
@Override
public Optional<DeviceSession> findByVin(String vin) {
String sid = vinToId.get(vin);
return sid == null ? Optional.empty() : findBySessionId(sid);
}
@Override
public Optional<DeviceSession> findByPhone(String phone) {
String sid = phoneToId.get(phone);
return sid == null ? Optional.empty() : findBySessionId(sid);
}
@Override
public synchronized Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater) {
DeviceSession old = byId.getIfPresent(sessionId);
if (old == null) return Optional.empty();
DeviceSession next = updater.apply(old);
put(next);
return Optional.of(next);
}
@Override
public void remove(String sessionId) {
DeviceSession s = byId.getIfPresent(sessionId);
if (s != null) {
byId.invalidate(sessionId);
if (s.vin() != null) vinToId.remove(s.vin(), sessionId);
if (s.phone() != null) phoneToId.remove(s.phone(), sessionId);
}
}
@Override
public int size() {
return (int) byId.estimatedSize();
}
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* 占位命令调度器:返回 {@link UnsupportedOperationException}
* 待 command-gateway 与协议模块的下行通道打通后替换为真实实现。
*/
public final class NoopCommandDispatcher implements CommandDispatcher {
private static final Logger log = LoggerFactory.getLogger(NoopCommandDispatcher.class);
@Override
public CompletableFuture<Void> notify(String sessionId, Object command) {
log.warn("[noop] notify sessionId={} command={} dropped", sessionId, command);
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"CommandDispatcher not yet implemented; configure a real one in protocol module."));
}
@Override
public <T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout) {
log.warn("[noop] request sessionId={} command={} responseType={} dropped",
sessionId, command, responseType.getSimpleName());
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"CommandDispatcher not yet implemented; configure a real one in protocol module."));
}
}

View File

@@ -0,0 +1,187 @@
package com.lingniu.ingest.session;
import com.lingniu.ingest.api.ProtocolId;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.UnaryOperator;
/**
* Redis-backed session store for multi-instance command routing metadata.
*
* <p>The Netty Channel itself stays local to the protocol instance. Redis keeps
* the stable terminal indexes so HTTP callers can resolve sessionId / VIN /
* phone consistently across restarts and replicas.
*/
public final class RedisSessionStore implements SessionStore {
private static final String PREFIX = "vehicle:session:";
private static final String ID_SET_KEY = PREFIX + "index:ids";
private final StringRedisTemplate redis;
private final Duration ttl;
public RedisSessionStore(StringRedisTemplate redis, Duration ttl) {
this.redis = redis;
this.ttl = ttl;
}
@Override
public void put(DeviceSession session) {
findBySessionId(session.sessionId()).ifPresent(old -> deleteIndexes(old));
redis.opsForHash().putAll(sessionKey(session.sessionId()), toHash(session));
redis.expire(sessionKey(session.sessionId()), ttl);
if (hasText(session.vin())) {
redis.opsForValue().set(vinIndexKey(session.vin()), session.sessionId(), ttl);
}
if (hasText(session.phone())) {
redis.opsForValue().set(phoneIndexKey(session.phone()), session.sessionId(), ttl);
}
redis.opsForSet().add(ID_SET_KEY, session.sessionId());
redis.expire(ID_SET_KEY, ttl);
}
@Override
public Optional<DeviceSession> findBySessionId(String sessionId) {
if (!hasText(sessionId)) {
return Optional.empty();
}
Map<Object, Object> values = redis.opsForHash().entries(sessionKey(sessionId));
if (values == null || values.isEmpty()) {
return Optional.empty();
}
redis.expire(sessionKey(sessionId), ttl);
return Optional.of(fromHash(values));
}
@Override
public Optional<DeviceSession> findByVin(String vin) {
if (!hasText(vin)) {
return Optional.empty();
}
String sessionId = redis.opsForValue().get(vinIndexKey(vin));
Optional<DeviceSession> session = findBySessionId(sessionId);
if (session.isEmpty() && hasText(sessionId)) {
redis.delete(vinIndexKey(vin));
}
return session;
}
@Override
public Optional<DeviceSession> findByPhone(String phone) {
if (!hasText(phone)) {
return Optional.empty();
}
String sessionId = redis.opsForValue().get(phoneIndexKey(phone));
Optional<DeviceSession> session = findBySessionId(sessionId);
if (session.isEmpty() && hasText(sessionId)) {
redis.delete(phoneIndexKey(phone));
}
return session;
}
@Override
public synchronized Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater) {
Optional<DeviceSession> current = findBySessionId(sessionId);
if (current.isEmpty()) {
return Optional.empty();
}
DeviceSession next = updater.apply(current.orElseThrow());
put(next);
return Optional.of(next);
}
@Override
public void remove(String sessionId) {
Optional<DeviceSession> current = findBySessionId(sessionId);
redis.delete(sessionKey(sessionId));
current.ifPresent(this::deleteIndexes);
redis.opsForSet().remove(ID_SET_KEY, sessionId);
}
@Override
public int size() {
Long size = redis.opsForSet().size(ID_SET_KEY);
if (size == null || size <= 0) {
return 0;
}
return size > Integer.MAX_VALUE ? Integer.MAX_VALUE : size.intValue();
}
private void deleteIndexes(DeviceSession session) {
if (hasText(session.vin())) {
redis.delete(vinIndexKey(session.vin()));
}
if (hasText(session.phone())) {
redis.delete(phoneIndexKey(session.phone()));
}
}
private static Map<String, String> toHash(DeviceSession session) {
Map<String, String> values = new LinkedHashMap<>();
put(values, "sessionId", session.sessionId());
put(values, "protocol", session.protocol() == null ? null : session.protocol().name());
put(values, "vin", session.vin());
put(values, "phone", session.phone());
put(values, "token", session.token());
put(values, "peerAddress", session.peerAddress());
put(values, "registeredAt", session.registeredAt() == null ? null : session.registeredAt().toString());
put(values, "lastSeenAt", session.lastSeenAt() == null ? null : session.lastSeenAt().toString());
if (session.attributes() != null) {
session.attributes().forEach((key, value) -> put(values, "attr." + key, value));
}
return values;
}
private static DeviceSession fromHash(Map<Object, Object> values) {
Map<String, String> attributes = new LinkedHashMap<>();
values.forEach((key, value) -> {
String k = String.valueOf(key);
if (k.startsWith("attr.") && value != null) {
attributes.put(k.substring("attr.".length()), String.valueOf(value));
}
});
return new DeviceSession(
value(values, "sessionId"),
ProtocolId.valueOf(value(values, "protocol")),
value(values, "vin"),
value(values, "phone"),
value(values, "token"),
value(values, "peerAddress"),
Instant.parse(value(values, "registeredAt")),
Instant.parse(value(values, "lastSeenAt")),
Map.copyOf(attributes));
}
private static void put(Map<String, String> values, String key, String value) {
if (value != null) {
values.put(key, value);
}
}
private static String value(Map<Object, Object> values, String key) {
Object value = values.get(key);
return value == null ? null : String.valueOf(value);
}
private static String sessionKey(String sessionId) {
return PREFIX + sessionId;
}
private static String vinIndexKey(String vin) {
return PREFIX + "index:vin:" + vin;
}
private static String phoneIndexKey(String phone) {
return PREFIX + "index:phone:" + phone;
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.session;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "lingniu.ingest.session")
public class SessionProperties {
private Store store = Store.MEMORY;
private Duration ttl = Duration.ofMinutes(30);
public Store getStore() {
return store;
}
public void setStore(Store store) {
this.store = store;
}
public Duration getTtl() {
return ttl;
}
public void setTtl(Duration ttl) {
this.ttl = ttl;
}
public enum Store {
MEMORY,
REDIS
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.session;
import java.util.Optional;
import java.util.function.UnaryOperator;
/**
* 设备会话存储 SPI。默认实现是内存 + Caffeine生产可替换为 Redis 兜底。
*/
public interface SessionStore {
void put(DeviceSession session);
Optional<DeviceSession> findBySessionId(String sessionId);
Optional<DeviceSession> findByVin(String vin);
Optional<DeviceSession> findByPhone(String phone);
/** 原子更新:读 → 修改 → 写。返回更新后的会话;若原会话不存在返回 empty。 */
Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater);
void remove(String sessionId);
int size();
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.session.config;
import com.lingniu.ingest.session.CommandDispatcher;
import com.lingniu.ingest.session.InMemorySessionStore;
import com.lingniu.ingest.session.NoopCommandDispatcher;
import com.lingniu.ingest.session.RedisSessionStore;
import com.lingniu.ingest.session.SessionProperties;
import com.lingniu.ingest.session.SessionStore;
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;
import org.springframework.data.redis.core.StringRedisTemplate;
@AutoConfiguration
@EnableConfigurationProperties(SessionProperties.class)
public class SessionCoreAutoConfiguration {
@Bean
@ConditionalOnBean(StringRedisTemplate.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis")
@ConditionalOnMissingBean
public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) {
return new RedisSessionStore(redis, properties.getTtl());
}
@Bean
@ConditionalOnMissingBean
public SessionStore sessionStore() {
return new InMemorySessionStore();
}
@Bean
@ConditionalOnMissingBean
public CommandDispatcher commandDispatcher() {
return new NoopCommandDispatcher();
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.session.config.SessionCoreAutoConfiguration

View File

@@ -0,0 +1,137 @@
package com.lingniu.ingest.session;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RedisSessionStoreTest {
@Test
void putStoresSessionHashAndThreeIndexesWithTtl() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(45));
DeviceSession session = sampleSession("sid-1", "VIN001", "13800138000");
store.put(session);
verify(fixture.hashOps).putAll(eq("vehicle:session:sid-1"), any(Map.class));
verify(fixture.valueOps).set("vehicle:session:index:vin:VIN001", "sid-1", Duration.ofMinutes(45));
verify(fixture.valueOps).set("vehicle:session:index:phone:13800138000", "sid-1", Duration.ofMinutes(45));
verify(fixture.setOps).add("vehicle:session:index:ids", "sid-1");
verify(fixture.redis).expire("vehicle:session:sid-1", Duration.ofMinutes(45));
verify(fixture.redis).expire("vehicle:session:index:ids", Duration.ofMinutes(45));
}
@Test
void findsBySessionIdVinAndPhoneFromRedisIndexes() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30));
Map<Object, Object> stored = storedSession("sid-1", "VIN001", "13800138000");
when(fixture.hashOps.entries("vehicle:session:sid-1")).thenReturn(stored);
when(fixture.valueOps.get("vehicle:session:index:vin:VIN001")).thenReturn("sid-1");
when(fixture.valueOps.get("vehicle:session:index:phone:13800138000")).thenReturn("sid-1");
Optional<DeviceSession> byId = store.findBySessionId("sid-1");
Optional<DeviceSession> byVin = store.findByVin("VIN001");
Optional<DeviceSession> byPhone = store.findByPhone("13800138000");
assertThat(byId).isPresent();
assertThat(byVin).contains(byId.orElseThrow());
assertThat(byPhone).contains(byId.orElseThrow());
assertThat(byId.orElseThrow().attributes()).containsEntry("identitySource", "BINDING");
verify(fixture.redis, atLeastOnce()).expire("vehicle:session:sid-1", Duration.ofMinutes(30));
}
@Test
void updateRewritesIndexesWhenVinChangesAndRemoveDeletesAllKeys() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30));
when(fixture.hashOps.entries("vehicle:session:sid-1"))
.thenReturn(storedSession("sid-1", "VIN001", "13800138000"))
.thenReturn(storedSession("sid-1", "VIN001", "13800138000"))
.thenReturn(storedSession("sid-1", "VIN002", "13800138000"));
Optional<DeviceSession> updated = store.update("sid-1", session -> session.withVin("VIN002"));
store.remove("sid-1");
assertThat(updated).isPresent();
verify(fixture.redis).delete("vehicle:session:index:vin:VIN001");
verify(fixture.valueOps).set("vehicle:session:index:vin:VIN002", "sid-1", Duration.ofMinutes(30));
verify(fixture.redis).delete("vehicle:session:sid-1");
verify(fixture.redis).delete("vehicle:session:index:vin:VIN002");
verify(fixture.redis, atLeastOnce()).delete("vehicle:session:index:phone:13800138000");
verify(fixture.setOps).remove("vehicle:session:index:ids", "sid-1");
}
@Test
void sizeReturnsRedisIdSetCardinality() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30));
when(fixture.setOps.size("vehicle:session:index:ids")).thenReturn(12L);
assertThat(store.size()).isEqualTo(12);
}
private static DeviceSession sampleSession(String sid, String vin, String phone) {
return new DeviceSession(
sid,
ProtocolId.JT808,
vin,
phone,
"token-1",
"127.0.0.1:9000",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:01:00Z"),
Map.of("identitySource", "BINDING"));
}
private static Map<Object, Object> storedSession(String sid, String vin, String phone) {
Map<Object, Object> values = new LinkedHashMap<>();
values.put("sessionId", sid);
values.put("protocol", "JT808");
values.put("vin", vin);
values.put("phone", phone);
values.put("token", "token-1");
values.put("peerAddress", "127.0.0.1:9000");
values.put("registeredAt", "2026-06-22T08:00:00Z");
values.put("lastSeenAt", "2026-06-22T08:01:00Z");
values.put("attr.identitySource", "BINDING");
return values;
}
private static final class RedisFixture {
private final StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
private final HashOperations<String, Object, Object> hashOps = mock(HashOperations.class);
@SuppressWarnings("unchecked")
private final ValueOperations<String, String> valueOps = mock(ValueOperations.class);
@SuppressWarnings("unchecked")
private final SetOperations<String, String> setOps = mock(SetOperations.class);
private RedisFixture() {
when(redis.opsForHash()).thenReturn(hashOps);
when(redis.opsForValue()).thenReturn(valueOps);
when(redis.opsForSet()).thenReturn(setOps);
when(hashOps.entries("vehicle:session:sid-1")).thenReturn(Map.of());
when(setOps.members("vehicle:session:index:ids")).thenReturn(Set.of());
}
}
}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.session.config;
import com.lingniu.ingest.session.InMemorySessionStore;
import com.lingniu.ingest.session.RedisSessionStore;
import com.lingniu.ingest.session.SessionStore;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class SessionCoreAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(SessionCoreAutoConfiguration.class));
@Test
void usesInMemorySessionStoreByDefault() {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(SessionStore.class);
assertThat(context).hasSingleBean(InMemorySessionStore.class);
});
}
@Test
void usesRedisSessionStoreWhenEnabledAndRedisTemplateExists() {
contextRunner
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withPropertyValues(
"lingniu.ingest.session.store=redis",
"lingniu.ingest.session.ttl=45m")
.run(context -> {
assertThat(context).hasSingleBean(SessionStore.class);
assertThat(context).hasSingleBean(RedisSessionStore.class);
});
}
}

View File

@@ -0,0 +1,47 @@
<?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>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>vehicle-identity</artifactId>
<name>vehicle-identity</name>
<description>跨协议车辆身份解析与外部标识绑定。</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-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,101 @@
package com.lingniu.ingest.identity;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lingniu.ingest.api.ProtocolId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/**
* 文件型车辆身份绑定表。
*
* <p>写入采用 append-only JSONL启动时顺序重放到内存索引。这样协议层只依赖 identity SPI
* 不直接耦合业务库;后续替换成 DB/配置中心时只需新增同接口实现。
*/
public final class FileVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
private static final Logger log = LoggerFactory.getLogger(FileVehicleIdentityService.class);
private final Path path;
private final ObjectMapper mapper;
private final InMemoryVehicleIdentityService delegate = new InMemoryVehicleIdentityService();
private final Object writeLock = new Object();
public FileVehicleIdentityService(Path path) {
this(path, new ObjectMapper());
}
public FileVehicleIdentityService(Path path, ObjectMapper mapper) {
this.path = path.toAbsolutePath();
this.mapper = mapper;
load();
}
@Override
public void bind(VehicleIdentityBinding binding) {
delegate.bind(binding);
synchronized (writeLock) {
try {
Files.createDirectories(path.getParent());
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) {
writer.write(mapper.writeValueAsString(BindingLine.from(binding)));
writer.newLine();
}
} catch (IOException e) {
throw new IllegalStateException("vehicle identity binding persist failed: " + path, e);
}
}
}
@Override
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
return delegate.resolve(lookup);
}
private void load() {
if (!Files.exists(path)) {
return;
}
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) {
continue;
}
try {
BindingLine binding = mapper.readValue(line, BindingLine.class);
delegate.bind(binding.toBinding());
} catch (Exception e) {
log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e);
}
}
} catch (IOException e) {
throw new IllegalStateException("vehicle identity binding load failed: " + path, e);
}
}
private record BindingLine(
ProtocolId protocol,
String vin,
String phone,
String deviceId,
String plate
) {
private static BindingLine from(VehicleIdentityBinding binding) {
return new BindingLine(
binding.protocol(), binding.vin(), binding.phone(), binding.deviceId(), binding.plate());
}
private VehicleIdentityBinding toBinding() {
return new VehicleIdentityBinding(protocol, vin, phone, deviceId, plate);
}
}
}

View File

@@ -0,0 +1,75 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class InMemoryVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
private final ConcurrentMap<String, String> phoneToVin = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> deviceIdToVin = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> plateToVin = new ConcurrentHashMap<>();
@Override
public void bind(VehicleIdentityBinding binding) {
if (!binding.phone().isBlank()) {
phoneToVin.put(key(binding.protocol(), binding.phone()), binding.vin());
}
if (!binding.deviceId().isBlank()) {
deviceIdToVin.put(key(binding.protocol(), binding.deviceId()), binding.vin());
}
if (!binding.plate().isBlank()) {
plateToVin.put(key(binding.protocol(), binding.plate()), binding.vin());
}
}
@Override
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
if (lookup == null) {
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
}
if (!lookup.vin().isBlank()) {
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
}
VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
if (phone != null) return phone;
VehicleIdentity device = resolveBound(deviceIdToVin, lookup.protocol(), lookup.deviceId(), VehicleIdentitySource.BOUND_DEVICE_ID);
if (device != null) return device;
VehicleIdentity plate = resolveBound(plateToVin, lookup.protocol(), lookup.plate(), VehicleIdentitySource.BOUND_PLATE);
if (plate != null) return plate;
if (!lookup.deviceId().isBlank()) {
return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
}
if (!lookup.phone().isBlank()) {
return new VehicleIdentity(lookup.phone(), false, VehicleIdentitySource.FALLBACK_PHONE);
}
if (!lookup.plate().isBlank()) {
return new VehicleIdentity(lookup.plate(), false, VehicleIdentitySource.FALLBACK_PLATE);
}
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
}
private static VehicleIdentity resolveBound(ConcurrentMap<String, String> map,
ProtocolId protocol,
String externalId,
VehicleIdentitySource source) {
if (externalId == null || externalId.isBlank()) {
return null;
}
String vin = map.get(key(protocol, externalId));
if (vin == null) {
return null;
}
return new VehicleIdentity(vin, true, source);
}
private static String key(ProtocolId protocol, String value) {
String p = protocol == null ? "UNKNOWN" : protocol.name();
return p + ":" + value.trim().toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,16 @@
package com.lingniu.ingest.identity;
public record VehicleIdentity(
String vin,
boolean resolved,
VehicleIdentitySource source
) {
public VehicleIdentity {
vin = normalize(vin);
source = source == null ? VehicleIdentitySource.UNKNOWN : source;
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
public record VehicleIdentityBinding(
ProtocolId protocol,
String vin,
String phone,
String deviceId,
String plate
) {
public VehicleIdentityBinding {
vin = normalize(vin);
phone = normalize(phone);
deviceId = normalize(deviceId);
plate = normalize(plate);
if (vin.isBlank()) {
throw new IllegalArgumentException("vin must not be blank");
}
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,22 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
public record VehicleIdentityLookup(
ProtocolId protocol,
String vin,
String phone,
String deviceId,
String plate
) {
public VehicleIdentityLookup {
vin = normalize(vin);
phone = normalize(phone);
deviceId = normalize(deviceId);
plate = normalize(plate);
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.identity;
public interface VehicleIdentityRegistry {
void bind(VehicleIdentityBinding binding);
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.identity;
public interface VehicleIdentityResolver {
VehicleIdentity resolve(VehicleIdentityLookup lookup);
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.identity;
public enum VehicleIdentitySource {
EXPLICIT_VIN,
BOUND_PHONE,
BOUND_DEVICE_ID,
BOUND_PLATE,
FALLBACK_DEVICE_ID,
FALLBACK_PHONE,
FALLBACK_PLATE,
UNKNOWN
}

Some files were not shown because too many files have changed in this diff Show More