feat: make gb32960 archive history query production ready
This commit is contained in:
733
docs/module-data-flow.html
Normal file
733
docs/module-data-flow.html
Normal 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 索引到 Parquet,DuckDB 按日查询"]
|
||||
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 topic,key=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 SPI;ProtocolId 提供 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/通道/时间分段写 ArchiveStore,Kafka 只发送 MediaMeta 引用,事件 metadata 暴露内部 vin、sim、channelId、dataType、packetType、segment、sequence、segmentSizeBytes、archiveKey 和 archiveRef,MediaMeta.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、identitySource;0200 映射 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>
|
||||
@@ -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=-1),parse 时消费若干字节后抛。模拟 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 body(parser 爆)
|
||||
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=0(offset 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 前进 fixedLen,continue 循环继续解析后续块
|
||||
- 变长块(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: 把配置注入 BodyParser(AutoConfiguration 连线)
|
||||
|
||||
**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
270
docs/target-architecture.md
Normal 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.
|
||||
146
docs/vehicle-telemetry-internal-fields.md
Normal file
146
docs/vehicle-telemetry-internal-fields.md
Normal 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`.
|
||||
Reference in New Issue
Block a user