feat: make gb32960 archive history query production ready
This commit is contained in:
30
modules/core/ingest-api/pom.xml
Normal file
30
modules/core/ingest-api/pom.xml
Normal 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>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lingniu.ingest.api;
|
||||
|
||||
/**
|
||||
* 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。
|
||||
* 新增协议需要在此处登记,避免散落的字符串常量。
|
||||
*/
|
||||
public enum ProtocolId {
|
||||
UNKNOWN,
|
||||
GB32960,
|
||||
JT808,
|
||||
JT1078,
|
||||
JSATL12,
|
||||
MQTT_YUTONG,
|
||||
XINDA_PUSH
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
|
||||
*
|
||||
* <p>处理方法签名需为 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>}。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface AsyncBatch {
|
||||
|
||||
int size() default 1000;
|
||||
|
||||
long waitMs() default 500;
|
||||
|
||||
int poolSize() default 2;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 声明当前 Handler 方法产出的事件类型。用于文档化和静态校验。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface EventEmit {
|
||||
Class<? extends VehicleEvent>[] value();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
|
||||
*
|
||||
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface IdempotentKey {
|
||||
String value();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 方法级路由注解:声明此方法处理哪些协议消息。
|
||||
*
|
||||
* <p>{@link #command} 和 {@link #infoType} 的含义由各协议自行解释,Dispatcher 只负责匹配:
|
||||
* <ul>
|
||||
* <li>GB/T 32960:{@code command} = 0x01 车辆登入 / 0x02 实时上报 / ...;{@code infoType} = 信息体 ID
|
||||
* <li>JT/T 808:{@code command} = 消息 ID(0x0100 / 0x0200 / ...);{@code infoType} 留空
|
||||
* <li>MQTT:{@code command} 可为 topic hash 或忽略
|
||||
* </ul>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface MessageMapping {
|
||||
|
||||
int[] command() default {};
|
||||
|
||||
int[] infoType() default {};
|
||||
|
||||
String desc() default "";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 标记一个类为协议 Handler Bean,由 {@code AnnotationHandlerProcessor} 自动扫描注册。
|
||||
*
|
||||
* <p>典型用法:
|
||||
* <pre>{@code
|
||||
* @ProtocolHandler(protocol = ProtocolId.GB32960)
|
||||
* public class Gb32960RealtimeHandler {
|
||||
* @MessageMapping(command = 0x02)
|
||||
* public VehicleEvent.Realtime handle(Gb32960Message msg) { ... }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ProtocolHandler {
|
||||
|
||||
ProtocolId protocol();
|
||||
|
||||
/** 可选:子协议版本(如 32960 的 2011 / 2017)。 */
|
||||
String version() default "";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface RateLimited {
|
||||
|
||||
/** 单 VIN 每秒最大消息数。 */
|
||||
int perVin() default 50;
|
||||
|
||||
/** 全局 QPS 上限;<=0 表示不设限。 */
|
||||
int global() default -1;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 为 Handler 方法开启 OpenTelemetry span。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface TraceSpan {
|
||||
String value();
|
||||
}
|
||||
@@ -0,0 +1,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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnvelopeDeadLetterSink {
|
||||
void publish(EnvelopeDeadLetterRecord record);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnvelopeIngestor {
|
||||
EnvelopeIngestResult tryIngest(byte[] kafkaValue);
|
||||
}
|
||||
@@ -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~15;2025 版: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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
/**
|
||||
* 实时数据载荷(扁平 record,字段用 {@code Double} / {@code Integer} 允许 null,表达"未上报")。
|
||||
*
|
||||
* <p>不再复刻旧服务 {@code VehicleDataActual} 的 273 字段巨型类 —— 协议特有细节通过
|
||||
* {@link VehicleEvent#metadata()} 传递。此处只保留跨协议通用字段。
|
||||
*/
|
||||
public record RealtimePayload(
|
||||
// ===== 动力 & 能源 =====
|
||||
Double speedKmh,
|
||||
Double totalMileageKm,
|
||||
Double batterySoc,
|
||||
Double batteryVoltageV,
|
||||
Double batteryCurrentA,
|
||||
|
||||
// ===== 燃料电池 / 氢能 =====
|
||||
Double fcVoltageV,
|
||||
Double fcCurrentA,
|
||||
Double fcTempC,
|
||||
Double hydrogenRemainingKg,
|
||||
Double hydrogenHighPressureMpa,
|
||||
Double hydrogenLowPressureMpa,
|
||||
|
||||
// ===== 车辆状态 =====
|
||||
VehicleState vehicleState,
|
||||
ChargingState chargingState,
|
||||
RunningMode runningMode,
|
||||
Integer gearLevel,
|
||||
Double acceleratorPedal,
|
||||
Double brakePedal,
|
||||
|
||||
// ===== 位置(冗余一份便于单主题消费)=====
|
||||
Double longitude,
|
||||
Double latitude,
|
||||
Double altitudeM,
|
||||
Double directionDeg,
|
||||
|
||||
// ===== 环境 =====
|
||||
Double ambientTempC,
|
||||
Double coolantTempC
|
||||
) {
|
||||
|
||||
public enum VehicleState { STARTED, SHUTDOWN, OTHER, INVALID }
|
||||
|
||||
public enum ChargingState { UNCHARGED, PARKED_CHARGING, DRIVING_CHARGING, CHARGED, OTHER, INVALID }
|
||||
|
||||
public enum RunningMode { ELECTRIC, HYBRID, FUEL, OTHER, INVALID }
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* lingniu-vehicle-ingest 公共 API 模块。零 Spring 依赖,只定义 SPI、sealed 领域事件、注解。
|
||||
*
|
||||
* <p>分包:
|
||||
* <ul>
|
||||
* <li>{@code annotation} - Handler 注解体系
|
||||
* <li>{@code event} - sealed {@code VehicleEvent} + payload records
|
||||
* <li>{@code pipeline} - RawFrame / IngestContext / IngestInterceptor
|
||||
* <li>{@code sink} - EventSink SPI
|
||||
* <li>{@code spi} - ProtocolPlugin / FrameDecoder / FrameEncoder / EventMapper
|
||||
* </ul>
|
||||
*/
|
||||
package com.lingniu.ingest.api;
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.lingniu.ingest.api.pipeline;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享,不需要同步。
|
||||
*/
|
||||
public final class IngestContext {
|
||||
|
||||
private final String traceId;
|
||||
private final Map<String, Object> attributes = new HashMap<>(8);
|
||||
private volatile boolean aborted;
|
||||
private volatile String abortReason;
|
||||
|
||||
public IngestContext(String traceId) {
|
||||
this.traceId = traceId;
|
||||
}
|
||||
|
||||
public String traceId() {
|
||||
return traceId;
|
||||
}
|
||||
|
||||
public <T> T attr(String key) {
|
||||
@SuppressWarnings("unchecked")
|
||||
T v = (T) attributes.get(key);
|
||||
return v;
|
||||
}
|
||||
|
||||
public void attr(String key, Object value) {
|
||||
attributes.put(key, value);
|
||||
}
|
||||
|
||||
public void abort(String reason) {
|
||||
this.aborted = true;
|
||||
this.abortReason = reason;
|
||||
}
|
||||
|
||||
public boolean aborted() {
|
||||
return aborted;
|
||||
}
|
||||
|
||||
public String abortReason() {
|
||||
return abortReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lingniu.ingest.api.pipeline;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
/**
|
||||
* 拦截器 SPI。内置实现:Auth / Dedup / RateLimit / CoordTransform / Tracing。
|
||||
* 实现 {@link org.springframework.core.Ordered}(或使用 {@code @Order})控制顺序。
|
||||
*/
|
||||
public interface IngestInterceptor {
|
||||
|
||||
/** 解码后、分发到 Handler 之前触发。返回 false 表示终止后续处理。 */
|
||||
default boolean before(RawFrame frame, IngestContext ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Handler 成功产出事件后触发,允许修饰事件或取消投递。 */
|
||||
default void after(VehicleEvent event, IngestContext ctx) {}
|
||||
|
||||
/** 处理链任一环节抛出异常时触发。 */
|
||||
default void onError(Throwable error, IngestContext ctx) {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.api.pipeline;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 进入 Pipeline 的原始帧描述。
|
||||
*
|
||||
* @param protocolId 来源协议
|
||||
* @param command 协议主命令 / 消息 ID(由各协议自行定义)
|
||||
* @param infoType 可选子类型(如 32960 的信息体 ID)
|
||||
* @param payload 已经被 {@link com.lingniu.ingest.api.spi.FrameDecoder} 解析出的协议对象
|
||||
* @param rawBytes 原始字节,用于冷存与排障(可能为 null,按配置开关)
|
||||
* @param sourceMeta 来源元数据:peer ip、端口、session id、topic 等
|
||||
* @param receivedAt 服务器接收时刻
|
||||
*/
|
||||
public record RawFrame(
|
||||
ProtocolId protocolId,
|
||||
int command,
|
||||
int infoType,
|
||||
Object payload,
|
||||
byte[] rawBytes,
|
||||
Map<String, String> sourceMeta,
|
||||
Instant receivedAt
|
||||
) {}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.lingniu.ingest.api.sink;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* 事件出口 SPI。Kafka、本地归档、指标打点都是一种 Sink。
|
||||
*
|
||||
* <p>实现应当线程安全、非阻塞(内部异步 IO)。由 Disruptor EventBus 扇出调用。
|
||||
*/
|
||||
public interface EventSink {
|
||||
|
||||
String name();
|
||||
|
||||
CompletableFuture<Void> publish(VehicleEvent event);
|
||||
|
||||
default CompletableFuture<Void> publishBatch(List<VehicleEvent> batch) {
|
||||
CompletableFuture<?>[] all = batch.stream().map(this::publish).toArray(CompletableFuture[]::new);
|
||||
return CompletableFuture.allOf(all);
|
||||
}
|
||||
|
||||
/** 是否接受此事件类型。默认全部接受。 */
|
||||
default boolean accepts(VehicleEvent event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
/** 解码阶段异常,指示数据体本身违反协议。应被 Dispatcher 路由到 DLQ。 */
|
||||
public class DecodeException extends RuntimeException {
|
||||
public DecodeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DecodeException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 协议消息 → 领域事件的映射器(Adapter 模式)。
|
||||
*
|
||||
* <p>实现类负责把"我这个协议特有的 Java 对象"翻译成统一的 {@link VehicleEvent}。
|
||||
* 允许一条协议消息展开为多条领域事件(例如 32960 的一次实时上报同时产出 Realtime + Location)。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface EventMapper<I> {
|
||||
List<VehicleEvent> toEvents(I message);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 协议帧解码器:字节流 → 协议消息对象。
|
||||
*
|
||||
* <p>实现类应是无状态的(拆包/粘包由上游 Netty 的帧解码器处理),此处只负责一条完整帧的解析。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FrameDecoder<I> {
|
||||
I decode(ByteBuffer buffer) throws DecodeException;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** 下行帧编码器。无下行的协议返回 null 即可。 */
|
||||
@FunctionalInterface
|
||||
public interface FrameEncoder<O> {
|
||||
ByteBuffer encode(O message);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 编程式 Handler 接口。推荐使用 {@code @ProtocolHandler + @MessageMapping} 注解方式代替。
|
||||
* 此接口保留是为了 SPI 场景下的脚本式扩展(例如动态加载的 groovy handler)。
|
||||
*/
|
||||
public interface MessageHandler<I> {
|
||||
|
||||
boolean supports(I message);
|
||||
|
||||
List<VehicleEvent> handle(I message);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 协议插件 SPI。每个协议模块提供一个实现,由 {@code ProtocolPluginRegistry} 通过 ServiceLoader
|
||||
* 或 Spring Bean 方式发现并装配。
|
||||
*
|
||||
* @param <I> 协议解码后的消息类型
|
||||
* @param <O> 下行消息类型(无下行的协议用 {@link Void})
|
||||
*/
|
||||
public interface ProtocolPlugin<I, O> {
|
||||
|
||||
ProtocolId id();
|
||||
|
||||
FrameDecoder<I> decoder();
|
||||
|
||||
FrameEncoder<O> encoder();
|
||||
|
||||
EventMapper<I> eventMapper();
|
||||
|
||||
/**
|
||||
* 插件自带的协议特定 Handler 列表(可选)。
|
||||
* 通常推荐使用 {@code @ProtocolHandler} + Spring Bean 的方式注册,此处返回空列表。
|
||||
*/
|
||||
default List<MessageHandler<I>> builtinHandlers() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user