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

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>ingest-api</artifactId>
<name>ingest-api</name>
<description>SPI、sealed 领域事件、注解定义。零 Spring 依赖。</description>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,15 @@
package com.lingniu.ingest.api;
/**
* 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。
* 新增协议需要在此处登记,避免散落的字符串常量。
*/
public enum ProtocolId {
UNKNOWN,
GB32960,
JT808,
JT1078,
JSATL12,
MQTT_YUTONG,
XINDA_PUSH
}

View File

@@ -0,0 +1,22 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
*
* <p>处理方法签名需为 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>}。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AsyncBatch {
int size() default 1000;
long waitMs() default 500;
int poolSize() default 2;
}

View File

@@ -0,0 +1,17 @@
package com.lingniu.ingest.api.annotation;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 声明当前 Handler 方法产出的事件类型。用于文档化和静态校验。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface EventEmit {
Class<? extends VehicleEvent>[] value();
}

View File

@@ -0,0 +1,17 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
*
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface IdempotentKey {
String value();
}

View File

@@ -0,0 +1,27 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 方法级路由注解:声明此方法处理哪些协议消息。
*
* <p>{@link #command} 和 {@link #infoType} 的含义由各协议自行解释Dispatcher 只负责匹配:
* <ul>
* <li>GB/T 32960{@code command} = 0x01 车辆登入 / 0x02 实时上报 / ...{@code infoType} = 信息体 ID
* <li>JT/T 808{@code command} = 消息 ID0x0100 / 0x0200 / ...{@code infoType} 留空
* <li>MQTT{@code command} 可为 topic hash 或忽略
* </ul>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MessageMapping {
int[] command() default {};
int[] infoType() default {};
String desc() default "";
}

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.api.annotation;
import com.lingniu.ingest.api.ProtocolId;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标记一个类为协议 Handler Bean由 {@code AnnotationHandlerProcessor} 自动扫描注册。
*
* <p>典型用法:
* <pre>{@code
* @ProtocolHandler(protocol = ProtocolId.GB32960)
* public class Gb32960RealtimeHandler {
* @MessageMapping(command = 0x02)
* public VehicleEvent.Realtime handle(Gb32960Message msg) { ... }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ProtocolHandler {
ProtocolId protocol();
/** 可选:子协议版本(如 32960 的 2011 / 2017。 */
String version() default "";
}

View File

@@ -0,0 +1,20 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimited {
/** 单 VIN 每秒最大消息数。 */
int perVin() default 50;
/** 全局 QPS 上限;<=0 表示不设限。 */
int global() default -1;
}

View File

@@ -0,0 +1,15 @@
package com.lingniu.ingest.api.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 为 Handler 方法开启 OpenTelemetry span。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface TraceSpan {
String value();
}

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

@@ -0,0 +1,65 @@
package com.lingniu.ingest.api.event;
import java.util.List;
import java.util.Set;
/**
* 报警事件载荷,跨协议归一。
*
* @param level 报警最高等级
* @param alarmTypeCode 协议原始 alarm type 编码(按协议自定义),可用于联查协议规范
* @param alarmTypeName 报警类型名称,如 {@code GB32960_ALARM}
* @param faultCodes 故障代码列表(协议私有编码字符串化,便于下游去重/聚合)
* @param activeBits 结构化的通用报警位集合2016 版0~152025 版0~27对照 GB/T 32960.3 表 24
* @param longitude 经度(可选,若事件伴随位置)
* @param latitude 纬度(可选)
* @param safetyCategory 内部安全分类。氢气泄露独立成类,不与普通故障混用
* @param hydrogenLeakDetected 是否检测到氢气泄露
* @param hydrogenLeakLevel 氢气泄露等级
* @param hydrogenLeakActionRequired 是否需要立即处置
*/
public record AlarmPayload(
AlarmLevel level,
int alarmTypeCode,
String alarmTypeName,
List<String> faultCodes,
Set<String> activeBits,
Double longitude,
Double latitude,
SafetyCategory safetyCategory,
boolean hydrogenLeakDetected,
HydrogenLeakLevel hydrogenLeakLevel,
boolean hydrogenLeakActionRequired
) {
/**
* 报警等级(归一化的跨协议分类)。
*
* <ul>
* <li>{@link #INFO}:提示级(对应 GB/T 32960.3 0/无故障)
* <li>{@link #MINOR}1 级 —— 不影响车辆正常行驶
* <li>{@link #MAJOR}2 级 —— 影响车辆性能,需驾驶员限制行驶
* <li>{@link #CRITICAL}3 级(驾驶员应立即停车)或 4 级(热事件最高级)
* </ul>
*/
public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL }
/**
* 面向运营统计的内部安全分类。
*/
public enum SafetyCategory {
GENERAL,
TANK_PRESSURE,
TANK_TEMPERATURE,
HYDROGEN_LEAK
}
/**
* 氢气泄露内部等级。明确泄露信号一律视为 CRITICAL。
*/
public enum HydrogenLeakLevel {
NONE,
WARNING,
CRITICAL,
UNKNOWN
}
}

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,49 @@
package com.lingniu.ingest.api.event;
/**
* 实时数据载荷(扁平 record字段用 {@code Double} / {@code Integer} 允许 null表达"未上报")。
*
* <p>不再复刻旧服务 {@code VehicleDataActual} 的 273 字段巨型类 —— 协议特有细节通过
* {@link VehicleEvent#metadata()} 传递。此处只保留跨协议通用字段。
*/
public record RealtimePayload(
// ===== 动力 & 能源 =====
Double speedKmh,
Double totalMileageKm,
Double batterySoc,
Double batteryVoltageV,
Double batteryCurrentA,
// ===== 燃料电池 / 氢能 =====
Double fcVoltageV,
Double fcCurrentA,
Double fcTempC,
Double hydrogenRemainingKg,
Double hydrogenHighPressureMpa,
Double hydrogenLowPressureMpa,
// ===== 车辆状态 =====
VehicleState vehicleState,
ChargingState chargingState,
RunningMode runningMode,
Integer gearLevel,
Double acceleratorPedal,
Double brakePedal,
// ===== 位置(冗余一份便于单主题消费)=====
Double longitude,
Double latitude,
Double altitudeM,
Double directionDeg,
// ===== 环境 =====
Double ambientTempC,
Double coolantTempC
) {
public enum VehicleState { STARTED, SHUTDOWN, OTHER, INVALID }
public enum ChargingState { UNCHARGED, PARKED_CHARGING, DRIVING_CHARGING, CHARGED, OTHER, INVALID }
public enum RunningMode { ELECTRIC, HYBRID, FUEL, OTHER, INVALID }
}

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,166 @@
package com.lingniu.ingest.api.event;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 领域事件根类型。所有协议归一到这里。
*
* <p>使用 sealed + record 表达有界闭合的事件集合,下游消费方可用 {@code switch} 模式匹配穷尽处理。
*
* <p>字段约定:
* <ul>
* <li>{@link #vin} 作为 Kafka 分区 key保证单车顺序
* <li>{@link #eventTime} 设备上报时间(而非服务器接收时间)
* <li>{@link #ingestTime} 服务器接收时间,用于延迟监控
* <li>{@link #traceId} W3C traceparent 上下文
* </ul>
*/
public sealed interface VehicleEvent
permits VehicleEvent.Realtime,
VehicleEvent.Location,
VehicleEvent.Alarm,
VehicleEvent.Login,
VehicleEvent.Logout,
VehicleEvent.Heartbeat,
VehicleEvent.MediaMeta,
VehicleEvent.Passthrough,
VehicleEvent.RawArchive {
String eventId();
String vin();
ProtocolId source();
Instant eventTime();
Instant ingestTime();
String traceId();
/** 通用元数据:协议版本、终端手机号、网关 IP 等。 */
Map<String, String> metadata();
// ===== 具体事件类型 =====
/** 整车实时数据32960 实时上报 / MQTT 宇通 / JT808 位置扩展等都归一到这里)。 */
record Realtime(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
RealtimePayload payload
) implements VehicleEvent {}
/** 纯位置事件808 的 0x0200 / Xinda 0200 映射到此)。 */
record Location(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
LocationPayload payload
) implements VehicleEvent {}
/** 报警事件。 */
record Alarm(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
AlarmPayload payload
) implements VehicleEvent {}
/** 设备登入 / 车辆登入。 */
record Login(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
String iccid,
String protocolVersion
) implements VehicleEvent {}
/** 设备登出。 */
record Logout(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata
) implements VehicleEvent {}
/** 心跳 / 链路保活。 */
record Heartbeat(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata
) implements VehicleEvent {}
/** 多媒体元数据(大文件本体走对象存储,通过 archiveRef 引用)。 */
record MediaMeta(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
String mediaId,
String mediaType,
long sizeBytes,
String archiveRef
) implements VehicleEvent {}
/** 透传 / 自定义扩展消息。 */
record Passthrough(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
int passthroughType,
byte[] data
) implements VehicleEvent {}
/**
* 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节交
* {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型
* (见 {@code KafkaEventSink.accepts})。
*
* <p>key 组装建议:{@code yyyy/MM/dd/<source>/<vin>/<eventId>.bin},具体由 sink 实现决定。
*
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
* @param infoType 协议子类型(可为 0同上
* @param rawBytes 原始入站字节,不可为 null
*/
record RawArchive(
String eventId,
String vin,
ProtocolId source,
Instant eventTime,
Instant ingestTime,
String traceId,
Map<String, String> metadata,
int command,
int infoType,
byte[] rawBytes
) implements VehicleEvent {}
}

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,13 @@
/**
* lingniu-vehicle-ingest 公共 API 模块。零 Spring 依赖,只定义 SPI、sealed 领域事件、注解。
*
* <p>分包:
* <ul>
* <li>{@code annotation} - Handler 注解体系
* <li>{@code event} - sealed {@code VehicleEvent} + payload records
* <li>{@code pipeline} - RawFrame / IngestContext / IngestInterceptor
* <li>{@code sink} - EventSink SPI
* <li>{@code spi} - ProtocolPlugin / FrameDecoder / FrameEncoder / EventMapper
* </ul>
*/
package com.lingniu.ingest.api;

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.api.pipeline;
import java.util.HashMap;
import java.util.Map;
/**
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享不需要同步。
*/
public final class IngestContext {
private final String traceId;
private final Map<String, Object> attributes = new HashMap<>(8);
private volatile boolean aborted;
private volatile String abortReason;
public IngestContext(String traceId) {
this.traceId = traceId;
}
public String traceId() {
return traceId;
}
public <T> T attr(String key) {
@SuppressWarnings("unchecked")
T v = (T) attributes.get(key);
return v;
}
public void attr(String key, Object value) {
attributes.put(key, value);
}
public void abort(String reason) {
this.aborted = true;
this.abortReason = reason;
}
public boolean aborted() {
return aborted;
}
public String abortReason() {
return abortReason;
}
}

View File

@@ -0,0 +1,21 @@
package com.lingniu.ingest.api.pipeline;
import com.lingniu.ingest.api.event.VehicleEvent;
/**
* 拦截器 SPI。内置实现Auth / Dedup / RateLimit / CoordTransform / Tracing。
* 实现 {@link org.springframework.core.Ordered}(或使用 {@code @Order})控制顺序。
*/
public interface IngestInterceptor {
/** 解码后、分发到 Handler 之前触发。返回 false 表示终止后续处理。 */
default boolean before(RawFrame frame, IngestContext ctx) {
return true;
}
/** Handler 成功产出事件后触发,允许修饰事件或取消投递。 */
default void after(VehicleEvent event, IngestContext ctx) {}
/** 处理链任一环节抛出异常时触发。 */
default void onError(Throwable error, IngestContext ctx) {}
}

View File

@@ -0,0 +1,27 @@
package com.lingniu.ingest.api.pipeline;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 进入 Pipeline 的原始帧描述。
*
* @param protocolId 来源协议
* @param command 协议主命令 / 消息 ID由各协议自行定义
* @param infoType 可选子类型(如 32960 的信息体 ID
* @param payload 已经被 {@link com.lingniu.ingest.api.spi.FrameDecoder} 解析出的协议对象
* @param rawBytes 原始字节,用于冷存与排障(可能为 null按配置开关
* @param sourceMeta 来源元数据peer ip、端口、session id、topic 等
* @param receivedAt 服务器接收时刻
*/
public record RawFrame(
ProtocolId protocolId,
int command,
int infoType,
Object payload,
byte[] rawBytes,
Map<String, String> sourceMeta,
Instant receivedAt
) {}

View File

@@ -0,0 +1,28 @@
package com.lingniu.ingest.api.sink;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 事件出口 SPI。Kafka、本地归档、指标打点都是一种 Sink。
*
* <p>实现应当线程安全、非阻塞(内部异步 IO。由 Disruptor EventBus 扇出调用。
*/
public interface EventSink {
String name();
CompletableFuture<Void> publish(VehicleEvent event);
default CompletableFuture<Void> publishBatch(List<VehicleEvent> batch) {
CompletableFuture<?>[] all = batch.stream().map(this::publish).toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(all);
}
/** 是否接受此事件类型。默认全部接受。 */
default boolean accepts(VehicleEvent event) {
return true;
}
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.api.spi;
/** 解码阶段异常,指示数据体本身违反协议。应被 Dispatcher 路由到 DLQ。 */
public class DecodeException extends RuntimeException {
public DecodeException(String message) {
super(message);
}
public DecodeException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,16 @@
package com.lingniu.ingest.api.spi;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.util.List;
/**
* 协议消息 → 领域事件的映射器Adapter 模式)。
*
* <p>实现类负责把"我这个协议特有的 Java 对象"翻译成统一的 {@link VehicleEvent}。
* 允许一条协议消息展开为多条领域事件(例如 32960 的一次实时上报同时产出 Realtime + Location
*/
@FunctionalInterface
public interface EventMapper<I> {
List<VehicleEvent> toEvents(I message);
}

View File

@@ -0,0 +1,13 @@
package com.lingniu.ingest.api.spi;
import java.nio.ByteBuffer;
/**
* 协议帧解码器:字节流 → 协议消息对象。
*
* <p>实现类应是无状态的(拆包/粘包由上游 Netty 的帧解码器处理),此处只负责一条完整帧的解析。
*/
@FunctionalInterface
public interface FrameDecoder<I> {
I decode(ByteBuffer buffer) throws DecodeException;
}

View File

@@ -0,0 +1,9 @@
package com.lingniu.ingest.api.spi;
import java.nio.ByteBuffer;
/** 下行帧编码器。无下行的协议返回 null 即可。 */
@FunctionalInterface
public interface FrameEncoder<O> {
ByteBuffer encode(O message);
}

View File

@@ -0,0 +1,16 @@
package com.lingniu.ingest.api.spi;
import com.lingniu.ingest.api.event.VehicleEvent;
import java.util.List;
/**
* 编程式 Handler 接口。推荐使用 {@code @ProtocolHandler + @MessageMapping} 注解方式代替。
* 此接口保留是为了 SPI 场景下的脚本式扩展(例如动态加载的 groovy handler
*/
public interface MessageHandler<I> {
boolean supports(I message);
List<VehicleEvent> handle(I message);
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.api.spi;
import com.lingniu.ingest.api.ProtocolId;
import java.util.List;
/**
* 协议插件 SPI。每个协议模块提供一个实现由 {@code ProtocolPluginRegistry} 通过 ServiceLoader
* 或 Spring Bean 方式发现并装配。
*
* @param <I> 协议解码后的消息类型
* @param <O> 下行消息类型(无下行的协议用 {@link Void}
*/
public interface ProtocolPlugin<I, O> {
ProtocolId id();
FrameDecoder<I> decoder();
FrameEncoder<O> encoder();
EventMapper<I> eventMapper();
/**
* 插件自带的协议特定 Handler 列表(可选)。
* 通常推荐使用 {@code @ProtocolHandler} + Spring Bean 的方式注册,此处返回空列表。
*/
default List<MessageHandler<I>> builtinHandlers() {
return List.of();
}
}

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();
}
}