feat: make gb32960 archive history query production ready
This commit is contained in:
128
modules/apps/bootstrap-all/pom.xml
Normal file
128
modules/apps/bootstrap-all/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
235
modules/apps/bootstrap-all/src/main/resources/application.yml
Normal file
235
modules/apps/bootstrap-all/src/main/resources/application.yml
Normal 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
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 sink:TestEventSink 成为唯一 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
53
modules/apps/command-gateway/pom.xml
Normal file
53
modules/apps/command-gateway/pom.xml
Normal 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>
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.gateway.CommandGatewayAutoConfiguration
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
30
modules/core/ingest-api/pom.xml
Normal file
30
modules/core/ingest-api/pom.xml
Normal 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>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lingniu.ingest.api;
|
||||
|
||||
/**
|
||||
* 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。
|
||||
* 新增协议需要在此处登记,避免散落的字符串常量。
|
||||
*/
|
||||
public enum ProtocolId {
|
||||
UNKNOWN,
|
||||
GB32960,
|
||||
JT808,
|
||||
JT1078,
|
||||
JSATL12,
|
||||
MQTT_YUTONG,
|
||||
XINDA_PUSH
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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} = 消息 ID(0x0100 / 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 "";
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnvelopeDeadLetterSink {
|
||||
void publish(EnvelopeDeadLetterRecord record);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnvelopeIngestor {
|
||||
EnvelopeIngestResult tryIngest(byte[] kafkaValue);
|
||||
}
|
||||
@@ -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~15;2025 版: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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** 下行帧编码器。无下行的协议返回 null 即可。 */
|
||||
@FunctionalInterface
|
||||
public interface FrameEncoder<O> {
|
||||
ByteBuffer encode(O message);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
30
modules/core/ingest-codec-common/pom.xml
Normal file
30
modules/core/ingest-codec-common/pom.xml
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
62
modules/core/ingest-core/pom.xml
Normal file
62
modules/core/ingest-core/pom.xml
Normal 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>
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 Adapter(Netty / MQTT / PushClient)都调用 {@link #dispatch(RawFrame)},
|
||||
* 协议差异在这里被彻底抹平。
|
||||
*
|
||||
* <p>对于带 {@code @AsyncBatch} 的 Handler,Dispatcher 不直接反射调用,而是把消息交给
|
||||
* {@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.core.config.IngestCoreAutoConfiguration
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
32
modules/core/observability/pom.xml
Normal file
32
modules/core/observability/pom.xml
Normal 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>
|
||||
@@ -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; // 尽早介入,晚于 Tracing(order=0)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.observability.ObservabilityAutoConfiguration
|
||||
56
modules/core/session-core/pom.xml
Normal file
56
modules/core/session-core/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.session.config.SessionCoreAutoConfiguration
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
47
modules/core/vehicle-identity/pom.xml
Normal file
47
modules/core/vehicle-identity/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public interface VehicleIdentityRegistry {
|
||||
|
||||
void bind(VehicleIdentityBinding binding);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public interface VehicleIdentityResolver {
|
||||
|
||||
VehicleIdentity resolve(VehicleIdentityLookup lookup);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user