chore: wire split apps deployment config
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline was canceled
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline was canceled
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.idea
|
||||
.worktrees
|
||||
archive
|
||||
**/.DS_Store
|
||||
10
DECISIONS.md
10
DECISIONS.md
@@ -36,11 +36,11 @@
|
||||
- **Decision**: 第一批迁移即覆盖 JT1078 信令 + JSATL12 报警附件上传
|
||||
- **Consequences**: Phase 2 工期相应拉长;JSATL12 需要对象存储后端(本地 / S3 / OSS)
|
||||
|
||||
## ADR-006 部署形态:all-in-one
|
||||
- **Status**: Accepted
|
||||
- **Context**: 当前规模无需按协议拆 Pod
|
||||
- **Decision**: 生产默认 `bootstrap-all`,通过配置开关按需启停各协议模块
|
||||
- **Consequences**: 单进程资源需求较高;未来若规模扩大可切换到 `bootstrap-slim`(保留能力,不交付)
|
||||
## ADR-006 部署形态:GB32960 三应用拆分
|
||||
- **Status**: Superseded
|
||||
- **Context**: 原 `bootstrap-all` 一体化启动已被 GB32960 拆分部署替代
|
||||
- **Decision**: 生产默认部署 `gb32960-ingest-app`、`vehicle-history-app`、`vehicle-analytics-app` 三个应用;旧 `bootstrap-all` 模块删除
|
||||
- **Consequences**: CI/CD 分别构建三个镜像;协议接入、历史查询、统计消费可以独立发布和回滚
|
||||
|
||||
## ADR-007 Java 25 + 虚拟线程 + Disruptor
|
||||
- **Status**: Accepted
|
||||
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM eclipse-temurin:25-jre
|
||||
|
||||
ARG APP_NAME
|
||||
ARG APP_VERSION
|
||||
|
||||
ENV APP_NAME=${APP_NAME}
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV JAVA_OPTS=""
|
||||
|
||||
WORKDIR /app
|
||||
COPY modules/apps/${APP_NAME}/target/${APP_NAME}.jar /app/app.jar
|
||||
|
||||
EXPOSE 20100 20200 20300 32960
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
|
||||
@@ -57,7 +57,9 @@ lingniu-vehicle-ingest/
|
||||
│ │ └── vehicle-stat-service/ Kafka 全字段事件消费 + 可配置日统计
|
||||
│ └── apps/
|
||||
│ ├── command-gateway/ HTTP → 设备下行命令(808 位置/参数/属性/控制/区域删除/报警确认 + 1078 音视频控制)
|
||||
│ └── bootstrap-all/ 一体化启动
|
||||
│ ├── gb32960-ingest-app/ GB32960 TCP 接入 + Kafka 投递
|
||||
│ ├── vehicle-history-app/ 历史查询 + 原始归档索引消费
|
||||
│ └── vehicle-analytics-app/ 车辆状态/日统计消费
|
||||
├── docs/ 架构文档、模块图、实施计划
|
||||
└── reference/ 参考资料
|
||||
```
|
||||
@@ -68,7 +70,7 @@ lingniu-vehicle-ingest/
|
||||
# 要求:JDK 25, Maven 3.9+
|
||||
mvn -v
|
||||
mvn clean install -DskipTests
|
||||
mvn -pl :bootstrap-all spring-boot:run
|
||||
mvn -pl :gb32960-ingest-app -am spring-boot:run
|
||||
```
|
||||
|
||||
## 迁移说明
|
||||
|
||||
@@ -180,10 +180,10 @@ If the split deployment is unhealthy:
|
||||
1. Stop `gb32960-ingest-app` first so new GB32960 ACKs are not issued against an unhealthy Kafka boundary.
|
||||
2. Keep Kafka topics intact for replay unless storage or privacy policy requires deletion.
|
||||
3. Restart or roll back `vehicle-history-app` and `vehicle-analytics-app` independently; their failures do not require protocol ACK rollback because they consume from Kafka offsets.
|
||||
4. If Kafka itself is unavailable, route GB32960 traffic back to the previous all-in-one runtime only if that runtime is configured with its known-good durability behavior.
|
||||
4. If Kafka itself is unavailable, stop `gb32960-ingest-app` or route GB32960 traffic to a previously verified release that preserves the Kafka durability boundary.
|
||||
5. After rollback, compare Kafka consumer group lag and DLQ contents before resuming the split services.
|
||||
|
||||
Rollback commands depend on the deployment environment. For local testing, stop the three Java processes and restart the previous `bootstrap-all` workflow if needed.
|
||||
Rollback commands depend on the deployment environment. For local testing, stop the three Java processes and restart the previous verified app jars or images.
|
||||
|
||||
## Task 8 Verification Notes
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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: ${GB32960_DIAGNOSTICS_MAX_LOGGED_FRAME_KEYS_PER_CHANNEL:4096}
|
||||
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}
|
||||
# 32960 历史索引库根路径。这里保存 Parquet 分区和 DuckDB sidecar 索引;
|
||||
# 全字段查询仍会按 rawArchiveUri 回读 archive 根目录下的 .bin 原始包。
|
||||
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
|
||||
@@ -1,134 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -57,6 +61,7 @@
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
<goal>build-info</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
spring:
|
||||
application:
|
||||
name: gb32960-ingest-app
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:${spring.application.name}.${NACOS_CONFIG_FILE_EXTENSION:yml}?group=${NACOS_GROUP:DEFAULT_GROUP}&refreshEnabled=${NACOS_REFRESH_ENABLED:true}
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
enabled: ${NACOS_CONFIG_ENABLED:true}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
group: ${NACOS_GROUP:DEFAULT_GROUP}
|
||||
file-extension: ${NACOS_CONFIG_FILE_EXTENSION:yml}
|
||||
username: ${NACOS_USERNAME:}
|
||||
password: ${NACOS_PASSWORD:}
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
|
||||
@@ -16,6 +16,10 @@ class Gb32960IngestAppDefaultsTest {
|
||||
|
||||
assertThat(properties)
|
||||
.containsEntry("spring.application.name", "gb32960-ingest-app")
|
||||
.containsEntry(
|
||||
"spring.config.import[0]",
|
||||
"optional:nacos:${spring.application.name}.${NACOS_CONFIG_FILE_EXTENSION:yml}?group=${NACOS_GROUP:DEFAULT_GROUP}&refreshEnabled=${NACOS_REFRESH_ENABLED:true}")
|
||||
.containsEntry("spring.cloud.nacos.config.server-addr", "${NACOS_SERVER_ADDR:127.0.0.1:8848}")
|
||||
.containsEntry("lingniu.ingest.gb32960.enabled", true)
|
||||
.containsEntry("lingniu.ingest.gb32960.server.enabled", true)
|
||||
.containsEntry("lingniu.ingest.gb32960.port", "${GB32960_PORT:32960}")
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -57,6 +61,7 @@
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
<goal>build-info</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
spring:
|
||||
application:
|
||||
name: vehicle-analytics-app
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:${spring.application.name}.${NACOS_CONFIG_FILE_EXTENSION:yml}?group=${NACOS_GROUP:DEFAULT_GROUP}&refreshEnabled=${NACOS_REFRESH_ENABLED:true}
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
enabled: ${NACOS_CONFIG_ENABLED:true}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
group: ${NACOS_GROUP:DEFAULT_GROUP}
|
||||
file-extension: ${NACOS_CONFIG_FILE_EXTENSION:yml}
|
||||
username: ${NACOS_USERNAME:}
|
||||
password: ${NACOS_PASSWORD:}
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
|
||||
@@ -16,6 +16,10 @@ class VehicleAnalyticsAppDefaultsTest {
|
||||
|
||||
assertThat(properties)
|
||||
.containsEntry("spring.application.name", "vehicle-analytics-app")
|
||||
.containsEntry(
|
||||
"spring.config.import[0]",
|
||||
"optional:nacos:${spring.application.name}.${NACOS_CONFIG_FILE_EXTENSION:yml}?group=${NACOS_GROUP:DEFAULT_GROUP}&refreshEnabled=${NACOS_REFRESH_ENABLED:true}")
|
||||
.containsEntry("spring.cloud.nacos.config.server-addr", "${NACOS_SERVER_ADDR:127.0.0.1:8848}")
|
||||
.containsEntry("lingniu.ingest.gb32960.enabled", false)
|
||||
.containsEntry("lingniu.ingest.sink.archive.enabled", false)
|
||||
.containsEntry("lingniu.ingest.event-file-store.enabled", false)
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -65,6 +69,7 @@
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
<goal>build-info</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
spring:
|
||||
application:
|
||||
name: vehicle-history-app
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:${spring.application.name}.${NACOS_CONFIG_FILE_EXTENSION:yml}?group=${NACOS_GROUP:DEFAULT_GROUP}&refreshEnabled=${NACOS_REFRESH_ENABLED:true}
|
||||
cloud:
|
||||
nacos:
|
||||
config:
|
||||
enabled: ${NACOS_CONFIG_ENABLED:true}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
group: ${NACOS_GROUP:DEFAULT_GROUP}
|
||||
file-extension: ${NACOS_CONFIG_FILE_EXTENSION:yml}
|
||||
username: ${NACOS_USERNAME:}
|
||||
password: ${NACOS_PASSWORD:}
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
|
||||
@@ -16,6 +16,10 @@ class VehicleHistoryAppDefaultsTest {
|
||||
|
||||
assertThat(properties)
|
||||
.containsEntry("spring.application.name", "vehicle-history-app")
|
||||
.containsEntry(
|
||||
"spring.config.import[0]",
|
||||
"optional:nacos:${spring.application.name}.${NACOS_CONFIG_FILE_EXTENSION:yml}?group=${NACOS_GROUP:DEFAULT_GROUP}&refreshEnabled=${NACOS_REFRESH_ENABLED:true}")
|
||||
.containsEntry("spring.cloud.nacos.config.server-addr", "${NACOS_SERVER_ADDR:127.0.0.1:8848}")
|
||||
.containsEntry("lingniu.ingest.gb32960.enabled", true)
|
||||
.containsEntry("lingniu.ingest.gb32960.server.enabled", false)
|
||||
.containsEntry("lingniu.ingest.sink.archive.enabled", "${SINK_ARCHIVE_ENABLED:true}")
|
||||
|
||||
9
pom.xml
9
pom.xml
@@ -39,7 +39,6 @@
|
||||
<module>modules/apps/gb32960-ingest-app</module>
|
||||
<module>modules/apps/vehicle-history-app</module>
|
||||
<module>modules/apps/vehicle-analytics-app</module>
|
||||
<module>modules/apps/bootstrap-all</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
@@ -51,6 +50,7 @@
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
|
||||
<spring-boot.version>3.5.3</spring-boot.version>
|
||||
<spring-cloud-alibaba.version>2025.0.0.0</spring-cloud-alibaba.version>
|
||||
<netty.version>4.2.9.Final</netty.version>
|
||||
<disruptor.version>4.0.0</disruptor.version>
|
||||
<protobuf.version>4.28.3</protobuf.version>
|
||||
@@ -93,6 +93,13 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
||||
<version>${spring-cloud-alibaba.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-bom</artifactId>
|
||||
|
||||
136
woodpecker.yml
Normal file
136
woodpecker.yml
Normal file
@@ -0,0 +1,136 @@
|
||||
steps:
|
||||
- name: maven-build
|
||||
image: maven:3.9.16-eclipse-temurin-25
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- manual
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
- main
|
||||
commands: |
|
||||
cd $CI_WORKSPACE
|
||||
|
||||
mvn -B -ntp -am -pl :gb32960-ingest-app,:vehicle-history-app,:vehicle-analytics-app package
|
||||
|
||||
BRANCH_NAME=$(echo ${CI_COMMIT_BRANCH:-local} | tr / -)
|
||||
PROJECT_VERSION=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version)
|
||||
SHORT_SHA=$(echo ${CI_COMMIT_SHA:-manual} | cut -c1-8)
|
||||
IMAGE_VERSION="$BRANCH_NAME-$PROJECT_VERSION-$SHORT_SHA"
|
||||
|
||||
echo "Project version: $PROJECT_VERSION"
|
||||
echo "Image version: $IMAGE_VERSION"
|
||||
echo "$IMAGE_VERSION" > $CI_WORKSPACE/project_version.txt
|
||||
|
||||
- name: docker-build-gb32960-ingest-app
|
||||
image: docker:24.0.5-cli
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- manual
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
- main
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands: |
|
||||
cd $CI_WORKSPACE
|
||||
APP_NAME=gb32960-ingest-app
|
||||
APP_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
||||
IMAGE=harbor.lnh2e.com/lingniu-v1/$APP_NAME:$APP_VERSION
|
||||
|
||||
docker build \
|
||||
--build-arg APP_NAME=$APP_NAME \
|
||||
--build-arg APP_VERSION=$APP_VERSION \
|
||||
-t $IMAGE .
|
||||
|
||||
mkdir -p /root/.docker
|
||||
cat > /root/.docker/config.json <<EOF
|
||||
{
|
||||
"auths": {
|
||||
"harbor.lnh2e.com": {
|
||||
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
docker push $IMAGE
|
||||
|
||||
- name: docker-build-vehicle-history-app
|
||||
image: docker:24.0.5-cli
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- manual
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
- main
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands: |
|
||||
cd $CI_WORKSPACE
|
||||
APP_NAME=vehicle-history-app
|
||||
APP_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
||||
IMAGE=harbor.lnh2e.com/lingniu-v1/$APP_NAME:$APP_VERSION
|
||||
|
||||
docker build \
|
||||
--build-arg APP_NAME=$APP_NAME \
|
||||
--build-arg APP_VERSION=$APP_VERSION \
|
||||
-t $IMAGE .
|
||||
|
||||
mkdir -p /root/.docker
|
||||
cat > /root/.docker/config.json <<EOF
|
||||
{
|
||||
"auths": {
|
||||
"harbor.lnh2e.com": {
|
||||
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
docker push $IMAGE
|
||||
|
||||
- name: docker-build-vehicle-analytics-app
|
||||
image: docker:24.0.5-cli
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
- manual
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
- main
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
commands: |
|
||||
cd $CI_WORKSPACE
|
||||
APP_NAME=vehicle-analytics-app
|
||||
APP_VERSION=$(cat $CI_WORKSPACE/project_version.txt)
|
||||
IMAGE=harbor.lnh2e.com/lingniu-v1/$APP_NAME:$APP_VERSION
|
||||
|
||||
docker build \
|
||||
--build-arg APP_NAME=$APP_NAME \
|
||||
--build-arg APP_VERSION=$APP_VERSION \
|
||||
-t $IMAGE .
|
||||
|
||||
mkdir -p /root/.docker
|
||||
cat > /root/.docker/config.json <<EOF
|
||||
{
|
||||
"auths": {
|
||||
"harbor.lnh2e.com": {
|
||||
"auth": "$(echo Y2ljZDpMbkBjaWNkMDE=)"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
docker push $IMAGE
|
||||
Reference in New Issue
Block a user