feat: make gb32960 archive history query production ready

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

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ logs/
data/ data/
build/ build/
out/ out/
archive/
# Local config / secrets # Local config / secrets
application-local.yml application-local.yml

View File

@@ -21,7 +21,7 @@
| 消息队列 | **Kafka**vin 分区,保证单车有序) | | 消息队列 | **Kafka**vin 分区,保证单车有序) |
| 线上消息格式 | **Protobuf**,调试走 JSON | | 线上消息格式 | **Protobuf**,调试走 JSON |
| MQTT 客户端 | HiveMQ MQTT Client | | MQTT 客户端 | HiveMQ MQTT Client |
| 会话缓存 | Caffeine + Redis 兜底 | | 会话缓存 | Redis 生产索引 + Caffeine 内存降级 |
| 熔断/限流 | Resilience4j 2 | | 熔断/限流 | Resilience4j 2 |
| 可观测 | Micrometer + Prometheus + OpenTelemetry | | 可观测 | Micrometer + Prometheus + OpenTelemetry |
| 冷存 | S3 / OSS / 本地 | | 冷存 | S3 / OSS / 本地 |
@@ -31,22 +31,35 @@
``` ```
lingniu-vehicle-ingest/ lingniu-vehicle-ingest/
├── bom/ 依赖版本统一 ├── modules/
├── ingest-api/ SPI + sealed 领域事件 + 注解 │ ├── core/
├── ingest-codec-common/ 公共编解码工具BCD/CRC/BCC/bit utils │ │ ├── ingest-api/ SPI + sealed 领域事件 + 注解
├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥 │ │ ├── ingest-codec-common/ 公共编解码工具BCD/CRC/BCC/bit utils
├── session-core/ 设备会话 + 鉴权 + Token │ │ ├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥
├── observability/ metrics / tracing / health │ │ ├── session-core/ 设备会话 + 鉴权 + Token + Redis/Memory SessionStore
├── sink-mq/ Kafka producer + Protobuf Envelope │ │ ├── vehicle-identity/ 跨协议车辆身份解析 + 外部标识绑定memory/file
├── sink-archive/ 原始报文冷存 │ │ └── observability/ metrics / tracing / health
├── protocol-gb32960/ GB/T 32960 ├── protocols/
├── protocol-jt808/ JT/T 808 │ │ ├── protocol-gb32960/ GB/T 32960
├── protocol-jt1078/ JT/T 1078 │ │ ├── protocol-jt808/ JT/T 808统一身份映射 + 事件 metadata 内部 VIN + 注册/鉴权/心跳/注销/位置/批量位置/参数/属性/媒体/透传/未知上行和坏帧兜底/断链清理会话/下行分包)
├── protocol-jsatl12/ 苏标主动安全报警附件 │ │ ├── protocol-jt1078/ JT/T 1078808 信令按需桥接 + 常用下行信令编码 + TCP/UDP RTP 媒体流分段归档 + 事件 metadata 内部 VIN + 坏 RTP 统一 RawArchive/Passthrough + 归档失败兜底 + SIM 身份映射)
├── inbound-mqtt/ MQTT 接入(宇通等 │ │ └── protocol-jsatl12/ 苏标主动安全报警附件(真实 DataPacket 分帧 + 附件流归档 + T1210 文件清单身份继承 + 9212 补传应答 + MediaMeta 引用事件 + 坏帧/坏信令/归档失败兜底
├── inbound-xinda-push/ 信达 Push 接入(重写) ├── inbound/
├── command-gateway/ HTTP → 设备下行命令 │ │ ├── inbound-mqtt/ MQTT 接入endpoint 生命周期 + profile 注册扩展 + 统一身份映射 + PEM 双向 TLS + 未知 profile/解析失败/profile异常/连接订阅失败兜底 + 统一 UNKNOWN 身份 metadata
└── bootstrap-all/ 一体化启动 │ │ └── inbound-xinda-push/ 信达 Push 接入(统一身份映射 + 事件 metadata 内部 VIN + 未知业务 cmd/解析失败/启动配置失败/业务处理异常兜底)
│ ├── sinks/
│ │ ├── sink-mq/ Kafka producer + Protobuf Envelope
│ │ ├── sink-archive/ 原始报文冷存
│ │ └── event-file-store/ Parquet + DuckDB 文件型明细库
│ ├── services/
│ │ ├── event-history-service/ Kafka 全字段事件消费 + 历史查询/导出
│ │ ├── vehicle-state-service/ Kafka 全字段事件消费 + Redis 热状态查询
│ │ └── vehicle-stat-service/ Kafka 全字段事件消费 + 可配置日统计
│ └── apps/
│ ├── command-gateway/ HTTP → 设备下行命令808 位置/参数/属性/控制/区域删除/报警确认 + 1078 音视频控制)
│ └── bootstrap-all/ 一体化启动
├── docs/ 架构文档、模块图、实施计划
└── reference/ 参考资料
``` ```
## 快速开始 ## 快速开始
@@ -55,17 +68,23 @@ lingniu-vehicle-ingest/
# 要求JDK 25, Maven 3.9+ # 要求JDK 25, Maven 3.9+
mvn -v mvn -v
mvn clean install -DskipTests mvn clean install -DskipTests
cd bootstrap-all && mvn spring-boot:run mvn -pl :bootstrap-all spring-boot:run
``` ```
## 迁移说明 ## 迁移说明
本项目是 `lingniu-vehicle-data-reception` 的 v2 重构,采用 strangler fig 渐进式迁移,旧项目保留只读参考。迁移路径与决策参见 `../REFRACTOR_PLAN.md` 本项目是 `lingniu-vehicle-data-reception` 的 v2 重构,采用 strangler fig 渐进式迁移,旧项目保留只读参考。迁移路径与决策参见 `../REFRACTOR_PLAN.md`
## 架构文档
- 目标架构:`docs/target-architecture.md`
- 内部字段模型:`docs/vehicle-telemetry-internal-fields.md`
- 模块与数据流:`docs/module-data-flow.html`
## 核心原则 ## 核心原则
1. **本服务不写业务库**:历史、行程、在线检测、里程统计全部由 Kafka 下游消费者实现 1. **本服务不写业务库**:历史、行程、在线检测、里程统计全部由 Kafka 下游消费者实现
2. **协议即插拔**:每个 `protocol-*` / `inbound-*` 都可独立开关(`lingniu.ingest.<name>.enabled` 2. **协议即插拔**:每个 `protocol-*` / `inbound-*` 都可独立开关(`lingniu.ingest.<name>.enabled`
3. **顺序保证**:同一 VIN 严格有序Disruptor hash + Kafka 分区 key 3. **顺序保证**:同一 VIN 严格有序Disruptor hash + Kafka 分区 key
4. **幂等消费**Envelope 带 `eventId`,下游去重 4. **幂等消费**Envelope 带 `eventId`,下游去重
5. **原始可回放**`vehicle.raw.archive` topic + 对象存储 5. **原始可回放**Dispatcher 会先发布 `RawArchive`,并把同一份 `rawArchiveKey/rawArchiveUri` 追加到业务事件 metadata下游明细库、Kafka Envelope 和导出都使用 `archive://...` 逻辑 URI 追溯原始 bytes实际文件由 `sink-archive`/`ArchiveStore` 管理;`event-file-store` 只额外保存 RawArchive 查询索引,不嵌入原始 bytes

View File

@@ -1,153 +0,0 @@
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
# ====================================================================
# 厂商扩展协议路由vendor extensions
# 命中规则按 list 顺序首匹first-match-wins命中后用对应套件的 parser 集合
# 在标准 0x01..0x09 之上叠加。已支持的套件:
# - guangdong-fc广东燃料电池汽车示范规范 0x30~0x34、0x80
# 来源reference/广东燃料电池汽车示范应用城市群综合监管平台-...pdf
# ====================================================================
vendor-extensions:
- name: guangdong-fc
match:
# 平台账号匹配精确匹配case-insensitive。这里和上面 auth.platforms
# 中的 username 保持一致,确保 lingniu 账号登入后所有 0x02 帧都走广东 profile。
platform-accounts:
- lingniu
# VIN 前缀匹配startsWith空 list = 不参与匹配
vin-prefixes: []
# VIN 精确匹配
vins: []
# 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成
# InfoBlock.Raw 后继续解析下一个块,不再整帧丢弃。仅在需要严格失败语义
# (灰度回滚或抓虫)时设为 false。
parse:
lenient-block-failure: true
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: DEBUG
com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder: DEBUG
com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser: DEBUG
# 信达 push 诊断:打开下面一行可以看到每一条收发的原始字节 hex dump
com.lingniu.ingest.inbound.xinda: DEBUG

View File

@@ -1,145 +0,0 @@
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);
}
}

733
docs/module-data-flow.html Normal file
View File

@@ -0,0 +1,733 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lingniu-vehicle-ingest 模块与数据流</title>
<style>
:root {
--bg: #f7f8fa;
--panel: #ffffff;
--ink: #172033;
--muted: #667085;
--line: #d8dee8;
--blue: #2563eb;
--green: #0f766e;
--amber: #b45309;
--red: #b42318;
--purple: #6d28d9;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.55;
}
header {
padding: 32px 40px 24px;
background: #101828;
color: #ffffff;
}
header h1 {
margin: 0 0 10px;
font-size: 30px;
line-height: 1.2;
letter-spacing: 0;
}
header p {
margin: 0;
max-width: 980px;
color: #d0d5dd;
font-size: 15px;
}
main {
max-width: 1420px;
margin: 0 auto;
padding: 28px 28px 44px;
}
section {
margin-bottom: 24px;
padding: 24px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
}
h2 {
margin: 0 0 14px;
font-size: 21px;
letter-spacing: 0;
}
h3 {
margin: 22px 0 10px;
font-size: 17px;
}
p {
margin: 0 0 12px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.summary-card {
padding: 14px 16px;
border: 1px solid var(--line);
border-left: 4px solid var(--blue);
border-radius: 6px;
background: #fbfcfe;
}
.summary-card strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
.summary-card span {
color: var(--muted);
font-size: 13px;
}
.diagram {
overflow-x: auto;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 14px 0 0;
color: var(--muted);
font-size: 13px;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot.entry { background: var(--blue); }
.dot.core { background: var(--green); }
.dot.sink { background: var(--amber); }
.dot.safety { background: var(--red); }
.dot.command { background: var(--purple); }
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 14px;
}
th,
td {
padding: 10px 12px;
border: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
th {
background: #f2f4f7;
color: #344054;
white-space: nowrap;
}
td:first-child {
width: 190px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
color: #101828;
white-space: nowrap;
}
.tag {
display: inline-block;
margin: 0 6px 6px 0;
padding: 2px 7px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 12px;
white-space: nowrap;
}
.tag.red {
background: #fee4e2;
color: #912018;
}
.tag.green {
background: #ccfbef;
color: #134e48;
}
.tag.amber {
background: #fef0c7;
color: #93370d;
}
.callout {
margin-top: 14px;
padding: 14px 16px;
border-left: 4px solid var(--red);
border-radius: 6px;
background: #fff7f5;
}
code {
padding: 1px 4px;
border-radius: 4px;
background: #eef2f6;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.95em;
}
@media (max-width: 900px) {
header {
padding: 24px 20px 20px;
}
main {
padding: 18px;
}
section {
padding: 18px;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
table {
display: block;
overflow-x: auto;
white-space: nowrap;
}
}
@media (max-width: 560px) {
.summary-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1>lingniu-vehicle-ingest 模块与数据流</h1>
<p>
本图基于当前 Maven 多模块项目整理,用于后续讨论协议接入、内部字段、氢能车辆运营统计、安全告警和下游消费边界。
当前架构将接入层、历史明细、Redis 热状态和日统计拆成独立模块,通过统一 Kafka Envelope 解耦。
</p>
</header>
<main>
<section>
<h2>一、系统定位</h2>
<p>
当前项目不是业务库写入服务。它的核心职责是把 GB/T 32960、JT/T 808、JT/T 1078、JSATL12、
MQTT、信达 Push 等不同来源的车辆数据统一转换为 <code>VehicleEvent</code> 和全字段 <code>TelemetrySnapshot</code>
再通过 Kafka、原始报文归档、Parquet 明细文件库、Redis 热状态和独立统计模块交给下游业务。
</p>
<div class="summary-grid">
<div class="summary-card">
<strong>接入方式</strong>
<span>Netty TCP、MQTT、HTTP Push、后续可扩展更多 Inbound Adapter。</span>
</div>
<div class="summary-card">
<strong>统一模型</strong>
<span>所有协议归一到 <code>VehicleEvent</code> 和全字段内部 <code>TelemetrySnapshot</code></span>
</div>
<div class="summary-card">
<strong>输出方式</strong>
<span>Kafka Protobuf Envelope + 原始 bytes 冷存 + Parquet/DuckDB + Redis 热状态。</span>
</div>
<div class="summary-card">
<strong>业务边界</strong>
<span>日统计由 <code>vehicle-stat-service</code> 消费 Kafka 实现,告警工单、资产业务继续由下游消费。</span>
</div>
</div>
</section>
<section>
<h2>二、模块分层图</h2>
<div class="diagram">
<pre class="mermaid">
flowchart TB
subgraph external["外部来源"]
vehicle["车辆终端<br/>GB/T 32960 / JT808 / JT1078"]
mqttSource["车企或平台 MQTT"]
xindaSource["信达 Push"]
commandClient["业务系统 / 运维平台<br/>HTTP 下行命令"]
end
subgraph protocolModules["协议模块 modules/protocols"]
gb["protocol-gb32960<br/>32960 Netty 接入、鉴权、ACK、解析"]
jt808["protocol-jt808<br/>808 Netty 接入、会话、位置/批量位置、媒体、透传、下行"]
jt1078["protocol-jt1078<br/>1078 信令 + TCP/UDP RTP 媒体流归档"]
jsatl12["protocol-jsatl12<br/>主动安全附件流接入"]
end
subgraph inbound["入口适配 modules/inbound"]
mqtt["inbound-mqtt<br/>MQTT endpoint 生命周期、PEM TLS"]
mqttProfile["MqttProfileRegistry<br/>按 endpoint profile 解析/映射"]
xinda["inbound-xinda-push<br/>HTTP Push 适配"]
end
subgraph protocolBase["核心与公共能力 modules/core"]
codec["ingest-codec-common<br/>BCD / CRC / BCC / bit 工具"]
session["session-core<br/>设备会话、命令分发接口、会话存储"]
identity["vehicle-identity<br/>跨协议身份解析、外部标识绑定、文件持久化"]
obs["observability<br/>metrics / tracing / health"]
api["ingest-api<br/>ProtocolId / RawFrame / VehicleEvent / 注解 SPI"]
registry["HandlerRegistry<br/>按协议、命令、infoType 路由"]
dispatcher["Dispatcher<br/>RawFrame 到 Handler 到 EventBus"]
bus["DisruptorEventBus<br/>高吞吐事件发布"]
end
subgraph sink["输出与明细存储 modules/sinks"]
mq["sink-mq<br/>VehicleEvent 到 Protobuf Envelope 到 Kafka"]
archive["sink-archive<br/>RawArchive 到本地/S3/OSS 冷存"]
fileStore["event-file-store<br/>VehicleEvent/RawArchive 索引到 ParquetDuckDB 按日查询"]
end
subgraph services["消费服务 modules/services"]
history["event-history-service<br/>Kafka 全字段事件到历史查询/导出"]
state["vehicle-state-service<br/>Kafka 全字段事件到 Redis 热状态"]
stat["vehicle-stat-service<br/>Kafka 全字段事件到可配置日统计"]
end
subgraph apps["应用入口 modules/apps"]
gateway["command-gateway<br/>HTTP 到设备命令入口"]
bootall["bootstrap-all<br/>聚合所有 AutoConfiguration 一体启动"]
end
vehicle --> gb
vehicle --> jt808
vehicle --> jt1078
vehicle --> jsatl12
mqttSource --> mqtt
mqtt --> mqttProfile
mqttProfile --> api
xindaSource --> xinda
commandClient --> gateway
gb --> codec
jt808 --> codec
jt1078 --> codec
jsatl12 --> codec
gb --> api
jt808 --> api
xinda --> api
xinda --> dispatcher
jsatl12 --> archive
jsatl12 --> jt808
api --> registry
registry --> dispatcher
dispatcher --> bus
bus --> mq
bus --> archive
bus --> fileStore
mq --> history
mq --> state
mq --> stat
gateway --> session
session --> jt808
session --> gb
identity --> jt808
identity --> mqttProfile
identity --> xinda
obs -.监控.-> dispatcher
obs -.监控.-> bus
obs -.监控.-> mq
bootall -.装配.-> gb
bootall -.装配.-> jt808
bootall -.装配.-> mqtt
bootall -.装配.-> xinda
bootall -.装配.-> mq
bootall -.装配.-> archive
bootall -.装配.-> fileStore
bootall -.装配.-> history
bootall -.装配.-> state
bootall -.装配.-> stat
classDef entry fill:#eff6ff,stroke:#2563eb,color:#1e3a8a;
classDef core fill:#ecfdf3,stroke:#0f766e,color:#134e48;
classDef sink fill:#fffbeb,stroke:#b45309,color:#7c2d12;
classDef command fill:#f5f3ff,stroke:#6d28d9,color:#4c1d95;
classDef support fill:#f8fafc,stroke:#64748b,color:#334155;
class gb,jt808,jt1078,jsatl12,mqtt,mqttProfile,xinda entry;
class api,registry,dispatcher,bus,identity core;
class mq,archive,fileStore sink;
class gateway,session command;
class codec,obs,bootall support;
</pre>
</div>
<div class="legend">
<span><i class="dot entry"></i>入口/协议模块</span>
<span><i class="dot core"></i>核心管线</span>
<span><i class="dot sink"></i>输出层</span>
<span><i class="dot command"></i>下行命令/会话</span>
</div>
</section>
<section>
<h2>三、上行数据流转</h2>
<div class="diagram">
<pre class="mermaid">
sequenceDiagram
autonumber
participant Device as 车辆/平台
participant Inbound as Inbound Adapter<br/>Netty/MQTT/Push
participant Decoder as FrameDecoder<br/>MessageDecoder
participant Dispatcher as Dispatcher
participant Handler as Protocol Handler<br/>Mapper
participant EventBus as DisruptorEventBus
participant MQ as sink-mq<br/>Kafka Envelope
participant Archive as sink-archive<br/>ArchiveStore
participant FileStore as event-file-store<br/>Parquet + DuckDB
participant Consumer as 下游业务消费者
Device->>Inbound: 原始报文 bytes / MQTT message / HTTP payload
Inbound->>Decoder: 粘包拆帧、校验、协议体解析
Inbound->>Inbound: MQTT 按 endpoint profile 选择厂商解析器
Decoder->>Dispatcher: RawFrame(protocol, command, infoType, payload, rawBytes)
Dispatcher->>EventBus: 先发布 RawArchive 事件
EventBus->>Archive: 保存原始 bytes生成可回放材料
Dispatcher->>Handler: 根据 protocol + command + infoType 路由
Handler->>Handler: 协议字段映射为内部字段
Dispatcher->>Handler: 绑定 rawArchiveKey / rawArchiveUri
Handler->>EventBus: VehicleEvent(Realtime / Location / Alarm / Login ...)
EventBus->>MQ: 投递规整后的业务事件
EventBus->>FileStore: 写入解析后明细事件
MQ->>MQ: EnvelopeMapper 转 Protobuf
MQ->>Consumer: Kafka topickey=vin单车有序
FileStore->>Consumer: 按日期范围正序/倒序查询和导出
Consumer->>Consumer: 历史明细查询、Redis 热状态、可配置日统计、告警工单
</pre>
</div>
<div class="callout">
<strong>关键边界:</strong>
协议字段只在协议模块内解释,出了 Mapper 以后都应该使用内部字段。统计每日里程、每日用电量、每日用氢量、储氢安全和氢泄露告警时,应消费全字段 TelemetrySnapshot不要直接依赖某个协议的原始字段名。
</div>
<div class="callout">
<strong>原始追溯:</strong>
Dispatcher 为所有带 rawBytes 的 RawFrame 生成统一的 <code>rawArchiveKey</code>,业务事件 metadata 使用 <code>rawArchiveUri=archive://...</code> 指向同一份原始 bytes。归档文件的真实本地/S3/OSS 地址只属于 ArchiveStore查询、导出和 Kafka Envelope 使用逻辑 URI 解耦存储实现。
</div>
</section>
<section>
<h2>四、32960 细化链路</h2>
<div class="diagram">
<pre class="mermaid">
flowchart LR
terminal["氢能车 TBOX<br/>GB/T 32960"] --> netty["Gb32960NettyServer<br/>TCP / TLS / Idle 检测"]
netty --> access["Gb32960AccessService<br/>VIN 白名单 / 平台登录鉴权"]
netty --> frame["Gb32960FrameDecoder<br/>拆帧、转义、校验"]
frame --> decoder["Gb32960MessageDecoder<br/>Header + Body 解析"]
decoder --> diag["Gb32960FrameDiagnostics<br/>首帧、Raw 块、异常诊断"]
decoder --> handler["Gb32960ChannelHandler<br/>ACK / NACK / dispatch"]
handler --> ack["Gb32960AckService<br/>登录应答、失败断开"]
handler --> dispatcher["Dispatcher"]
dispatcher --> rtHandler["Gb32960RealtimeHandler"]
rtHandler --> mapper["Gb32960EventMapper<br/>协议字段到内部字段"]
mapper --> realtime["RealtimePayload<br/>速度、里程、电量、氢量、压力、温度"]
mapper --> alarm["AlarmPayload<br/>安全分类、氢泄露、告警等级"]
mapper --> login["Login / Logout / Heartbeat"]
realtime --> bus["DisruptorEventBus"]
alarm --> bus
login --> bus
bus --> kafka["Kafka Envelope"]
bus --> archive["RawArchive 冷存"]
classDef safety fill:#fff1f3,stroke:#b42318,color:#7a271a;
classDef core fill:#ecfdf3,stroke:#0f766e,color:#134e48;
classDef entry fill:#eff6ff,stroke:#2563eb,color:#1e3a8a;
classDef sink fill:#fffbeb,stroke:#b45309,color:#7c2d12;
class terminal,netty,frame,decoder,handler entry;
class access,diag,ack,dispatcher,rtHandler,mapper,bus core;
class alarm safety;
class kafka,archive sink;
</pre>
</div>
</section>
<section>
<h2>五、模块职责表</h2>
<table>
<thead>
<tr>
<th>模块</th>
<th>职责</th>
<th>主要输入</th>
<th>主要输出</th>
<th>业务开发关注点</th>
</tr>
</thead>
<tbody>
<tr>
<td>modules/core/ingest-api</td>
<td>定义系统边界:协议 ID、RawFrame、IngestContext、VehicleEvent、Payload、Handler 注解、Sink SPIProtocolId 提供 UNKNOWN 隔离桶供消费侧兼容未来协议或脏数据不作为正式接入协议使用consumer 包定义 EnvelopeIngestor、EnvelopeConsumerProcessor 和 EnvelopeDeadLetterSink统一 Kafka 消费结果与死信边界。</td>
<td>无直接外部输入。</td>
<td>全项目共享的接口和内部事件模型。</td>
<td><span class="tag red">内部字段定义</span><span class="tag">新增事件类型</span><span class="tag">字段兼容性</span></td>
</tr>
<tr>
<td>modules/core/ingest-codec-common</td>
<td>公共编解码工具,承载 BCD、CRC、BCC、bit 操作等协议底层能力。</td>
<td>协议模块传入的 byte、bit、校验材料。</td>
<td>解析辅助结果。</td>
<td><span class="tag">协议基础能力</span></td>
</tr>
<tr>
<td>modules/core/ingest-core</td>
<td>核心管线。扫描 Handler按协议和命令路由执行拦截器发布 VehicleEvent 到 Disruptor。</td>
<td>RawFrame。</td>
<td>VehicleEvent、RawArchive。</td>
<td><span class="tag green">吞吐</span><span class="tag green">路由</span><span class="tag amber">原始可回放</span></td>
</tr>
<tr>
<td>modules/core/session-core</td>
<td>设备会话、命令下发抽象、SessionStore、CommandDispatcher 默认实现。SessionStore 支持 <code>memory</code><code>redis</code> 两种后端;生产默认使用 Redis 保存 <code>sessionId</code>、VIN 和 phone 三索引及 TTL内存实现作为本地开发/无 Redis 降级,协议模块只依赖 SPI不感知存储后端。</td>
<td>设备连接、命令请求。</td>
<td>会话状态、命令分发结果、Redis 会话索引。</td>
<td><span class="tag">在线状态</span><span class="tag">下行控制</span><span class="tag green">多实例会话索引</span></td>
</tr>
<tr>
<td>modules/core/vehicle-identity</td>
<td>跨协议车辆身份解析,维护 phone、deviceId、plate 到 VIN 的绑定关系;支持 memory 和 append-only JSONL file 后端,避免重启丢失绑定。</td>
<td>协议模块传入的外部标识。</td>
<td>稳定 VIN、解析来源、是否已命中绑定。</td>
<td><span class="tag red">统计主键</span><span class="tag">多协议归一</span><span class="tag green">持久化绑定</span></td>
</tr>
<tr>
<td>modules/core/observability</td>
<td>监控、指标、追踪、健康检查等横切能力。</td>
<td>核心管线和 Sink 运行状态。</td>
<td>Micrometer 指标、健康信息。</td>
<td><span class="tag green">可用性</span><span class="tag">延迟监控</span></td>
</tr>
<tr>
<td>modules/protocols/protocol-gb32960</td>
<td>GB/T 32960 接入、鉴权、ACK、报文解析、实时数据和告警映射。</td>
<td>32960 TCP 报文。</td>
<td>Realtime、Location、Alarm、Login、Logout、Heartbeat、RawFrame。</td>
<td><span class="tag red">氢泄露</span><span class="tag red">储罐安全</span><span class="tag">电量/氢量</span><span class="tag">里程</span></td>
</tr>
<tr>
<td>modules/protocols/protocol-jt808</td>
<td>JT/T 808 接入、注册/鉴权/心跳/注销、位置/位置附加项、批量位置、参数/属性、多媒体、透传、未知上行兜底透传、分帧边界异常和协议解析异常坏帧兜底、下行命令和超长下行分包能力0x0200 位置附加项 0x01 总里程按 0.1km 解码为内部字段 <code>total_mileage_km</code>,后续每日里程统计不依赖 808 原始字段0x0102 鉴权帧解析会保留完整 token并尽量提取 IMEI 和软件版本;注册和鉴权会话均使用共享身份解析后的内部 VIN避免 deviceId/IMEI 污染后续会话和下行边界;终端连接断开时同步解绑 Channel 并清理 <code>SessionStore</code> 会话,避免 command-gateway 误判离线车辆仍可下行;正常上行 RawArchive 和业务事件 metadata 均使用共享身份解析后的内部 vin并保留 phone、identityResolved、identitySource 便于冷存和业务事件按同一车辆查询;无法解析终端身份的坏帧 RawArchive 和 Passthrough 均按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 标记0x0900 透传事件按原始消息 ID 归类,透传类型写入 passthroughType metadata。</td>
<td>808 TCP 报文。</td>
<td>Location、Login、Logout、Heartbeat、MediaMeta、Passthrough、未知上行 Raw 兜底、坏帧 Passthrough、会话状态、下行响应。</td>
<td><span class="tag">GPS 位置</span><span class="tag">在线状态</span><span class="tag">媒体证据</span><span class="tag">命令链路</span></td>
</tr>
<tr>
<td>modules/protocols/protocol-jt1078</td>
<td>JT/T 1078 音视频能力。信令在 JT808 mapper 存在时桥接复用 JT808 连接和包头0x1005 乘客流量保留原始 body同时结构化 channelId、startTime、endTime、passengerGetOn、passengerGetOff metadata 便于查询/导出0x1205 文件列表保留原始 body同时结构化 responseSerialNo、fileCount 摘要;下行信令编码由本模块提供,覆盖 0x1003 音视频属性查询、0x9101 实时预览、0x9102 实时控制、0x9201 历史回放、0x9202 回放控制、0x9205 资源列表查询、0x9206 文件上传和 0x9207 文件上传控制,命令发送仍由 command-gateway 经 CommandDispatcher 复用 JT808 在线通道TCP/UDP RTP 媒体流独立端口接入,生产默认端口按旧接收服务对齐为 <code>11078</code>,按 VIN/通道/时间分段写 ArchiveStoreKafka 只发送 MediaMeta 引用,事件 metadata 暴露内部 vin、sim、channelId、dataType、packetType、segment、sequence、segmentSizeBytes、archiveKey 和 archiveRefMediaMeta.sizeBytes 同步当前片段大小MediaMeta 按 archiveKey 去重,避免同一 SIM 运行中从 fallback 身份切换到内部 VIN 时漏发新归档引用,便于文件明细库追踪、展示和导出;超长/短包/坏魔数等 RTP 入口异常和媒体归档失败都会兜底产出 Passthrough 错误事件,坏 RTP 统一走 Dispatcher 产出 RawArchive 和 Passthrough统一标记 parseError 和 parseErrorMessage并保留内部 vin、原始 bytes、peer、长度、原因、RawArchive 引用、segment 和 archiveKey 便于排障;坏 RTP 头部可读时会提取 SIM 并通过共享身份服务映射 VIN。</td>
<td>1078 信令报文、TCP/UDP RTP 媒体流。</td>
<td>MediaMeta、乘客流量 Passthrough、文件列表 Passthrough、坏 RTP/归档失败 Passthrough、归档媒体分段。</td>
<td><span class="tag">视频扩展</span><span class="tag green">信令事件化</span><span class="tag amber">大流冷存</span></td>
</tr>
<tr>
<td>modules/protocols/protocol-jsatl12</td>
<td>苏标主动安全报警附件接入。依赖 ArchiveStore 和 JT808 decoder生产默认端口按旧接收服务对齐为 <code>7612</code>;附件 DataPacket 按旧服务真实布局 <code>01cd + 50B文件名 + offset + length + data</code> 分帧解析,不再把文件名前 4 字节误判为帧长;附件数据写 ArchiveStore 后通过 Dispatcher 产出 MediaMeta 引用事件,事件 metadata 保留 fileName、fileOffset、declaredChunkSizeBytes、archiveKey 和 archiveRef便于附件明细查询、导出和补传诊断内置附件状态机处理 T1210 文件清单、DataPacket 分块区间和 T1212 上传完成消息,数据分块会按 T1210 文件清单中的文件名继承手机号并通过共享身份解析器得到内部 VIN入口 RawFrame、ArchivedChunk 和 MediaMeta 均保留 phone、vin、identityResolved、identitySource只有找不到文件归属时才标记 UNKNOWN按文件名合并无身份数据分块并返回 0x9212 完成/补传应答,补传应答包含缺失 offset/length 区间;附件归档失败会产出 JSATL12 Passthrough 错误事件并保留原始分块JT_MESSAGE 信令复用 JT808 decoder 后进入 Dispatcher桥接 RawFrame 使用共享身份解析后的内部 VIN并保留 phone、identityResolved、identitySource坏 JT 信令入口 RawFrame 同样标记 UNKNOWN 身份;超长/畸形附件帧、未闭合超长 JT 信令和坏 JT 信令都以 JSATL12 Passthrough 兜底,附件帧异常同时标记 frameError 与统一 parseError/parseErrorMessage保留原始字节、peer、长度和错误元数据帧长和 worker 线程支持部署配置。</td>
<td>主动安全附件流、JT808 信令帧。</td>
<td>归档文件、MediaMeta、JT808 统一事件、坏帧/坏信令/归档失败 Passthrough。</td>
<td><span class="tag amber">附件归档</span><span class="tag">报警证据</span><span class="tag">信令复用</span><span class="tag">错误隔离</span></td>
</tr>
<tr>
<td>modules/inbound/inbound-mqtt</td>
<td>MQTT 多 endpoint 生命周期管理,支持 PEM CA、客户端证书/私钥双向 TLS按 endpoint profile 路由到厂商解析/映射实现,默认提供宇通 profile生产默认接入口径参考旧接收服务<code>ssl://cpxlm.axxc.cn:38883</code>、topic <code>/ytforward/shln/+</code>、QoS 2、cleanSession=false、keepAlive=20s、connectTimeout=10s用户名和密码仍通过环境变量注入入站 RawFrame 和事件映射均接入统一车辆身份解析,解析器保留 externalVin、phone、mqttDeviceId、plateNo优先按设备号/终端 ID/IMEI 绑定解析内部 VIN未绑定且字段形态为 VIN 时再作为显式 VIN 兜底,冷存和事件 metadata 均保留这些外部标识、identityResolved、identitySource未知 profile、解析失败、profile 运行时异常、endpoint 初始化/连接/订阅失败均兜底为 Passthrough解析失败诊断和 profile 异常诊断都会同步写入 RawArchive metadata并在最终 Passthrough 事件保留 parseErrorMessage 或 operational reason无法解析设备身份的兜底事件和运行类归档都按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 标记,保留原始 payload 或 operational 错误信息便于排障和归档追溯。</td>
<td>MQTT topic/message、endpoint profile、TLS PEM 配置。</td>
<td>统一 RawFrame、Realtime、Location、Passthrough。</td>
<td><span class="tag">车企平台接入</span><span class="tag green">profile 扩展</span><span class="tag green">身份映射</span><span class="tag green">双向 TLS</span><span class="tag green">错误隔离</span></td>
</tr>
<tr>
<td>modules/inbound/inbound-xinda-push</td>
<td>信达 Push 接入适配,基于 PushClient 接收真实协议帧并统一转换为 RawFrame入站 RawFrame 和事件映射均接入统一车辆身份解析,冷存和事件 metadata 均携带内部 vin并保留 externalVin、phone、deviceId、plateNo、identityResolved、identitySource0200 映射 Location并将 totalMileage、mileage 等里程别名归一为内部字段 <code>total_mileage_km</code>0300 映射标准 AlarmPayload氢气泄漏类报警统一提升为 HYDROGEN_LEAK/CRITICAL 并标记 hydrogenLeakDetected、hydrogenLeakActionRequired报警元数据保留 alarmType、alarmName、alarmLevel未知业务 cmd、非法十六进制 cmd、JSON 异常、身份解析/业务处理异常、启动配置缺失、启动异常、登录拒绝和坏登录应答均兜底为 Passthrough运行类错误按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 归档,非法 cmd 命令码归零并标记 malformedCommand业务消息 JSON 异常会在 RawArchive metadata 和最终 Passthrough 事件同时保留 parseErrorMessage业务处理异常会保留原始 payload 并标记 operationalError、phase=message 和 reason便于归档追溯。</td>
<td>信达 Push payload。</td>
<td>RawFrame、Location、Passthrough、operational error。</td>
<td><span class="tag">第三方平台接入</span><span class="tag green">统一 Dispatcher</span><span class="tag green">身份映射</span></td>
</tr>
<tr>
<td>modules/sinks/sink-mq</td>
<td>Kafka Sink。把 VehicleEvent 转成 Protobuf Envelope按 VIN 分区投递;业务事件携带全字段 TelemetrySnapshot 和 rawArchiveUri显式 RawArchive Envelope 会填充 archive:// 逻辑 URI 与 size默认 Kafka Sink 仍不发送原始 bytes 本体;同时提供 KafkaEnvelopeConsumerFactory、KafkaEnvelopeConsumerRunner、KafkaEnvelopeConsumerWorker 和 KafkaEnvelopeDeadLetterSink统一服务侧 Kafka 消费启动、独立 group 绑定、死信发布和 topic 到 EnvelopeConsumerProcessor 的分发。</td>
<td>VehicleEvent。</td>
<td>Kafka Protobuf 消息、消费侧 DLQ 记录。</td>
<td><span class="tag green">单车有序</span><span class="tag">Schema 演进</span><span class="tag red">安全字段下发给消费者</span></td>
</tr>
<tr>
<td>modules/sinks/sink-archive</td>
<td>原始报文冷存,当前有本地文件系统实现,后续可替换 S3/OSS写入键与业务事件 metadata 中的 <code>rawArchiveKey</code> 保持一致。</td>
<td>RawArchive 事件、附件流。</td>
<td>可回放原始文件、<code>archive://...</code> 逻辑引用。</td>
<td><span class="tag amber">问题排查</span><span class="tag amber">合规留痕</span></td>
</tr>
<tr>
<td>modules/sinks/event-file-store</td>
<td>文件型明细库,使用 Parquet 存储解析后的事件明细和 RawArchive 可查询索引,使用 DuckDB 按日期范围正序/倒序查询RawArchive 只存 archive:// 逻辑 URI、命令码、长度和 metadata不嵌入原始 bytes原始 bytes 本体仍由 sink-archive 管理;读取历史文件时未知 protocol 会降级为 UNKNOWN避免查询中断。</td>
<td>VehicleEvent 或 EventFileRecord。</td>
<td>protocol/date 分区的 Parquet 文件和查询结果。</td>
<td><span class="tag green">按日查询</span><span class="tag green">导出</span><span class="tag">字段配置</span></td>
</tr>
<tr>
<td>modules/services/event-history-service</td>
<td>消费 Kafka 全字段事件,写入文件型明细库,并提供历史查询和 CSV 导出边界;导出支持通过 <code>fields</code> 参数选择内部字段并平铺为 CSV 列,也支持 <code>metadata.xxx</code> 导出协议侧诊断、媒体归档、报警类型等 metadata 字段;未知 Envelope source 会进入 UNKNOWN 协议分区,并在 metadata 保留 <code>originalSource</code> 便于回溯;生产消费可使用 <code>tryIngest</code> 和自动装配的 <code>EnvelopeConsumerProcessor</code> 将坏 protobuf、缺快照和存储异常收敛为结构化结果并通过 <code>EnvelopeDeadLetterSink</code> 发布死信,避免阻塞 Kafka 分区。</td>
<td>Kafka Protobuf Envelope。</td>
<td>EventFileRecord、查询结果、可配置字段 CSV。</td>
<td><span class="tag green">历史明细</span><span class="tag green">多日导出</span><span class="tag">字段配置</span></td>
</tr>
<tr>
<td>modules/services/vehicle-state-service</td>
<td>消费 Kafka 全字段事件,更新车辆最新状态、位置、安全和最后事件到 Redis生产消费可使用 <code>tryIngest</code> 和自动装配的 <code>EnvelopeConsumerProcessor</code> 隔离坏 protobuf 和缺快照 Envelope并把失败记录交给死信出口。</td>
<td>Kafka Protobuf Envelope。</td>
<td><code>vehicle:state:{vin}</code><code>vehicle:location:{vin}</code><code>vehicle:safety:{vin}</code></td>
<td><span class="tag green">毫秒级热查询</span><span class="tag red">氢泄露安全</span></td>
</tr>
<tr>
<td>modules/services/vehicle-stat-service</td>
<td>消费 Kafka 全字段事件,抽取统计源点,按车辆规则计算每日里程等日统计;生产消费可使用 <code>tryIngest</code> 和自动装配的 <code>EnvelopeConsumerProcessor</code> 隔离坏 protobuf缺少 telemetry_snapshot 的消息会标记为 SKIPPED 并进入死信出口。</td>
<td>Kafka Protobuf Envelope、车辆统计规则。</td>
<td><code>vin + stat_date</code> 保存的日统计结果。</td>
<td><span class="tag green">每日里程</span><span class="tag">规则配置</span></td>
</tr>
<tr>
<td>modules/apps/command-gateway</td>
<td>HTTP 到设备下行命令入口。支持会话查询、808 位置查询、参数查询/设置、终端控制、平台通用应答、终端属性查询 0x8107、区域删除 0x8601、人工报警确认 0x8203以及 1078 音视频属性查询、实时预览/控制、历史回放/控制、资源列表查询、文件上传/控制;通过 CommandDispatcher 复用 protocol-jt808 的在线通道、流水号和同步应答等待能力。</td>
<td>业务系统命令请求。</td>
<td>CommandDispatcher 调用、设备应答摘要。</td>
<td><span class="tag">远程控制</span><span class="tag">参数设置</span><span class="tag">属性查询</span><span class="tag">区域删除</span><span class="tag">报警确认</span><span class="tag">视频控制</span></td>
</tr>
<tr>
<td>modules/apps/bootstrap-all</td>
<td>一体化启动模块,聚合各模块 AutoConfiguration 和运行配置。</td>
<td>application.yml、Spring Boot 启动。</td>
<td>完整接入服务实例。</td>
<td><span class="tag green">部署</span><span class="tag green">开关配置</span></td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>六、氢能业务开发落点</h2>
<table>
<thead>
<tr>
<th>业务主题</th>
<th>当前建议落点</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>车辆状态、速度、位置</td>
<td><code>RealtimePayload</code><code>LocationPayload</code></td>
<td>32960 和 808 都可能提供位置。统计侧需要明确优先级,例如优先 32960缺失时用 808 补点。</td>
</tr>
<tr>
<td>每日里程</td>
<td>Kafka 下游统计服务</td>
<td>接入层只投递总里程和实时点位;日增里程建议下游按 VIN、自然日、事件时间聚合处理回补和乱序。</td>
</tr>
<tr>
<td>每日用电量</td>
<td>内部电池字段 + 下游统计</td>
<td>接入层保留 SOC、电压、电流等瞬时字段若有累计电耗字段可加入内部字段模型后统一投递。</td>
</tr>
<tr>
<td>每日用氢量</td>
<td><code>hydrogenRemainingKg</code> + 下游统计</td>
<td>优先使用累计氢耗字段;没有累计值时用氢余量差值估算,并在统计结果里标记估算口径。</td>
</tr>
<tr>
<td>储氢安全</td>
<td><code>RealtimePayload</code> 压力/温度 + <code>AlarmPayload.safetyCategory</code></td>
<td>高压、低压、温度、异常 bit 都应统一归入储罐安全域,下游可做趋势、阈值和连续异常判断。</td>
</tr>
<tr>
<td>氢气泄露</td>
<td><code>AlarmPayload.hydrogenLeakDetected</code></td>
<td>当前已作为高优先级安全事件。32960 中出现 <code>HYDROGEN_LEAK</code> 时强制映射为 <code>CRITICAL</code></td>
</tr>
<tr>
<td>原始报文追溯</td>
<td><code>RawArchive</code> + <code>rawArchiveUri</code> + <code>sink-archive</code></td>
<td>业务统计出现争议时,用事件 metadata 中的 <code>archive://...</code> 逻辑 URI 回查原始 bytes结合 eventId、traceId、VIN、时间复现解析链路。</td>
</tr>
<tr>
<td>明细展示和导出</td>
<td><code>event-file-store</code></td>
<td>解析后的事件明细和 RawArchive 索引按 protocol/date 写 Parquet。DuckDB 查询时按日期范围读取,并按 event_time 正序或倒序返回。</td>
</tr>
</tbody>
</table>
<div class="callout">
<strong>后续开发建议:</strong>
当前 Kafka Protobuf 已加入全字段 <code>TelemetrySnapshot</code>。后续业务统计服务继续只依赖内部字段,不依赖 32960 原始字段名,这样接入 808、MQTT、车企私有协议时不会重写统计口径。
</div>
</section>
</main>
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({
startOnLoad: true,
theme: "base",
securityLevel: "strict",
flowchart: {
htmlLabels: true,
curve: "basis"
},
themeVariables: {
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif",
primaryColor: "#eff6ff",
primaryTextColor: "#172033",
primaryBorderColor: "#2563eb",
lineColor: "#667085",
secondaryColor: "#ecfdf3",
tertiaryColor: "#fffbeb"
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,600 @@
# GB32960 Body Parser — Per-Block Exception Isolation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `Gb32960BodyParser` 主循环对单信息块的解析异常实现**单块隔离**:一块解析失败不再整帧放弃,改为把失败块兜成 `InfoBlock.Raw` 并继续解析后续信息块。
**Architecture:** 在主循环每次 `parser.parse(body)` 外面套 try/catch仅捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`,放行 RuntimeException 以免掩盖 parser bug。失败时
-`fixedLen ≥ 0` 且剩余字节 ≥ fixedLen → 回滚 reader → 按 fixedLen 截取 Raw → position 前进 fixedLen → `continue` 循环
- 否则(变长 parser 或剩余不足)→ 剩余字节全部兜成 Raw → `break` 循环(变长块无法安全找下一块边界)
通过 `Gb32960Properties.Parse.lenientBlockFailure`(默认 true控制开关可以回退到严格模式。**不改 `InfoBlock.Raw` record 结构**(避免 downstream 兼容风险——`Gb32960EventMapper``findBlock(Class)` 类型查找,新增 Raw 不影响其行为)。
**Tech Stack:** Java 25, JUnit 5, AssertJ, Spring Boot ConfigurationProperties, Maven。仅改动 `protocol-gb32960` 模块。
---
## File Structure
- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java` — 新增 `Parse` 内嵌类 + `parse` 字段/getter/setter
- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java` — 主循环 try/catch + recovery 逻辑 + `lenientBlockFailure` 字段
- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` — 把配置穿给 BodyParser
- **Create** `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java` — 3 个隔离场景 + 1 个严格模式回退场景
- **Modify** `bootstrap-all/src/main/resources/application.yml` — 添加 `parse:` 注释段示例
- **Modify** `CHANGELOG.md` — 追加本次变更条目
---
## Task 1: 基线验证 —— 现有测试全绿
**Files:**
- [ ] **Step 1.1: 跑 protocol-gb32960 模块测试**
Run: `mvn -pl protocol-gb32960 test -q`
Expected: BUILD SUCCESS所有现有测试通过`Gb32960DecoderGoldenTest``Gb32960FullBlocksTest``Gb32960DecoderTest``GuangdongFcEndToEndTest`、parser/* 各 Block Parser 单测、profile/* 选择器单测)。
如果基线红了,**先停下来修复基线**再进入 Task 2。
---
## Task 2: 添加 Parse 配置子节点
**Files:**
- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java`
- [ ] **Step 2.1: 在 `Gb32960Properties` 类体中(在 `Auth` 字段后、`Tls` 字段前)新增 `parse` 字段**
位置参考:现在 L24 `private Auth auth = new Auth();` 下面一行,在 L27 `private Tls tls = new Tls();` 之前插入:
```java
/**
* 报文解析行为配置。
*
* <p>默认 {@code lenientBlockFailure=true}:单个信息块解析异常时兜成
* {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} 继续解析,
* 不再整帧放弃。关闭后恢复旧行为(任意异常直接抛 {@code DecodeException}
* 放弃整帧),仅在灰度回滚时使用。
*/
private Parse parse = new Parse();
```
- [ ] **Step 2.2: 在类底部(`Tls` 静态类之后、`VendorExtension` 静态类之前)添加 `Parse` 静态内嵌类**
```java
/**
* 报文解析容错策略。
*/
public static class Parse {
/**
* 单块解析异常时是否兜底为 {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw}
* 继续解析。默认开启。
*
* <ul>
* <li>{@code true}默认parser 抛 DecodeException / BufferUnderflowException /
* IndexOutOfBoundsException 时,固定长度块按 fixedLen 截取 Raw 后 continue
* 变长块或剩余字节不足时,剩余全部兜成 Raw 后 break 循环。
* <li>{@code false}:保留旧行为,任意异常直接抛出 DecodeException 放弃整帧。
* </ul>
*/
private boolean lenientBlockFailure = true;
public boolean isLenientBlockFailure() { return lenientBlockFailure; }
public void setLenientBlockFailure(boolean lenientBlockFailure) {
this.lenientBlockFailure = lenientBlockFailure;
}
}
```
- [ ] **Step 2.3: 在属性 getter/setter 区域(现有 `getTls` 之后)补充 `getParse` / `setParse`**
```java
public Parse getParse() { return parse; }
public void setParse(Parse parse) { this.parse = parse; }
```
- [ ] **Step 2.4: 编译验证**
Run: `mvn -pl protocol-gb32960 compile -q`
Expected: BUILD SUCCESS。
- [ ] **Step 2.5: 提交**
```bash
git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java
git commit -m "$(cat <<'EOF'
config(gb32960): add parse.lenientBlockFailure toggle
新增 lingniu.ingest.gb32960.parse.lenientBlockFailure 配置开关(默认 true
为 Gb32960BodyParser 的单块异常隔离行为做回退开关。实现在后续 commit。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: RED —— 写三个失败测试覆盖隔离场景
**Files:**
- Create: `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java`
- [ ] **Step 3.1: 创建测试文件**
完整内容:
```java
package com.lingniu.ingest.protocol.gb32960.codec;
import com.lingniu.ingest.api.spi.DecodeException;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
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.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 验证 {@link Gb32960BodyParser} 单块异常隔离行为。
*
* <p>三个隔离场景:
* <ol>
* <li>固定长度块 parser 抛异常 → 失败块兜 Raw按 fixedLen 截取)+ 后续块继续解析
* <li>固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环
* <li>变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环
* </ol>
*
* <p>外加一个严格模式回退测试:{@code lenientBlockFailure=false} 时任意异常应抛 DecodeException。
*/
class Gb32960BodyParserIsolationTest {
/** 构造一个"看起来合法但中途 parser 失败"用来注入失败的 Position parser。 */
private static final InfoBlockParser EXPLODING_FIXED_LEN_POSITION = new 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 buffer) {
// 模拟 parser 读了几字节后发现不合法就抛
buffer.get(); buffer.get();
throw new DecodeException("simulated parser failure in Position");
}
};
/** 变长块fixedLength=-1parse 时消费若干字节后抛。模拟 Alarm/Voltage 类列表读越界。 */
private static final InfoBlockParser EXPLODING_VAR_LEN_ALARM = new 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 buffer) {
// 假装读了个长度字段就爆
buffer.get();
throw new DecodeException("simulated parser failure in Alarm list-length read");
}
};
@Test
void fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed() {
// 帧布局Vehicle(0x01, 20B) + Position(0x05, 9B 但 parser 爆) + Engine(0x04) 不构造,
// 简化为 Vehicle + FailingPosition + Vehicle 再次,验证"Position 之后还能继续"。
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
EXPLODING_FIXED_LEN_POSITION));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os); // 1+20 = 21B
writePositionTypeAnd9ByteBody(os); // 1+9 = 10B (parser 会爆)
writeValidVehicle(os); // 1+20 = 21B
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
var result = parser.parse(ProtocolVersion.V2016, body);
assertThat(result.blocks()).hasSize(3);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
assertThat(raw.typeCode()).isEqualTo(0x05);
assertThat(raw.type()).isEqualTo(InfoBlockType.RAW);
assertThat(raw.bytes()).hasSize(9); // 按 fixedLen 截取
});
assertThat(result.blocks().get(2)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(body.hasRemaining()).isFalse();
}
@Test
void truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates() {
// Vehicle(21B) + Position(type 0x05)但 body 只给 3B —— 不足 9B
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
new PositionV2016BlockParser()));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os);
os.write(0x05); // Position typeCode
os.write(0); os.write(0); os.write(0); // 只 3B远远不够 9B
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
var result = parser.parse(ProtocolVersion.V2016, body);
assertThat(result.blocks()).hasSize(2);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
assertThat(raw.typeCode()).isEqualTo(0x05);
assertThat(raw.bytes()).hasSize(3); // 剩余字节全兜成 Raw
});
}
@Test
void variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks() {
// Vehicle(21B) + FailingAlarm(0x07)后面还有一个 Vehicle但因 Alarm 变长无法安全跳过,
// 整段 Alarm 起的剩余字节都被兜成 Raw 后 break。
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
EXPLODING_VAR_LEN_ALARM));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os); // 21B
os.write(0x07); // Alarm typeCode
for (int i = 0; i < 10; i++) os.write(0xAA); // 10B 的 alarm bodyparser 爆)
writeValidVehicle(os); // 21B应当不再被解析
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
var result = parser.parse(ProtocolVersion.V2016, body);
assertThat(result.blocks()).hasSize(2);
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
assertThat(raw.typeCode()).isEqualTo(0x07);
// 剩余 10B alarm body + 1+20=21B 后续 Vehicle = 31B
assertThat(raw.bytes()).hasSize(31);
});
}
@Test
void strictMode_throwsOnAnyBlockFailure() {
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
new VehicleV2016BlockParser(),
EXPLODING_FIXED_LEN_POSITION));
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
parser.setLenientBlockFailure(false);
ByteArrayOutputStream os = new ByteArrayOutputStream();
writeValidVehicle(os);
writePositionTypeAnd9ByteBody(os);
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
assertThatThrownBy(() -> parser.parse(ProtocolVersion.V2016, body))
.isInstanceOf(DecodeException.class);
}
// ------------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------------
/** 写一个完整的合法 V2016 Vehicle 块typeCode + 20B body。 */
private static void writeValidVehicle(ByteArrayOutputStream os) {
os.write(0x01); // typeCode Vehicle
os.write(0x01); // vehicleState=1
os.write(0x01); // chargingState=1
os.write(0x01); // runningMode=1
os.write(0); os.write(0); // speed=0
os.write(0); os.write(0); os.write(0); os.write(0); // mileage=0
os.write(0); os.write(0); // totalVoltage=0
os.write(0); os.write(0); // totalCurrent=0offset 1000原值0
os.write(50); // soc=50%
os.write(0x01); // dcdc
os.write(0); // gear
os.write(0); os.write(0); // insulation
os.write(0); // accelerator
os.write(0); // brake
}
/** 写 Position typeCode(0x05) + 9 字节 body内容不重要parser 替换成 EXPLODING 的)。 */
private static void writePositionTypeAnd9ByteBody(ByteArrayOutputStream os) {
os.write(0x05);
for (int i = 0; i < 9; i++) os.write(0);
}
}
```
- [ ] **Step 3.2: 跑测试验证 RED**
Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q`
Expected:
- `fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed` FAIL当前会抛 DecodeException
- `truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates` FAIL当前 L132 会抛 DecodeException
- `variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks` FAIL异常冒出整帧失败
- `strictMode_throwsOnAnyBlockFailure` FAIL没有 `setLenientBlockFailure` 方法,编译就红)
**验证点**编译失败strictMode test 里 `setLenientBlockFailure` 尚未存在)是**预期** RED 信号——下一步实现。
---
## Task 4: GREEN —— 实现单块异常隔离
**Files:**
- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java`
- [ ] **Step 4.1: 在类字段区新增 `lenientBlockFailure` 字段 + setter**
`private final VendorExtensionSelector selector;`(现 L54下面插入
```java
/**
* 单块解析异常时是否兜底为 Raw 后继续。由
* {@link com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties.Parse#isLenientBlockFailure()}
* 注入;默认 true。
*/
private boolean lenientBlockFailure = true;
public void setLenientBlockFailure(boolean lenientBlockFailure) {
this.lenientBlockFailure = lenientBlockFailure;
}
```
- [ ] **Step 4.2: 改造主循环:替换 L130~L150 的整个 "parse + 长度校验" 段**
**完整替换块**:以下代码替换原来从 `int fixedLen = parser.fixedLength();`L130开始到 `blocks.add(block);`L150结束的整块。
```java
int fixedLen = parser.fixedLength();
int posBefore = body.position();
// 旧行为fixedLen 预检不足 → 直接抛。新行为lenient 模式):走统一恢复路径。
if (fixedLen >= 0 && body.remaining() < fixedLen) {
if (!lenientBlockFailure) {
throw new DecodeException(
"info block 0x" + Integer.toHexString(typeCode)
+ " needs " + fixedLen + " bytes but got " + body.remaining());
}
// 剩余字节数不足 fixedLen —— 无法按块截取,整尾兜 Raw + break
int remaining = body.remaining();
byte[] tail = new byte[remaining];
body.get(tail);
log.warn("[gb32960] truncated block typeCode=0x{} declaredFixedLen={} remaining={} — wrapping tail as Raw",
Integer.toHexString(typeCode), fixedLen, remaining);
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail));
break;
}
InfoBlock block;
try {
block = parser.parse(body);
} catch (DecodeException | java.nio.BufferUnderflowException | IndexOutOfBoundsException e) {
if (!lenientBlockFailure) {
if (e instanceof DecodeException de) throw de;
throw new DecodeException(
"parser " + parser.getClass().getSimpleName() + " failed: " + e.getMessage(), e);
}
// 恢复路径:先回滚 position 到 parse 入口
body.position(posBefore);
if (fixedLen >= 0) {
// 固定长度块:按 fixedLen 截取 → 兜 Raw → continue 循环
byte[] corrupt = new byte[fixedLen];
body.get(corrupt);
log.warn("[gb32960] block parse failed typeCode=0x{} parser={} fixedLen={} — isolated as Raw, continuing",
Integer.toHexString(typeCode), parser.getClass().getSimpleName(), fixedLen, e);
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, corrupt));
continue;
} else {
// 变长块:无法安全找下一块边界 → 剩余整段兜 Raw + break
int remaining = body.remaining();
byte[] tail = new byte[remaining];
body.get(tail);
log.warn("[gb32960] variable-length block parse failed typeCode=0x{} parser={} remaining={} — wrapping remainder as Raw, stopping loop",
Integer.toHexString(typeCode), parser.getClass().getSimpleName(), remaining, e);
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail));
break;
}
}
int consumed = body.position() - posBefore;
if (fixedLen >= 0 && consumed != fixedLen) {
// 这是 parser 契约违反(声明 fixedLen=X 但实际读了 Y保持抛异常以暴露 parser bug。
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);
```
- [ ] **Step 4.3: 运行隔离测试验证 GREEN**
Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q`
Expected: 4 个测试全部 PASS。
- [ ] **Step 4.4: 运行全模块测试检查回归**
Run: `mvn -pl protocol-gb32960 test -q`
Expected: BUILD SUCCESS所有原有测试含 Golden、FullBlocks、GuangdongFcEndToEnd依旧通过。
如果有原有测试红了:**停下来诊断**。最可能的原因是某个现有测试之前靠"抛异常"行为验证错误帧的,需要手动判断这是测试问题还是实现问题。
- [ ] **Step 4.5: 提交**
```bash
git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java \
protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java
git commit -m "$(cat <<'EOF'
feat(gb32960): isolate per-block parse failures in body parser
主循环改造:单信息块 parser 抛 DecodeException / BufferUnderflowException /
IndexOutOfBoundsException 时不再放弃整帧。
- 固定长度块fixedLen≥0回滚 reader按 fixedLen 截取字节兜成 InfoBlock.Raw
position 前进 fixedLencontinue 循环继续解析后续块
- 变长块fixedLen=-1或剩余不足 fixedLen剩余字节全部兜成 Raw 后 break
- parser 契约违反consumed != declared fixedLen仍抛异常以暴露 parser bug
- 通过 lingniu.ingest.gb32960.parse.lenientBlockFailure=false 可回退严格模式
- 新增 Gb32960BodyParserIsolationTest 覆盖 3 种失败场景 + 1 严格模式
不改 InfoBlock.Raw record 结构;下游 Gb32960EventMapper 按 findBlock(Class) 类型
查找,新增 Raw 不影响其行为。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: 把配置注入 BodyParserAutoConfiguration 连线)
**Files:**
- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java`
- [ ] **Step 5.1: 定位 `Gb32960BodyParser` bean 定义**
先查看:`grep -n "Gb32960BodyParser" protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java`
根据返回的行号,在构造 `Gb32960BodyParser`@Bean 方法里,构造完成后立即调用:
```java
Gb32960BodyParser parser = new Gb32960BodyParser(profileRegistry, selector);
parser.setLenientBlockFailure(properties.getParse().isLenientBlockFailure());
return parser;
```
(如果目前的构造返回一行表达式,改成先赋给 local 变量再设 flag 再 return。
- [ ] **Step 5.2: 编译并运行全模块测试**
Run: `mvn -pl protocol-gb32960 test -q`
Expected: BUILD SUCCESS。
- [ ] **Step 5.3: 提交**
```bash
git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java
git commit -m "$(cat <<'EOF'
config(gb32960): wire parse.lenientBlockFailure into BodyParser bean
Gb32960AutoConfiguration 在创建 Gb32960BodyParser bean 后,将
Gb32960Properties.Parse.lenientBlockFailure 通过 setter 注入,让运行期配置生效。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: bootstrap-all 配置文档化
**Files:**
- Modify: `bootstrap-all/src/main/resources/application.yml`
- [ ] **Step 6.1: 在 `lingniu.ingest.gb32960` 节点的 `vendor-extensions` 段之后、`jt808` 之前,插入 parse 配置示例**
参考位置:现有 application.yml L57 末尾(`vendor-extensions` list 结束)。在 L58 `jt808:` 之前加入:
```yaml
# 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成 Raw 后继续,
# 不再整帧丢弃。仅在需要严格失败语义(灰度回滚或抓虫)时设为 false。
parse:
lenient-block-failure: true
```
- [ ] **Step 6.2: 启动一次 bootstrap-all 的 dry-run 编译**
Run: `mvn -pl bootstrap-all compile -q`
Expected: BUILD SUCCESS只是配置段注释变化不会影响编译
- [ ] **Step 6.3: 提交**
```bash
git add bootstrap-all/src/main/resources/application.yml
git commit -m "$(cat <<'EOF'
config(gb32960): document parse.lenient-block-failure in application.yml
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: 全仓库回归测试
**Files:** 无(只是验证)
- [ ] **Step 7.1: 跑完整项目测试**
Run: `mvn test -q`
Expected: BUILD SUCCESS。重点关注
- `protocol-gb32960` 模块全绿Golden、FullBlocks、Isolation、GuangdongFcEndToEnd、Decoder、Mapper、所有 parser 单测、profile
- 其它模块(`ingest-core``sink-mq` 等)不受影响(无改动应自动通过)
若有红,停下来诊断。
---
## Task 8: 更新 CHANGELOG
**Files:**
- Modify: `CHANGELOG.md`
- [ ] **Step 8.1: 在 CHANGELOG.md 文件顶部(在 `## [0.1.0] — 2026-04-15` 标题之前)新增一个 `## [Unreleased]` 段落;如已存在则追加**
新增段落内容(如已存在 Unreleased 段则在其 `### Added` / `### Changed` 内部追加):
```markdown
## [Unreleased]
### Changed —— GB/T 32960 Body Parser 单块异常隔离
- `Gb32960BodyParser` 主循环对单信息块的 parser 异常不再放弃整帧:
- 固定长度块(`fixedLength ≥ 0`):回滚 reader → 按 fixedLen 截取字节兜成
`InfoBlock.Raw` → 继续解析后续块;
- 变长块或剩余字节不足:剩余字节全部兜成 `Raw` 后终止循环;
- `parser` 契约违反(声明 fixedLen=X 但实际读了 Y仍抛 `DecodeException`
以暴露 parser bug不被静默吞掉
- 只捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`
`RuntimeException` 继续向上抛,保留 bug 可见性。
- 新增配置 `lingniu.ingest.gb32960.parse.lenient-block-failure`(默认 `true`
回退严格模式用 `false`
- `InfoBlock.Raw` record 结构**未变**,下游 `Gb32960EventMapper``findBlock(Class)`
类型匹配,新增 Raw 不影响业务事件映射。
- 覆盖测试:`Gb32960BodyParserIsolationTest` —— 3 种隔离场景 + 严格模式回退。
```
- [ ] **Step 8.2: 提交**
```bash
git add CHANGELOG.md
git commit -m "$(cat <<'EOF'
docs(changelog): record gb32960 body parser block isolation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Self-Review Checklist
- [x] **Spec coverage**: 原讨论的 A1~A7 全部覆盖——A1 状态机Task 4 Step 4.2、A2 三类错误策略、A3 收窄异常种类(同,只 catch 三种、A4 日志分类warn 带 parser 名/typeCode/原因、A5 配置开关Task 2、A6 合成帧测试Task 3 三场景 + 严格回退、A7 下游兼容(不改 Raw record`findBlock` 类型匹配不受影响plan 中已说明)。
- [x] **Placeholder scan**: 无 TBD / TODO / "实现上面的" 等占位;每个 Step 含实际代码 or 命令。
- [x] **Type consistency**: `lenientBlockFailure` 在 Properties / BodyParser 字段 / setter / 测试中拼写一致。`InfoBlockType.RAW` 枚举值依赖当前存在(已核对 InfoBlock.Raw record
- [x] **测试命名一致**`Gb32960BodyParserIsolationTest` 在 Task 3 创建、Task 4/7 引用一致。
- [x] **未引入新枚举/proto 变更**InfoBlock.Raw 沿用现有 `(int typeCode, InfoBlockType type, byte[] bytes)` 构造。

270
docs/target-architecture.md Normal file
View File

@@ -0,0 +1,270 @@
# Target Architecture
This document defines the target architecture for Lingniu vehicle ingest. The
main goal is to keep protocol ingest, raw archive, historical detail storage,
realtime state, and statistics as separate responsibilities connected by stable
event contracts.
## Goals
- The ingest service receives protocol messages, archives raw frames, and
publishes normalized full-field events to Kafka.
- Historical detail storage consumes Kafka events and stores raw parsed event
details in Parquet, queried through DuckDB.
- Realtime vehicle state consumes Kafka events and writes hot state to Redis.
- Statistics consumes Kafka events and calculates daily operational metrics by
configurable rules.
- Kafka consumers expose non-throwing ingest results for bad protobuf payloads,
unsupported envelopes, and downstream failures so one bad record does not
block a vehicle partition.
- Consumer services expose `EnvelopeConsumerProcessor` beans. Kafka workers or
framework listeners should pass topic, partition, offset, key, and value into
the processor; `SKIPPED`, `INVALID_ENVELOPE`, and `FAILED` results are routed
through `EnvelopeDeadLetterSink`.
- `sink-mq` owns Kafka-specific consumer plumbing: `KafkaEnvelopeConsumerFactory`
creates one independent Kafka consumer per service processor binding,
`KafkaEnvelopeConsumerRunner` manages the long-running poll loop, and
`KafkaEnvelopeDeadLetterSink` publishes failed records to the configured DLQ
topic with diagnostic headers.
- Business consumers depend on internal fields, not protocol-specific field
names.
## Non-Goals
- The ingest service must not write business tables.
- The ingest service must not serve historical detail queries.
- Parquet history storage must not store derived daily statistics.
- Redis hot state must not become the long-term historical store.
- Statistics services must not parse GB/T 32960 frames directly.
## Service Boundaries
### Ingest Service
The ingest service owns protocol IO and event publication.
Responsibilities:
- Accept GB/T 32960, JT/T 808, MQTT, Xinda Push, and future inbound protocols.
- Decode frames and acknowledge protocol messages where required.
- Emit `RawArchive` before deduplication and rate limiting when raw bytes are
available.
- Write raw bytes through `sink-archive`.
- Map protocol messages into internal telemetry events.
- Publish internal events to Kafka with VIN as the partition key.
The ingest service must not depend on Redis, DuckDB query endpoints, or
statistics rule repositories.
### Event Contract
The event contract is the only dependency shared by ingest, history, realtime
state, and statistics.
Core event identity:
| Field | Meaning |
|---|---|
| `event_id` | Idempotency key for the parsed event |
| `trace_id` | Cross-service trace id |
| `vin` | Vehicle VIN and Kafka partition key |
| `source_protocol` | Protocol source such as `GB32960`; consumers may map unknown future source names to `UNKNOWN` and preserve the original source in metadata |
| `protocol_version` | Source protocol version |
| `event_type` | Business event type, for example `REALTIME`, `LOCATION`, `ALARM` |
| `event_time` | Device collection time |
| `ingest_time` | Platform receive time |
| `raw_archive_uri` | Optional reference to archived raw bytes |
Full-field telemetry is represented as internal field values:
| Field | Meaning |
|---|---|
| `field_key` | Stable internal field key such as `total_mileage_km` |
| `value_type` | `STRING`, `DOUBLE`, `LONG`, `BOOLEAN`, `INSTANT`, `JSON` |
| `value` | String representation used for durable serialization |
| `unit` | Stable unit when applicable |
| `quality` | `GOOD`, `ESTIMATED`, `INVALID`, `MISSING` |
| `source_path` | Protocol source path, for traceability |
The internal field list is defined in
`docs/vehicle-telemetry-internal-fields.md`. New protocol-specific fields start
as extension fields and are promoted only after a business meaning and unit are
stable.
### Historical Detail Service
The historical detail service owns raw parsed event detail storage and exports.
Responsibilities:
- Consume normalized telemetry events from Kafka.
- Store events in Parquet partitioned by protocol and event date.
- Query Parquet through DuckDB.
- Provide HTTP APIs for date-range query and export.
- Support ascending and descending time order.
- Support multi-day concatenated export.
Storage partition:
```text
<root>/
protocol=GB32960/
date=2026-06-22/
part-*.parquet
```
The service stores detail events and field snapshots only. It does not calculate
or store daily mileage, daily hydrogen usage, or other derived metrics.
### Realtime State Service
The realtime state service owns hot vehicle state.
Responsibilities:
- Consume normalized telemetry events from Kafka.
- Keep latest vehicle state by VIN in Redis.
- Keep latest location by VIN in Redis.
- Keep latest safety state and hydrogen leak state by VIN in Redis.
- Optionally keep short recent windows for operations screens.
Suggested Redis keys:
| Key | Value |
|---|---|
| `vehicle:state:{vin}` | Latest full state snapshot |
| `vehicle:location:{vin}` | Latest location snapshot |
| `vehicle:safety:{vin}` | Latest safety snapshot |
| `vehicle:event:last:{vin}` | Latest event identity and timestamps |
Redis is for millisecond-level reads and recent state. It is not the historical
source of truth.
Current module: `vehicle-state-service`.
Implemented boundaries:
- `VehicleStateEnvelopeIngestor` parses Kafka envelope bytes.
- `VehicleStateUpdater` maps full-field telemetry snapshots to Redis JSON
payloads.
- `VehicleStateRepository` isolates storage from event parsing.
- `VehicleStateController` exposes read-only latest state APIs.
### Statistics Service
The statistics service owns derived operational metrics.
Responsibilities:
- Consume normalized telemetry events from Kafka.
- Maintain day-level vehicle point state needed for statistics.
- Run scheduled and event-triggered calculations.
- Store daily results in a statistics database.
- Support per-vehicle calculation rules.
Initial daily mileage strategies:
| Strategy | Formula |
|---|---|
| `CURRENT_LAST_MINUS_PREVIOUS_LAST` | Current day last valid total mileage minus previous day last valid total mileage |
| `DAY_MAX_MINUS_DAY_MIN` | Current day max valid total mileage minus current day min valid total mileage |
Hydrogen and electricity statistics use internal fields from the same event
contract. Refueling and charging resets must be handled in statistics logic, not
in protocol mappers.
Current module: `vehicle-stat-service`.
Implemented boundaries:
- `VehicleStatEnvelopeIngestor` parses Kafka envelope bytes.
- `VehicleStatEventProcessor` extracts statistic source points from internal
fields such as `total_mileage_km`.
- `VehicleStatRepository` isolates point/result persistence from calculation.
- `VehicleStatRuleRepository` isolates per-vehicle calculation rules.
- `DailyVehicleStatService` calculates and saves day-level results.
## Data Flow
```mermaid
flowchart LR
vehicle["Vehicle / Platform"] --> ingest["ingest service"]
ingest --> archive["sink-archive<br/>raw bytes"]
ingest --> kafka["Kafka<br/>full-field telemetry events"]
kafka --> history["event-history-service"]
kafka --> state["vehicle-state-service"]
kafka --> stat["vehicle-stat-service"]
history --> parquet["Parquet files"]
history --> duckdb["DuckDB query"]
state --> redis["Redis hot state"]
stat --> statdb["Statistics DB"]
```
## Kafka Topic Boundaries
Suggested topics:
| Topic | Producer | Consumer |
|---|---|---|
| `vehicle.realtime` | protocol ingress | history, realtime state, statistics |
| `vehicle.location` | protocol ingress | history, realtime state, statistics |
| `vehicle.alarm` | protocol ingress | history, realtime state |
| `vehicle.session` | protocol ingress | history |
| `vehicle.media.meta` | protocol ingress | history, media tooling |
| `vehicle.raw.archive` | optional archive publisher | replay or audit services |
| `vehicle.dlq` | any consumer | operations and replay tooling |
All telemetry event topics use VIN as the partition key to preserve per-vehicle
ordering.
## Failure Handling
- Ingest must keep receiving traffic when history, Redis, or statistics services
are down.
- Kafka publish failures go through the existing sink circuit breaker and DLQ
strategy.
- History consumer commits Kafka offsets only after Parquet append succeeds.
- Redis state consumer can replay from Kafka to rebuild hot state.
- Statistics consumer stores checkpoints and can recompute a day from historical
detail events when rules change.
- Raw archive failures are logged and alerted, but parsed event publication
remains independent.
## Implementation Phases
### Phase 1: Full-Field Contract And Historical Detail
- Add a full-field telemetry contract to `ingest-api`.
- Map GB/T 32960 parsed blocks into internal field snapshots.
- Extend Kafka envelope schema for full-field events.
- Make `event-file-store` store full-field telemetry records.
- Add query and export APIs for date-range historical detail.
### Phase 2: Redis Hot State
- Add a Redis-backed realtime state module.
- Consume full-field telemetry events.
- Store latest state, location, and safety snapshots by VIN.
- Provide query APIs for operations screens.
### Phase 3: Statistics Service
- Add a statistics module with rule configuration.
- Consume full-field telemetry events.
- Implement daily mileage strategies.
- Add result persistence boundary and calculation tests.
## Acceptance Criteria
- Ingest has no compile-time dependency on history query controllers, Redis
state repositories, or statistics calculators.
- Every service boundary communicates through Kafka events or explicit public
APIs, not shared mutable state.
- GB/T 32960 detail fields are traceable from internal field key back to
protocol source path.
- Historical query works across multiple days with ascending and descending
order.
- Redis state can be rebuilt from Kafka replay.
- Daily mileage can be calculated by at least the two initial strategies.
- Tests cover mapper output, Kafka serialization, Parquet query order, Redis
state update, and mileage strategy calculation.

View File

@@ -0,0 +1,146 @@
# Vehicle Telemetry Internal Fields
This document defines the internal telemetry field model for Lingniu hydrogen
vehicle operations. Protocol fields such as GB/T 32960, JT808, MQTT, and vendor
extensions must be mapped into these fields before storage, statistics, or
business reporting.
## Design Rules
- Business statistics use internal fields, not protocol field names.
- Every internal field has one stable unit and one business meaning.
- Protocol-specific raw values remain traceable through `source_protocol`,
`protocol_version`, `metadata`, and raw archive references.
- Core operational fields are stored as typed columns or typed payload fields.
- Unstable vendor-specific fields can stay in extension metadata until promoted.
- Hydrogen leak safety is a first-class critical safety event, not a generic
alarm count.
## Identity And Time
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|---|---:|---|---|---|
| `vin` | string | - | Vehicle VIN and partition key | header VIN |
| `event_time` | instant | - | Device collection time | command body timestamp |
| `ingest_time` | instant | - | Platform receive time | ingest server clock |
| `source_protocol` | enum | - | Source adapter | `GB32960` |
| `protocol_version` | string | - | Source protocol version | `V2016` / `V2025` |
## Real-Time Vehicle State
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|---|---:|---|---|---|
| `vehicle_state` | enum | - | Started, shutdown, other, invalid | vehicle block `vehicleState` |
| `charging_state` | enum | - | Charging state | vehicle block `chargingState` |
| `running_mode` | enum | - | Electric, hybrid, fuel, other | vehicle block `runningMode` |
| `speed_kmh` | double | km/h | Current speed | vehicle block `speedKmh` |
| `total_mileage_km` | double | km | Odometer mileage | vehicle block `totalMileageKm` |
| `gear_level` | integer | - | Gear value | vehicle block `gearRaw` low 4 bits |
| `accelerator_pedal` | double | % | Accelerator pedal opening | V2016 vehicle block |
| `brake_pedal` | double | % | Brake pedal opening | V2016 vehicle block |
## Location
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|---|---:|---|---|---|
| `longitude` | double | deg | Longitude | position block |
| `latitude` | double | deg | Latitude | position block |
| `altitude_m` | double | m | Altitude when available | extension or other protocol |
| `direction_deg` | double | deg | Direction when available | extension or other protocol |
| `location_status_raw` | long | - | Raw location status bits | position block `statusFlag` |
## Battery And Electricity
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|---|---:|---|---|---|
| `battery_soc` | double | % | Battery SOC | vehicle block `socPercent` |
| `battery_voltage_v` | double | V | Battery total voltage | vehicle block `totalVoltageV` |
| `battery_current_a` | double | A | Battery total current | vehicle block `totalCurrentA` |
| `battery_power_kw` | double | kW | Derived instantaneous battery power | `voltage * current / 1000` in statistics |
| `battery_temperature_max_c` | integer | C | Max battery temperature | temperature or extreme blocks |
| `battery_temperature_min_c` | integer | C | Min battery temperature | temperature or extreme blocks |
| `daily_electricity_kwh` | double | kWh | Daily electricity usage | statistics time integral |
Daily electricity must be computed in the statistics layer from typed telemetry
points. Do not accumulate protocol raw values directly.
## Hydrogen And Fuel Cell
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|---|---:|---|---|---|
| `fc_voltage_v` | double | V | Fuel cell voltage | fuel cell block |
| `fc_current_a` | double | A | Fuel cell current | fuel cell block |
| `fc_temp_c` | double | C | Fuel cell or hydrogen system temperature | fuel cell block |
| `hydrogen_remaining_kg` | double | kg | Remaining hydrogen mass | vendor extension or capacity conversion |
| `hydrogen_remaining_percent` | double | % | Remaining hydrogen percent | V2025 fuel cell block |
| `hydrogen_high_pressure_mpa` | double | MPa | Hydrogen high pressure | fuel cell block |
| `hydrogen_low_pressure_mpa` | double | MPa | Hydrogen low pressure when available | extension or other protocol |
| `daily_hydrogen_kg` | double | kg | Daily hydrogen consumed | statistics layer |
| `hydrogen_mileage_efficiency` | double | km/kg | Mileage per kg hydrogen | statistics layer |
Daily hydrogen usage should be calculated from adjacent hydrogen remaining
values. A rising value indicates refueling and must not be counted as negative
consumption.
## Hydrogen Tank Safety
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|---|---:|---|---|---|
| `safety_category` | enum | - | `GENERAL`, `TANK_PRESSURE`, `TANK_TEMPERATURE`, `HYDROGEN_LEAK` | alarm bit classification |
| `hydrogen_leak_detected` | boolean | - | Whether hydrogen leak is detected | general alarm bit `HYDROGEN_LEAK` |
| `hydrogen_leak_level` | enum | - | `NONE`, `WARNING`, `CRITICAL`, `UNKNOWN` | internal rule |
| `hydrogen_leak_action_required` | boolean | - | Whether immediate handling is required | internal rule |
| `tank_pressure_status` | enum | - | Normal, warning, critical | hydrogen pressure bit or threshold rule |
| `tank_temperature_status` | enum | - | Normal, warning, critical | hydrogen temp bit or threshold rule |
Safety rule:
```text
If HYDROGEN_LEAK is present:
alarm.level = CRITICAL
safety_category = HYDROGEN_LEAK
hydrogen_leak_detected = true
hydrogen_leak_level = CRITICAL
hydrogen_leak_action_required = true
```
Hydrogen leak overrides otherwise normal vehicle, speed, battery, or hydrogen
state. It must be counted independently from generic alarms.
## Daily Operation Statistics
Daily statistics are generated by `stat_date + vin`.
| Internal field | Type | Unit | Meaning |
|---|---:|---|---|
| `stat_date` | date | - | Statistics date |
| `vin` | string | - | Vehicle VIN |
| `first_event_time` | instant | - | First valid telemetry time |
| `last_event_time` | instant | - | Last valid telemetry time |
| `online_minutes` | double | min | Online duration |
| `running_minutes` | double | min | Running duration |
| `daily_mileage_km` | double | km | Daily mileage |
| `daily_electricity_kwh` | double | kWh | Daily electricity usage |
| `daily_hydrogen_kg` | double | kg | Daily hydrogen usage |
| `hydrogen_added_kg` | double | kg | Refueled hydrogen |
| `km_per_kg_hydrogen` | double | km/kg | Hydrogen efficiency |
| `kwh_per_100km` | double | kWh/100km | Electricity efficiency |
| `alarm_count` | integer | - | Generic alarm count |
| `tank_safety_alarm_count` | integer | - | Tank safety alarm count |
| `hydrogen_leak_alarm_count` | integer | - | Hydrogen leak alarm count |
| `hydrogen_leak_duration_seconds` | long | s | Hydrogen leak duration |
| `data_quality_level` | enum | - | Good, partial, bad |
## Implementation Status
- `RealtimePayload` already uses internal operational field names for state,
location, battery, fuel cell, and hydrogen fields.
- `AlarmPayload` carries internal hydrogen safety fields.
- `Gb32960EventMapper` maps GB/T 32960 alarm bits into internal safety fields.
- `TelemetrySnapshot` publishes full-field internal telemetry values through the
Kafka protobuf envelope.
- `event-history-service`, `vehicle-state-service`, and `vehicle-stat-service`
consume the same full-field telemetry snapshot contract.
- `event-history-service` CSV export can flatten selected internal fields with
`fields=field_a,field_b`; media archive references are exposed through
`rawArchiveUri` by falling back to `MediaMeta.archiveRef`.

View File

@@ -1,152 +0,0 @@
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());
}
}

View File

@@ -1,99 +0,0 @@
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;
}
}

View File

@@ -1,67 +0,0 @@
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));
}
}

View File

@@ -1,267 +0,0 @@
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;
}
}

View File

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

View File

@@ -1,114 +0,0 @@
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.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 核心分发器RawFrame → 拦截链 → Handler → 事件 → Disruptor EventBus。
*
* <p>所有 Inbound AdapterNetty / MQTT / PushClient都调用 {@link #dispatch(RawFrame)}
* 协议差异在这里被彻底抹平。
*
* <p>对于带 {@code @AsyncBatch} 的 HandlerDispatcher 不直接反射调用,而是把消息交给
* {@link AsyncBatchExecutor} 进行聚合 → 批量调用 → 异步发布事件。
*/
public final class Dispatcher {
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
private final HandlerRegistry registry;
private final InterceptorChain interceptors;
private final HandlerInvoker invoker;
private final DisruptorEventBus eventBus;
private final AsyncBatchExecutor batchExecutor;
public Dispatcher(HandlerRegistry registry,
InterceptorChain interceptors,
HandlerInvoker invoker,
DisruptorEventBus eventBus,
AsyncBatchExecutor batchExecutor) {
this.registry = registry;
this.interceptors = interceptors;
this.invoker = invoker;
this.eventBus = eventBus;
this.batchExecutor = batchExecutor;
}
public void dispatch(RawFrame frame) {
IngestContext ctx = new IngestContext(UUID.randomUUID().toString());
try {
// 在 interceptor 之前发 RawArchive保证原始字节被无条件落盘dedup/rate-limit
// 不会过滤它),满足"原始可回放"目标。archive 的写盘靠下游 ArchiveEventSink 消费。
emitRawArchive(frame, ctx);
if (!interceptors.before(frame, ctx)) {
log.debug("frame aborted: {}", ctx.abortReason());
return;
}
List<HandlerDefinition> handlers = registry.resolve(
frame.protocolId(), frame.command(), frame.infoType());
if (handlers.isEmpty()) {
log.debug("no handler for {} cmd=0x{} info=0x{}",
frame.protocolId(), Integer.toHexString(frame.command()),
Integer.toHexString(frame.infoType()));
return;
}
for (HandlerDefinition def : handlers) {
if (def.asyncBatch() != null) {
batchExecutor.submit(def, frame.payload());
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);
}
}
/**
* 从 {@link RawFrame} 构造一条 {@link VehicleEvent.RawArchive} 发到 EventBus。
* 仅在 {@code rawBytes} 非空时发——有些入站适配器(未来)可能只传解析后的对象。
*
* <p>VIN 取自 sourceMeta 里的 {@code vin} key由各入站适配器负责填充缺失时
* 留空字符串archive sink 会用 "unknown-vin" 占位保证 key 可解析。
*/
private void emitRawArchive(RawFrame frame, IngestContext ctx) {
byte[] bytes = frame.rawBytes();
if (bytes == null || bytes.length == 0) return;
Map<String, String> meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta();
String vin = meta.getOrDefault("vin", "");
Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now();
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
UUID.randomUUID().toString(),
vin,
frame.protocolId(),
ingestTime,
ingestTime,
ctx.traceId(),
meta,
frame.command(),
frame.infoType(),
bytes);
eventBus.publish(raw);
}
}

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>bootstrap-all</artifactId> <artifactId>bootstrap-all</artifactId>
<name>bootstrap-all</name> <name>bootstrap-all</name>
@@ -34,6 +35,22 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>sink-archive</artifactId> <artifactId>sink-archive</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>event-file-store</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>event-history-service</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>vehicle-state-service</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>vehicle-stat-service</artifactId>
</dependency>
<!-- Protocols --> <!-- Protocols -->
<dependency> <dependency>
@@ -73,6 +90,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId> <artifactId>spring-boot-starter</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- 测试 --> <!-- 测试 -->
<dependency> <dependency>

View File

@@ -0,0 +1,235 @@
spring:
application:
name: lingniu-vehicle-ingest
threads:
virtual:
enabled: true
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /v3/api-docs
server:
port: 20000
lingniu:
ingest:
gb32960:
enabled: true
port: ${GB32960_PORT:32960}
boss-threads: 1
worker-threads: 0 # 0 = Runtime.availableProcessors() * 2
# VIN 白名单认证内存配置。GB/T 32960 设备没有账号密码,身份由 VIN 决定。
auth:
enabled: ${GB32960_AUTH_ENABLED:false} # true 启用白名单false 放行所有 VIN
case-sensitive: false # 默认大小写不敏感
whitelist: # VIN 列表,未列入的 0x01 登入返回 0x04 VIN_NOT_EXIST
# - LTEST000000000001
# - LGWEF4A58RE123456
# 平台登入 0x05 账号列表GB/T 32960.3 表 29
# 列表为空时放行所有平台登入(兼容旧行为),否则 username/password 必须匹配,
# 失败返回 0x02 OTHER_ERROR 应答并关闭连接。
platforms:
# 现代 (XIANDAI)
- username: ${GB32960_PLATFORM_USER_1:lingniu}
password: ${GB32960_PLATFORM_PWD_1:Lingniu.2024#}
allowed-ips: # 可选:限制来源 IP为空不限制
description: 外部下级平台 - 现代 (原旧服务客户端)
# 现代平台真实 GB32960 接入账号
- username: ${GB32960_PLATFORM_USER_HYUNDAI:Hyundai}
password: ${GB32960_PLATFORM_PWD_HYUNDAI:f2e3445d7cda409fb4f278f6fb890734}
allowed-ips:
- ${GB32960_PLATFORM_IP_HYUNDAI:8.134.95.166}
description: 外部下级平台 - Hyundai
# 飞驰 (FEICHI)
- username: ${GB32960_PLATFORM_USER_2:ln@feichi}
password: ${GB32960_PLATFORM_PWD_2:Lingniu.2024#}
allowed-ips:
description: 外部下级平台 - 飞驰
# 海珀特 (HAIPOTE)
- username: ${GB32960_PLATFORM_USER_3:hbt@lingniu}
password: ${GB32960_PLATFORM_PWD_3:sgmi88b@&9}
allowed-ips:
description: 外部下级平台 - 海珀特
# 跃进 (YUEJIN)
- username: ${GB32960_PLATFORM_USER_4:yj01@lingniu}
password: ${GB32960_PLATFORM_PWD_4:01234567890123456789}
allowed-ips:
description: 外部下级平台 - 跃进
# TLS 双向认证(可选)。启用后 Netty pipeline 前置 SslHandler 并强制客户端证书。
tls:
enabled: ${GB32960_TLS_ENABLED:false}
cert-path: ${GB32960_TLS_CERT:} # 服务端证书 PEM 路径
key-path: ${GB32960_TLS_KEY:} # 服务端私钥 PEM 路径PKCS#8 未加密)
trust-cert-path: ${GB32960_TLS_CA:} # 受信 CA 证书 PEM用于校验客户端证书
require-client-auth: true # 强制双向false=单向 TLS
# ====================================================================
# 厂商扩展协议路由vendor extensions
# 命中规则按 list 顺序首匹first-match-wins命中后用对应套件的 parser 集合
# 在标准 0x01..0x09 之上叠加。已支持的套件:
# - guangdong-fc广东燃料电池汽车示范规范 0x30~0x34、0x80
# 来源reference/广东燃料电池汽车示范应用城市群综合监管平台-...pdf
# ====================================================================
vendor-extensions:
- name: guangdong-fc
match:
# 平台账号匹配精确匹配case-insensitive。这里和上面 auth.platforms
# 中的 username 保持一致,确保 lingniu 账号登入后所有 0x02 帧都走广东 profile。
platform-accounts:
- lingniu
- Hyundai
- ln@feichi
# VIN 前缀匹配startsWith空 list = 不参与匹配
vin-prefixes: []
# VIN 精确匹配
vins: []
# 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成
# InfoBlock.Raw 后继续解析下一个块,不再整帧丢弃。仅在需要严格失败语义
# (灰度回滚或抓虫)时设为 false。
parse:
lenient-block-failure: true
diagnostics:
max-logged-frame-keys-per-channel: 128
jt808:
# 1078 信令和 JSATL12 嵌入 JT808 信令都依赖本模块;一体化启动默认开启,
# 需要只跑 GB32960 时可用 JT808_ENABLED=false 显式关闭。
enabled: ${JT808_ENABLED:true}
port: ${JT808_PORT:10808}
max-frame-length: ${JT808_MAX_FRAME_LENGTH:1048}
jt1078:
# 1078 媒体流归档可独立启用1078 信令事件复用 JT808 mapper
# 只有同时启用 jt808.enabled=true 时才会装配信令桥接 handler。
enabled: ${JT1078_ENABLED:true}
media-stream:
enabled: ${JT1078_MEDIA_STREAM_ENABLED:true}
tcp-enabled: ${JT1078_MEDIA_STREAM_TCP_ENABLED:true}
udp-enabled: ${JT1078_MEDIA_STREAM_UDP_ENABLED:false}
port: ${JT1078_MEDIA_STREAM_PORT:11078}
udp-port: ${JT1078_MEDIA_STREAM_UDP_PORT:11078}
worker-threads: ${JT1078_MEDIA_STREAM_WORKERS:0}
max-packet-length: ${JT1078_MEDIA_STREAM_MAX_PACKET_LENGTH:1048576}
segment-seconds: ${JT1078_MEDIA_STREAM_SEGMENT_SECONDS:300}
jsatl12:
# JSATL12 附件流依赖 ArchiveStore嵌入的 JT808 信令解析依赖
# jt808.enabled=true。缺任一依赖时不会装配附件接入 server。
enabled: ${JSATL12_ENABLED:true}
port: ${JSATL12_PORT:7612}
worker-threads: ${JSATL12_WORKERS:0}
max-frame-length: ${JSATL12_MAX_FRAME_LENGTH:65536}
file-store: ${JSATL12_FILE_STORE:file:///var/lingniu/alarm-files}
mqtt:
enabled: ${MQTT_ENABLED:false}
endpoints:
- name: yutong
uri: ${MQTT_URI:ssl://cpxlm.axxc.cn:38883}
topic: ${MQTT_TOPIC:/ytforward/shln/+}
qos: 2
clean-session: false
keep-alive-seconds: 20
connection-timeout-seconds: 10
client-id: ${MQTT_CLIENT_ID:shlnClientId}
username: ${MQTT_USER:shln}
password: ${MQTT_PWD:}
tls:
ca-pem: ${MQTT_CA_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/ca.crt}
client-pem: ${MQTT_CLIENT_PEM:/opt/lingniuServices/certificate/yutong/vehicledatareception/client.crt}
client-key: ${MQTT_CLIENT_KEY:/opt/lingniuServices/certificate/yutong/vehicledatareception/client-key-pkcs8.pem}
xinda-push:
enabled: ${XINDA_PUSH_ENABLED:false}
host: ${XINDA_HOST:}
port: ${XINDA_PORT:10100}
username: ${XINDA_USER:}
password: ${XINDA_PWD:}
subscribe-msg-ids: ${XINDA_SUBSCRIBE_MSG_IDS:0200,0300,0401}
client-desc: ${XINDA_CLIENT_DESC:lingniu-ingest}
pipeline:
disruptor:
ring-buffer-size: 131072 # 2^17
wait-strategy: yielding # blocking | sleeping | yielding | busy-spin
producer-type: multi
dedup:
enabled: true
cache-size: 200000
ttl-seconds: 600
rate-limit:
per-vin-qps: 50
session:
# memory | redis。生产默认 Redis会话索引用于多实例/重启后的下行命令解析。
store: ${SESSION_STORE:redis}
ttl: ${SESSION_TTL:30m}
identity:
# memory 适合本地开发file 是生产最低可用持久化后端,后续可替换 DB/配置中心实现。
store: ${VEHICLE_IDENTITY_STORE:file}
file:
path: ${VEHICLE_IDENTITY_FILE:./data/vehicle-identity.jsonl}
sink:
mq:
# MQ Sink 总开关。true=装配 Kafka Producer 并投递事件false=完全不装配(适合本地开发或 Kafka 不可达)。
enabled: ${KAFKA_ENABLED:false}
type: kafka # 后端选择kafka | (rocketmq | pulsar 预留)
bootstrap-servers: ${KAFKA_BROKERS:114.55.58.251:9092}
compression-type: zstd
linger-ms: 20
batch-size: 65536
acks: all
enable-idempotence: true
topics:
realtime: vehicle.realtime
location: vehicle.location
alarm: vehicle.alarm
session: vehicle.session
media-meta: vehicle.media.meta
raw-archive: vehicle.raw.archive
dlq: vehicle.dlq
consumer:
# true=启动服务侧 Kafka Envelope 消费 worker默认绑定 event-history/state/stat 三类 processor。
enabled: ${KAFKA_CONSUMER_ENABLED:false}
auto-startup: true
client-id-prefix: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX:lingniu-envelope-consumer}
poll-timeout-millis: ${KAFKA_CONSUMER_POLL_TIMEOUT_MILLIS:1000}
loop-backoff-millis: ${KAFKA_CONSUMER_LOOP_BACKOFF_MILLIS:1000}
auto-offset-reset: ${KAFKA_CONSUMER_AUTO_OFFSET_RESET:earliest}
max-poll-records: ${KAFKA_CONSUMER_MAX_POLL_RECORDS:500}
archive:
enabled: true
type: local # local | s3 | oss
path: ./archive/
event-file-store:
enabled: ${EVENT_FILE_STORE_ENABLED:true}
path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500}
flush-interval-millis: ${EVENT_FILE_STORE_FLUSH_INTERVAL_MILLIS:1000}
event-history:
enabled: ${EVENT_HISTORY_ENABLED:true}
vehicle-state:
enabled: ${VEHICLE_STATE_ENABLED:false}
vehicle-stat:
enabled: ${VEHICLE_STAT_ENABLED:true}
file-path: ${VEHICLE_STAT_FILE_PATH:./target/vehicle-stat/}
zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,env
metrics:
tags:
application: lingniu-vehicle-ingest
logging:
level:
root: INFO
com.lingniu.ingest: INFO
com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler: INFO
com.lingniu.ingest.protocol.gb32960.codec.Gb32960FrameDecoder: INFO
com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser: INFO
# 信达 push 诊断:打开下面一行可以看到每一条收发的原始字节 hex dump
com.lingniu.ingest.inbound.xinda: INFO

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>command-gateway</artifactId> <artifactId>command-gateway</artifactId>
<name>command-gateway</name> <name>command-gateway</name>
@@ -26,6 +27,10 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt808</artifactId> <artifactId>protocol-jt808</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-jt1078</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
@@ -34,5 +39,15 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </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> </dependencies>
</project> </project>

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>ingest-api</artifactId> <artifactId>ingest-api</artifactId>
<name>ingest-api</name> <name>ingest-api</name>

View File

@@ -5,6 +5,7 @@ package com.lingniu.ingest.api;
* 新增协议需要在此处登记避免散落的字符串常量 * 新增协议需要在此处登记避免散落的字符串常量
*/ */
public enum ProtocolId { public enum ProtocolId {
UNKNOWN,
GB32960, GB32960,
JT808, JT808,
JT1078, JT1078,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,10 @@ import java.util.Set;
* @param activeBits 结构化的通用报警位集合2016 0~152025 0~27对照 GB/T 32960.3 24 * @param activeBits 结构化的通用报警位集合2016 0~152025 0~27对照 GB/T 32960.3 24
* @param longitude 经度可选若事件伴随位置 * @param longitude 经度可选若事件伴随位置
* @param latitude 纬度可选 * @param latitude 纬度可选
* @param safetyCategory 内部安全分类氢气泄露独立成类不与普通故障混用
* @param hydrogenLeakDetected 是否检测到氢气泄露
* @param hydrogenLeakLevel 氢气泄露等级
* @param hydrogenLeakActionRequired 是否需要立即处置
*/ */
public record AlarmPayload( public record AlarmPayload(
AlarmLevel level, AlarmLevel level,
@@ -21,7 +25,11 @@ public record AlarmPayload(
List<String> faultCodes, List<String> faultCodes,
Set<String> activeBits, Set<String> activeBits,
Double longitude, Double longitude,
Double latitude Double latitude,
SafetyCategory safetyCategory,
boolean hydrogenLeakDetected,
HydrogenLeakLevel hydrogenLeakLevel,
boolean hydrogenLeakActionRequired
) { ) {
/** /**
* 报警等级归一化的跨协议分类 * 报警等级归一化的跨协议分类
@@ -34,4 +42,24 @@ public record AlarmPayload(
* </ul> * </ul>
*/ */
public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL } public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL }
/**
* 面向运营统计的内部安全分类
*/
public enum SafetyCategory {
GENERAL,
TANK_PRESSURE,
TANK_TEMPERATURE,
HYDROGEN_LEAK
}
/**
* 氢气泄露内部等级明确泄露信号一律视为 CRITICAL
*/
public enum HydrogenLeakLevel {
NONE,
WARNING,
CRITICAL,
UNKNOWN
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>ingest-codec-common</artifactId> <artifactId>ingest-codec-common</artifactId>
<name>ingest-codec-common</name> <name>ingest-codec-common</name>

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>ingest-core</artifactId> <artifactId>ingest-core</artifactId>
<name>ingest-core</name> <name>ingest-core</name>

View File

@@ -16,6 +16,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.UnaryOperator;
/** /**
* {@link AsyncBatch} 注解的执行器 Handler 方法从"单条调用"变成"批量调用" * {@link AsyncBatch} 注解的执行器 Handler 方法从"单条调用"变成"批量调用"
@@ -51,7 +52,12 @@ public final class AsyncBatchExecutor implements AutoCloseable {
/** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */ /** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */
public void submit(HandlerDefinition def, Object message) { public void submit(HandlerDefinition def, Object message) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher)); Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(message); b.offer(new BatchItem(message, UnaryOperator.identity(), false));
}
public void submit(HandlerDefinition def, Object message, UnaryOperator<VehicleEvent> eventTransformer) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, eventTransformer == null ? UnaryOperator.identity() : eventTransformer, true));
} }
@Override @Override
@@ -68,7 +74,7 @@ public final class AsyncBatchExecutor implements AutoCloseable {
private final Consumer<VehicleEvent> publisher; private final Consumer<VehicleEvent> publisher;
private final int batchSize; private final int batchSize;
private final long waitMs; private final long waitMs;
private final BlockingQueue<Object> queue; private final BlockingQueue<BatchItem> queue;
private final Thread[] workers; private final Thread[] workers;
private volatile boolean running = true; private volatile boolean running = true;
@@ -92,7 +98,7 @@ public final class AsyncBatchExecutor implements AutoCloseable {
def.method().getName(), batchSize, waitMs, pool); def.method().getName(), batchSize, waitMs, pool);
} }
void offer(Object item) { void offer(BatchItem item) {
try { try {
if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) { if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) {
log.warn("batcher queue full, dropping item for {}", def.method().getName()); log.warn("batcher queue full, dropping item for {}", def.method().getName());
@@ -105,15 +111,15 @@ public final class AsyncBatchExecutor implements AutoCloseable {
private void loop() { private void loop() {
while (running) { while (running) {
try { try {
Object first = queue.poll(200, TimeUnit.MILLISECONDS); BatchItem first = queue.poll(200, TimeUnit.MILLISECONDS);
if (first == null) continue; if (first == null) continue;
List<Object> buf = new ArrayList<>(batchSize); List<BatchItem> buf = new ArrayList<>(batchSize);
buf.add(first); buf.add(first);
long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L; long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L;
while (buf.size() < batchSize) { while (buf.size() < batchSize) {
long remain = deadlineNanos - System.nanoTime(); long remain = deadlineNanos - System.nanoTime();
if (remain <= 0) break; if (remain <= 0) break;
Object next = queue.poll(remain, TimeUnit.NANOSECONDS); BatchItem next = queue.poll(remain, TimeUnit.NANOSECONDS);
if (next == null) break; if (next == null) break;
buf.add(next); buf.add(next);
} }
@@ -127,11 +133,35 @@ public final class AsyncBatchExecutor implements AutoCloseable {
} }
} }
private void flush(List<BatchItem> buf) {
if (requiresPerItemTransform(buf)) {
for (BatchItem item : buf) {
flushTransformedItem(item);
}
return;
}
publishEvents(invoke(buf.stream().map(BatchItem::message).toList()), UnaryOperator.identity());
}
private boolean requiresPerItemTransform(List<BatchItem> buf) {
for (BatchItem item : buf) {
if (item.transformRequired()) {
return true;
}
}
return false;
}
private void flushTransformedItem(BatchItem item) {
publishEvents(invoke(List.of(item.message())), item.eventTransformer());
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void flush(List<Object> buf) { private List<VehicleEvent> invoke(List<Object> messages) {
List<VehicleEvent> events; List<VehicleEvent> events;
try { try {
Object result = def.method().invoke(def.bean(), buf); Object result = def.method().invoke(def.bean(), messages);
events = switch (result) { events = switch (result) {
case null -> List.of(); case null -> List.of();
case List<?> list -> (List<VehicleEvent>) list; case List<?> list -> (List<VehicleEvent>) list;
@@ -141,14 +171,18 @@ public final class AsyncBatchExecutor implements AutoCloseable {
}; };
} catch (InvocationTargetException e) { } catch (InvocationTargetException e) {
log.error("batcher invoke failed method={}", def.method().getName(), e.getCause()); log.error("batcher invoke failed method={}", def.method().getName(), e.getCause());
return; return List.of();
} catch (IllegalAccessException e) { } catch (IllegalAccessException e) {
log.error("batcher access denied method={}", def.method().getName(), e); log.error("batcher access denied method={}", def.method().getName(), e);
return; return List.of();
} }
return events;
}
private void publishEvents(List<VehicleEvent> events, UnaryOperator<VehicleEvent> eventTransformer) {
for (VehicleEvent e : events) { for (VehicleEvent e : events) {
try { try {
publisher.accept(e); publisher.accept(eventTransformer.apply(e));
} catch (Exception ex) { } catch (Exception ex) {
log.warn("batcher publish failed eventId={}", e.eventId(), ex); log.warn("batcher publish failed eventId={}", e.eventId(), ex);
} }
@@ -161,4 +195,9 @@ public final class AsyncBatchExecutor implements AutoCloseable {
for (Thread t : workers) t.interrupt(); for (Thread t : workers) t.interrupt();
} }
} }
private record BatchItem(Object message,
UnaryOperator<VehicleEvent> eventTransformer,
boolean transformRequired) {
}
} }

View File

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

View File

@@ -34,6 +34,9 @@ public final class HandlerRegistry {
if (d.matches(protocol, command, infoType)) result.add(d); if (d.matches(protocol, command, infoType)) result.add(d);
} }
} }
if (!result.isEmpty()) {
return result;
}
for (HandlerDefinition d : wildcardHandlers) { for (HandlerDefinition d : wildcardHandlers) {
if (d.matches(protocol, command, infoType)) result.add(d); if (d.matches(protocol, command, infoType)) result.add(d);
} }

View File

@@ -7,6 +7,7 @@ import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame; import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import java.util.Arrays;
import java.time.Duration; import java.time.Duration;
/** /**
@@ -29,7 +30,10 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
@Override @Override
public boolean before(RawFrame frame, IngestContext ctx) { public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown"); String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
String seq = frame.sourceMeta().getOrDefault("seq", "0"); String seq = frame.sourceMeta().get("seq");
if (seq == null || seq.isBlank()) {
seq = rawFingerprint(frame);
}
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq; String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) { if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
ctx.abort("duplicate:" + key); ctx.abort("duplicate:" + key);
@@ -42,4 +46,12 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
public int getOrder() { public int getOrder() {
return 100; return 100;
} }
private static String rawFingerprint(RawFrame frame) {
byte[] rawBytes = frame.rawBytes();
if (rawBytes != null && rawBytes.length > 0) {
return "raw:" + Integer.toHexString(Arrays.hashCode(rawBytes));
}
return "receivedAt:" + frame.receivedAt();
}
} }

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>observability</artifactId> <artifactId>observability</artifactId>
<name>observability</name> <name>observability</name>

View File

@@ -5,6 +5,7 @@
<groupId>com.lingniu.ingest</groupId> <groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId> <artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version> <version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent> </parent>
<artifactId>session-core</artifactId> <artifactId>session-core</artifactId>
<name>session-core</name> <name>session-core</name>
@@ -23,6 +24,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId> <artifactId>spring-boot-autoconfigure</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
@@ -37,5 +42,15 @@
<artifactId>assertj-core</artifactId> <artifactId>assertj-core</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

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

View File

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

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