chore: initial import of lingniu-vehicle-ingest
Multi-module Spring Boot ingest service for vehicle telemetry. Modules: - ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher, Disruptor event bus, BCC/BCD codec helpers - protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser packages v2016/v2025), platform login auth, VIN whitelist, idle handler - protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound - inbound-mqtt / inbound-xinda-push: alternative ingest channels - session-core: per-channel session state - sink-archive / sink-mq: persistence sinks (local file / Kafka) - command-gateway: terminal control command gateway - bootstrap-all: aggregator Spring Boot app - observability: Micrometer / Actuator wiring Includes hex-dump golden samples under protocol-gb32960/src/test/resources and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.log
|
||||
*.log.*
|
||||
app.log
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
.factorypath
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
dependency-reduced-pom.xml
|
||||
logs/
|
||||
data/
|
||||
build/
|
||||
out/
|
||||
|
||||
# Local config / secrets
|
||||
application-local.yml
|
||||
application-local.yaml
|
||||
application-local.properties
|
||||
*.local.yml
|
||||
*.local.yaml
|
||||
*.local.properties
|
||||
|
||||
# Temp & JVM crash dumps
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
65
DECISIONS.md
Normal file
65
DECISIONS.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 架构决策记录(ADR 汇总)
|
||||
|
||||
> 本文件记录 lingniu-vehicle-ingest v2 的关键架构决策,后续每次重大调整追加条目(不删除旧条目)。
|
||||
|
||||
## ADR-001 MQ 选型:Kafka
|
||||
- **Status**: Accepted
|
||||
- **Context**: 需要高吞吐、严格单车有序、成熟生态
|
||||
- **Decision**: 使用 Kafka,分区 key = VIN
|
||||
- **Consequences**: 下游消费者需使用消费组 + 分区内顺序消费;不使用 RocketMQ 的事务/顺序特性
|
||||
|
||||
## ADR-002 线上消息格式:Protobuf
|
||||
- **Status**: Accepted
|
||||
- **Context**: 需要向前兼容、体积小、性能好
|
||||
- **Decision**: 线上用 Protobuf,调试/诊断保留 JSON 序列化能力
|
||||
- **Consequences**: 需维护 `.proto` schema,`ingest-api` 模块负责生成 Java stub
|
||||
|
||||
## ADR-003 command-gateway 独立模块
|
||||
- **Status**: Accepted
|
||||
- **Context**: 下行命令链路不应与接入进程耦合
|
||||
- **Decision**: 拆出独立 `command-gateway` 模块,复用 `session-core`;本次重构同步交付
|
||||
- **Consequences**: HTTP 对外接口(原 JT808Controller / JT1078Controller)迁移到此模块
|
||||
|
||||
## ADR-004 信达 Push:保留模块但彻底重写
|
||||
- **Status**: Accepted
|
||||
- **Context**: 旧实现硬编码凭证、无重连、0300/0401 空实现
|
||||
- **Decision**:
|
||||
1. 新模块 `inbound-xinda-push` 隔离第三方库 `gps-push-client`
|
||||
2. 配置外置(yml + env)
|
||||
3. 自动重连 + Resilience4j 熔断
|
||||
4. 0200/0300/0401 **仅做协议解析**,转换为 `VehicleEvent` 后直接投 Kafka
|
||||
5. **业务处理全部下沉到下游消费者**,本服务不再实现报警/透传业务逻辑
|
||||
- **Consequences**: 旧代码 messageReceived 里的 TODO 不再回到本仓库;需要协调下游消费者团队认领
|
||||
|
||||
## ADR-005 JT1078 / JSATL12 进第一批
|
||||
- **Status**: Accepted
|
||||
- **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-007 Java 25 + 虚拟线程 + Disruptor
|
||||
- **Status**: Accepted
|
||||
- **Decision**:
|
||||
- Netty EventLoop 只做解码与 RingBuffer 投递,严禁阻塞
|
||||
- 业务 Handler 走虚拟线程(`Thread.ofVirtual()`)
|
||||
- 协议间背压通过 Disruptor RingBuffer 表达
|
||||
- 禁用 `synchronized`,使用 `ReentrantLock` 避免虚拟线程 pinning
|
||||
- **Consequences**: 需开启 `jdk.VirtualThreadPinned` JFR 监控
|
||||
|
||||
## ADR-008 本服务不写业务库
|
||||
- **Status**: Accepted
|
||||
- **Decision**: 所有业务表(vehicle_data_actual / history / travel / gps_data 等)不再由本服务写入
|
||||
- **Consequences**: 需要新建消费服务 `vehicle-analytics-service` 承接;迁移期采用双跑对账
|
||||
|
||||
## ADR-009 构建工具:Maven
|
||||
- **Status**: Accepted
|
||||
- **Decision**: 沿用 Maven,统一 BOM 管理版本,Spotless 格式化,ArchUnit 守护分层
|
||||
|
||||
## ADR-010 框架:Spring Boot 3.4
|
||||
- **Status**: Accepted
|
||||
- **Decision**: Spring Boot 3.4.x,支持 JDK 25;AutoConfiguration 用于按需启停
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# lingniu-vehicle-ingest
|
||||
|
||||
> 羚牛车辆数据接入平台 v2
|
||||
|
||||
## 设计目标
|
||||
|
||||
- **协议接入统一抽象**:GB/T 32960、JT/T 808、JT/T 1078、JSATL12、MQTT、信达 Push 全部变成可插拔 Inbound Adapter
|
||||
- **原子能力化**:每个协议一个独立 Maven 模块 + 独立 AutoConfiguration + 独立配置开关
|
||||
- **业务 / 实时彻底解耦**:本服务只负责 **收 → 解析 → 校验 → 规整 → 投递 Kafka**,不碰业务库
|
||||
- **高并发低延迟**:Netty + Disruptor + Java 25 虚拟线程;目标单节点 ≥ 5 万 msg/s,P99 < 50 ms
|
||||
- **可观测、可回放、可灰度**:统一 traceId、Envelope、幂等键、DLQ、原始报文冷存
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 选型 |
|
||||
|---|---|
|
||||
| 语言 | Java **25** |
|
||||
| 框架 | Spring Boot 3.4.x |
|
||||
| 网络 | Netty 4.1 |
|
||||
| 并发 | Disruptor 4 + 虚拟线程 + `StructuredTaskScope` |
|
||||
| 消息队列 | **Kafka**(vin 分区,保证单车有序) |
|
||||
| 线上消息格式 | **Protobuf**,调试走 JSON |
|
||||
| MQTT 客户端 | HiveMQ MQTT Client |
|
||||
| 会话缓存 | Caffeine + Redis 兜底 |
|
||||
| 熔断/限流 | Resilience4j 2 |
|
||||
| 可观测 | Micrometer + Prometheus + OpenTelemetry |
|
||||
| 冷存 | S3 / OSS / 本地 |
|
||||
| 构建 | Maven + Spotless + ArchUnit |
|
||||
|
||||
## 模块划分
|
||||
|
||||
```
|
||||
lingniu-vehicle-ingest/
|
||||
├── bom/ 依赖版本统一
|
||||
├── ingest-api/ SPI + sealed 领域事件 + 注解
|
||||
├── ingest-codec-common/ 公共编解码工具(BCD/CRC/BCC/bit utils)
|
||||
├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥
|
||||
├── session-core/ 设备会话 + 鉴权 + Token
|
||||
├── observability/ metrics / tracing / health
|
||||
├── sink-mq/ Kafka producer + Protobuf Envelope
|
||||
├── sink-archive/ 原始报文冷存
|
||||
├── protocol-gb32960/ GB/T 32960
|
||||
├── protocol-jt808/ JT/T 808
|
||||
├── protocol-jt1078/ JT/T 1078
|
||||
├── protocol-jsatl12/ 苏标主动安全报警附件
|
||||
├── inbound-mqtt/ MQTT 接入(宇通等)
|
||||
├── inbound-xinda-push/ 信达 Push 接入(重写)
|
||||
├── command-gateway/ HTTP → 设备下行命令
|
||||
└── bootstrap-all/ 一体化启动
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 要求:JDK 25, Maven 3.9+
|
||||
mvn -v
|
||||
mvn clean install -DskipTests
|
||||
cd bootstrap-all && mvn spring-boot:run
|
||||
```
|
||||
|
||||
## 迁移说明
|
||||
|
||||
本项目是 `lingniu-vehicle-data-reception` 的 v2 重构,采用 strangler fig 渐进式迁移,旧项目保留只读参考。迁移路径与决策参见 `../REFRACTOR_PLAN.md`。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **本服务不写业务库**:历史、行程、在线检测、里程统计全部由 Kafka 下游消费者实现
|
||||
2. **协议即插拔**:每个 `protocol-*` / `inbound-*` 都可独立开关(`lingniu.ingest.<name>.enabled`)
|
||||
3. **顺序保证**:同一 VIN 严格有序(Disruptor hash + Kafka 分区 key)
|
||||
4. **幂等消费**:Envelope 带 `eventId`,下游去重
|
||||
5. **原始可回放**:`vehicle.raw.archive` topic + 对象存储
|
||||
107
bootstrap-all/pom.xml
Normal file
107
bootstrap-all/pom.xml
Normal file
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>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>
|
||||
|
||||
<!-- 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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
bootstrap-all/src/main/resources/application.yml
Normal file
130
bootstrap-all/src/main/resources/application.yml
Normal file
@@ -0,0 +1,130 @@
|
||||
spring:
|
||||
application:
|
||||
name: lingniu-vehicle-ingest
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
|
||||
server:
|
||||
port: 20000
|
||||
|
||||
lingniu:
|
||||
ingest:
|
||||
gb32960:
|
||||
enabled: true
|
||||
port: 9000
|
||||
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:
|
||||
- username: ${GB32960_PLATFORM_USER_1:lingniu}
|
||||
password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#}
|
||||
allowed-ips: # 可选:限制来源 IP,为空不限制
|
||||
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
|
||||
jt808:
|
||||
enabled: false
|
||||
port: 10808
|
||||
max-frame-length: 1048
|
||||
jt1078:
|
||||
enabled: true
|
||||
jsatl12:
|
||||
enabled: true
|
||||
port: 7612
|
||||
file-store: file:///var/lingniu/alarm-files
|
||||
mqtt:
|
||||
enabled: false
|
||||
endpoints:
|
||||
- name: yutong
|
||||
uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883}
|
||||
topic: ${MQTT_TOPIC:/ytforward/shln/+}
|
||||
qos: 1
|
||||
client-id: ${MQTT_CLIENT_ID:shlnClientId}
|
||||
username: ${MQTT_USER:shln}
|
||||
password: ${MQTT_PWD:}
|
||||
tls:
|
||||
ca-pem: ${MQTT_CA_PEM:}
|
||||
client-pem: ${MQTT_CLIENT_PEM:}
|
||||
client-key: ${MQTT_CLIENT_KEY:}
|
||||
xinda-push:
|
||||
enabled: false
|
||||
host: ${XINDA_HOST:115.231.168.135}
|
||||
port: ${XINDA_PORT:10100}
|
||||
username: ${XINDA_USER:浙江羚牛}
|
||||
password: ${XINDA_PWD:xd123456+}
|
||||
subscribe-msg-ids: 0200,0300,0401
|
||||
client-desc: 羚牛客户端
|
||||
|
||||
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:
|
||||
store: memory-and-redis
|
||||
token-ttl-seconds: 86400
|
||||
|
||||
sink:
|
||||
mq:
|
||||
# MQ Sink 总开关。true=装配 Kafka Producer 并投递事件;false=完全不装配(适合本地开发或 Kafka 不可达)。
|
||||
enabled: ${KAFKA_ENABLED:false}
|
||||
type: kafka # 后端选择:kafka | (rocketmq | pulsar 预留)
|
||||
bootstrap-servers: ${KAFKA_BROKERS:localhost: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
|
||||
archive:
|
||||
enabled: true
|
||||
type: local # local | s3 | oss
|
||||
path: ./archive/
|
||||
|
||||
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,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));
|
||||
}
|
||||
}
|
||||
38
command-gateway/pom.xml
Normal file
38
command-gateway/pom.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>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>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</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,145 @@
|
||||
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.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 CommandResult.notImplemented("0x8107 query-attributes");
|
||||
}
|
||||
|
||||
@DeleteMapping("/area")
|
||||
public CommandResult deleteArea(@RequestParam String clientId, @RequestParam String ids) {
|
||||
return CommandResult.notImplemented("0x8601 delete-area");
|
||||
}
|
||||
|
||||
@PostMapping("/alarm_ack")
|
||||
public CommandResult alarmAck(@RequestParam String clientId, @RequestBody Map<String, Object> body) {
|
||||
return CommandResult.notImplemented("0x8203 alarm-ack");
|
||||
}
|
||||
|
||||
// ===== 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.gateway.CommandGatewayAutoConfiguration
|
||||
50
inbound-mqtt/pom.xml
Normal file
50
inbound-mqtt/pom.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>inbound-mqtt</artifactId>
|
||||
<name>inbound-mqtt</name>
|
||||
<description>MQTT 接入模块(支持多 endpoint,宇通等),HiveMQ MQTT Client + 双向 TLS。</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</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.hivemq</groupId>
|
||||
<artifactId>hivemq-mqtt-client</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>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.client;
|
||||
|
||||
import com.hivemq.client.mqtt.datatypes.MqttQos;
|
||||
import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient;
|
||||
import com.hivemq.client.mqtt.mqtt3.Mqtt3Client;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.pipeline.RawFrame;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.inbound.mqtt.config.MqttInboundProperties;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* MQTT 多 endpoint 生命周期管理。
|
||||
*
|
||||
* <p>为每个 endpoint 创建一个 HiveMQ 异步客户端 → 连接 → 订阅 → 注册回调 →
|
||||
* 回调里解析 payload → {@link Dispatcher#dispatch(RawFrame)}。
|
||||
*
|
||||
* <p>Netty EventLoop 回调不做业务处理,虚拟线程由 Dispatcher 侧的 Disruptor 承接。
|
||||
*/
|
||||
public final class MqttEndpointManager implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MqttEndpointManager.class);
|
||||
|
||||
private final MqttInboundProperties props;
|
||||
private final MqttPayloadParser payloadParser;
|
||||
private final Dispatcher dispatcher;
|
||||
private final List<Mqtt3AsyncClient> clients = new ArrayList<>();
|
||||
|
||||
public MqttEndpointManager(MqttInboundProperties props,
|
||||
MqttPayloadParser payloadParser,
|
||||
Dispatcher dispatcher) {
|
||||
this.props = props;
|
||||
this.payloadParser = payloadParser;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) {
|
||||
try {
|
||||
Mqtt3AsyncClient client = buildClient(ep);
|
||||
clients.add(client);
|
||||
connectAndSubscribe(client, ep);
|
||||
} catch (Exception e) {
|
||||
log.error("mqtt endpoint [{}] init failed, skipped", ep.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Mqtt3AsyncClient buildClient(MqttInboundProperties.Endpoint ep) {
|
||||
URI uri = URI.create(ep.getUri());
|
||||
int port = uri.getPort() > 0 ? uri.getPort() : defaultPort(uri.getScheme());
|
||||
var builder = Mqtt3Client.builder()
|
||||
.identifier(ep.getClientId() == null || ep.getClientId().isBlank()
|
||||
? "lingniu-" + UUID.randomUUID() : ep.getClientId())
|
||||
.serverHost(uri.getHost())
|
||||
.serverPort(port)
|
||||
.automaticReconnectWithDefaultConfig();
|
||||
|
||||
if ("ssl".equalsIgnoreCase(uri.getScheme()) || "tls".equalsIgnoreCase(uri.getScheme())
|
||||
|| "mqtts".equalsIgnoreCase(uri.getScheme())) {
|
||||
// TODO: 基于 ep.getTls() 的 caPem / clientPem / clientKey 构造自定义 SslConfig
|
||||
builder = builder.sslWithDefaultConfig();
|
||||
}
|
||||
return builder.buildAsync();
|
||||
}
|
||||
|
||||
private void connectAndSubscribe(Mqtt3AsyncClient client, MqttInboundProperties.Endpoint ep) {
|
||||
var connectBuilder = client.connectWith();
|
||||
if (ep.getUsername() != null && !ep.getUsername().isBlank()) {
|
||||
connectBuilder = connectBuilder.simpleAuth()
|
||||
.username(ep.getUsername())
|
||||
.password(ep.getPassword() == null ? new byte[0] : ep.getPassword().getBytes())
|
||||
.applySimpleAuth();
|
||||
}
|
||||
connectBuilder.send().whenComplete((ack, err) -> {
|
||||
if (err != null) {
|
||||
log.error("mqtt endpoint [{}] connect failed", ep.getName(), err);
|
||||
return;
|
||||
}
|
||||
log.info("mqtt endpoint [{}] connected code={}", ep.getName(), ack.getReturnCode());
|
||||
client.subscribeWith()
|
||||
.topicFilter(ep.getTopic())
|
||||
.qos(mapQos(ep.getQos()))
|
||||
.callback(publish -> onMessage(ep, publish.getTopic().toString(), publish.getPayloadAsBytes()))
|
||||
.send()
|
||||
.whenComplete((subAck, subErr) -> {
|
||||
if (subErr != null) {
|
||||
log.error("mqtt endpoint [{}] subscribe failed topic={}", ep.getName(), ep.getTopic(), subErr);
|
||||
} else {
|
||||
log.info("mqtt endpoint [{}] subscribed topic={} qos={}",
|
||||
ep.getName(), ep.getTopic(), ep.getQos());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void onMessage(MqttInboundProperties.Endpoint ep, String topic, byte[] payload) {
|
||||
try {
|
||||
MqttPayload parsed = payloadParser.parse(ep.getName(), ep.getProfile(), topic, payload);
|
||||
Map<String, String> meta = new HashMap<>(4);
|
||||
meta.put("vin", parsed.vin() == null ? "unknown" : parsed.vin());
|
||||
meta.put("endpoint", ep.getName());
|
||||
meta.put("topic", topic);
|
||||
RawFrame rf = new RawFrame(
|
||||
ProtocolId.MQTT_YUTONG, 0, 0,
|
||||
parsed, payload, meta, Instant.now());
|
||||
dispatcher.dispatch(rf);
|
||||
} catch (Exception e) {
|
||||
log.warn("mqtt endpoint [{}] message handling failed topic={} len={}",
|
||||
ep.getName(), topic, payload.length, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static MqttQos mapQos(int level) {
|
||||
return switch (level) {
|
||||
case 0 -> MqttQos.AT_MOST_ONCE;
|
||||
case 2 -> MqttQos.EXACTLY_ONCE;
|
||||
default -> MqttQos.AT_LEAST_ONCE;
|
||||
};
|
||||
}
|
||||
|
||||
private static int defaultPort(String scheme) {
|
||||
if (scheme == null) return 1883;
|
||||
return switch (scheme.toLowerCase()) {
|
||||
case "ssl", "tls", "mqtts" -> 8883;
|
||||
default -> 1883;
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (Mqtt3AsyncClient c : clients) {
|
||||
try {
|
||||
c.disconnect();
|
||||
} catch (Exception ignored) {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
log.info("mqtt endpoint manager stopped, clients={}", clients.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.inbound.mqtt.client.MqttEndpointManager;
|
||||
import com.lingniu.ingest.inbound.mqtt.handler.MqttRealtimeHandler;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(MqttInboundProperties.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.mqtt", name = "enabled", havingValue = "true")
|
||||
public class MqttInboundAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public ObjectMapper mqttObjectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MqttPayloadParser mqttPayloadParser(ObjectMapper objectMapper) {
|
||||
return new MqttPayloadParser(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public YutongEventMapper yutongEventMapper() {
|
||||
return new YutongEventMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MqttRealtimeHandler mqttRealtimeHandler(YutongEventMapper mapper) {
|
||||
return new MqttRealtimeHandler(mapper);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@ConditionalOnMissingBean
|
||||
public MqttEndpointManager mqttEndpointManager(MqttInboundProperties props,
|
||||
MqttPayloadParser parser,
|
||||
Dispatcher dispatcher) {
|
||||
return new MqttEndpointManager(props, parser, dispatcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Boot 启动完成后触发 MQTT 连接,避免与 Netty / Kafka 启动竞争。
|
||||
*/
|
||||
@Bean
|
||||
public ApplicationRunner mqttStartupRunner(MqttEndpointManager manager) {
|
||||
return new ApplicationRunner() {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
manager.start();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MQTT 接入配置。支持多 endpoint(每个 endpoint 可对应一个车厂 / 一个 broker)。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.mqtt")
|
||||
public class MqttInboundProperties {
|
||||
|
||||
private boolean enabled = false;
|
||||
private List<Endpoint> endpoints = new ArrayList<>();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public List<Endpoint> getEndpoints() { return endpoints; }
|
||||
public void setEndpoints(List<Endpoint> endpoints) { this.endpoints = endpoints; }
|
||||
|
||||
public static class Endpoint {
|
||||
/** 逻辑名称,用于指标与日志。 */
|
||||
private String name;
|
||||
/** 对端 URI。形如 ssl://host:port 或 tcp://host:port。 */
|
||||
private String uri;
|
||||
/** 订阅 topic,支持 + / # 通配符。 */
|
||||
private String topic;
|
||||
/** QoS 0/1/2。 */
|
||||
private int qos = 1;
|
||||
private String clientId;
|
||||
private String username;
|
||||
private String password;
|
||||
/** JSON 字段适配器 profile。默认 yutong。未来可支持 haipote / others。 */
|
||||
private String profile = "yutong";
|
||||
private Tls tls = new Tls();
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getUri() { return uri; }
|
||||
public void setUri(String uri) { this.uri = uri; }
|
||||
public String getTopic() { return topic; }
|
||||
public void setTopic(String topic) { this.topic = topic; }
|
||||
public int getQos() { return qos; }
|
||||
public void setQos(int qos) { this.qos = qos; }
|
||||
public String getClientId() { return clientId; }
|
||||
public void setClientId(String clientId) { this.clientId = clientId; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public String getProfile() { return profile; }
|
||||
public void setProfile(String profile) { this.profile = profile; }
|
||||
public Tls getTls() { return tls; }
|
||||
public void setTls(Tls tls) { this.tls = tls; }
|
||||
}
|
||||
|
||||
public static class Tls {
|
||||
private String caPem;
|
||||
private String clientPem;
|
||||
private String clientKey;
|
||||
public String getCaPem() { return caPem; }
|
||||
public void setCaPem(String caPem) { this.caPem = caPem; }
|
||||
public String getClientPem() { return clientPem; }
|
||||
public void setClientPem(String clientPem) { this.clientPem = clientPem; }
|
||||
public String getClientKey() { return clientKey; }
|
||||
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.handler;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.annotation.EventEmit;
|
||||
import com.lingniu.ingest.api.annotation.MessageMapping;
|
||||
import com.lingniu.ingest.api.annotation.ProtocolHandler;
|
||||
import com.lingniu.ingest.api.annotation.RateLimited;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MQTT 接入示例 Handler。
|
||||
*
|
||||
* <p>MQTT 没有"命令字"概念,使用默认的 wildcard 匹配({@code command} 为空)。
|
||||
* 同一 Dispatcher 仍能正确路由,因为协议 ID 做了隔离。
|
||||
*/
|
||||
@ProtocolHandler(protocol = ProtocolId.MQTT_YUTONG)
|
||||
public class MqttRealtimeHandler {
|
||||
|
||||
private final YutongEventMapper mapper;
|
||||
|
||||
public MqttRealtimeHandler(YutongEventMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@MessageMapping(desc = "宇通 MQTT 实时数据")
|
||||
@RateLimited(perVin = 20)
|
||||
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class})
|
||||
public List<VehicleEvent> onMqtt(MqttPayload payload) {
|
||||
return mapper.toEvents(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.mapper;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.LocationPayload;
|
||||
import com.lingniu.ingest.api.event.RealtimePayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.spi.EventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 宇通 MQTT payload → 领域事件。
|
||||
*
|
||||
* <p>字段名取自旧项目的 {@code ReceptionDataVO}(大写 + 下划线),对字段映射一致性做出显式断言,
|
||||
* 便于与旧服务双跑对账。
|
||||
*/
|
||||
public final class YutongEventMapper implements EventMapper<MqttPayload> {
|
||||
|
||||
@Override
|
||||
public List<VehicleEvent> toEvents(MqttPayload payload) {
|
||||
JsonNode d = payload.data();
|
||||
if (d == null || !d.isObject()) return List.of();
|
||||
|
||||
Instant ingestTime = Instant.now();
|
||||
String vin = payload.vin();
|
||||
Map<String, String> meta = Map.of(
|
||||
"endpoint", payload.endpointName() == null ? "" : payload.endpointName(),
|
||||
"topic", payload.topic() == null ? "" : payload.topic(),
|
||||
"profile", payload.profile() == null ? "" : payload.profile());
|
||||
|
||||
Double speed = optDouble(d, "METER_SPEED");
|
||||
Double mileage = optDouble(d, "TOTAL_MILEAGE");
|
||||
Double soc = optDouble(d, "BATTERY_CAPACITY_SOC");
|
||||
Double fcVoltage = optDouble(d, "VOLTAGE_OF_FC");
|
||||
Double fcCurrent = optDouble(d, "CURRENT_OF_FC");
|
||||
Double fcTemp = optDouble(d, "TEMPT_OF_FC");
|
||||
Double leftH2 = optDouble(d, "LEFT_HYDROGEN");
|
||||
Double lowH2Pressure = optDouble(d, "HYDROGEN_LOW_PRESSURE");
|
||||
Double longitude = optCoord(d, "LONGITUDE");
|
||||
Double latitude = optCoord(d, "LATITUDE");
|
||||
Double direction = optDouble(d, "GPSDirection");
|
||||
Double acc = optDouble(d, "ACC_PEDAL_APT");
|
||||
Double brake = optDouble(d, "BRAKEPEDALAPT");
|
||||
Integer gear = optInt(d, "ON_GEAR");
|
||||
|
||||
RealtimePayload realtime = new RealtimePayload(
|
||||
speed, mileage, soc, null, null,
|
||||
fcVoltage, fcCurrent, fcTemp, leftH2, null, lowH2Pressure,
|
||||
null, null, null, gear, acc, brake,
|
||||
longitude, latitude, null, direction,
|
||||
null, null);
|
||||
|
||||
List<VehicleEvent> out = new ArrayList<>(2);
|
||||
out.add(new VehicleEvent.Realtime(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime,
|
||||
null, meta, realtime));
|
||||
|
||||
if (longitude != null && latitude != null) {
|
||||
LocationPayload loc = new LocationPayload(
|
||||
longitude, latitude, 0.0,
|
||||
speed == null ? 0.0 : speed,
|
||||
direction == null ? 0.0 : direction,
|
||||
0, 0);
|
||||
out.add(new VehicleEvent.Location(
|
||||
UUID.randomUUID().toString(),
|
||||
vin, ProtocolId.MQTT_YUTONG, payload.deviceTime(), ingestTime,
|
||||
null, meta, loc));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Double optDouble(JsonNode n, String field) {
|
||||
JsonNode v = n.get(field);
|
||||
if (v == null || v.isNull() || !v.isNumber()) return null;
|
||||
return v.asDouble();
|
||||
}
|
||||
|
||||
private static Integer optInt(JsonNode n, String field) {
|
||||
JsonNode v = n.get(field);
|
||||
if (v == null || v.isNull() || !v.isNumber()) return null;
|
||||
return v.asInt();
|
||||
}
|
||||
|
||||
/** 宇通坐标约定:1e6 整数 或 已经是小数。同时过滤无效值 999999 / 0。 */
|
||||
private static Double optCoord(JsonNode n, String field) {
|
||||
Double v = optDouble(n, field);
|
||||
if (v == null) return null;
|
||||
if (v.intValue() == 999_999 || v == 0.0) return null;
|
||||
if (Math.abs(v) > 1000) return v / 1_000_000.0;
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* MQTT 解析后的统一消息体:{@code Dispatcher} 收到的 RawFrame payload 就是这个。
|
||||
*
|
||||
* @param endpointName 来源 endpoint 逻辑名
|
||||
* @param profile 字段映射 profile(如 yutong / haipote)
|
||||
* @param topic 原始 topic
|
||||
* @param vin VIN(解析自 payload 的 device / vin 字段)
|
||||
* @param deviceTime 设备上报时刻
|
||||
* @param data 原始 JSON 树,供 Mapper 按 profile 提取字段
|
||||
*/
|
||||
public record MqttPayload(
|
||||
String endpointName,
|
||||
String profile,
|
||||
String topic,
|
||||
String vin,
|
||||
Instant deviceTime,
|
||||
JsonNode data
|
||||
) {}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.lingniu.ingest.inbound.mqtt.parser;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 宇通 MQTT 消息字段适配器(对标旧 {@code VehicleDataReceptionVO} + {@code ReceptionDataVO})。
|
||||
*
|
||||
* <p>约定宇通 MQTT 的 payload JSON 形如:
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "id": "xxx",
|
||||
* "device": "LTEST000000000001", // VIN
|
||||
* "code": "C001",
|
||||
* "time": "20260413100000", // yyyyMMddHHmmss
|
||||
* "data": { "METER_SPEED": 52, "LATITUDE": 39916527, ... }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>其它厂商 profile 只需新增一个 Parser 实现即可,由 profile 路由。
|
||||
*/
|
||||
public final class MqttPayloadParser {
|
||||
|
||||
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public MqttPayloadParser(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public MqttPayload parse(String endpointName, String profile, String topic, byte[] bytes) {
|
||||
try {
|
||||
JsonNode root = mapper.readTree(bytes);
|
||||
String vin = text(root, "device", "vin");
|
||||
Instant deviceTime = parseTime(text(root, "time"));
|
||||
JsonNode data = root.has("data") ? root.get("data") : root;
|
||||
return new MqttPayload(endpointName, profile, topic, vin, deviceTime, data);
|
||||
} catch (Exception e) {
|
||||
throw new DecodeException("mqtt payload parse failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(JsonNode root, String... keys) {
|
||||
for (String k : keys) {
|
||||
JsonNode n = root.get(k);
|
||||
if (n != null && !n.isNull()) return n.asText();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static Instant parseTime(String raw) {
|
||||
if (raw == null || raw.isEmpty()) return Instant.now();
|
||||
try {
|
||||
return LocalDateTime.parse(raw, TIME_FMT).atZone(ZONE).toInstant();
|
||||
} catch (Exception e) {
|
||||
return Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.inbound.mqtt.config.MqttInboundAutoConfiguration
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.lingniu.ingest.inbound.mqtt;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.inbound.mqtt.mapper.YutongEventMapper;
|
||||
import com.lingniu.ingest.inbound.mqtt.model.MqttPayload;
|
||||
import com.lingniu.ingest.inbound.mqtt.parser.MqttPayloadParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class YutongEventMapperTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final MqttPayloadParser parser = new MqttPayloadParser(objectMapper);
|
||||
private final YutongEventMapper mapper = new YutongEventMapper();
|
||||
|
||||
@Test
|
||||
void parsesYutongSamplePayload() {
|
||||
String json = """
|
||||
{
|
||||
"id": "abc",
|
||||
"device": "LTEST000000000001",
|
||||
"code": "C1",
|
||||
"time": "20260413100000",
|
||||
"data": {
|
||||
"METER_SPEED": 52.3,
|
||||
"TOTAL_MILEAGE": 123456.7,
|
||||
"BATTERY_CAPACITY_SOC": 70,
|
||||
"LONGITUDE": 116397128,
|
||||
"LATITUDE": 39916527,
|
||||
"GPSDirection": 90,
|
||||
"LEFT_HYDROGEN": 5.5,
|
||||
"VOLTAGE_OF_FC": 600.0,
|
||||
"CURRENT_OF_FC": 120.0,
|
||||
"TEMPT_OF_FC": 65.0,
|
||||
"ON_GEAR": 3,
|
||||
"ACC_PEDAL_APT": 25,
|
||||
"BRAKEPEDALAPT": 0
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
MqttPayload payload = parser.parse("yutong", "yutong", "/ytforward/shln/dev1", json.getBytes());
|
||||
assertThat(payload.vin()).isEqualTo("LTEST000000000001");
|
||||
|
||||
List<VehicleEvent> events = mapper.toEvents(payload);
|
||||
assertThat(events).hasSize(2);
|
||||
|
||||
VehicleEvent.Realtime rt = (VehicleEvent.Realtime) events.stream()
|
||||
.filter(e -> e instanceof VehicleEvent.Realtime).findFirst().orElseThrow();
|
||||
assertThat(rt.source()).isEqualTo(ProtocolId.MQTT_YUTONG);
|
||||
assertThat(rt.vin()).isEqualTo("LTEST000000000001");
|
||||
assertThat(rt.payload().speedKmh()).isEqualTo(52.3);
|
||||
assertThat(rt.payload().batterySoc()).isEqualTo(70.0);
|
||||
assertThat(rt.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(rt.payload().latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(rt.payload().hydrogenRemainingKg()).isEqualTo(5.5);
|
||||
|
||||
VehicleEvent.Location loc = (VehicleEvent.Location) events.stream()
|
||||
.filter(e -> e instanceof VehicleEvent.Location).findFirst().orElseThrow();
|
||||
assertThat(loc.payload().longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
}
|
||||
}
|
||||
74
inbound-xinda-push/pom.xml
Normal file
74
inbound-xinda-push/pom.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>inbound-xinda-push</artifactId>
|
||||
<name>inbound-xinda-push</name>
|
||||
<description>
|
||||
信达平台推送接入。基于私仓 org.lingniu:gps-push-client:1.0 封装的
|
||||
com.gps31.push.netty.PushClient 重写,保留信达真实帧格式,
|
||||
登录/心跳/订阅/位置/报警 全程对接成功后把事件直接投到 Kafka。
|
||||
</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</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 信达私仓 Push 客户端。com.gps31.push.netty.PushClient 封装了真实信达帧格式。 -->
|
||||
<dependency>
|
||||
<groupId>org.lingniu</groupId>
|
||||
<artifactId>gps-push-client</artifactId>
|
||||
<version>1.0</version>
|
||||
</dependency>
|
||||
<!-- gps-push-client 依赖 fastjson(未在其 pom 里声明,这里补齐) -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>fastjson</artifactId>
|
||||
<version>1.2.83</version>
|
||||
</dependency>
|
||||
<!-- commons-collections4(PushClient 内部用到 ListOrderedSet) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-collections4</artifactId>
|
||||
<version>4.4</version>
|
||||
</dependency>
|
||||
<!-- commons-lang3(PushClient.setSubMsgIds / channelActive 里调用 StringUtils.isBlank / isNotBlank) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
</dependency>
|
||||
<!-- commons-codec(TcpClientHandler 用到 Hex 做字节日志打印) -->
|
||||
<dependency>
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</dependency>
|
||||
<!-- commons-logging:PushClient 使用 JCL;由 Spring Boot 的 jcl-over-slf4j 桥接到 logback,无需额外处理 -->
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.lingniu.ingest.inbound.xinda;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.gps31.push.netty.PushClient;
|
||||
import com.gps31.push.netty.PushMsg;
|
||||
import com.gps31.push.netty.client.TcpClient;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.LocationPayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.inbound.xinda.config.XindaPushProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 信达 Push 接入客户端。基于 {@code org.lingniu:gps-push-client:1.0} 的 {@link PushClient}
|
||||
* 实现,完全使用信达真实协议帧格式(帧头 / 长度 / cmd / json / 帧尾 + 序列号 + 心跳)。
|
||||
*
|
||||
* <p>行为:
|
||||
* <ul>
|
||||
* <li>{@link #start()} 调用基类 {@code TcpClient.start()} 建立连接并自动重连
|
||||
* <li>基类在 {@code channelActive} 自动发送 {@code 0x0001 登录} 帧,携带 userName / pwd / csTag / desc
|
||||
* <li>登录成功收到应答后(由基类内部处理)会自动订阅 {@code subMsgIds}(默认空,需显式调用 setSubMsgIds)
|
||||
* <li>{@link #messageReceived(TcpClient, PushMsg)} 为业务入口:cmd 字符串区分消息类型
|
||||
* <li>位置 {@code 0200} → {@link VehicleEvent.Location}
|
||||
* <li>报警 {@code 0300} → {@link VehicleEvent.Alarm}(转为 Passthrough 保留原始 json 供下游分析)
|
||||
* <li>透传 {@code 0401} → {@link VehicleEvent.Passthrough}
|
||||
* </ul>
|
||||
*
|
||||
* <p>类继承基类 {@code PushClient},不再需要手工处理帧编解码、心跳、重连、订阅等机制。
|
||||
*/
|
||||
public class XindaPushClient extends PushClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(XindaPushClient.class);
|
||||
|
||||
private final XindaPushProperties props;
|
||||
private final Dispatcher dispatcher;
|
||||
private final DisruptorEventBus eventBus;
|
||||
|
||||
public XindaPushClient(XindaPushProperties props, Dispatcher dispatcher, DisruptorEventBus eventBus) {
|
||||
super();
|
||||
this.props = props;
|
||||
this.dispatcher = dispatcher;
|
||||
this.eventBus = eventBus;
|
||||
this.isLog = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动客户端:填入 host/port/user/pwd/desc,配置订阅消息 ID,交给基类建立连接。
|
||||
*/
|
||||
public void start() {
|
||||
if (props.getHost() == null || props.getHost().isBlank()) {
|
||||
log.error("[xinda-push] host not configured, module stays idle");
|
||||
return;
|
||||
}
|
||||
if (props.getUsername() == null || props.getPassword() == null) {
|
||||
log.error("[xinda-push] credentials missing, module stays idle "
|
||||
+ "(set lingniu.ingest.xinda-push.username/password)");
|
||||
return;
|
||||
}
|
||||
setHost(props.getHost());
|
||||
setPort(props.getPort());
|
||||
setUserName(props.getUsername());
|
||||
setPwd(props.getPassword());
|
||||
setDesc(props.getClientDesc());
|
||||
// 订阅消息 ID:信达基类 PushClient.setSubMsgIds 用管道符 "|" 作为分隔符
|
||||
// (内部调用 StringUtil.splitStr(s, "|"))
|
||||
if (props.getSubscribeMsgIds() != null && !props.getSubscribeMsgIds().isEmpty()) {
|
||||
setSubMsgIds(String.join("|", props.getSubscribeMsgIds()));
|
||||
}
|
||||
// 基类 PushClient 构造器默认 isLog=false / isLogBytes=false;
|
||||
// 这里尊重默认值,不再覆盖为 true,避免 <<<Log发送 / >>>Log接收 每条帧都被 ERROR 级打印。
|
||||
// 真要打开 wire-level 诊断时,临时改为 setLog(true) 即可。
|
||||
try {
|
||||
log.info("[xinda-push] starting host={} port={} user={} subMsgIds={}",
|
||||
getHost(), getPort(), getUserName(), getSubMsgIds());
|
||||
super.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[xinda-push] start failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 生命周期停止钩子。
|
||||
*/
|
||||
public void stop() {
|
||||
try {
|
||||
destroy();
|
||||
log.info("[xinda-push] stopped");
|
||||
} catch (Exception e) {
|
||||
log.warn("[xinda-push] stop error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 业务回调:收到完整的 PushMsg 后分派事件
|
||||
// ========================================================================
|
||||
|
||||
@Override
|
||||
public void messageReceived(TcpClient client, PushMsg msg) throws Exception {
|
||||
super.messageReceived(client, msg);
|
||||
String cmd = msg.getCmd();
|
||||
if (cmd == null) return;
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "8001" -> handleLoginAck(msg);
|
||||
case "8002" -> log.debug("[xinda-push] heartbeat ack");
|
||||
case "8003" -> log.info("[xinda-push] subscribe ack json={}", msg.getJson());
|
||||
case "0200" -> handleLocation(msg);
|
||||
case "0300" -> handleAlarm(msg);
|
||||
case "0401" -> handlePassthrough(msg);
|
||||
default -> log.debug("[xinda-push] rx cmd={} json={}", cmd, msg.getJson());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 8001 登录应答后处理:若 {@code rspResult=0} 则主动发送 0003 订阅命令。
|
||||
*
|
||||
* <p>信达订阅命令的 body 格式(从旧 {@code PushClientHandler} 和 8003 错误提示反推得到):
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "seq": "1", // 序列号
|
||||
* "action": "add", // 操作: add 订阅 / del 取消
|
||||
* "msgIds": "[\"0200\",\"0300\",\"0401\"]" // JSON 数组字符串(注意是字符串,不是数组)
|
||||
* }
|
||||
* }</pre>
|
||||
* <p>
|
||||
* 其中 {@code msgIds} 的值来自基类 {@link PushClient#getSubCmdSet()},内容由
|
||||
* {@link PushClient#setSubMsgIds(String)} 填充(分隔符是 {@code |})。
|
||||
*/
|
||||
private void handleLoginAck(PushMsg msg) throws Exception {
|
||||
String json = msg.getJson() == null ? "" : msg.getJson();
|
||||
log.info("[xinda-push] login ack json={}", json);
|
||||
JSONObject parsed = JSON.parseObject(json);
|
||||
String rsp = parsed == null ? null : parsed.getString("rspResult");
|
||||
if (!"0".equals(rsp)) {
|
||||
log.error("[xinda-push] login REJECTED rspResult={}", rsp);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new java.util.HashMap<>();
|
||||
body.put("seq", "1");
|
||||
body.put("action", "add");
|
||||
body.put("msgIds", JSON.toJSONString(getSubCmdSet()));
|
||||
PushMsg subscribe = getInstance("0003", body);
|
||||
sendMsg(subscribe);
|
||||
log.info("[xinda-push] subscribe sent action=add msgIds={}", getSubCmdSet());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelActive(TcpClient client) throws Exception {
|
||||
super.channelActive(client);
|
||||
log.info("[xinda-push] channel active, login frame sent");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(TcpClient client) throws Exception {
|
||||
super.channelInactive(client);
|
||||
log.warn("[xinda-push] channel inactive, will auto-reconnect");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 业务处理(0200/0300/0401 → VehicleEvent)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 0200 定位消息 → {@link VehicleEvent.Location}。
|
||||
* <p>信达 0200 常见字段(不同版本可能略有差异):
|
||||
* <pre>
|
||||
* plateNo 车牌号
|
||||
* lat/lng WGS84 度 * 1e6 (整数)
|
||||
* speed km/h
|
||||
* drct 方向
|
||||
* height 海拔 m
|
||||
* alarmStts 报警状态位
|
||||
* statusStts 状态位
|
||||
* time yyyyMMddHHmmss
|
||||
* </pre>
|
||||
*/
|
||||
private void handleLocation(PushMsg msg) {
|
||||
JSONObject json = JSON.parseObject(msg.getJson());
|
||||
String vin = firstNonBlank(json, "vin", "plateNo", "carNo");
|
||||
double lon = asDouble(json, "lng", "lon", "longitude") / 1_000_000.0;
|
||||
double lat = asDouble(json, "lat", "latitude") / 1_000_000.0;
|
||||
// 有的实现已经是十进制度,做一次保护:如果 |lon| > 1000 显然是 1e6 单位再除一次
|
||||
if (Math.abs(lon) > 1000) lon /= 1.0; // already divided above
|
||||
double speed = asDouble(json, "speed");
|
||||
double direction = asDouble(json, "drct", "direction");
|
||||
long alarmFlag = (long) asDouble(json, "alarmStts", "alarmFlag");
|
||||
long statusFlag = (long) asDouble(json, "statusStts", "statusFlag");
|
||||
|
||||
VehicleEvent.Location loc = new VehicleEvent.Location(
|
||||
UUID.randomUUID().toString(),
|
||||
vin.isBlank() ? "unknown" : vin,
|
||||
ProtocolId.XINDA_PUSH,
|
||||
Instant.now(), Instant.now(),
|
||||
null,
|
||||
Map.of("source", "xinda-push", "cmd", "0200"),
|
||||
new LocationPayload(lon, lat, 0.0, speed, direction, alarmFlag, statusFlag));
|
||||
eventBus.publish(loc);
|
||||
log.debug("[xinda-push] 0200 location vin={} lon={} lat={} speed={}", vin, lon, lat, speed);
|
||||
}
|
||||
|
||||
private void handleAlarm(PushMsg msg) {
|
||||
emitPassthrough(0x0300, msg);
|
||||
log.info("[xinda-push] 0300 alarm json={}", msg.getJson());
|
||||
}
|
||||
|
||||
private void handlePassthrough(PushMsg msg) {
|
||||
emitPassthrough(0x0401, msg);
|
||||
log.debug("[xinda-push] 0401 passthrough json={}", msg.getJson());
|
||||
}
|
||||
|
||||
private void emitPassthrough(int type, PushMsg msg) {
|
||||
JSONObject json = JSON.parseObject(msg.getJson());
|
||||
String vin = firstNonBlank(json, "vin", "plateNo", "carNo");
|
||||
byte[] raw = msg.getJson() == null
|
||||
? new byte[0] : msg.getJson().getBytes(StandardCharsets.UTF_8);
|
||||
VehicleEvent.Passthrough pt = new VehicleEvent.Passthrough(
|
||||
UUID.randomUUID().toString(),
|
||||
vin.isBlank() ? "unknown" : vin,
|
||||
ProtocolId.XINDA_PUSH,
|
||||
Instant.now(), Instant.now(),
|
||||
null,
|
||||
Map.of("source", "xinda-push", "cmd", String.format("%04x", type),
|
||||
"rawJson", msg.getJson() == null ? "" : msg.getJson()),
|
||||
type, raw);
|
||||
eventBus.publish(pt);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// JSON helpers
|
||||
// ========================================================================
|
||||
|
||||
private static String firstNonBlank(JSONObject node, String... keys) {
|
||||
if (node == null) return "";
|
||||
for (String k : keys) {
|
||||
Object v = node.get(k);
|
||||
if (v != null && !v.toString().isBlank()) return v.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static double asDouble(JSONObject node, String... keys) {
|
||||
if (node == null) return 0.0;
|
||||
for (String k : keys) {
|
||||
Object v = node.get(k);
|
||||
if (v instanceof Number n) return n.doubleValue();
|
||||
if (v != null) {
|
||||
try {
|
||||
return Double.parseDouble(v.toString());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.lingniu.ingest.inbound.xinda.config;
|
||||
|
||||
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
|
||||
import com.lingniu.ingest.core.dispatcher.Dispatcher;
|
||||
import com.lingniu.ingest.inbound.xinda.XindaPushClient;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(XindaPushProperties.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.xinda-push", name = "enabled", havingValue = "true")
|
||||
public class XindaPushAutoConfiguration {
|
||||
|
||||
@Bean(destroyMethod = "stop")
|
||||
@ConditionalOnMissingBean
|
||||
public XindaPushClient xindaPushClient(XindaPushProperties props,
|
||||
Dispatcher dispatcher,
|
||||
DisruptorEventBus eventBus) {
|
||||
return new XindaPushClient(props, dispatcher, eventBus);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApplicationRunner xindaPushStartupRunner(XindaPushClient client) {
|
||||
return new ApplicationRunner() {
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
client.start();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.lingniu.ingest.inbound.xinda.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.xinda-push")
|
||||
public class XindaPushProperties {
|
||||
|
||||
private boolean enabled = false;
|
||||
private String host;
|
||||
private int port = 10100;
|
||||
private String username;
|
||||
private String password;
|
||||
private String clientDesc = "lingniu-ingest";
|
||||
private List<String> subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401"));
|
||||
/** 断线重连间隔(秒)。 */
|
||||
private int reconnectIntervalSec = 5;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public String getHost() { return host; }
|
||||
public void setHost(String host) { this.host = host; }
|
||||
public int getPort() { return port; }
|
||||
public void setPort(int port) { this.port = port; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public String getClientDesc() { return clientDesc; }
|
||||
public void setClientDesc(String clientDesc) { this.clientDesc = clientDesc; }
|
||||
public List<String> getSubscribeMsgIds() { return subscribeMsgIds; }
|
||||
public void setSubscribeMsgIds(List<String> subscribeMsgIds) { this.subscribeMsgIds = subscribeMsgIds; }
|
||||
public int getReconnectIntervalSec() { return reconnectIntervalSec; }
|
||||
public void setReconnectIntervalSec(int reconnectIntervalSec) { this.reconnectIntervalSec = reconnectIntervalSec; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.inbound.xinda.config.XindaPushAutoConfiguration
|
||||
29
ingest-api/pom.xml
Normal file
29
ingest-api/pom.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>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,14 @@
|
||||
package com.lingniu.ingest.api;
|
||||
|
||||
/**
|
||||
* 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。
|
||||
* 新增协议需要在此处登记,避免散落的字符串常量。
|
||||
*/
|
||||
public enum ProtocolId {
|
||||
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,37 @@
|
||||
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 纬度(可选)
|
||||
*/
|
||||
public record AlarmPayload(
|
||||
AlarmLevel level,
|
||||
int alarmTypeCode,
|
||||
String alarmTypeName,
|
||||
List<String> faultCodes,
|
||||
Set<String> activeBits,
|
||||
Double longitude,
|
||||
Double latitude
|
||||
) {
|
||||
/**
|
||||
* 报警等级(归一化的跨协议分类)。
|
||||
*
|
||||
* <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 }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
/** 位置事件载荷。坐标已统一转换为 WGS84 十进制度。 */
|
||||
public record LocationPayload(
|
||||
double longitude,
|
||||
double latitude,
|
||||
double altitudeM,
|
||||
double speedKmh,
|
||||
double directionDeg,
|
||||
long alarmFlag,
|
||||
long statusFlag
|
||||
) {}
|
||||
@@ -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,141 @@
|
||||
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 {
|
||||
|
||||
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 {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
29
ingest-codec-common/pom.xml
Normal file
29
ingest-codec-common/pom.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>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);
|
||||
}
|
||||
}
|
||||
61
ingest-core/pom.xml
Normal file
61
ingest-core/pom.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>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,164 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* {@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(message);
|
||||
}
|
||||
|
||||
@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<Object> 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(Object 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 {
|
||||
Object first = queue.poll(200, TimeUnit.MILLISECONDS);
|
||||
if (first == null) continue;
|
||||
List<Object> 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;
|
||||
Object 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void flush(List<Object> buf) {
|
||||
List<VehicleEvent> events;
|
||||
try {
|
||||
Object result = def.method().invoke(def.bean(), buf);
|
||||
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;
|
||||
} catch (IllegalAccessException e) {
|
||||
log.error("batcher access denied method={}", def.method().getName(), e);
|
||||
return;
|
||||
}
|
||||
for (VehicleEvent e : events) {
|
||||
try {
|
||||
publisher.accept(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,79 @@
|
||||
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 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.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 核心分发器: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 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 {
|
||||
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());
|
||||
continue;
|
||||
}
|
||||
List<VehicleEvent> events = invoker.invoke(def, frame, ctx);
|
||||
for (VehicleEvent e : events) {
|
||||
interceptors.after(e, ctx);
|
||||
eventBus.publish(e);
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
log.error("dispatch failure traceId={}", ctx.traceId(), t);
|
||||
interceptors.onError(t, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,48 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
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,45 @@
|
||||
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.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().getOrDefault("seq", "0");
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
31
observability/pom.xml
Normal file
31
observability/pom.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>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
|
||||
296
pom.xml
Normal file
296
pom.xml
Normal file
@@ -0,0 +1,296 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>lingniu-vehicle-ingest</name>
|
||||
<description>
|
||||
羚牛车辆数据接入平台 v2。
|
||||
模块化原子能力 + 协议解耦 + 业务/实时解耦 + 数据出 Kafka 不落业务库。
|
||||
Java 25 + Spring Boot 3.4 + Netty + Disruptor + Virtual Threads。
|
||||
</description>
|
||||
|
||||
<modules>
|
||||
<module>ingest-api</module>
|
||||
<module>ingest-codec-common</module>
|
||||
<module>ingest-core</module>
|
||||
<module>session-core</module>
|
||||
<module>observability</module>
|
||||
<module>sink-mq</module>
|
||||
<module>sink-archive</module>
|
||||
<module>protocol-gb32960</module>
|
||||
<module>protocol-jt808</module>
|
||||
<module>protocol-jt1078</module>
|
||||
<module>protocol-jsatl12</module>
|
||||
<module>inbound-mqtt</module>
|
||||
<module>inbound-xinda-push</module>
|
||||
<module>command-gateway</module>
|
||||
<module>bootstrap-all</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<java.version>25</java.version>
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
|
||||
<spring-boot.version>3.5.3</spring-boot.version>
|
||||
<netty.version>4.2.9.Final</netty.version>
|
||||
<disruptor.version>4.0.0</disruptor.version>
|
||||
<protobuf.version>4.28.3</protobuf.version>
|
||||
<kafka.version>3.8.1</kafka.version>
|
||||
<caffeine.version>3.1.8</caffeine.version>
|
||||
<resilience4j.version>2.2.0</resilience4j.version>
|
||||
<micrometer.version>1.13.6</micrometer.version>
|
||||
<otel.version>1.42.1</otel.version>
|
||||
<hivemq-mqtt.version>1.3.3</hivemq-mqtt.version>
|
||||
<lombok.version>1.18.34</lombok.version>
|
||||
<junit.version>5.11.3</junit.version>
|
||||
<assertj.version>3.26.3</assertj.version>
|
||||
<mockito.version>5.14.2</mockito.version>
|
||||
<archunit.version>1.3.0</archunit.version>
|
||||
<testcontainers.version>1.20.3</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<!--
|
||||
所有版本管理集中在父 pom 的 dependencyManagement。
|
||||
子模块继承父 pom 即可直接使用,无需单独 import BOM,避免循环引用。
|
||||
-->
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- 外部 BOM —— Maven dependencyManagement 是先到先得,
|
||||
netty-bom 必须放在 spring-boot-dependencies 之前才能覆盖
|
||||
后者带的旧版 netty-all/netty-common(否则会被锁回 4.1.x)。 -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-bom</artifactId>
|
||||
<version>${netty.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-bom</artifactId>
|
||||
<version>${resilience4j.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-bom</artifactId>
|
||||
<version>${micrometer.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-bom</artifactId>
|
||||
<version>${otel.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-bom</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 内部模块 -->
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-codec-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>session-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>observability</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>sink-mq</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>sink-archive</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>protocol-gb32960</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>protocol-jt808</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>protocol-jt1078</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>protocol-jsatl12</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>inbound-mqtt</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>inbound-xinda-push</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>command-gateway</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 第三方(非 BOM 管理) -->
|
||||
<dependency>
|
||||
<groupId>com.lmax</groupId>
|
||||
<artifactId>disruptor</artifactId>
|
||||
<version>${disruptor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java</artifactId>
|
||||
<version>${protobuf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.protobuf</groupId>
|
||||
<artifactId>protobuf-java-util</artifactId>
|
||||
<version>${protobuf.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.kafka</groupId>
|
||||
<artifactId>kafka-clients</artifactId>
|
||||
<version>${kafka.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.hivemq</groupId>
|
||||
<artifactId>hivemq-mqtt-client</artifactId>
|
||||
<version>${hivemq-mqtt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>${caffeine.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>${assertj.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>${archunit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<!--
|
||||
羚牛私仓:托管 org.lingniu:gps-push-client:1.0 等内部 artifact。
|
||||
访问凭证配置在 ~/.m2/settings.xml 的 <server><id>lingniu-nexus</id></server> 中,
|
||||
pom 不保存明文密码。
|
||||
-->
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>lingniu-nexus</id>
|
||||
<name>Lingniu Private Maven Repository</name>
|
||||
<url>https://nexus.lnh2e.com/repository/maven-public/</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
58
protocol-gb32960/pom.xml
Normal file
58
protocol-gb32960/pom.xml
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<artifactId>protocol-gb32960</artifactId>
|
||||
<name>protocol-gb32960</name>
|
||||
<description>GB/T 32960 新能源车国标协议接入(Netty Server,默认 TCP 9000)。</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>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-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-all</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</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,94 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.auth;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 平台登入(0x05)账号白名单校验器。
|
||||
*
|
||||
* <p>与 {@link Gb32960VinAuthorizer} 区别:
|
||||
* <ul>
|
||||
* <li>车辆登入靠 VIN 白名单(无密码)
|
||||
* <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单
|
||||
* </ul>
|
||||
*
|
||||
* <p>匹配规则:
|
||||
* <ol>
|
||||
* <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权)
|
||||
* <li>用户名不存在 → {@link Result#DENY_UNKNOWN_USER}
|
||||
* <li>密码不匹配 → {@link Result#DENY_BAD_PASSWORD}
|
||||
* <li>IP 白名单非空且 peer IP 不在其中 → {@link Result#DENY_IP_NOT_ALLOWED}
|
||||
* <li>全部通过 → {@link Result#ALLOW}
|
||||
* </ol>
|
||||
*/
|
||||
public final class Gb32960PlatformAuthorizer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Gb32960PlatformAuthorizer.class);
|
||||
|
||||
private final Map<String, Gb32960Properties.Auth.Platform> byUsername;
|
||||
|
||||
public Gb32960PlatformAuthorizer(List<Gb32960Properties.Auth.Platform> platforms) {
|
||||
Map<String, Gb32960Properties.Auth.Platform> map = new HashMap<>();
|
||||
for (Gb32960Properties.Auth.Platform p : platforms) {
|
||||
if (p.getUsername() == null || p.getUsername().isBlank()) continue;
|
||||
map.put(p.getUsername().trim(), p);
|
||||
}
|
||||
this.byUsername = Collections.unmodifiableMap(map);
|
||||
log.info("[gb32960] platform authorizer loaded {} platform(s)", this.byUsername.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权一次平台登入请求。
|
||||
*
|
||||
* @param username 终端发来的用户名
|
||||
* @param password 终端发来的密码
|
||||
* @param peerIp 连接对端 IP(纯 IPv4/IPv6,不含端口)
|
||||
*/
|
||||
public Result authenticate(String username, String password, String peerIp) {
|
||||
if (byUsername.isEmpty()) return Result.ALLOW_NO_POLICY;
|
||||
Gb32960Properties.Auth.Platform entry = byUsername.get(username == null ? "" : username.trim());
|
||||
if (entry == null) return Result.DENY_UNKNOWN_USER;
|
||||
if (!Objects.equals(entry.getPassword(), password)) return Result.DENY_BAD_PASSWORD;
|
||||
Set<String> allowed = entry.getAllowedIps() == null
|
||||
? Set.of()
|
||||
: Set.copyOf(entry.getAllowedIps());
|
||||
if (!allowed.isEmpty() && peerIp != null && !allowed.contains(peerIp)) {
|
||||
return Result.DENY_IP_NOT_ALLOWED;
|
||||
}
|
||||
return Result.ALLOW;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return byUsername.size();
|
||||
}
|
||||
|
||||
public boolean isEnforcing() {
|
||||
return !byUsername.isEmpty();
|
||||
}
|
||||
|
||||
/** 鉴权结果枚举,调用方根据结果选择应答标志和日志级别。 */
|
||||
public enum Result {
|
||||
/** 匹配成功。 */
|
||||
ALLOW(true),
|
||||
/** 配置列表为空,放行但标记未鉴权。 */
|
||||
ALLOW_NO_POLICY(true),
|
||||
/** 用户名不存在。 */
|
||||
DENY_UNKNOWN_USER(false),
|
||||
/** 密码错误。 */
|
||||
DENY_BAD_PASSWORD(false),
|
||||
/** IP 白名单拒绝。 */
|
||||
DENY_IP_NOT_ALLOWED(false);
|
||||
|
||||
private final boolean accepted;
|
||||
Result(boolean accepted) { this.accepted = accepted; }
|
||||
public boolean accepted() { return accepted; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.auth;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* GB/T 32960 VIN 白名单校验器。
|
||||
*
|
||||
* <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单
|
||||
* (未来可扩展为从 Nacos / 数据库定时刷新)。
|
||||
*/
|
||||
public final class Gb32960VinAuthorizer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Gb32960VinAuthorizer.class);
|
||||
|
||||
private final boolean enabled;
|
||||
private final boolean caseSensitive;
|
||||
private final AtomicReference<Set<String>> whitelist;
|
||||
|
||||
public Gb32960VinAuthorizer(Gb32960Properties.Auth cfg) {
|
||||
this.enabled = cfg.isEnabled();
|
||||
this.caseSensitive = cfg.isCaseSensitive();
|
||||
this.whitelist = new AtomicReference<>(cfg.normalizedWhitelist());
|
||||
log.info("[gb32960] VIN authorizer enabled={} caseSensitive={} whitelistSize={}",
|
||||
enabled, caseSensitive, this.whitelist.get().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权。
|
||||
*
|
||||
* @return true 通过;false 拒绝(调用方应发送 0x04 VIN_NOT_EXIST 应答并关闭连接)
|
||||
*/
|
||||
public boolean isAllowed(String vin) {
|
||||
if (!enabled) return true;
|
||||
if (vin == null || vin.isBlank()) return false;
|
||||
String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase();
|
||||
return whitelist.get().contains(key);
|
||||
}
|
||||
|
||||
public boolean isEnforcing() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public int whitelistSize() {
|
||||
return whitelist.get().size();
|
||||
}
|
||||
|
||||
/** 运行时热更新。接入 Nacos/DB 监听后调用。 */
|
||||
public void replaceWhitelist(Set<String> newVins) {
|
||||
Set<String> norm = Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>(newVins.size()));
|
||||
for (String v : newVins) {
|
||||
if (v == null || v.isBlank()) continue;
|
||||
norm.add(caseSensitive ? v.trim() : v.trim().toUpperCase());
|
||||
}
|
||||
this.whitelist.set(norm);
|
||||
log.info("[gb32960] VIN whitelist replaced size={}", norm.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 实时/补发上报数据单元循环解析器。
|
||||
*
|
||||
* <p>数据单元布局(表 7):{@code timestamp(6B) | (typeFlag(1B) + infoBody)* | [0xFF 签名信息]}。
|
||||
* 其中时间戳由 {@link Gb32960MessageDecoder} 负责剥离,本 parser 只处理信息体循环。
|
||||
*
|
||||
* <p>支持的信息类型标志(表 9):
|
||||
* <table>
|
||||
* <tr><th>2016</th><th>2025</th><th>含义</th></tr>
|
||||
* <tr><td>0x01</td><td>0x01</td><td>整车数据</td></tr>
|
||||
* <tr><td>0x02</td><td>0x02</td><td>驱动电机数据</td></tr>
|
||||
* <tr><td>0x03</td><td>0x03</td><td>燃料电池发动机及车载氢系统数据</td></tr>
|
||||
* <tr><td>0x04</td><td>0x04</td><td>发动机数据</td></tr>
|
||||
* <tr><td>0x05</td><td>0x05</td><td>车辆位置数据(2025 版增加坐标系字段)</td></tr>
|
||||
* <tr><td>0x06 极值</td><td>0x06 报警</td><td> </td></tr>
|
||||
* <tr><td>0x07 报警</td><td>0x07 动力蓄电池最小并联单元电压</td><td> </td></tr>
|
||||
* <tr><td>0x08 储能电压</td><td>0x08 动力蓄电池温度</td><td> </td></tr>
|
||||
* <tr><td>0x09 储能温度</td><td>—</td><td> </td></tr>
|
||||
* <tr><td>—</td><td>0x30</td><td>燃料电池电堆</td></tr>
|
||||
* <tr><td>—</td><td>0x31</td><td>超级电容器</td></tr>
|
||||
* <tr><td>—</td><td>0x32</td><td>超级电容器极值</td></tr>
|
||||
* <tr><td>—</td><td>0xFF</td><td>签名数据开始标识(2025 版新增)</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>处理策略:
|
||||
* <ul>
|
||||
* <li>遇到 {@code 0xFF}(2025 版签名起始标识):停止循环,剩余字节作为签名原始字节返回
|
||||
* <li>遇到未注册的类型:作为单个 {@link InfoBlock.Raw} 包装剩余字节并终止循环,避免丢失整帧
|
||||
* </ul>
|
||||
*/
|
||||
public final class Gb32960BodyParser {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Gb32960BodyParser.class);
|
||||
|
||||
/** 2025 版实时上报尾部签名标识(表 9)。 */
|
||||
private static final int SIGNATURE_MARKER = 0xFF;
|
||||
|
||||
private final InfoBlockParserRegistry registry;
|
||||
|
||||
public Gb32960BodyParser(InfoBlockParserRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
public Gb32960MessageDecoder.BodyParseResult parse(ProtocolVersion version, ByteBuffer body) {
|
||||
List<InfoBlock> blocks = new ArrayList<>(8);
|
||||
byte[] signature = null;
|
||||
// 捕获 info-block 区间的起止位置,用于未知块出现时打 dump 帮助手动定位漂移点。
|
||||
// body 由 Gb32960MessageDecoder 通过 ByteBuffer.wrap(frame, bodyStart+6, dataLength-6) 构造,
|
||||
// arrayOffset()=0,position() = bodyStart+6,limit() = bodyStart + dataLength。
|
||||
final int infoBlocksStart = body.position();
|
||||
final int infoBlocksEnd = body.limit();
|
||||
|
||||
while (body.hasRemaining()) {
|
||||
int typeStartPos = body.position();
|
||||
int typeCode = body.get() & 0xFF;
|
||||
|
||||
if (typeCode == SIGNATURE_MARKER && version == ProtocolVersion.V2025) {
|
||||
// 剩余全部字节即为签名内容(表 8)
|
||||
signature = new byte[body.remaining()];
|
||||
body.get(signature);
|
||||
break;
|
||||
}
|
||||
|
||||
InfoBlockParser parser = registry.find(version, typeCode);
|
||||
if (parser == null) {
|
||||
// lenient:未知类型无法安全跳过,吞剩余字节作为 Raw 后终止。
|
||||
// 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。
|
||||
int remaining = body.remaining();
|
||||
byte[] rest = new byte[remaining];
|
||||
body.get(rest);
|
||||
log.warn("[gb32960] unknown info block typeCode=0x{} version={} bodyPos={} parsedBlocks={} remaining={} preview={}",
|
||||
Integer.toHexString(typeCode), version, typeStartPos,
|
||||
blocks.size(), remaining, hexPreview(rest, 16));
|
||||
// 一次性把整段 info-block 区间的原始字节打出来,并在 typeStartPos 处插入标记,
|
||||
// 帮助人工对比规范字段长度、定位是哪个早期块对齐错。
|
||||
if (body.hasArray()) {
|
||||
log.warn("[gb32960] body dump (info-block region) infoBlocksStart={} infoBlocksEnd={} typeStartPos={} totalLen={} alreadyParsed={}\n{}",
|
||||
infoBlocksStart, infoBlocksEnd, typeStartPos,
|
||||
infoBlocksEnd - infoBlocksStart, blocks.size(),
|
||||
hexDumpWithMarker(body.array(), infoBlocksStart, infoBlocksEnd, typeStartPos));
|
||||
}
|
||||
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, rest));
|
||||
break;
|
||||
}
|
||||
|
||||
int fixedLen = parser.fixedLength();
|
||||
int posBefore = body.position();
|
||||
if (fixedLen >= 0 && body.remaining() < fixedLen) {
|
||||
throw new DecodeException(
|
||||
"info block 0x" + Integer.toHexString(typeCode)
|
||||
+ " needs " + fixedLen + " bytes but got " + body.remaining());
|
||||
}
|
||||
InfoBlock block = parser.parse(body);
|
||||
int consumed = body.position() - posBefore;
|
||||
if (fixedLen >= 0 && consumed != fixedLen) {
|
||||
throw new DecodeException(
|
||||
"parser " + parser.getClass().getSimpleName()
|
||||
+ " consumed " + consumed
|
||||
+ " bytes but declared " + fixedLen);
|
||||
}
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[gb32960] block typeCode=0x{} parser={} pos {}→{} consumed={} declaredFixed={}",
|
||||
Integer.toHexString(typeCode), parser.getClass().getSimpleName(),
|
||||
typeStartPos, body.position(), consumed, fixedLen);
|
||||
}
|
||||
blocks.add(block);
|
||||
}
|
||||
|
||||
return new Gb32960MessageDecoder.BodyParseResult(blocks, signature);
|
||||
}
|
||||
|
||||
private static String hexPreview(byte[] bytes, int max) {
|
||||
int n = Math.min(bytes.length, max);
|
||||
StringBuilder sb = new StringBuilder(n * 2);
|
||||
for (int i = 0; i < n; i++) sb.append(String.format("%02x", bytes[i]));
|
||||
if (bytes.length > max) sb.append("..");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 16 字节/行的 hex dump,遇到 typeStartPos 时在该字节后插入 {@code |<<<HERE} 标记。
|
||||
* 行首打印绝对偏移(与 BodyParser 上下文里的 bodyPos 字段一致),方便逐字段倒查。
|
||||
*/
|
||||
private static String hexDumpWithMarker(byte[] arr, int from, int toExclusive, int markerAbs) {
|
||||
StringBuilder sb = new StringBuilder((toExclusive - from) * 4);
|
||||
for (int i = from; i < toExclusive; i += 16) {
|
||||
sb.append(String.format("%04d:", i));
|
||||
for (int j = 0; j < 16 && i + j < toExclusive; j++) {
|
||||
int p = i + j;
|
||||
sb.append(' ').append(String.format("%02x", arr[p]));
|
||||
if (p == markerAbs) sb.append("<");
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 非实时上报的命令数据单元解析器。覆盖:
|
||||
* <ul>
|
||||
* <li>0x01 车辆登入(表 6)
|
||||
* <li>0x04 车辆登出(表 28)
|
||||
* <li>0x05 平台登入(表 29)
|
||||
* <li>0x06 平台登出(表 30)
|
||||
* <li>0x07 心跳(空)
|
||||
* <li>0x08 终端校时(空)
|
||||
* <li>0x09 激活(表 B.3)
|
||||
* <li>0x0A 激活应答(表 B.4)
|
||||
* <li>0x0B 密钥交换(表 31)
|
||||
* </ul>
|
||||
*
|
||||
* <p>其它命令(查询/设置/控制/自定义)保留为 {@link CommandBody.RawCommand}。
|
||||
*/
|
||||
public final class Gb32960CommandParser {
|
||||
|
||||
private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
public CommandBody parse(CommandType command, ByteBuffer buf) {
|
||||
return switch (command) {
|
||||
case VEHICLE_LOGIN -> parseVehicleLogin(buf);
|
||||
case VEHICLE_LOGOUT -> parseVehicleLogout(buf);
|
||||
case PLATFORM_LOGIN -> parsePlatformLogin(buf);
|
||||
case PLATFORM_LOGOUT -> parsePlatformLogout(buf);
|
||||
case HEARTBEAT -> new CommandBody.Heartbeat();
|
||||
case TIME_CALIBRATION -> new CommandBody.TimeCalibration();
|
||||
case ACTIVATION -> parseActivation(buf);
|
||||
case ACTIVATION_RESPONSE -> parseActivationResponse(buf);
|
||||
case KEY_EXCHANGE -> parseKeyExchange(buf);
|
||||
case QUERY -> parseQueryParams(buf);
|
||||
case SET_PARAMS -> parseSetParams(buf);
|
||||
case TERMINAL_CONTROL -> parseTerminalControl(buf);
|
||||
default -> captureRaw(command, buf);
|
||||
};
|
||||
}
|
||||
|
||||
/** 表 6:车辆登入。 */
|
||||
private CommandBody.VehicleLogin parseVehicleLogin(ByteBuffer buf) {
|
||||
Instant time = readTime(buf);
|
||||
int serialNo = buf.getShort() & 0xFFFF;
|
||||
String iccid = readAscii(buf, 20);
|
||||
// 2025 版新增:电池管理系统数 n + 各系统包数 + 编码列表
|
||||
int batterySystemCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
|
||||
List<Integer> packCounts = new ArrayList<>();
|
||||
int totalPacks = 0;
|
||||
for (int i = 0; i < batterySystemCount && buf.hasRemaining(); i++) {
|
||||
int c = buf.get() & 0xFF;
|
||||
packCounts.add(c);
|
||||
totalPacks += c;
|
||||
}
|
||||
List<String> codes = new ArrayList<>(totalPacks);
|
||||
for (int i = 0; i < totalPacks && buf.remaining() >= 24; i++) {
|
||||
codes.add(readAscii(buf, 24));
|
||||
}
|
||||
return new CommandBody.VehicleLogin(time, serialNo, iccid, batterySystemCount, packCounts, codes);
|
||||
}
|
||||
|
||||
private CommandBody.VehicleLogout parseVehicleLogout(ByteBuffer buf) {
|
||||
return new CommandBody.VehicleLogout(readTime(buf), buf.getShort() & 0xFFFF);
|
||||
}
|
||||
|
||||
private CommandBody.PlatformLogin parsePlatformLogin(ByteBuffer buf) {
|
||||
Instant time = readTime(buf);
|
||||
int serial = buf.getShort() & 0xFFFF;
|
||||
String user = readAscii(buf, 12);
|
||||
String pwd = readAscii(buf, 20);
|
||||
EncryptType enc = EncryptType.of(buf.get() & 0xFF);
|
||||
return new CommandBody.PlatformLogin(time, serial, user, pwd, enc);
|
||||
}
|
||||
|
||||
private CommandBody.PlatformLogout parsePlatformLogout(ByteBuffer buf) {
|
||||
return new CommandBody.PlatformLogout(readTime(buf), buf.getShort() & 0xFFFF);
|
||||
}
|
||||
|
||||
private CommandBody.Activation parseActivation(ByteBuffer buf) {
|
||||
Instant time = readTime(buf);
|
||||
String chipId = readAscii(buf, 16);
|
||||
int keyLen = buf.getShort() & 0xFFFF;
|
||||
byte[] pubKey = new byte[Math.min(keyLen, buf.remaining())];
|
||||
buf.get(pubKey);
|
||||
String vin = readAscii(buf, 17);
|
||||
byte[] signature = new byte[buf.remaining()];
|
||||
buf.get(signature);
|
||||
return new CommandBody.Activation(time, chipId, pubKey, vin, signature);
|
||||
}
|
||||
|
||||
private CommandBody.ActivationResponse parseActivationResponse(ByteBuffer buf) {
|
||||
return new CommandBody.ActivationResponse(buf.get() & 0xFF, buf.get() & 0xFF);
|
||||
}
|
||||
|
||||
private CommandBody.KeyExchange parseKeyExchange(ByteBuffer buf) {
|
||||
EncryptType keyType = EncryptType.of(buf.get() & 0xFF);
|
||||
int keyLen = buf.getShort() & 0xFFFF;
|
||||
byte[] key = new byte[Math.min(keyLen, buf.remaining() - 12)];
|
||||
buf.get(key);
|
||||
Instant activateAt = readTime(buf);
|
||||
Instant expireAt = readTime(buf);
|
||||
return new CommandBody.KeyExchange(keyType, key, activateAt, expireAt);
|
||||
}
|
||||
|
||||
private CommandBody.RawCommand captureRaw(CommandType command, ByteBuffer buf) {
|
||||
byte[] bytes = new byte[buf.remaining()];
|
||||
buf.get(bytes);
|
||||
return new CommandBody.RawCommand(command.code(), bytes);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 表 B.5-B.13 下行命令
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 参数查询 0x80(表 B.5):{@code time(6) + count(1) + paramId(count)}。
|
||||
* 如果 count > 0,则是查询命令;如果 count == 0 可能是终端应答(表 B.6),
|
||||
* 应答带参数项列表(表 B.7),paramValue 长度根据 paramId 查表 B.8 决定。
|
||||
*/
|
||||
private CommandBody parseQueryParams(ByteBuffer buf) {
|
||||
Instant time = readTime(buf);
|
||||
int count = buf.get() & 0xFF;
|
||||
|
||||
// 判定是查询命令还是应答:命令方向下 count 后紧跟 paramId 列表(每项 1 字节);
|
||||
// 应答方向下 count 后是完整的 paramId + paramValue 列表(表 B.7)。
|
||||
// 通过剩余字节数判断:命令形态 remaining == count,应答形态 remaining > count
|
||||
if (buf.remaining() == count) {
|
||||
List<Integer> ids = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) ids.add(buf.get() & 0xFF);
|
||||
return new CommandBody.QueryParams(time, ids);
|
||||
}
|
||||
// 作为应答处理
|
||||
Map<Integer, byte[]> params = readParamItems(buf, count);
|
||||
return new CommandBody.QueryParamsResponse(time, params);
|
||||
}
|
||||
|
||||
/** 参数设置 0x81(表 B.9):{@code time(6) + count(1) + paramItems}。 */
|
||||
private CommandBody.SetParams parseSetParams(ByteBuffer buf) {
|
||||
Instant time = readTime(buf);
|
||||
int count = buf.get() & 0xFF;
|
||||
Map<Integer, byte[]> params = readParamItems(buf, count);
|
||||
return new CommandBody.SetParams(time, params);
|
||||
}
|
||||
|
||||
/** 读取 N 个参数项(表 B.7):{@code paramId(1) + paramValue(按表 B.8 解释)}。 */
|
||||
private Map<Integer, byte[]> readParamItems(ByteBuffer buf, int count) {
|
||||
Map<Integer, byte[]> out = new HashMap<>(count);
|
||||
for (int i = 0; i < count && buf.hasRemaining(); i++) {
|
||||
int paramId = buf.get() & 0xFF;
|
||||
int len = paramValueLength(paramId, buf);
|
||||
if (len < 0 || len > buf.remaining()) {
|
||||
// 长度未知或越界:吞剩余字节作为该参数值后终止
|
||||
byte[] rest = new byte[buf.remaining()];
|
||||
buf.get(rest);
|
||||
out.put(paramId, rest);
|
||||
break;
|
||||
}
|
||||
byte[] value = new byte[len];
|
||||
buf.get(value);
|
||||
out.put(paramId, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表 B.8 返回参数值长度。
|
||||
*
|
||||
* <p>对于变长参数(0x05 域名 / 0x0E 公共平台域名),前一个参数(0x04/0x0D)给出长度;
|
||||
* 这里简化处理:变长参数使用 {@code buf.get() & 0xFF + 1} 预读 + 取值。
|
||||
*/
|
||||
private int paramValueLength(int paramId, ByteBuffer buf) {
|
||||
return switch (paramId) {
|
||||
case 0x01 -> 2; // 本地存储时间周期 WORD
|
||||
case 0x02 -> 1; // 正常信息上报时间周期
|
||||
case 0x03 -> 1; // 报警信息上报时间周期
|
||||
case 0x04 -> 1; // 平台域名长度 M
|
||||
case 0x05 -> {
|
||||
// 平台域名 M 字节,M 由前一个参数的值给出。
|
||||
// 安全起见这里一次吞掉剩余字节(实际场景参数 0x04 和 0x05 通常一起下发)
|
||||
yield buf.remaining();
|
||||
}
|
||||
case 0x06 -> 2; // 平台端口 WORD
|
||||
case 0x07, 0x08 -> 5; // 硬件/固件版本 5 字节
|
||||
case 0x09 -> 1; // 心跳发送周期
|
||||
case 0x0A -> 2; // 终端应答超时时间
|
||||
case 0x0B -> 2; // 平台应答超时时间
|
||||
case 0x0C -> 1; // 登入失败后下次登入间隔
|
||||
case 0x0D -> 1; // 公共平台域名长度 N
|
||||
case 0x0E -> buf.remaining(); // 公共平台域名
|
||||
case 0x0F -> 2; // 公共平台端口
|
||||
case 0x10 -> 1; // 是否抽样监测中
|
||||
default -> -1; // 未知参数:无法决定长度
|
||||
};
|
||||
}
|
||||
|
||||
/** 车载终端控制 0x82(表 B.10)。 */
|
||||
private CommandBody.TerminalControl parseTerminalControl(ByteBuffer buf) {
|
||||
Instant time = readTime(buf);
|
||||
int cmdId = buf.get() & 0xFF;
|
||||
CommandBody.ControlCommand commandId = CommandBody.ControlCommand.of(cmdId);
|
||||
CommandBody.ControlPayload payload = switch (commandId) {
|
||||
case REMOTE_UPGRADE -> parseRemoteUpgrade(buf);
|
||||
case ALARM_COMMAND -> parseAlarmCommand(buf);
|
||||
case SHUTDOWN, RESET, FACTORY_RESET, DISCONNECT, SAMPLING_LINK_ON -> new CommandBody.ControlPayload.None();
|
||||
case UNKNOWN -> captureRawPayload(buf);
|
||||
};
|
||||
return new CommandBody.TerminalControl(time, commandId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表 B.12 远程升级参数。字段之间用半角分号 {@code ;} 分隔。
|
||||
*
|
||||
* <p>字段顺序:URL;apn;dialUser;dialPwd;serverAddress;serverPort;manufacturerCode;hwVer;fwVer;timeoutMin
|
||||
*/
|
||||
private CommandBody.ControlPayload.RemoteUpgrade parseRemoteUpgrade(ByteBuffer buf) {
|
||||
byte[] all = new byte[buf.remaining()];
|
||||
buf.get(all);
|
||||
String s = new String(all, Charset.forName("GBK"));
|
||||
String[] parts = s.split(";", -1);
|
||||
String url = parts.length > 0 ? parts[0] : "";
|
||||
String apn = parts.length > 1 ? parts[1] : "";
|
||||
String user = parts.length > 2 ? parts[2] : "";
|
||||
String pwd = parts.length > 3 ? parts[3] : "";
|
||||
byte[] addr = parts.length > 4 ? parts[4].getBytes(StandardCharsets.US_ASCII) : new byte[0];
|
||||
int port = parts.length > 5 ? parseIntSafe(parts[5]) : 0;
|
||||
String manu = parts.length > 6 ? parts[6] : "";
|
||||
String hw = parts.length > 7 ? parts[7] : "";
|
||||
String fw = parts.length > 8 ? parts[8] : "";
|
||||
int timeoutMin = parts.length > 9 ? parseIntSafe(parts[9]) : 0;
|
||||
return new CommandBody.ControlPayload.RemoteUpgrade(
|
||||
apn, user, pwd, addr, port, manu, hw, fw, url, timeoutMin);
|
||||
}
|
||||
|
||||
/** 表 B.13 报警/预警命令。 */
|
||||
private CommandBody.ControlPayload.AlarmCommand parseAlarmCommand(ByteBuffer buf) {
|
||||
int warningLevel = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
|
||||
byte[] info = new byte[buf.remaining()];
|
||||
buf.get(info);
|
||||
return new CommandBody.ControlPayload.AlarmCommand(warningLevel, info);
|
||||
}
|
||||
|
||||
private CommandBody.ControlPayload.RawPayload captureRawPayload(ByteBuffer buf) {
|
||||
byte[] bytes = new byte[buf.remaining()];
|
||||
buf.get(bytes);
|
||||
return new CommandBody.ControlPayload.RawPayload(bytes);
|
||||
}
|
||||
|
||||
private static int parseIntSafe(String s) {
|
||||
try { return Integer.parseInt(s.trim()); } catch (Exception e) { return 0; }
|
||||
}
|
||||
|
||||
/** 表 5:时间定义 YY MM DD HH MM SS,GMT+8。 */
|
||||
private Instant readTime(ByteBuffer buf) {
|
||||
if (buf.remaining() < 6) {
|
||||
throw new DecodeException("time field requires 6 bytes, got " + buf.remaining());
|
||||
}
|
||||
int year = 2000 + (buf.get() & 0xFF);
|
||||
int month = buf.get() & 0xFF;
|
||||
int day = buf.get() & 0xFF;
|
||||
int hour = buf.get() & 0xFF;
|
||||
int minute = buf.get() & 0xFF;
|
||||
int second = buf.get() & 0xFF;
|
||||
try {
|
||||
return LocalDateTime.of(year, month, day, hour, minute, second).atZone(ZONE_GMT8).toInstant();
|
||||
} catch (Exception e) {
|
||||
throw new DecodeException("invalid time: " + year + "-" + month + "-" + day
|
||||
+ " " + hour + ":" + minute + ":" + second, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String readAscii(ByteBuffer buf, int len) {
|
||||
int safeLen = Math.min(len, buf.remaining());
|
||||
byte[] b = new byte[safeLen];
|
||||
buf.get(b);
|
||||
return new String(b, StandardCharsets.US_ASCII).trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufUtil;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Netty 层的帧分帧器:从字节流里提取一条完整的 32960 帧。
|
||||
*
|
||||
* <p>支持两种起始符:{@code 0x23 0x23 (##)} 2016 版,{@code 0x24 0x24 ($$)} 2025 版。
|
||||
* 只负责拆包粘包,不做业务解析。具体字段解析交给 {@link Gb32960MessageDecoder}。
|
||||
*
|
||||
* <p>开启 DEBUG 日志可以看到每次读到的字节、每条产出的完整帧以及因长度/起始符不匹配被丢弃的字节数。
|
||||
*/
|
||||
public class Gb32960FrameDecoder extends ByteToMessageDecoder {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Gb32960FrameDecoder.class);
|
||||
|
||||
private static final int HEADER_LEN = 24; // 起始(2) + cmd(1) + rsp(1) + vin(17) + enc(1) + len(2)
|
||||
private static final int MAX_FRAME = 8 * 1024;
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
|
||||
if (log.isDebugEnabled() && in.readableBytes() > 0) {
|
||||
int peek = Math.min(in.readableBytes(), 64);
|
||||
log.debug("[gb32960] frame-decoder rx bytes={} head={}",
|
||||
in.readableBytes(),
|
||||
ByteBufUtil.hexDump(in, in.readerIndex(), peek));
|
||||
}
|
||||
while (in.readableBytes() >= HEADER_LEN) {
|
||||
int startIdx = findStart(in);
|
||||
if (startIdx < 0 || startIdx > in.writerIndex() - HEADER_LEN) {
|
||||
int drop = Math.max(0, in.readableBytes() - 1);
|
||||
if (drop > 0) {
|
||||
log.debug("[gb32960] frame-decoder skip {} bytes (no start symbol)", drop);
|
||||
in.skipBytes(drop);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (startIdx != in.readerIndex()) {
|
||||
int drop = startIdx - in.readerIndex();
|
||||
log.debug("[gb32960] frame-decoder skip {} leading bytes before start", drop);
|
||||
in.skipBytes(drop);
|
||||
}
|
||||
int dataLen = in.getUnsignedShort(in.readerIndex() + 22);
|
||||
int frameLen = HEADER_LEN + dataLen + 1;
|
||||
if (frameLen > MAX_FRAME) {
|
||||
log.warn("[gb32960] frame-decoder oversized frame len={} peer={}, skip start",
|
||||
frameLen, ctx.channel().remoteAddress());
|
||||
in.skipBytes(2);
|
||||
continue;
|
||||
}
|
||||
if (in.readableBytes() < frameLen) return;
|
||||
|
||||
byte[] frame = new byte[frameLen];
|
||||
in.readBytes(frame);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[gb32960] frame-decoder emit len={} start={} peer={}",
|
||||
frameLen,
|
||||
String.format("%02x%02x", frame[0], frame[1]),
|
||||
ctx.channel().remoteAddress());
|
||||
}
|
||||
out.add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** 查找下一个合法起始位置:两字节连续 0x23 0x23 或 0x24 0x24。 */
|
||||
private static int findStart(ByteBuf in) {
|
||||
int limit = in.writerIndex() - 1;
|
||||
for (int i = in.readerIndex(); i < limit; i++) {
|
||||
byte b = in.getByte(i);
|
||||
if ((b == 0x23 || b == 0x24) && in.getByte(i + 1) == b) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* GB/T 32960.3 下行帧编码器。主要用于应答场景:平台收到终端上行命令后发送应答。
|
||||
*
|
||||
* <p>应答规则(§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
|
||||
* 重新计算校验位。
|
||||
*/
|
||||
public final class Gb32960FrameEncoder {
|
||||
|
||||
private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
private Gb32960FrameEncoder() {}
|
||||
|
||||
/**
|
||||
* 构造简易应答帧:复用原命令的 command/vin/encryptType,修改应答标志,
|
||||
* 可选携带数据单元(通常只带 6 字节采集时间)。
|
||||
*
|
||||
* @param version 协议版本(决定起始符)
|
||||
* @param command 命令标识(与原命令相同)
|
||||
* @param responseFlag 应答标志:{@link ResponseFlag#SUCCESS}/{@link ResponseFlag#VIN_NOT_EXIST} 等
|
||||
* @param vin 17 字节 VIN
|
||||
* @param collectTime 数据采集时间(保留原帧时间,若无则传 {@code Instant.now()})
|
||||
* @param extraBody 附加数据(可为 null)
|
||||
*/
|
||||
public static byte[] buildResponse(ProtocolVersion version,
|
||||
CommandType command,
|
||||
ResponseFlag responseFlag,
|
||||
String vin,
|
||||
Instant collectTime,
|
||||
byte[] extraBody) {
|
||||
return buildResponse(version, command, responseFlag, vinPadded(vin), collectTime, extraBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同 {@link #buildResponse(ProtocolVersion, CommandType, ResponseFlag, String, Instant, byte[])},
|
||||
* 但直接接收 17 字节原始 VIN。用于回执时原样回显请求帧的 VIN 字节,避免
|
||||
* 平台登入这类 VIN 全 0 的场景被我们用空格重写后导致对端判定 VIN 不匹配。
|
||||
*/
|
||||
public static byte[] buildResponse(ProtocolVersion version,
|
||||
CommandType command,
|
||||
ResponseFlag responseFlag,
|
||||
byte[] vin17,
|
||||
Instant collectTime,
|
||||
byte[] extraBody) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
// 数据采集时间 6 字节(表 5)
|
||||
if (collectTime != null) {
|
||||
LocalDateTime ldt = LocalDateTime.ofInstant(collectTime, ZONE_GMT8);
|
||||
body.write(ldt.getYear() - 2000);
|
||||
body.write(ldt.getMonthValue());
|
||||
body.write(ldt.getDayOfMonth());
|
||||
body.write(ldt.getHour());
|
||||
body.write(ldt.getMinute());
|
||||
body.write(ldt.getSecond());
|
||||
}
|
||||
if (extraBody != null && extraBody.length > 0) {
|
||||
body.write(extraBody, 0, extraBody.length);
|
||||
}
|
||||
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
int dataLength = bodyBytes.length;
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
// 起始符
|
||||
if (version == ProtocolVersion.V2025) {
|
||||
out.write(0x24); out.write(0x24);
|
||||
} else {
|
||||
out.write(0x23); out.write(0x23);
|
||||
}
|
||||
out.write(command.code());
|
||||
out.write(responseFlag.code());
|
||||
// VIN(17 字节,由调用方给出;平台登入等场景直接回显请求的原始字节)
|
||||
byte[] vinBytes = vin17 != null && vin17.length == 17 ? vin17 : vinPadded(null);
|
||||
out.write(vinBytes, 0, 17);
|
||||
// 加密方式:应答不加密
|
||||
out.write(EncryptType.UNENCRYPTED.code());
|
||||
// 数据单元长度 WORD 大端
|
||||
out.write((dataLength >> 8) & 0xFF);
|
||||
out.write(dataLength & 0xFF);
|
||||
out.write(bodyBytes, 0, bodyBytes.length);
|
||||
// BCC:从 cmd(offset 2) 到数据单元结尾
|
||||
byte[] almost = out.toByteArray();
|
||||
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
|
||||
out.write(bcc & 0xFF);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] vinPadded(String vin) {
|
||||
byte[] out = new byte[17];
|
||||
// 填充空格
|
||||
java.util.Arrays.fill(out, (byte) ' ');
|
||||
if (vin == null) return out;
|
||||
byte[] src = vin.getBytes(StandardCharsets.US_ASCII);
|
||||
int len = Math.min(src.length, 17);
|
||||
System.arraycopy(src, 0, out, 0, len);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.api.spi.FrameDecoder;
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 完整帧 → {@link Gb32960Message} 的解码器。
|
||||
*
|
||||
* <p>支持 GB/T 32960.3 两个版本:
|
||||
* <ul>
|
||||
* <li>{@code 0x23 0x23 (##)} → 2016 版({@link ProtocolVersion#V2016})
|
||||
* <li>{@code 0x24 0x24 ($$)} → 2025 版({@link ProtocolVersion#V2025})
|
||||
* </ul>
|
||||
*
|
||||
* <p>完整数据包结构(表 2 / 表 B.1):
|
||||
* <pre>
|
||||
* offset 0: 起始符 (2B)
|
||||
* offset 2: 命令标识 (1B)
|
||||
* offset 3: 应答标志 (1B)
|
||||
* offset 4: 唯一识别码 VIN (17B ASCII)
|
||||
* offset 21: 数据单元加密方式 (1B)
|
||||
* offset 22: 数据单元长度 (2B WORD 大端)
|
||||
* offset 24: 数据单元 (dataLength 字节)
|
||||
* last byte: 校验码 (1B BCC)
|
||||
* </pre>
|
||||
*
|
||||
* <p>BCC 校验范围:从命令单元的第一个字节开始(offset 2),同后一字节异或,
|
||||
* 直到校验码前一字节为止。注意:当数据单元存在加密时,应先加密后校验,解析时先校验后解密。
|
||||
*
|
||||
* <p>上游 Netty 的 frame handler 已经保证 {@code buffer} 内是一条完整帧。
|
||||
*/
|
||||
public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message> {
|
||||
|
||||
/** 最小帧长:2(起始符)+1(cmd)+1(rsp)+17(vin)+1(enc)+2(len)+0(body)+1(bcc)。 */
|
||||
private static final int MIN_FRAME = 25;
|
||||
private static final ZoneId ZONE_GMT8 = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
private final Gb32960BodyParser bodyParser;
|
||||
private final Gb32960CommandParser commandParser;
|
||||
|
||||
public Gb32960MessageDecoder(Gb32960BodyParser bodyParser) {
|
||||
this(bodyParser, new Gb32960CommandParser());
|
||||
}
|
||||
|
||||
public Gb32960MessageDecoder(Gb32960BodyParser bodyParser, Gb32960CommandParser commandParser) {
|
||||
this.bodyParser = bodyParser;
|
||||
this.commandParser = commandParser;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Gb32960Message decode(ByteBuffer buffer) {
|
||||
int frameLen = buffer.remaining();
|
||||
if (frameLen < MIN_FRAME) {
|
||||
throw new DecodeException("frame too short: " + frameLen);
|
||||
}
|
||||
|
||||
byte[] frame = new byte[frameLen];
|
||||
buffer.get(frame);
|
||||
|
||||
ProtocolVersion version = detectVersion(frame);
|
||||
|
||||
// BCC 校验:从 cmd(offset 2) 开始到 BCC 前一字节
|
||||
byte expectedBcc = frame[frame.length - 1];
|
||||
if (!BccChecksum.verify(frame, 2, frame.length - 3, expectedBcc)) {
|
||||
throw new DecodeException("BCC mismatch");
|
||||
}
|
||||
|
||||
int commandCode = frame[2] & 0xFF;
|
||||
int responseCode = frame[3] & 0xFF;
|
||||
String vin = new String(frame, 4, 17, StandardCharsets.US_ASCII).trim();
|
||||
int encryptCode = frame[21] & 0xFF;
|
||||
int dataLength = ((frame[22] & 0xFF) << 8) | (frame[23] & 0xFF);
|
||||
|
||||
int bodyStart = 24;
|
||||
if (bodyStart + dataLength + 1 != frame.length) {
|
||||
throw new DecodeException("declared body length " + dataLength
|
||||
+ " does not match frame remainder " + (frame.length - bodyStart - 1));
|
||||
}
|
||||
|
||||
CommandType cmdType = CommandType.of(commandCode);
|
||||
ResponseFlag rspFlag = ResponseFlag.of(responseCode);
|
||||
EncryptType encType = EncryptType.of(encryptCode);
|
||||
|
||||
// 实时 / 补发上报:解析信息体(和 2025 版尾部签名)
|
||||
if (cmdType == CommandType.REALTIME_REPORT || cmdType == CommandType.RESEND_REPORT) {
|
||||
if (dataLength < 6) throw new DecodeException("report body missing timestamp");
|
||||
Instant eventTime = parseTimestamp(frame, bodyStart);
|
||||
ByteBuffer body = ByteBuffer.wrap(frame, bodyStart + 6, dataLength - 6);
|
||||
BodyParseResult result = bodyParser.parse(version, body);
|
||||
Gb32960Header header = new Gb32960Header(
|
||||
version, cmdType, rspFlag, vin, encType, dataLength, eventTime);
|
||||
return new Gb32960Message(header, result.blocks(), null, result.signature());
|
||||
}
|
||||
|
||||
// 其他命令:解析 command body
|
||||
// 车辆/平台登入登出 body 首 6B 为采集时间(表 5),预解析并写入 header.eventTime,
|
||||
// 以便 handler 构造应答帧时按 §6.3.2 保留原报文时间。
|
||||
Instant eventTime = null;
|
||||
if (hasLeadingTimestamp(cmdType) && dataLength >= 6) {
|
||||
eventTime = parseTimestamp(frame, bodyStart);
|
||||
}
|
||||
Gb32960Header headerStub = new Gb32960Header(
|
||||
version, cmdType, rspFlag, vin, encType, dataLength, eventTime);
|
||||
ByteBuffer body = ByteBuffer.wrap(frame, bodyStart, dataLength);
|
||||
CommandBody cmdBody = commandParser.parse(cmdType, body);
|
||||
return new Gb32960Message(headerStub, Collections.emptyList(), cmdBody, null);
|
||||
}
|
||||
|
||||
private static ProtocolVersion detectVersion(byte[] frame) {
|
||||
if (frame[0] == 0x23 && frame[1] == 0x23) return ProtocolVersion.V2016;
|
||||
if (frame[0] == 0x24 && frame[1] == 0x24) return ProtocolVersion.V2025;
|
||||
throw new DecodeException("unknown start symbol: "
|
||||
+ String.format("%02x %02x", frame[0] & 0xFF, frame[1] & 0xFF));
|
||||
}
|
||||
|
||||
private static boolean hasLeadingTimestamp(CommandType cmd) {
|
||||
return cmd == CommandType.VEHICLE_LOGIN
|
||||
|| cmd == CommandType.VEHICLE_LOGOUT
|
||||
|| cmd == CommandType.PLATFORM_LOGIN
|
||||
|| cmd == CommandType.PLATFORM_LOGOUT;
|
||||
}
|
||||
|
||||
/** 时间定义(表 5):YY MM DD HH MM SS,各 1 字节,GMT+8。 */
|
||||
private static Instant parseTimestamp(byte[] frame, int offset) {
|
||||
int year = 2000 + (frame[offset] & 0xFF);
|
||||
int month = frame[offset + 1] & 0xFF;
|
||||
int day = frame[offset + 2] & 0xFF;
|
||||
int hour = frame[offset + 3] & 0xFF;
|
||||
int minute = frame[offset + 4] & 0xFF;
|
||||
int second = frame[offset + 5] & 0xFF;
|
||||
try {
|
||||
return LocalDateTime.of(year, month, day, hour, minute, second)
|
||||
.atZone(ZONE_GMT8)
|
||||
.toInstant();
|
||||
} catch (Exception e) {
|
||||
throw new DecodeException("invalid timestamp " + year + "-" + month + "-" + day
|
||||
+ " " + hour + ":" + minute + ":" + second, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 实时上报 body 解析结果:信息体列表 + 可选签名字节。 */
|
||||
public record BodyParseResult(List<InfoBlock> blocks, byte[] signature) {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 单一信息体解析器 SPI。
|
||||
*
|
||||
* <p>替代旧代码里 2500 行 {@code informationReporting()} 巨方法。每个
|
||||
* {@code (ProtocolVersion, typeCode)} 组合对应一个独立实现。同一个协议 typeCode
|
||||
* 在 2016 / 2025 可能语义不同(如 0x07),因此需要两个 Parser 分别声明 version。
|
||||
*
|
||||
* <p>实现约定:
|
||||
* <ul>
|
||||
* <li>{@link #parse(ByteBuffer)} 被调用时 buffer 的 position 已指向信息体第一个字节(不含 1 字节类型前缀)
|
||||
* <li>解析完成后 position 应恰好移动到信息体末尾
|
||||
* <li>实现应无状态,可被单例复用
|
||||
* </ul>
|
||||
*/
|
||||
public interface InfoBlockParser {
|
||||
|
||||
/** 本 Parser 适用的协议版本({@link ProtocolVersion#V2016} 或 {@link ProtocolVersion#V2025})。 */
|
||||
ProtocolVersion version();
|
||||
|
||||
/** 本 Parser 对应的协议信息类型标志字节(表 9)。 */
|
||||
int typeCode();
|
||||
|
||||
/** 信息体固定长度;变长类型返回 -1 并自行在 parse 中读取长度字段。 */
|
||||
int fixedLength();
|
||||
|
||||
InfoBlock parse(ByteBuffer buffer);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 双键策略注册中心:按 {@code (ProtocolVersion, typeCode)} 索引解析器。
|
||||
*
|
||||
* <p>同一 typeCode 在 2016 / 2025 版本可能语义不同(如 0x07 在 2016 是报警,在 2025 是
|
||||
* 动力蓄电池最小并联单元电压),必须按版本分派。
|
||||
*/
|
||||
public final class InfoBlockParserRegistry {
|
||||
|
||||
private final Map<Key, InfoBlockParser> parsers = new HashMap<>();
|
||||
|
||||
public InfoBlockParserRegistry(List<InfoBlockParser> list) {
|
||||
for (InfoBlockParser p : list) {
|
||||
parsers.put(new Key(p.version(), p.typeCode()), p);
|
||||
}
|
||||
}
|
||||
|
||||
public InfoBlockParser find(ProtocolVersion version, int typeCode) {
|
||||
return parsers.get(new Key(version, typeCode));
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return parsers.size();
|
||||
}
|
||||
|
||||
private record Key(ProtocolVersion version, int typeCode) {}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 规范异常值处理辅助:GB/T 32960.3 约定 {@code 0xFE}/{@code 0xFFFE}/{@code 0xFFFFFFFE} 为异常,
|
||||
* {@code 0xFF}/{@code 0xFFFF}/{@code 0xFFFFFFFF} 为无效。本类把字段读取 + 异常检查封装起来,
|
||||
* 异常或无效时返回 {@code null},让 record 字段保持 {@link Integer}/{@link Double} 装箱类型。
|
||||
*/
|
||||
public final class ValueDecoder {
|
||||
|
||||
private ValueDecoder() {}
|
||||
|
||||
// ------------ 1 字节无符号 ------------
|
||||
|
||||
public static Integer u8(ByteBuffer buf) {
|
||||
int v = buf.get() & 0xFF;
|
||||
if (v == 0xFE || v == 0xFF) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 原始值,不做异常处理(状态位等不能以 null 表达时用此方法)。 */
|
||||
public static int u8raw(ByteBuffer buf) {
|
||||
return buf.get() & 0xFF;
|
||||
}
|
||||
|
||||
// ------------ 2 字节无符号大端 ------------
|
||||
|
||||
public static Integer u16(ByteBuffer buf) {
|
||||
int v = buf.getShort() & 0xFFFF;
|
||||
if (v == 0xFFFE || v == 0xFFFF) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
public static int u16raw(ByteBuffer buf) {
|
||||
return buf.getShort() & 0xFFFF;
|
||||
}
|
||||
|
||||
// ------------ 4 字节无符号大端 ------------
|
||||
|
||||
public static Long u32(ByteBuffer buf) {
|
||||
long v = buf.getInt() & 0xFFFFFFFFL;
|
||||
if (v == 0xFFFFFFFEL || v == 0xFFFFFFFFL) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
public static long u32raw(ByteBuffer buf) {
|
||||
return buf.getInt() & 0xFFFFFFFFL;
|
||||
}
|
||||
|
||||
// ------------ 业务标度辅助 ------------
|
||||
|
||||
/** 读 u16,scale 后返回(例如 0.1 转换)。异常/无效返回 null。 */
|
||||
public static Double scaledU16(ByteBuffer buf, double scale) {
|
||||
Integer raw = u16(buf);
|
||||
return raw == null ? null : raw * scale;
|
||||
}
|
||||
|
||||
/** 读 u16 + 偏移 + scale。例:读总电流 -> scaledU16WithOffset(buf, 0.1, -1000) */
|
||||
public static Double scaledU16WithOffset(ByteBuffer buf, double scale, double offset) {
|
||||
Integer raw = u16(buf);
|
||||
return raw == null ? null : raw * scale + offset;
|
||||
}
|
||||
|
||||
public static Double scaledU32(ByteBuffer buf, double scale) {
|
||||
Long raw = u32(buf);
|
||||
return raw == null ? null : raw * scale;
|
||||
}
|
||||
|
||||
public static Double scaledU32WithOffset(ByteBuffer buf, double scale, double offset) {
|
||||
Long raw = u32(buf);
|
||||
return raw == null ? null : raw * scale + offset;
|
||||
}
|
||||
|
||||
/** 温度字段:原始值 - 40 ℃,范围 -40~+210。 */
|
||||
public static Integer tempOffset40(ByteBuffer buf) {
|
||||
Integer raw = u8(buf);
|
||||
return raw == null ? null : raw - 40;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 报警数据 (0x07) —— GB/T 32960.3-2016 附录 B 表 B.17。变长。
|
||||
* maxAlarmLevel(1) + generalAlarmFlag(4) + 4 组故障列表(count(1)+entries(4*N))。
|
||||
*
|
||||
* <p>2025 版报警数据 typeCode 改为 0x06 且尾部多 N5 个 (flagBit,level) —— 见 v2025 包。
|
||||
*/
|
||||
public final class AlarmV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x07; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer maxLevel = ValueDecoder.u8(buf);
|
||||
long generalFlag = ValueDecoder.u32raw(buf);
|
||||
List<Long> battery = readFaultList(buf);
|
||||
List<Long> motor = readFaultList(buf);
|
||||
List<Long> engine = readFaultList(buf);
|
||||
List<Long> other = readFaultList(buf);
|
||||
return new InfoBlock.Alarm(
|
||||
ProtocolVersion.V2016, maxLevel, generalFlag,
|
||||
battery, motor, engine, other, null);
|
||||
}
|
||||
|
||||
static List<Long> readFaultList(ByteBuffer buf) {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<Long> out = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
out.add(buf.getInt() & 0xFFFFFFFFL);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 驱动电机数据 (0x02) —— GB/T 32960.3-2016 表 B.16。变长。
|
||||
* 1 字节电机个数 + N × 12 字节电机:
|
||||
* serialNo(1) state(1) ctrlTempC(1) rpm(2,offset -20000)
|
||||
* torque(2,offset -20000,0.1 N·m) motorTempC(1)
|
||||
* ctrlVoltageV(2,0.1V) ctrlCurrentA(2,0.1A,offset -1000)。
|
||||
*/
|
||||
public final class DriveMotorV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x02; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<InfoBlock.DriveMotor.Motor> motors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
Integer serialNo = ValueDecoder.u8(buf);
|
||||
Integer state = ValueDecoder.u8(buf);
|
||||
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
|
||||
Integer rawRpm = ValueDecoder.u16(buf);
|
||||
Integer rpm = rawRpm == null ? null : rawRpm - 20_000;
|
||||
Integer rawTq = ValueDecoder.u16(buf);
|
||||
Double torque = rawTq == null ? null : (rawTq - 20_000) * 0.1;
|
||||
Integer motorTempC = ValueDecoder.tempOffset40(buf);
|
||||
Double ctrlVoltage = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Double ctrlCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
|
||||
motors.add(new InfoBlock.DriveMotor.Motor(
|
||||
serialNo, state, ctrlTempC, rpm, torque, motorTempC, ctrlVoltage, ctrlCurrent));
|
||||
}
|
||||
return new InfoBlock.DriveMotor(motors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 发动机数据 (0x04) —— GB/T 32960.3-2016 表 20。固定 5 字节:
|
||||
* engineState(1) + crankshaftRpm(2) + fuelConsumptionRate(2,0.01 L/100km)。
|
||||
* 停车充电过程和增程式车辆纯电模式无需传输。
|
||||
*/
|
||||
public final class EngineV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x04; }
|
||||
@Override public int fixedLength() { return 5; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer engineState = ValueDecoder.u8(buf);
|
||||
Integer crankshaftRpm = ValueDecoder.u16(buf);
|
||||
Double fuelRate = ValueDecoder.scaledU16(buf, 0.01);
|
||||
return new InfoBlock.Engine(engineState, crankshaftRpm, fuelRate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 动力蓄电池极值数据 (0x06) —— GB/T 32960.3-2016 表 B.16。固定 14 字节。
|
||||
* 2025 版 0x06 语义已变更为报警数据。
|
||||
*/
|
||||
public final class ExtremeValueV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x06; }
|
||||
@Override public int fixedLength() { return 14; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer maxVSub = ValueDecoder.u8(buf);
|
||||
Integer maxVCell = ValueDecoder.u8(buf);
|
||||
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
|
||||
Integer minVSub = ValueDecoder.u8(buf);
|
||||
Integer minVCell = ValueDecoder.u8(buf);
|
||||
Double minV = ValueDecoder.scaledU16(buf, 0.001);
|
||||
Integer maxTSub = ValueDecoder.u8(buf);
|
||||
Integer maxTProbe = ValueDecoder.u8(buf);
|
||||
Integer maxT = ValueDecoder.tempOffset40(buf);
|
||||
Integer minTSub = ValueDecoder.u8(buf);
|
||||
Integer minTProbe = ValueDecoder.u8(buf);
|
||||
Integer minT = ValueDecoder.tempOffset40(buf);
|
||||
return new InfoBlock.Extreme(
|
||||
maxVSub, maxVCell, maxV,
|
||||
minVSub, minVCell, minV,
|
||||
maxTSub, maxTProbe, maxT,
|
||||
minTSub, minTProbe, minT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 燃料电池数据 (0x03) —— GB/T 32960.3-2016 表 B.13。变长。
|
||||
* fcVoltageV(2) + fcCurrentA(2) + hydrogenConsumption(2,0.01 kg/100km)
|
||||
* + probeCount N(2) + probes[N](1B,-40 offset) + 后续氢系统 10 字节固定段。
|
||||
*/
|
||||
public final class FuelCellV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x03; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Double fcVoltage = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Double fcCurrent = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Double consumption = ValueDecoder.scaledU16(buf, 0.01);
|
||||
int probeCount = ValueDecoder.u16raw(buf);
|
||||
|
||||
// 防御:probeCount 异常时回退为空列表避免吞 buffer
|
||||
List<Integer> probes = new ArrayList<>();
|
||||
int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节
|
||||
if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) {
|
||||
for (int i = 0; i < probeCount; i++) {
|
||||
Integer t = ValueDecoder.tempOffset40(buf);
|
||||
if (t != null) probes.add(t);
|
||||
}
|
||||
}
|
||||
Double maxHydrogenTempC = ValueDecoder.scaledU16WithOffset(buf, 0.1, -40.0);
|
||||
Integer maxHydrogenTempProbeId = ValueDecoder.u8(buf);
|
||||
Double maxHydrogenConcentration = ValueDecoder.scaledU16(buf, 0.000001);
|
||||
Integer maxHydrogenConcentrationProbeId = ValueDecoder.u8(buf);
|
||||
Double maxHydrogenPressure = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Integer maxHydrogenPressureProbeId = ValueDecoder.u8(buf);
|
||||
Integer hvDcDcStatus = ValueDecoder.u8(buf);
|
||||
|
||||
return new InfoBlock.FuelCellV2016(
|
||||
fcVoltage, fcCurrent, consumption, probes,
|
||||
maxHydrogenTempC, maxHydrogenTempProbeId,
|
||||
maxHydrogenConcentration, maxHydrogenConcentrationProbeId,
|
||||
maxHydrogenPressure, maxHydrogenPressureProbeId,
|
||||
hvDcDcStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 车辆位置数据 (0x05) —— GB/T 32960.3-2016 表 22。固定 9 字节:
|
||||
* 状态位(1) + 经度(4,1e-6 度) + 纬度(4,1e-6 度)。
|
||||
*
|
||||
* <p>状态位 bit0 0=有效定位 / bit1 0=北纬 1=南纬 / bit2 0=东经 1=西经。
|
||||
*/
|
||||
public final class PositionV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x05; }
|
||||
@Override public int fixedLength() { return 9; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int statusFlag = buf.get() & 0xFF;
|
||||
long lonRaw = buf.getInt() & 0xFFFFFFFFL;
|
||||
long latRaw = buf.getInt() & 0xFFFFFFFFL;
|
||||
double longitude = lonRaw / 1_000_000.0;
|
||||
double latitude = latRaw / 1_000_000.0;
|
||||
if ((statusFlag & 0b100) != 0) longitude = -longitude;
|
||||
if ((statusFlag & 0b010) != 0) latitude = -latitude;
|
||||
return new InfoBlock.Position(statusFlag, null, longitude, latitude);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 可充电储能装置温度数据 (0x09) —— GB/T 32960.3-2016 表 B.20。变长。
|
||||
* subSystemCount(1) + 每子系统 [batteryIndex(1) + probeCount(2) + probes(probeCount*1,-40 offset)]。
|
||||
*
|
||||
* <p>2025 版已删除该 typeCode(动力蓄电池温度改用 0x08)。
|
||||
*/
|
||||
public final class TemperatureV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x09; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int subCount = buf.get() & 0xFF;
|
||||
int maxT = Integer.MIN_VALUE;
|
||||
int minT = Integer.MAX_VALUE;
|
||||
for (int i = 0; i < subCount; i++) {
|
||||
buf.get(); // batteryIndex
|
||||
int probes = buf.getShort() & 0xFFFF;
|
||||
for (int p = 0; p < probes; p++) {
|
||||
int t = (buf.get() & 0xFF) - 40;
|
||||
if (t > maxT) maxT = t;
|
||||
if (t < minT) minT = t;
|
||||
}
|
||||
}
|
||||
if (maxT == Integer.MIN_VALUE) maxT = 0;
|
||||
if (minT == Integer.MAX_VALUE) minT = 0;
|
||||
return new InfoBlock.Temperature(subCount, maxT, minT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 整车数据 (0x01) —— GB/T 32960.3-2016 表 B.10。固定 20 字节:
|
||||
* vehicleState(1) + chargingState(1) + runningMode(1) + speedKmh(2)
|
||||
* + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2)
|
||||
* + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2)
|
||||
* + accelPedal(1) + brakePedal(1)。
|
||||
*/
|
||||
public final class VehicleV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x01; }
|
||||
@Override public int fixedLength() { return 20; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer vehicleState = ValueDecoder.u8(buf);
|
||||
Integer chargingState = ValueDecoder.u8(buf);
|
||||
Integer runningMode = ValueDecoder.u8(buf);
|
||||
Double speedKmh = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Double mileageKm = ValueDecoder.scaledU32(buf, 0.1);
|
||||
Double totalVoltageV = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Double totalCurrentA = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
|
||||
Integer soc = ValueDecoder.u8(buf);
|
||||
Integer dcDcStatus = ValueDecoder.u8(buf);
|
||||
Integer gearRaw = ValueDecoder.u8raw(buf);
|
||||
Integer insulation = ValueDecoder.u16(buf);
|
||||
Integer accelPedal = ValueDecoder.u8(buf);
|
||||
Integer brakePedal = ValueDecoder.u8(buf);
|
||||
return new InfoBlock.Vehicle(vehicleState, chargingState, runningMode, speedKmh, mileageKm,
|
||||
totalVoltageV, totalCurrentA, soc, dcDcStatus, gearRaw, insulation,
|
||||
accelPedal, brakePedal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2016;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 可充电储能装置电压数据 (0x08) —— GB/T 32960.3-2016 表 B.18。变长。
|
||||
* subSystemCount(1) + 每子系统 [batteryIndex(1) + voltage(2,0.1V) + current(2,0.1A,offset -1000)
|
||||
* + cellCount(2) + frameCellStart(2) + frameCellCount(1) + cellVoltages(frameCellCount*2,0.001V)]。
|
||||
*
|
||||
* <p>PoC 汇总:保留子系统数 + 总电压 + 单体最高/最低值。
|
||||
* 2025 版 0x08 语义已变更为动力蓄电池温度。
|
||||
*/
|
||||
public final class VoltageV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x08; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int subCount = buf.get() & 0xFF;
|
||||
double totalV = 0;
|
||||
double maxCell = Double.MIN_VALUE;
|
||||
double minCell = Double.MAX_VALUE;
|
||||
for (int i = 0; i < subCount; i++) {
|
||||
buf.get(); // batteryIndex
|
||||
double voltage = (buf.getShort() & 0xFFFF) * 0.1;
|
||||
totalV += voltage;
|
||||
buf.getShort(); // current (skip)
|
||||
int cellCount = buf.getShort() & 0xFFFF;
|
||||
buf.getShort(); // frameCellStart
|
||||
int frameCellCount = buf.get() & 0xFF;
|
||||
int actual = Math.min(frameCellCount, cellCount);
|
||||
for (int c = 0; c < actual; c++) {
|
||||
double cellV = (buf.getShort() & 0xFFFF) * 0.001;
|
||||
if (cellV > maxCell) maxCell = cellV;
|
||||
if (cellV < minCell) minCell = cellV;
|
||||
}
|
||||
for (int c = actual; c < frameCellCount; c++) buf.getShort();
|
||||
}
|
||||
if (maxCell == Double.MIN_VALUE) maxCell = 0;
|
||||
if (minCell == Double.MAX_VALUE) minCell = 0;
|
||||
return new InfoBlock.Voltage(subCount, maxCell, minCell, totalV);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 报警数据 (0x06) —— GB/T 32960.3-2025 §7.2.4.9 表 23。变长。
|
||||
* 前半部分与 2016 版相同,尾部新增"通用报警故障总数(1) + N5×(flagBit(1)+level(1))"。
|
||||
*/
|
||||
public final class AlarmV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
|
||||
@Override public int typeCode() { return 0x06; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer maxLevel = ValueDecoder.u8(buf);
|
||||
long generalFlag = ValueDecoder.u32raw(buf);
|
||||
List<Long> battery = readFaultList(buf);
|
||||
List<Long> motor = readFaultList(buf);
|
||||
List<Long> engine = readFaultList(buf);
|
||||
List<Long> other = readFaultList(buf);
|
||||
|
||||
int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
|
||||
List<InfoBlock.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount);
|
||||
for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) {
|
||||
int bit = buf.get() & 0xFF;
|
||||
int level = buf.get() & 0xFF;
|
||||
levels.add(new InfoBlock.Alarm.GeneralAlarmEntry(bit, level));
|
||||
}
|
||||
|
||||
return new InfoBlock.Alarm(
|
||||
ProtocolVersion.V2025, maxLevel, generalFlag,
|
||||
battery, motor, engine, other, levels);
|
||||
}
|
||||
|
||||
private static List<Long> readFaultList(ByteBuffer buf) {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<Long> out = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
out.add(buf.getInt() & 0xFFFFFFFFL);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 动力蓄电池温度数据 (0x08) —— GB/T 32960.3-2025 §7.2.4.3。变长。
|
||||
* 2016 版 0x08 为可充电储能装置电压,由 v2016 包处理。
|
||||
*/
|
||||
public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
|
||||
@Override public int typeCode() { return 0x08; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int subCount = buf.get() & 0xFF;
|
||||
List<InfoBlock.BatteryTemperature.Pack> packs = new ArrayList<>(subCount);
|
||||
for (int i = 0; i < subCount; i++) {
|
||||
int packNo = buf.get() & 0xFF;
|
||||
int probeCount = buf.getShort() & 0xFFFF;
|
||||
List<Integer> temps = new ArrayList<>(probeCount);
|
||||
int safeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
}
|
||||
packs.add(new InfoBlock.BatteryTemperature.Pack(packNo, probeCount, temps));
|
||||
}
|
||||
return new InfoBlock.BatteryTemperature(subCount, packs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 驱动电机数据 (0x02) —— GB/T 32960.3-2025 表 16。变长。
|
||||
* 1 字节电机个数 + N × 14 字节电机:
|
||||
* serialNo(1) state(1) ctrlTempC(1) rpm(2,offset -32000)
|
||||
* torque(4,offset -20000,0.1 N·m) motorTempC(1)
|
||||
* ctrlVoltageV(2,0.1V) ctrlCurrentA(2,0.1A,offset -1000)。
|
||||
*
|
||||
* <p>相比 2016 版:rpm 偏移变为 -32000;torque 由 2 字节扩展为 4 字节。
|
||||
*/
|
||||
public final class DriveMotorV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
|
||||
@Override public int typeCode() { return 0x02; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<InfoBlock.DriveMotor.Motor> motors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
Integer serialNo = ValueDecoder.u8(buf);
|
||||
Integer state = ValueDecoder.u8(buf);
|
||||
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
|
||||
Integer rawRpm = ValueDecoder.u16(buf);
|
||||
Integer rpm = rawRpm == null ? null : rawRpm - 32_000;
|
||||
Long rawTq = ValueDecoder.u32(buf);
|
||||
Double torque = rawTq == null ? null : (rawTq - 20_000L) * 0.1;
|
||||
Integer motorTempC = ValueDecoder.tempOffset40(buf);
|
||||
Double ctrlVoltage = ValueDecoder.scaledU16(buf, 0.1);
|
||||
Double ctrlCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -1000.0);
|
||||
motors.add(new InfoBlock.DriveMotor.Motor(
|
||||
serialNo, state, ctrlTempC, rpm, torque, motorTempC, ctrlVoltage, ctrlCurrent));
|
||||
}
|
||||
return new InfoBlock.DriveMotor(motors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.v2025;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.ValueDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 发动机数据 (0x04) —— GB/T 32960.3-2025。固定 5 字节。
|
||||
* 字段与 2016 版完全一致;保留独立类只为遵守"双版本不共用"原则。
|
||||
*/
|
||||
public final class EngineV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2025; }
|
||||
@Override public int typeCode() { return 0x04; }
|
||||
@Override public int fixedLength() { return 5; }
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer engineState = ValueDecoder.u8(buf);
|
||||
Integer crankshaftRpm = ValueDecoder.u16(buf);
|
||||
Double fuelRate = ValueDecoder.scaledU16(buf, 0.01);
|
||||
return new InfoBlock.Engine(engineState, crankshaftRpm, fuelRate);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user