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

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-codec-common</artifactId>
<name>ingest-codec-common</name>
<description>BCD / CRC / BCC / bit utils 等公共编解码工具。</description>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,19 @@
package com.lingniu.ingest.codec;
/** BCC异或校验。GB/T 32960 与 JT/T 808 都使用此方式做帧尾校验。 */
public final class BccChecksum {
private BccChecksum() {}
public static byte compute(byte[] data, int offset, int length) {
byte sum = 0;
for (int i = offset; i < offset + length; i++) {
sum ^= data[i];
}
return sum;
}
public static boolean verify(byte[] data, int offset, int length, byte expected) {
return compute(data, offset, length) == expected;
}
}

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.codec;
/** BCD (Binary-Coded Decimal) 编解码。JT/T 808 的手机号、时间戳常用。 */
public final class BcdCodec {
private BcdCodec() {}
public static byte[] encode(String decimal) {
int len = (decimal.length() + 1) / 2;
byte[] out = new byte[len];
int idx = decimal.length() % 2;
if (idx == 1) {
out[0] = (byte) Character.digit(decimal.charAt(0), 10);
}
for (int i = idx; i < decimal.length(); i += 2) {
int hi = Character.digit(decimal.charAt(i), 10);
int lo = Character.digit(decimal.charAt(i + 1), 10);
out[(i + idx) / 2] = (byte) ((hi << 4) | lo);
}
return out;
}
public static String decode(byte[] bcd) {
StringBuilder sb = new StringBuilder(bcd.length * 2);
for (byte b : bcd) {
sb.append((b >> 4) & 0x0F).append(b & 0x0F);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,34 @@
package com.lingniu.ingest.codec;
/** 位运算工具:从字节数组读写无符号整数。 */
public final class BitUtils {
private BitUtils() {}
public static int readUint8(byte[] buf, int offset) {
return buf[offset] & 0xFF;
}
public static int readUint16BE(byte[] buf, int offset) {
return ((buf[offset] & 0xFF) << 8) | (buf[offset + 1] & 0xFF);
}
public static long readUint32BE(byte[] buf, int offset) {
return ((long) (buf[offset] & 0xFF) << 24)
| ((long) (buf[offset + 1] & 0xFF) << 16)
| ((long) (buf[offset + 2] & 0xFF) << 8)
| (buf[offset + 3] & 0xFF);
}
public static void writeUint16BE(byte[] buf, int offset, int value) {
buf[offset] = (byte) ((value >> 8) & 0xFF);
buf[offset + 1] = (byte) (value & 0xFF);
}
public static void writeUint32BE(byte[] buf, int offset, long value) {
buf[offset] = (byte) ((value >> 24) & 0xFF);
buf[offset + 1] = (byte) ((value >> 16) & 0xFF);
buf[offset + 2] = (byte) ((value >> 8) & 0xFF);
buf[offset + 3] = (byte) (value & 0xFF);
}
}

View File

@@ -0,0 +1,28 @@
package com.lingniu.ingest.codec;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BcdCodecTest {
@Test
void roundTripEvenLength() {
// 偶数位直接等值
byte[] bcd = BcdCodec.encode("1380013800");
assertThat(BcdCodec.decode(bcd)).isEqualTo("1380013800");
}
@Test
void roundTripOddLengthPadsLeadingZero() {
// 奇数位会在高位补 0
byte[] bcd = BcdCodec.encode("13800138000");
assertThat(BcdCodec.decode(bcd)).isEqualTo("013800138000");
}
@Test
void checksumMatches() {
byte[] data = {0x01, 0x02, 0x03, 0x04};
assertThat(BccChecksum.compute(data, 0, data.length)).isEqualTo((byte) 0x04);
}
}

View File

@@ -0,0 +1,62 @@
<?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-core</artifactId>
<name>ingest-core</name>
<description>Dispatcher / Interceptor Chain / Disruptor Event Bus / 注解扫描与自动注册。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-codec-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.lmax</groupId>
<artifactId>disruptor</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-circuitbreaker</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,203 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.core.dispatcher.HandlerDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
/**
* {@link AsyncBatch} 注解的执行器:把 Handler 方法从"单条调用"变成"批量调用"。
*
* <p>每个 Handler 方法对应一个 {@link Batcher},背后是一个固定容量的 {@link BlockingQueue}
* 和 {@link AsyncBatch#poolSize()} 条守护虚拟线程。批量触发条件:
* <ul>
* <li>累积 {@link AsyncBatch#size()} 条立即 flush
* <li>从第一条入队起等待 {@link AsyncBatch#waitMs()} 毫秒后强制 flush
* </ul>
*
* <p>目标 Handler 方法签名约定:
* <pre>{@code
* @MessageMapping(...)
* @AsyncBatch(size = 4000, waitMs = 1000, poolSize = 2)
* public List<VehicleEvent> onBatch(List<PayloadType> batch) { ... }
* }</pre>
*
* <p>flush 产出的事件由构造器注入的 {@code eventPublisher} 异步投递(通常是
* {@link DisruptorEventBus#publish}),不阻塞 Netty EventLoop。
*/
public final class AsyncBatchExecutor implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(AsyncBatchExecutor.class);
private final Consumer<VehicleEvent> eventPublisher;
private final ConcurrentMap<Method, Batcher> batchers = new ConcurrentHashMap<>();
public AsyncBatchExecutor(Consumer<VehicleEvent> eventPublisher) {
this.eventPublisher = eventPublisher;
}
/** 供 Dispatcher 调用:把单条消息交给目标 Handler 的 batcher。 */
public void submit(HandlerDefinition def, Object message) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, UnaryOperator.identity(), false));
}
public void submit(HandlerDefinition def, Object message, UnaryOperator<VehicleEvent> eventTransformer) {
Batcher b = batchers.computeIfAbsent(def.method(), m -> new Batcher(def, eventPublisher));
b.offer(new BatchItem(message, eventTransformer == null ? UnaryOperator.identity() : eventTransformer, true));
}
@Override
public void close() {
batchers.values().forEach(Batcher::close);
batchers.clear();
log.info("AsyncBatchExecutor closed");
}
// ===== internals =====
private static final class Batcher implements AutoCloseable {
private final HandlerDefinition def;
private final Consumer<VehicleEvent> publisher;
private final int batchSize;
private final long waitMs;
private final BlockingQueue<BatchItem> queue;
private final Thread[] workers;
private volatile boolean running = true;
Batcher(HandlerDefinition def, Consumer<VehicleEvent> publisher) {
AsyncBatch cfg = def.asyncBatch();
this.def = def;
this.publisher = publisher;
this.batchSize = Math.max(1, cfg.size());
this.waitMs = Math.max(1, cfg.waitMs());
this.queue = new ArrayBlockingQueue<>(Math.max(batchSize * 4, 16));
int pool = Math.max(1, cfg.poolSize());
this.workers = new Thread[pool];
for (int i = 0; i < pool; i++) {
Thread t = Thread.ofVirtual()
.name("batcher-" + def.method().getName() + "-" + i)
.unstarted(this::loop);
workers[i] = t;
t.start();
}
log.info("batcher started method={} size={} waitMs={} pool={}",
def.method().getName(), batchSize, waitMs, pool);
}
void offer(BatchItem item) {
try {
if (!queue.offer(item, 100, TimeUnit.MILLISECONDS)) {
log.warn("batcher queue full, dropping item for {}", def.method().getName());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void loop() {
while (running) {
try {
BatchItem first = queue.poll(200, TimeUnit.MILLISECONDS);
if (first == null) continue;
List<BatchItem> buf = new ArrayList<>(batchSize);
buf.add(first);
long deadlineNanos = System.nanoTime() + waitMs * 1_000_000L;
while (buf.size() < batchSize) {
long remain = deadlineNanos - System.nanoTime();
if (remain <= 0) break;
BatchItem next = queue.poll(remain, TimeUnit.NANOSECONDS);
if (next == null) break;
buf.add(next);
}
flush(buf);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
log.error("batcher loop error method={}", def.method().getName(), e);
}
}
}
private void flush(List<BatchItem> buf) {
if (requiresPerItemTransform(buf)) {
for (BatchItem item : buf) {
flushTransformedItem(item);
}
return;
}
publishEvents(invoke(buf.stream().map(BatchItem::message).toList()), UnaryOperator.identity());
}
private boolean requiresPerItemTransform(List<BatchItem> buf) {
for (BatchItem item : buf) {
if (item.transformRequired()) {
return true;
}
}
return false;
}
private void flushTransformedItem(BatchItem item) {
publishEvents(invoke(List.of(item.message())), item.eventTransformer());
}
@SuppressWarnings("unchecked")
private List<VehicleEvent> invoke(List<Object> messages) {
List<VehicleEvent> events;
try {
Object result = def.method().invoke(def.bean(), messages);
events = switch (result) {
case null -> List.of();
case List<?> list -> (List<VehicleEvent>) list;
case VehicleEvent e -> List.of(e);
default -> throw new IllegalStateException(
"@AsyncBatch method must return List<VehicleEvent>: " + def.method());
};
} catch (InvocationTargetException e) {
log.error("batcher invoke failed method={}", def.method().getName(), e.getCause());
return List.of();
} catch (IllegalAccessException e) {
log.error("batcher access denied method={}", def.method().getName(), e);
return List.of();
}
return events;
}
private void publishEvents(List<VehicleEvent> events, UnaryOperator<VehicleEvent> eventTransformer) {
for (VehicleEvent e : events) {
try {
publisher.accept(eventTransformer.apply(e));
} catch (Exception ex) {
log.warn("batcher publish failed eventId={}", e.eventId(), ex);
}
}
}
@Override
public void close() {
running = false;
for (Thread t : workers) t.interrupt();
}
}
private record BatchItem(Object message,
UnaryOperator<VehicleEvent> eventTransformer,
boolean transformRequired) {
}
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.sink.EventSink;
import com.lmax.disruptor.BlockingWaitStrategy;
import com.lmax.disruptor.BusySpinWaitStrategy;
import com.lmax.disruptor.EventHandler;
import com.lmax.disruptor.SleepingWaitStrategy;
import com.lmax.disruptor.WaitStrategy;
import com.lmax.disruptor.YieldingWaitStrategy;
import com.lmax.disruptor.dsl.Disruptor;
import com.lmax.disruptor.dsl.ProducerType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
/**
* 基于 Disruptor 的事件总线Dispatcher 投递事件 → RingBuffer → Sink 扇出。
*
* <p>使用虚拟线程作为 Sink 消费线程,避免阻塞 IO 占用平台线程。
* 单 VIN 有序性由上游 Netty + 分区投递保证RingBuffer 不做 per-vin 排序。
*/
public final class DisruptorEventBus implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(DisruptorEventBus.class);
private final Disruptor<VehicleEventSlot> disruptor;
private final AtomicLong published = new AtomicLong();
private final AtomicLong failed = new AtomicLong();
public DisruptorEventBus(int ringBufferSize, String waitStrategyName, List<EventSink> sinks) {
ThreadFactory tf = Thread.ofVirtual().name("ingest-bus-", 0).factory();
this.disruptor = new Disruptor<>(
VehicleEventSlot::new,
ringBufferSize,
tf,
ProducerType.MULTI,
waitStrategy(waitStrategyName));
EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
.map(this::toHandler)
.toArray(EventHandler[]::new);
disruptor.handleEventsWith(handlers);
disruptor.start();
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
ringBufferSize, waitStrategyName, sinks.size());
}
public void publish(VehicleEvent event) {
disruptor.getRingBuffer().publishEvent((slot, seq, e) -> slot.event = e, event);
published.incrementAndGet();
}
@Override
public void close() {
disruptor.shutdown();
log.info("DisruptorEventBus stopped published={} failed={}", published.get(), failed.get());
}
private EventHandler<VehicleEventSlot> toHandler(EventSink sink) {
return (slot, seq, endOfBatch) -> {
VehicleEvent e = slot.event;
if (e == null || !sink.accepts(e)) return;
try {
sink.publish(e).exceptionally(ex -> {
failed.incrementAndGet();
log.warn("sink {} publish failed", sink.name(), ex);
return null;
});
} finally {
if (endOfBatch) slot.clear();
}
};
}
private static WaitStrategy waitStrategy(String name) {
return switch (name == null ? "yielding" : name.toLowerCase()) {
case "blocking" -> new BlockingWaitStrategy();
case "sleeping" -> new SleepingWaitStrategy();
case "busy-spin" -> new BusySpinWaitStrategy();
default -> new YieldingWaitStrategy();
};
}
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.core.concurrency;
import com.lingniu.ingest.api.event.VehicleEvent;
/** Disruptor RingBuffer 槽位:持有可变引用,避免每次发布都分配新对象。 */
public final class VehicleEventSlot {
public VehicleEvent event;
public void clear() {
this.event = null;
}
}

View File

@@ -0,0 +1,87 @@
package com.lingniu.ingest.core.config;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.sink.EventSink;
import com.lingniu.ingest.core.concurrency.AsyncBatchExecutor;
import com.lingniu.ingest.core.concurrency.DisruptorEventBus;
import com.lingniu.ingest.core.dispatcher.AnnotationHandlerBeanPostProcessor;
import com.lingniu.ingest.core.dispatcher.Dispatcher;
import com.lingniu.ingest.core.dispatcher.HandlerInvoker;
import com.lingniu.ingest.core.dispatcher.HandlerRegistry;
import com.lingniu.ingest.core.pipeline.InterceptorChain;
import com.lingniu.ingest.core.pipeline.builtin.DedupInterceptor;
import com.lingniu.ingest.core.pipeline.builtin.RateLimitInterceptor;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import java.util.List;
/**
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
*/
@AutoConfiguration
@EnableConfigurationProperties(IngestCoreProperties.class)
public class IngestCoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public HandlerRegistry handlerRegistry() {
return new HandlerRegistry();
}
@Bean
@ConditionalOnMissingBean
public HandlerInvoker handlerInvoker() {
return new HandlerInvoker();
}
@Bean
public AnnotationHandlerBeanPostProcessor annotationHandlerBeanPostProcessor(HandlerRegistry registry) {
return new AnnotationHandlerBeanPostProcessor(registry);
}
@Bean
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
}
@Bean
public RateLimitInterceptor rateLimitInterceptor(IngestCoreProperties props) {
return new RateLimitInterceptor(props.getRateLimit().getPerVinQps(), props.getRateLimit().getMaxVins());
}
@Bean
@ConditionalOnMissingBean
public InterceptorChain interceptorChain(List<IngestInterceptor> interceptors) {
return new InterceptorChain(interceptors);
}
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public DisruptorEventBus disruptorEventBus(IngestCoreProperties props, List<EventSink> sinks) {
return new DisruptorEventBus(
props.getDisruptor().getRingBufferSize(),
props.getDisruptor().getWaitStrategy(),
sinks);
}
@Bean(destroyMethod = "close")
@ConditionalOnMissingBean
public AsyncBatchExecutor asyncBatchExecutor(DisruptorEventBus eventBus) {
return new AsyncBatchExecutor(eventBus::publish);
}
@Bean
@ConditionalOnMissingBean
public Dispatcher dispatcher(HandlerRegistry registry,
InterceptorChain chain,
HandlerInvoker invoker,
DisruptorEventBus eventBus,
AsyncBatchExecutor batchExecutor) {
return new Dispatcher(registry, chain, invoker, eventBus, batchExecutor);
}
}

View File

@@ -0,0 +1,56 @@
package com.lingniu.ingest.core.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.pipeline")
public class IngestCoreProperties {
private Disruptor disruptor = new Disruptor();
private Dedup dedup = new Dedup();
private RateLimit rateLimit = new RateLimit();
public Disruptor getDisruptor() { return disruptor; }
public void setDisruptor(Disruptor disruptor) { this.disruptor = disruptor; }
public Dedup getDedup() { return dedup; }
public void setDedup(Dedup dedup) { this.dedup = dedup; }
public RateLimit getRateLimit() { return rateLimit; }
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
public static class Disruptor {
private int ringBufferSize = 131072;
private String waitStrategy = "yielding";
private String producerType = "multi";
public int getRingBufferSize() { return ringBufferSize; }
public void setRingBufferSize(int ringBufferSize) { this.ringBufferSize = ringBufferSize; }
public String getWaitStrategy() { return waitStrategy; }
public void setWaitStrategy(String waitStrategy) { this.waitStrategy = waitStrategy; }
public String getProducerType() { return producerType; }
public void setProducerType(String producerType) { this.producerType = producerType; }
}
public static class Dedup {
private boolean enabled = true;
private int cacheSize = 200000;
private long ttlSeconds = 600;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public int getCacheSize() { return cacheSize; }
public void setCacheSize(int cacheSize) { this.cacheSize = cacheSize; }
public long getTtlSeconds() { return ttlSeconds; }
public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
}
public static class RateLimit {
private int perVinQps = 50;
private int maxVins = 100000;
public int getPerVinQps() { return perVinQps; }
public void setPerVinQps(int perVinQps) { this.perVinQps = perVinQps; }
public int getMaxVins() { return maxVins; }
public void setMaxVins(int maxVins) { this.maxVins = maxVins; }
}
}

View File

@@ -0,0 +1,64 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.IdempotentKey;
import com.lingniu.ingest.api.annotation.MessageMapping;
import com.lingniu.ingest.api.annotation.ProtocolHandler;
import com.lingniu.ingest.api.annotation.RateLimited;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.annotation.AnnotatedElementUtils;
import java.lang.reflect.Method;
/**
* Spring BeanPostProcessor扫描带 {@link ProtocolHandler} 注解的 Bean
* 把每个带 {@link MessageMapping} 的方法注册到 {@link HandlerRegistry}。
*
* <p>替代旧代码里遍布的 {@code if (msgId == 0x0100) ... else if (msgId == 0x0102) ...} 风格。
*/
public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerBeanPostProcessor.class);
private final HandlerRegistry registry;
public AnnotationHandlerBeanPostProcessor(HandlerRegistry registry) {
this.registry = registry;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
Class<?> type = bean.getClass();
ProtocolHandler classAnno = AnnotatedElementUtils.findMergedAnnotation(type, ProtocolHandler.class);
if (classAnno == null) return bean;
for (Method method : type.getMethods()) {
MessageMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, MessageMapping.class);
if (mapping == null) continue;
Class<?> paramType = method.getParameterCount() > 0 ? method.getParameterTypes()[0] : Object.class;
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);
for (int cmd : commands) {
for (int info : infoTypes) {
HandlerDefinition def = new HandlerDefinition(
classAnno.protocol(), cmd, info, mapping.desc(),
bean, method, paramType, rl, ik, ab);
registry.register(def);
log.info("registered handler {} protocol={} command=0x{} info=0x{}",
type.getSimpleName() + "#" + method.getName(),
classAnno.protocol(), Integer.toHexString(cmd), Integer.toHexString(info));
}
}
}
return bean;
}
}

View File

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

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.annotation.AsyncBatch;
import com.lingniu.ingest.api.annotation.IdempotentKey;
import com.lingniu.ingest.api.annotation.RateLimited;
import java.lang.reflect.Method;
/**
* 一个被注解扫描到的 Handler 方法的静态描述。不可变。
*/
public record HandlerDefinition(
ProtocolId protocol,
int command,
int infoType,
String desc,
Object bean,
Method method,
Class<?> parameterType,
RateLimited rateLimited,
IdempotentKey idempotentKey,
AsyncBatch asyncBatch
) {
public boolean matches(ProtocolId p, int cmd, int info) {
if (p != protocol) return false;
if (command != 0 && command != cmd) return false;
if (infoType != 0 && infoType != info) return false;
return true;
}
}

View File

@@ -0,0 +1,33 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.RawFrame;
import java.lang.reflect.InvocationTargetException;
import java.util.Collections;
import java.util.List;
/**
* 反射调用 Handler。单独抽出是为了后续可插拔未来可替换为 MethodHandle 或字节码生成。
*/
public class HandlerInvoker {
@SuppressWarnings("unchecked")
public List<VehicleEvent> invoke(HandlerDefinition def, RawFrame frame, IngestContext ctx) {
try {
Object result = def.method().invoke(def.bean(), frame.payload());
return switch (result) {
case null -> Collections.emptyList();
case VehicleEvent e -> List.of(e);
case List<?> list -> (List<VehicleEvent>) list;
default -> throw new IllegalStateException(
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());
};
} catch (InvocationTargetException e) {
throw new RuntimeException("Handler threw exception: " + def.method(), e.getCause());
} catch (IllegalAccessException e) {
throw new RuntimeException("Handler not accessible: " + def.method(), e);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.lingniu.ingest.core.dispatcher;
import com.lingniu.ingest.api.ProtocolId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Handler 注册中心。按 {@code (ProtocolId, command)} 索引O(1) 查找;同 key 可注册多个 Handler
* {@link HandlerDefinition#matches} 进一步精确匹配 {@code infoType}。
*/
public final class HandlerRegistry {
private final Map<RoutingKey, List<HandlerDefinition>> byRoute = new ConcurrentHashMap<>();
private final List<HandlerDefinition> wildcardHandlers = Collections.synchronizedList(new ArrayList<>());
public void register(HandlerDefinition def) {
if (def.command() == 0) {
wildcardHandlers.add(def);
return;
}
byRoute.computeIfAbsent(new RoutingKey(def.protocol(), def.command()),
k -> Collections.synchronizedList(new ArrayList<>())).add(def);
}
public List<HandlerDefinition> resolve(ProtocolId protocol, int command, int infoType) {
List<HandlerDefinition> exact = byRoute.get(new RoutingKey(protocol, command));
List<HandlerDefinition> result = new ArrayList<>();
if (exact != null) {
for (HandlerDefinition d : exact) {
if (d.matches(protocol, command, infoType)) result.add(d);
}
}
if (!result.isEmpty()) {
return result;
}
for (HandlerDefinition d : wildcardHandlers) {
if (d.matches(protocol, command, infoType)) result.add(d);
}
return result;
}
public int size() {
return byRoute.values().stream().mapToInt(List::size).sum() + wildcardHandlers.size();
}
private record RoutingKey(ProtocolId protocol, int command) {}
}

View File

@@ -0,0 +1,45 @@
package com.lingniu.ingest.core.pipeline;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import java.util.ArrayList;
import java.util.List;
/**
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
*/
public final class InterceptorChain {
private final List<IngestInterceptor> interceptors;
public InterceptorChain(List<IngestInterceptor> interceptors) {
List<IngestInterceptor> sorted = new ArrayList<>(interceptors);
AnnotationAwareOrderComparator.sort(sorted);
this.interceptors = List.copyOf(sorted);
}
public boolean before(RawFrame frame, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
if (!i.before(frame, ctx) || ctx.aborted()) {
return false;
}
}
return true;
}
public void after(VehicleEvent event, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
i.after(event, ctx);
}
}
public void onError(Throwable error, IngestContext ctx) {
for (IngestInterceptor i : interceptors) {
i.onError(error, ctx);
}
}
}

View File

@@ -0,0 +1,57 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import org.springframework.core.Ordered;
import java.util.Arrays;
import java.time.Duration;
/**
* 基于 Caffeine 的本地幂等去重。
*
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性,
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。
*/
public class DedupInterceptor implements IngestInterceptor, Ordered {
private final Cache<String, Boolean> seen;
public DedupInterceptor(int maxSize, long ttlSeconds) {
this.seen = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(Duration.ofSeconds(ttlSeconds))
.build();
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
String seq = frame.sourceMeta().get("seq");
if (seq == null || seq.isBlank()) {
seq = rawFingerprint(frame);
}
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
if (seen.asMap().putIfAbsent(key, Boolean.TRUE) != null) {
ctx.abort("duplicate:" + key);
return false;
}
return true;
}
@Override
public int getOrder() {
return 100;
}
private static String rawFingerprint(RawFrame frame) {
byte[] rawBytes = frame.rawBytes();
if (rawBytes != null && rawBytes.length > 0) {
return "raw:" + Integer.toHexString(Arrays.hashCode(rawBytes));
}
return "receivedAt:" + frame.receivedAt();
}
}

View File

@@ -0,0 +1,49 @@
package com.lingniu.ingest.core.pipeline.builtin;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.core.Ordered;
import java.time.Duration;
/**
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter由 Caffeine 按 LRU 管理。
*/
public class RateLimitInterceptor implements IngestInterceptor, Ordered {
private final Cache<String, RateLimiter> limiters;
private final RateLimiterConfig defaultConfig;
public RateLimitInterceptor(int perVinQps, int maxVins) {
this.defaultConfig = RateLimiterConfig.custom()
.limitForPeriod(perVinQps)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ZERO)
.build();
this.limiters = Caffeine.newBuilder()
.maximumSize(maxVins)
.expireAfterAccess(Duration.ofMinutes(10))
.build();
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
if (!rl.acquirePermission()) {
ctx.abort("rate-limited:" + vin);
return false;
}
return true;
}
@Override
public int getOrder() {
return 200;
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.core.config.IngestCoreAutoConfiguration

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
<?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>observability</artifactId>
<name>observability</name>
<description>Micrometer + Prometheus + 业务指标封装事件计数、Dispatcher 耗时、DLQ 计数等)。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,84 @@
package com.lingniu.ingest.observability;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.api.event.VehicleEvent;
import com.lingniu.ingest.api.pipeline.IngestContext;
import com.lingniu.ingest.api.pipeline.IngestInterceptor;
import com.lingniu.ingest.api.pipeline.RawFrame;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import org.springframework.core.Ordered;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* 指标拦截器:自动以 {@link IngestInterceptor} 形式接入拦截链,无侵入采集核心流量指标。
*
* <p>暴露指标:
* <ul>
* <li>{@code ingest_frames_total{protocol}} - 入站帧计数
* <li>{@code ingest_events_total{protocol,type}} - 事件产出计数
* <li>{@code ingest_errors_total{protocol}} - 异常计数
* <li>{@code ingest_frame_duration_seconds{protocol}} - 单帧处理耗时
* </ul>
*/
public class IngestMetrics implements IngestInterceptor, Ordered {
private static final String ATTR_TIMER_SAMPLE = "metrics.timer.sample";
private final MeterRegistry registry;
private final ConcurrentMap<ProtocolId, Counter> frameCounters = new ConcurrentHashMap<>();
private final ConcurrentMap<ProtocolId, Counter> errorCounters = new ConcurrentHashMap<>();
private final ConcurrentMap<ProtocolId, Timer> frameTimers = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Counter> eventCounters = new ConcurrentHashMap<>();
public IngestMetrics(MeterRegistry registry) {
this.registry = registry;
}
@Override
public boolean before(RawFrame frame, IngestContext ctx) {
ProtocolId p = frame.protocolId();
frameCounters.computeIfAbsent(p, k ->
Counter.builder("ingest_frames_total").tag("protocol", k.name()).register(registry)).increment();
Timer.Sample sample = Timer.start(registry);
ctx.attr(ATTR_TIMER_SAMPLE, sample);
return true;
}
@Override
public void after(VehicleEvent event, IngestContext ctx) {
String key = event.source().name() + ":" + event.getClass().getSimpleName();
eventCounters.computeIfAbsent(key, k -> {
String[] parts = k.split(":");
return Counter.builder("ingest_events_total")
.tags(Tags.of("protocol", parts[0], "type", parts[1]))
.register(registry);
}).increment();
Timer.Sample s = ctx.attr(ATTR_TIMER_SAMPLE);
if (s != null) {
Timer t = frameTimers.computeIfAbsent(event.source(), p ->
Timer.builder("ingest_frame_duration_seconds").tag("protocol", p.name()).register(registry));
s.stop(t);
ctx.attr(ATTR_TIMER_SAMPLE, null);
}
}
@Override
public void onError(Throwable error, IngestContext ctx) {
// 无协议上下文时归到 UNKNOWN
Counter c = errorCounters.computeIfAbsent(
ProtocolId.GB32960, // 默认桶:由上层拦截器设置具体协议更精确
p -> Counter.builder("ingest_errors_total").tag("protocol", "unknown").register(registry));
c.increment();
}
@Override
public int getOrder() {
return 10; // 尽早介入,晚于 Tracingorder=0
}
}

View File

@@ -0,0 +1,18 @@
package com.lingniu.ingest.observability;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
@ConditionalOnBean(MeterRegistry.class)
public class ObservabilityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public IngestMetrics ingestMetrics(MeterRegistry registry) {
return new IngestMetrics(registry);
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.observability.ObservabilityAutoConfiguration

View File

@@ -0,0 +1,56 @@
<?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>session-core</artifactId>
<name>session-core</name>
<description>设备会话 + 鉴权 + Token + Redis 兜底。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</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>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,23 @@
package com.lingniu.ingest.session;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* 下行命令调度器 SPI。
*
* <p>用于 command-gateway 把 HTTP 请求转为设备命令,等待设备应答。真正的实现需要与协议
* 模块(如 jt808的 Netty 通道绑定:按 sessionId 找到 Channel → 发送命令字节 → 用
* {@code CompletableFuture + 序列号 map} 实现同步请求/应答。
*
* <p>当前阶段提供接口 + 占位实现,具体 Netty 绑定在 command-gateway / protocol-jt808
* 的下一批次落地。
*/
public interface CommandDispatcher {
/** 异步下发命令,不等应答。 */
CompletableFuture<Void> notify(String sessionId, Object command);
/** 同步下发命令并等待应答。 */
<T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout);
}

View File

@@ -0,0 +1,46 @@
package com.lingniu.ingest.session;
import com.lingniu.ingest.api.ProtocolId;
import java.time.Instant;
import java.util.Map;
/**
* 设备会话领域模型。不可变;更新通过 {@link SessionStore#update} 原子替换。
*
* @param sessionId 会话 ID通常由 Netty channel id + 终端号生成)
* @param protocol 协议 ID
* @param vin 车架号(注册/鉴权后补齐,注册前为 null
* @param phone 终端手机号 / 设备号
* @param token 鉴权 Token平台下发
* @param peerAddress 对端 IP:port
* @param registeredAt 注册时间
* @param lastSeenAt 最后活跃时间
* @param attributes 协议特定属性(协议版本、型号等)
*/
public record DeviceSession(
String sessionId,
ProtocolId protocol,
String vin,
String phone,
String token,
String peerAddress,
Instant registeredAt,
Instant lastSeenAt,
Map<String, String> attributes
) {
public DeviceSession touch(Instant now) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, now, attributes);
}
public DeviceSession withVin(String vin) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, lastSeenAt, attributes);
}
public DeviceSession withToken(String token) {
return new DeviceSession(sessionId, protocol, vin, phone, token, peerAddress,
registeredAt, lastSeenAt, attributes);
}
}

View File

@@ -0,0 +1,82 @@
package com.lingniu.ingest.session;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.UnaryOperator;
/**
* 纯内存会话存储。支持按 sessionId / vin / phone 三种 key 查询。
*
* <p>失活清理:基于 {@link Caffeine} 的 {@code expireAfterAccess 30 分钟}。
* 生产环境若需多节点一致性,可用 Redis 实现替换此 Bean。
*/
public final class InMemorySessionStore implements SessionStore {
private final Cache<String, DeviceSession> byId;
private final ConcurrentMap<String, String> vinToId = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> phoneToId = new ConcurrentHashMap<>();
public InMemorySessionStore() {
this.byId = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(30))
.removalListener((String sid, DeviceSession s, com.github.benmanes.caffeine.cache.RemovalCause c) -> {
if (s == null) return;
if (s.vin() != null) vinToId.remove(s.vin(), sid);
if (s.phone() != null) phoneToId.remove(s.phone(), sid);
})
.build();
}
@Override
public void put(DeviceSession session) {
byId.put(session.sessionId(), session);
if (session.vin() != null) vinToId.put(session.vin(), session.sessionId());
if (session.phone() != null) phoneToId.put(session.phone(), session.sessionId());
}
@Override
public Optional<DeviceSession> findBySessionId(String sessionId) {
return Optional.ofNullable(byId.getIfPresent(sessionId));
}
@Override
public Optional<DeviceSession> findByVin(String vin) {
String sid = vinToId.get(vin);
return sid == null ? Optional.empty() : findBySessionId(sid);
}
@Override
public Optional<DeviceSession> findByPhone(String phone) {
String sid = phoneToId.get(phone);
return sid == null ? Optional.empty() : findBySessionId(sid);
}
@Override
public synchronized Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater) {
DeviceSession old = byId.getIfPresent(sessionId);
if (old == null) return Optional.empty();
DeviceSession next = updater.apply(old);
put(next);
return Optional.of(next);
}
@Override
public void remove(String sessionId) {
DeviceSession s = byId.getIfPresent(sessionId);
if (s != null) {
byId.invalidate(sessionId);
if (s.vin() != null) vinToId.remove(s.vin(), sessionId);
if (s.phone() != null) phoneToId.remove(s.phone(), sessionId);
}
}
@Override
public int size() {
return (int) byId.estimatedSize();
}
}

View File

@@ -0,0 +1,31 @@
package com.lingniu.ingest.session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
/**
* 占位命令调度器:返回 {@link UnsupportedOperationException}
* 待 command-gateway 与协议模块的下行通道打通后替换为真实实现。
*/
public final class NoopCommandDispatcher implements CommandDispatcher {
private static final Logger log = LoggerFactory.getLogger(NoopCommandDispatcher.class);
@Override
public CompletableFuture<Void> notify(String sessionId, Object command) {
log.warn("[noop] notify sessionId={} command={} dropped", sessionId, command);
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"CommandDispatcher not yet implemented; configure a real one in protocol module."));
}
@Override
public <T> CompletableFuture<T> request(String sessionId, Object command, Class<T> responseType, Duration timeout) {
log.warn("[noop] request sessionId={} command={} responseType={} dropped",
sessionId, command, responseType.getSimpleName());
return CompletableFuture.failedFuture(new UnsupportedOperationException(
"CommandDispatcher not yet implemented; configure a real one in protocol module."));
}
}

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.session;
import java.util.Optional;
import java.util.function.UnaryOperator;
/**
* 设备会话存储 SPI。默认实现是内存 + Caffeine生产可替换为 Redis 兜底。
*/
public interface SessionStore {
void put(DeviceSession session);
Optional<DeviceSession> findBySessionId(String sessionId);
Optional<DeviceSession> findByVin(String vin);
Optional<DeviceSession> findByPhone(String phone);
/** 原子更新:读 → 修改 → 写。返回更新后的会话;若原会话不存在返回 empty。 */
Optional<DeviceSession> update(String sessionId, UnaryOperator<DeviceSession> updater);
void remove(String sessionId);
int size();
}

View File

@@ -0,0 +1,40 @@
package com.lingniu.ingest.session.config;
import com.lingniu.ingest.session.CommandDispatcher;
import com.lingniu.ingest.session.InMemorySessionStore;
import com.lingniu.ingest.session.NoopCommandDispatcher;
import com.lingniu.ingest.session.RedisSessionStore;
import com.lingniu.ingest.session.SessionProperties;
import com.lingniu.ingest.session.SessionStore;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.StringRedisTemplate;
@AutoConfiguration
@EnableConfigurationProperties(SessionProperties.class)
public class SessionCoreAutoConfiguration {
@Bean
@ConditionalOnBean(StringRedisTemplate.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis")
@ConditionalOnMissingBean
public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) {
return new RedisSessionStore(redis, properties.getTtl());
}
@Bean
@ConditionalOnMissingBean
public SessionStore sessionStore() {
return new InMemorySessionStore();
}
@Bean
@ConditionalOnMissingBean
public CommandDispatcher commandDispatcher() {
return new NoopCommandDispatcher();
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.session.config.SessionCoreAutoConfiguration

View File

@@ -0,0 +1,137 @@
package com.lingniu.ingest.session;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.SetOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RedisSessionStoreTest {
@Test
void putStoresSessionHashAndThreeIndexesWithTtl() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(45));
DeviceSession session = sampleSession("sid-1", "VIN001", "13800138000");
store.put(session);
verify(fixture.hashOps).putAll(eq("vehicle:session:sid-1"), any(Map.class));
verify(fixture.valueOps).set("vehicle:session:index:vin:VIN001", "sid-1", Duration.ofMinutes(45));
verify(fixture.valueOps).set("vehicle:session:index:phone:13800138000", "sid-1", Duration.ofMinutes(45));
verify(fixture.setOps).add("vehicle:session:index:ids", "sid-1");
verify(fixture.redis).expire("vehicle:session:sid-1", Duration.ofMinutes(45));
verify(fixture.redis).expire("vehicle:session:index:ids", Duration.ofMinutes(45));
}
@Test
void findsBySessionIdVinAndPhoneFromRedisIndexes() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30));
Map<Object, Object> stored = storedSession("sid-1", "VIN001", "13800138000");
when(fixture.hashOps.entries("vehicle:session:sid-1")).thenReturn(stored);
when(fixture.valueOps.get("vehicle:session:index:vin:VIN001")).thenReturn("sid-1");
when(fixture.valueOps.get("vehicle:session:index:phone:13800138000")).thenReturn("sid-1");
Optional<DeviceSession> byId = store.findBySessionId("sid-1");
Optional<DeviceSession> byVin = store.findByVin("VIN001");
Optional<DeviceSession> byPhone = store.findByPhone("13800138000");
assertThat(byId).isPresent();
assertThat(byVin).contains(byId.orElseThrow());
assertThat(byPhone).contains(byId.orElseThrow());
assertThat(byId.orElseThrow().attributes()).containsEntry("identitySource", "BINDING");
verify(fixture.redis, atLeastOnce()).expire("vehicle:session:sid-1", Duration.ofMinutes(30));
}
@Test
void updateRewritesIndexesWhenVinChangesAndRemoveDeletesAllKeys() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30));
when(fixture.hashOps.entries("vehicle:session:sid-1"))
.thenReturn(storedSession("sid-1", "VIN001", "13800138000"))
.thenReturn(storedSession("sid-1", "VIN001", "13800138000"))
.thenReturn(storedSession("sid-1", "VIN002", "13800138000"));
Optional<DeviceSession> updated = store.update("sid-1", session -> session.withVin("VIN002"));
store.remove("sid-1");
assertThat(updated).isPresent();
verify(fixture.redis).delete("vehicle:session:index:vin:VIN001");
verify(fixture.valueOps).set("vehicle:session:index:vin:VIN002", "sid-1", Duration.ofMinutes(30));
verify(fixture.redis).delete("vehicle:session:sid-1");
verify(fixture.redis).delete("vehicle:session:index:vin:VIN002");
verify(fixture.redis, atLeastOnce()).delete("vehicle:session:index:phone:13800138000");
verify(fixture.setOps).remove("vehicle:session:index:ids", "sid-1");
}
@Test
void sizeReturnsRedisIdSetCardinality() {
RedisFixture fixture = new RedisFixture();
RedisSessionStore store = new RedisSessionStore(fixture.redis, Duration.ofMinutes(30));
when(fixture.setOps.size("vehicle:session:index:ids")).thenReturn(12L);
assertThat(store.size()).isEqualTo(12);
}
private static DeviceSession sampleSession(String sid, String vin, String phone) {
return new DeviceSession(
sid,
ProtocolId.JT808,
vin,
phone,
"token-1",
"127.0.0.1:9000",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:01:00Z"),
Map.of("identitySource", "BINDING"));
}
private static Map<Object, Object> storedSession(String sid, String vin, String phone) {
Map<Object, Object> values = new LinkedHashMap<>();
values.put("sessionId", sid);
values.put("protocol", "JT808");
values.put("vin", vin);
values.put("phone", phone);
values.put("token", "token-1");
values.put("peerAddress", "127.0.0.1:9000");
values.put("registeredAt", "2026-06-22T08:00:00Z");
values.put("lastSeenAt", "2026-06-22T08:01:00Z");
values.put("attr.identitySource", "BINDING");
return values;
}
private static final class RedisFixture {
private final StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
private final HashOperations<String, Object, Object> hashOps = mock(HashOperations.class);
@SuppressWarnings("unchecked")
private final ValueOperations<String, String> valueOps = mock(ValueOperations.class);
@SuppressWarnings("unchecked")
private final SetOperations<String, String> setOps = mock(SetOperations.class);
private RedisFixture() {
when(redis.opsForHash()).thenReturn(hashOps);
when(redis.opsForValue()).thenReturn(valueOps);
when(redis.opsForSet()).thenReturn(setOps);
when(hashOps.entries("vehicle:session:sid-1")).thenReturn(Map.of());
when(setOps.members("vehicle:session:index:ids")).thenReturn(Set.of());
}
}
}

View File

@@ -0,0 +1,39 @@
package com.lingniu.ingest.session.config;
import com.lingniu.ingest.session.InMemorySessionStore;
import com.lingniu.ingest.session.RedisSessionStore;
import com.lingniu.ingest.session.SessionStore;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class SessionCoreAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(SessionCoreAutoConfiguration.class));
@Test
void usesInMemorySessionStoreByDefault() {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(SessionStore.class);
assertThat(context).hasSingleBean(InMemorySessionStore.class);
});
}
@Test
void usesRedisSessionStoreWhenEnabledAndRedisTemplateExists() {
contextRunner
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withPropertyValues(
"lingniu.ingest.session.store=redis",
"lingniu.ingest.session.ttl=45m")
.run(context -> {
assertThat(context).hasSingleBean(SessionStore.class);
assertThat(context).hasSingleBean(RedisSessionStore.class);
});
}
}

View File

@@ -0,0 +1,47 @@
<?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>vehicle-identity</artifactId>
<name>vehicle-identity</name>
<description>跨协议车辆身份解析与外部标识绑定。</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>ingest-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,101 @@
package com.lingniu.ingest.identity;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lingniu.ingest.api.ProtocolId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/**
* 文件型车辆身份绑定表。
*
* <p>写入采用 append-only JSONL启动时顺序重放到内存索引。这样协议层只依赖 identity SPI
* 不直接耦合业务库;后续替换成 DB/配置中心时只需新增同接口实现。
*/
public final class FileVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
private static final Logger log = LoggerFactory.getLogger(FileVehicleIdentityService.class);
private final Path path;
private final ObjectMapper mapper;
private final InMemoryVehicleIdentityService delegate = new InMemoryVehicleIdentityService();
private final Object writeLock = new Object();
public FileVehicleIdentityService(Path path) {
this(path, new ObjectMapper());
}
public FileVehicleIdentityService(Path path, ObjectMapper mapper) {
this.path = path.toAbsolutePath();
this.mapper = mapper;
load();
}
@Override
public void bind(VehicleIdentityBinding binding) {
delegate.bind(binding);
synchronized (writeLock) {
try {
Files.createDirectories(path.getParent());
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) {
writer.write(mapper.writeValueAsString(BindingLine.from(binding)));
writer.newLine();
}
} catch (IOException e) {
throw new IllegalStateException("vehicle identity binding persist failed: " + path, e);
}
}
}
@Override
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
return delegate.resolve(lookup);
}
private void load() {
if (!Files.exists(path)) {
return;
}
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) {
continue;
}
try {
BindingLine binding = mapper.readValue(line, BindingLine.class);
delegate.bind(binding.toBinding());
} catch (Exception e) {
log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e);
}
}
} catch (IOException e) {
throw new IllegalStateException("vehicle identity binding load failed: " + path, e);
}
}
private record BindingLine(
ProtocolId protocol,
String vin,
String phone,
String deviceId,
String plate
) {
private static BindingLine from(VehicleIdentityBinding binding) {
return new BindingLine(
binding.protocol(), binding.vin(), binding.phone(), binding.deviceId(), binding.plate());
}
private VehicleIdentityBinding toBinding() {
return new VehicleIdentityBinding(protocol, vin, phone, deviceId, plate);
}
}
}

View File

@@ -0,0 +1,75 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class InMemoryVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
private final ConcurrentMap<String, String> phoneToVin = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> deviceIdToVin = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> plateToVin = new ConcurrentHashMap<>();
@Override
public void bind(VehicleIdentityBinding binding) {
if (!binding.phone().isBlank()) {
phoneToVin.put(key(binding.protocol(), binding.phone()), binding.vin());
}
if (!binding.deviceId().isBlank()) {
deviceIdToVin.put(key(binding.protocol(), binding.deviceId()), binding.vin());
}
if (!binding.plate().isBlank()) {
plateToVin.put(key(binding.protocol(), binding.plate()), binding.vin());
}
}
@Override
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
if (lookup == null) {
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
}
if (!lookup.vin().isBlank()) {
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
}
VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
if (phone != null) return phone;
VehicleIdentity device = resolveBound(deviceIdToVin, lookup.protocol(), lookup.deviceId(), VehicleIdentitySource.BOUND_DEVICE_ID);
if (device != null) return device;
VehicleIdentity plate = resolveBound(plateToVin, lookup.protocol(), lookup.plate(), VehicleIdentitySource.BOUND_PLATE);
if (plate != null) return plate;
if (!lookup.deviceId().isBlank()) {
return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
}
if (!lookup.phone().isBlank()) {
return new VehicleIdentity(lookup.phone(), false, VehicleIdentitySource.FALLBACK_PHONE);
}
if (!lookup.plate().isBlank()) {
return new VehicleIdentity(lookup.plate(), false, VehicleIdentitySource.FALLBACK_PLATE);
}
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
}
private static VehicleIdentity resolveBound(ConcurrentMap<String, String> map,
ProtocolId protocol,
String externalId,
VehicleIdentitySource source) {
if (externalId == null || externalId.isBlank()) {
return null;
}
String vin = map.get(key(protocol, externalId));
if (vin == null) {
return null;
}
return new VehicleIdentity(vin, true, source);
}
private static String key(ProtocolId protocol, String value) {
String p = protocol == null ? "UNKNOWN" : protocol.name();
return p + ":" + value.trim().toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,16 @@
package com.lingniu.ingest.identity;
public record VehicleIdentity(
String vin,
boolean resolved,
VehicleIdentitySource source
) {
public VehicleIdentity {
vin = normalize(vin);
source = source == null ? VehicleIdentitySource.UNKNOWN : source;
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,25 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
public record VehicleIdentityBinding(
ProtocolId protocol,
String vin,
String phone,
String deviceId,
String plate
) {
public VehicleIdentityBinding {
vin = normalize(vin);
phone = normalize(phone);
deviceId = normalize(deviceId);
plate = normalize(plate);
if (vin.isBlank()) {
throw new IllegalArgumentException("vin must not be blank");
}
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,22 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
public record VehicleIdentityLookup(
ProtocolId protocol,
String vin,
String phone,
String deviceId,
String plate
) {
public VehicleIdentityLookup {
vin = normalize(vin);
phone = normalize(phone);
deviceId = normalize(deviceId);
plate = normalize(plate);
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.identity;
public interface VehicleIdentityRegistry {
void bind(VehicleIdentityBinding binding);
}

View File

@@ -0,0 +1,6 @@
package com.lingniu.ingest.identity;
public interface VehicleIdentityResolver {
VehicleIdentity resolve(VehicleIdentityLookup lookup);
}

View File

@@ -0,0 +1,12 @@
package com.lingniu.ingest.identity;
public enum VehicleIdentitySource {
EXPLICIT_VIN,
BOUND_PHONE,
BOUND_DEVICE_ID,
BOUND_PLATE,
FALLBACK_DEVICE_ID,
FALLBACK_PHONE,
FALLBACK_PLATE,
UNKNOWN
}

View File

@@ -0,0 +1,41 @@
package com.lingniu.ingest.identity.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lingniu.ingest.identity.FileVehicleIdentityService;
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import java.nio.file.Path;
@AutoConfiguration
@EnableConfigurationProperties(VehicleIdentityProperties.class)
public class VehicleIdentityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public ObjectMapper vehicleIdentityObjectMapper() {
return new ObjectMapper();
}
@Bean
@ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class})
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file")
public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties,
ObjectMapper objectMapper) {
return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper);
}
@Bean
@ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class})
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "memory",
matchIfMissing = true)
public InMemoryVehicleIdentityService vehicleIdentityService() {
return new InMemoryVehicleIdentityService();
}
}

View File

@@ -0,0 +1,22 @@
package com.lingniu.ingest.identity.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.identity")
public class VehicleIdentityProperties {
private String store = "memory";
private File file = new File();
public String getStore() { return store; }
public void setStore(String store) { this.store = store; }
public File getFile() { return file; }
public void setFile(File file) { this.file = file; }
public static class File {
private String path = "./data/vehicle-identity.jsonl";
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration

View File

@@ -0,0 +1,41 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FileVehicleIdentityServiceTest {
@TempDir
Path tempDir;
@Test
void persistsBindingsAndReloadsThemAfterRestart() throws Exception {
Path store = tempDir.resolve("vehicle-identity.jsonl");
FileVehicleIdentityService first = new FileVehicleIdentityService(store);
first.bind(new VehicleIdentityBinding(
ProtocolId.JT808, "LNVIN000000000099", "13900000099", "DEV099", "粤B09999"));
FileVehicleIdentityService restarted = new FileVehicleIdentityService(store);
VehicleIdentity byPhone = restarted.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "13900000099", "", ""));
VehicleIdentity byDevice = restarted.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "", "DEV099", ""));
VehicleIdentity byPlate = restarted.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "", "", "粤B09999"));
assertThat(byPhone.vin()).isEqualTo("LNVIN000000000099");
assertThat(byPhone.resolved()).isTrue();
assertThat(byPhone.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
assertThat(byDevice.vin()).isEqualTo("LNVIN000000000099");
assertThat(byPlate.vin()).isEqualTo("LNVIN000000000099");
assertThat(Files.readString(store)).contains("\"vin\":\"LNVIN000000000099\"");
}
}

View File

@@ -0,0 +1,47 @@
package com.lingniu.ingest.identity;
import com.lingniu.ingest.api.ProtocolId;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class InMemoryVehicleIdentityServiceTest {
private final InMemoryVehicleIdentityService service = new InMemoryVehicleIdentityService();
@Test
void explicitVinWinsOverExternalIdentifiers() {
VehicleIdentity identity = service.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "LNVIN000000000001", "13900000000", "DEV001", "粤B12345"));
assertThat(identity.vin()).isEqualTo("LNVIN000000000001");
assertThat(identity.resolved()).isTrue();
assertThat(identity.source()).isEqualTo(VehicleIdentitySource.EXPLICIT_VIN);
}
@Test
void resolvesBoundPhoneDeviceIdAndPlateToVin() {
service.bind(new VehicleIdentityBinding(
ProtocolId.JT808, "LNVIN000000000002", "13900000001", "DEV002", "粤B22222"));
assertThat(service.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "13900000001", "", "")).vin())
.isEqualTo("LNVIN000000000002");
assertThat(service.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "", "DEV002", "")).vin())
.isEqualTo("LNVIN000000000002");
assertThat(service.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "", "", "粤B22222")).vin())
.isEqualTo("LNVIN000000000002");
}
@Test
void fallsBackToStableExternalIdentifierWhenNoBindingExists() {
VehicleIdentity identity = service.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, "", "13900000003", "DEV003", "粤B33333"));
assertThat(identity.vin()).isEqualTo("DEV003");
assertThat(identity.resolved()).isFalse();
assertThat(identity.source()).isEqualTo(VehicleIdentitySource.FALLBACK_DEVICE_ID);
}
}

View File

@@ -0,0 +1,44 @@
package com.lingniu.ingest.identity.config;
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
import com.lingniu.ingest.identity.VehicleIdentityResolver;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class VehicleIdentityAutoConfigurationTest {
@TempDir
Path tempDir;
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(VehicleIdentityAutoConfiguration.class));
@Test
void createsDefaultIdentityService() {
runner.run(context -> {
assertThat(context).hasSingleBean(VehicleIdentityResolver.class);
assertThat(context).hasSingleBean(VehicleIdentityRegistry.class);
assertThat(context.getBean(VehicleIdentityResolver.class))
.isSameAs(context.getBean(VehicleIdentityRegistry.class));
});
}
@Test
void createsFileIdentityServiceWhenConfigured() {
runner
.withPropertyValues(
"lingniu.ingest.identity.store=file",
"lingniu.ingest.identity.file.path=" + tempDir.resolve("identity.jsonl"))
.run(context -> {
assertThat(context).hasSingleBean(VehicleIdentityResolver.class);
assertThat(context.getBean(VehicleIdentityResolver.class).getClass().getSimpleName())
.isEqualTo("FileVehicleIdentityService");
});
}
}