feat: make gb32960 archive history query production ready
This commit is contained in:
30
modules/core/ingest-api/pom.xml
Normal file
30
modules/core/ingest-api/pom.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
<name>ingest-api</name>
|
||||
<description>SPI、sealed 领域事件、注解定义。零 Spring 依赖。</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lingniu.ingest.api;
|
||||
|
||||
/**
|
||||
* 协议标识,作为所有 SPI / 注解 / 路由 key 的统一枚举。
|
||||
* 新增协议需要在此处登记,避免散落的字符串常量。
|
||||
*/
|
||||
public enum ProtocolId {
|
||||
UNKNOWN,
|
||||
GB32960,
|
||||
JT808,
|
||||
JT1078,
|
||||
JSATL12,
|
||||
MQTT_YUTONG,
|
||||
XINDA_PUSH
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
|
||||
*
|
||||
* <p>处理方法签名需为 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>}。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface AsyncBatch {
|
||||
|
||||
int size() default 1000;
|
||||
|
||||
long waitMs() default 500;
|
||||
|
||||
int poolSize() default 2;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 声明当前 Handler 方法产出的事件类型。用于文档化和静态校验。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface EventEmit {
|
||||
Class<? extends VehicleEvent>[] value();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
|
||||
*
|
||||
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface IdempotentKey {
|
||||
String value();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 方法级路由注解:声明此方法处理哪些协议消息。
|
||||
*
|
||||
* <p>{@link #command} 和 {@link #infoType} 的含义由各协议自行解释,Dispatcher 只负责匹配:
|
||||
* <ul>
|
||||
* <li>GB/T 32960:{@code command} = 0x01 车辆登入 / 0x02 实时上报 / ...;{@code infoType} = 信息体 ID
|
||||
* <li>JT/T 808:{@code command} = 消息 ID(0x0100 / 0x0200 / ...);{@code infoType} 留空
|
||||
* <li>MQTT:{@code command} 可为 topic hash 或忽略
|
||||
* </ul>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface MessageMapping {
|
||||
|
||||
int[] command() default {};
|
||||
|
||||
int[] infoType() default {};
|
||||
|
||||
String desc() default "";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 标记一个类为协议 Handler Bean,由 {@code AnnotationHandlerProcessor} 自动扫描注册。
|
||||
*
|
||||
* <p>典型用法:
|
||||
* <pre>{@code
|
||||
* @ProtocolHandler(protocol = ProtocolId.GB32960)
|
||||
* public class Gb32960RealtimeHandler {
|
||||
* @MessageMapping(command = 0x02)
|
||||
* public VehicleEvent.Realtime handle(Gb32960Message msg) { ... }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ProtocolHandler {
|
||||
|
||||
ProtocolId protocol();
|
||||
|
||||
/** 可选:子协议版本(如 32960 的 2011 / 2017)。 */
|
||||
String version() default "";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface RateLimited {
|
||||
|
||||
/** 单 VIN 每秒最大消息数。 */
|
||||
int perVin() default 50;
|
||||
|
||||
/** 全局 QPS 上限;<=0 表示不设限。 */
|
||||
int global() default -1;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lingniu.ingest.api.annotation;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 为 Handler 方法开启 OpenTelemetry span。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface TraceSpan {
|
||||
String value();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
public final class EnvelopeConsumerProcessor {
|
||||
|
||||
private static final Set<EnvelopeIngestResult.Status> DEAD_LETTER_STATUSES = EnumSet.of(
|
||||
EnvelopeIngestResult.Status.SKIPPED,
|
||||
EnvelopeIngestResult.Status.INVALID_ENVELOPE,
|
||||
EnvelopeIngestResult.Status.FAILED
|
||||
);
|
||||
|
||||
private final String service;
|
||||
private final EnvelopeIngestor ingestor;
|
||||
private final EnvelopeDeadLetterSink deadLetterSink;
|
||||
|
||||
public EnvelopeConsumerProcessor(String service, EnvelopeIngestor ingestor, EnvelopeDeadLetterSink deadLetterSink) {
|
||||
if (ingestor == null) {
|
||||
throw new IllegalArgumentException("ingestor must not be null");
|
||||
}
|
||||
if (deadLetterSink == null) {
|
||||
throw new IllegalArgumentException("deadLetterSink must not be null");
|
||||
}
|
||||
this.service = service == null || service.isBlank() ? "unknown" : service;
|
||||
this.ingestor = ingestor;
|
||||
this.deadLetterSink = deadLetterSink;
|
||||
}
|
||||
|
||||
public EnvelopeIngestResult process(EnvelopeConsumerRecord record) {
|
||||
if (record == null) {
|
||||
throw new IllegalArgumentException("record must not be null");
|
||||
}
|
||||
EnvelopeIngestResult result = ingestor.tryIngest(record.payload());
|
||||
if (DEAD_LETTER_STATUSES.contains(result.status())) {
|
||||
deadLetterSink.publish(toDeadLetter(record, result));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private EnvelopeDeadLetterRecord toDeadLetter(EnvelopeConsumerRecord record, EnvelopeIngestResult result) {
|
||||
return new EnvelopeDeadLetterRecord(
|
||||
service,
|
||||
record.topic(),
|
||||
record.partition(),
|
||||
record.offset(),
|
||||
record.key(),
|
||||
result.status(),
|
||||
result.eventId(),
|
||||
result.vin(),
|
||||
result.message(),
|
||||
record.payload(),
|
||||
Instant.now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
public record EnvelopeConsumerRecord(
|
||||
String topic,
|
||||
int partition,
|
||||
long offset,
|
||||
String key,
|
||||
byte[] payload
|
||||
) {
|
||||
public EnvelopeConsumerRecord {
|
||||
topic = topic == null ? "" : topic;
|
||||
key = key == null ? "" : key;
|
||||
payload = payload == null ? new byte[0] : payload.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] payload() {
|
||||
return payload.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record EnvelopeDeadLetterRecord(
|
||||
String service,
|
||||
String topic,
|
||||
int partition,
|
||||
long offset,
|
||||
String key,
|
||||
EnvelopeIngestResult.Status status,
|
||||
String eventId,
|
||||
String vin,
|
||||
String message,
|
||||
byte[] payload,
|
||||
Instant createdAt
|
||||
) {
|
||||
public EnvelopeDeadLetterRecord {
|
||||
service = service == null ? "" : service;
|
||||
topic = topic == null ? "" : topic;
|
||||
key = key == null ? "" : key;
|
||||
if (status == null) {
|
||||
throw new IllegalArgumentException("status must not be null");
|
||||
}
|
||||
eventId = eventId == null ? "" : eventId;
|
||||
vin = vin == null ? "" : vin;
|
||||
message = message == null ? "" : message;
|
||||
payload = payload == null ? new byte[0] : payload.clone();
|
||||
createdAt = createdAt == null ? Instant.now() : createdAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] payload() {
|
||||
return payload.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnvelopeDeadLetterSink {
|
||||
void publish(EnvelopeDeadLetterRecord record);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
/**
|
||||
* Non-throwing result for Kafka envelope consumers. It lets production
|
||||
* listeners isolate bad records without blocking the partition.
|
||||
*/
|
||||
public record EnvelopeIngestResult(Status status, String eventId, String vin, String message) {
|
||||
|
||||
public enum Status {
|
||||
STORED,
|
||||
PROCESSED,
|
||||
SKIPPED,
|
||||
INVALID_ENVELOPE,
|
||||
FAILED
|
||||
}
|
||||
|
||||
public EnvelopeIngestResult {
|
||||
if (status == null) {
|
||||
throw new IllegalArgumentException("status must not be null");
|
||||
}
|
||||
eventId = eventId == null ? "" : eventId;
|
||||
vin = vin == null ? "" : vin;
|
||||
message = message == null ? "" : message;
|
||||
}
|
||||
|
||||
public static EnvelopeIngestResult stored(String eventId, String vin) {
|
||||
return new EnvelopeIngestResult(Status.STORED, eventId, vin, "");
|
||||
}
|
||||
|
||||
public static EnvelopeIngestResult processed(String eventId, String vin) {
|
||||
return new EnvelopeIngestResult(Status.PROCESSED, eventId, vin, "");
|
||||
}
|
||||
|
||||
public static EnvelopeIngestResult skipped(String eventId, String vin, String message) {
|
||||
return new EnvelopeIngestResult(Status.SKIPPED, eventId, vin, message);
|
||||
}
|
||||
|
||||
public static EnvelopeIngestResult invalid(String message) {
|
||||
return new EnvelopeIngestResult(Status.INVALID_ENVELOPE, "", "", message);
|
||||
}
|
||||
|
||||
public static EnvelopeIngestResult failed(String eventId, String vin, String message) {
|
||||
return new EnvelopeIngestResult(Status.FAILED, eventId, vin, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnvelopeIngestor {
|
||||
EnvelopeIngestResult tryIngest(byte[] kafkaValue);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 报警事件载荷,跨协议归一。
|
||||
*
|
||||
* @param level 报警最高等级
|
||||
* @param alarmTypeCode 协议原始 alarm type 编码(按协议自定义),可用于联查协议规范
|
||||
* @param alarmTypeName 报警类型名称,如 {@code GB32960_ALARM}
|
||||
* @param faultCodes 故障代码列表(协议私有编码字符串化,便于下游去重/聚合)
|
||||
* @param activeBits 结构化的通用报警位集合(2016 版:0~15;2025 版:0~27),对照 GB/T 32960.3 表 24
|
||||
* @param longitude 经度(可选,若事件伴随位置)
|
||||
* @param latitude 纬度(可选)
|
||||
* @param safetyCategory 内部安全分类。氢气泄露独立成类,不与普通故障混用
|
||||
* @param hydrogenLeakDetected 是否检测到氢气泄露
|
||||
* @param hydrogenLeakLevel 氢气泄露等级
|
||||
* @param hydrogenLeakActionRequired 是否需要立即处置
|
||||
*/
|
||||
public record AlarmPayload(
|
||||
AlarmLevel level,
|
||||
int alarmTypeCode,
|
||||
String alarmTypeName,
|
||||
List<String> faultCodes,
|
||||
Set<String> activeBits,
|
||||
Double longitude,
|
||||
Double latitude,
|
||||
SafetyCategory safetyCategory,
|
||||
boolean hydrogenLeakDetected,
|
||||
HydrogenLeakLevel hydrogenLeakLevel,
|
||||
boolean hydrogenLeakActionRequired
|
||||
) {
|
||||
/**
|
||||
* 报警等级(归一化的跨协议分类)。
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #INFO}:提示级(对应 GB/T 32960.3 0/无故障)
|
||||
* <li>{@link #MINOR}:1 级 —— 不影响车辆正常行驶
|
||||
* <li>{@link #MAJOR}:2 级 —— 影响车辆性能,需驾驶员限制行驶
|
||||
* <li>{@link #CRITICAL}:3 级(驾驶员应立即停车)或 4 级(热事件最高级)
|
||||
* </ul>
|
||||
*/
|
||||
public enum AlarmLevel { INFO, MINOR, MAJOR, CRITICAL }
|
||||
|
||||
/**
|
||||
* 面向运营统计的内部安全分类。
|
||||
*/
|
||||
public enum SafetyCategory {
|
||||
GENERAL,
|
||||
TANK_PRESSURE,
|
||||
TANK_TEMPERATURE,
|
||||
HYDROGEN_LEAK
|
||||
}
|
||||
|
||||
/**
|
||||
* 氢气泄露内部等级。明确泄露信号一律视为 CRITICAL。
|
||||
*/
|
||||
public enum HydrogenLeakLevel {
|
||||
NONE,
|
||||
WARNING,
|
||||
CRITICAL,
|
||||
UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
/** 位置事件载荷。坐标已统一转换为 WGS84 十进制度。 */
|
||||
public record LocationPayload(
|
||||
double longitude,
|
||||
double latitude,
|
||||
double altitudeM,
|
||||
double speedKmh,
|
||||
double directionDeg,
|
||||
long alarmFlag,
|
||||
long statusFlag,
|
||||
Double totalMileageKm
|
||||
) {
|
||||
public LocationPayload(double longitude,
|
||||
double latitude,
|
||||
double altitudeM,
|
||||
double speedKmh,
|
||||
double directionDeg,
|
||||
long alarmFlag,
|
||||
long statusFlag) {
|
||||
this(longitude, latitude, altitudeM, speedKmh, directionDeg, alarmFlag, statusFlag, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared raw-archive key conventions used by Dispatcher, archive sink, and query services.
|
||||
*/
|
||||
public final class RawArchiveKeys {
|
||||
|
||||
public static final String META_EVENT_ID = "rawArchiveEventId";
|
||||
public static final String META_KEY = "rawArchiveKey";
|
||||
public static final String META_URI = "rawArchiveUri";
|
||||
|
||||
private static final DateTimeFormatter DATE_KEY =
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
private RawArchiveKeys() {
|
||||
}
|
||||
|
||||
public static String key(Instant ingestTime, ProtocolId source, String vin, String eventId) {
|
||||
String dateKey = DATE_KEY.format(ingestTime == null ? Instant.EPOCH : ingestTime);
|
||||
String safeVin = vin == null || vin.isBlank() ? "unknown-vin" : vin;
|
||||
String safeEventId = eventId == null || eventId.isBlank()
|
||||
? Long.toHexString(System.nanoTime()) : eventId;
|
||||
String safeSource = source == null ? "UNKNOWN" : source.name();
|
||||
return dateKey + "/" + safeSource + "/" + safeVin + "/" + safeEventId + ".bin";
|
||||
}
|
||||
|
||||
public static String key(VehicleEvent.RawArchive raw) {
|
||||
String key = metadataValue(raw.metadata(), META_KEY);
|
||||
if (!key.isBlank()) {
|
||||
return key;
|
||||
}
|
||||
return key(raw.ingestTime(), raw.source(), raw.vin(), raw.eventId());
|
||||
}
|
||||
|
||||
public static String logicalUri(String key) {
|
||||
return key == null || key.isBlank() ? "" : "archive://" + key;
|
||||
}
|
||||
|
||||
private static String metadataValue(Map<String, String> metadata, String key) {
|
||||
if (metadata == null || key == null) {
|
||||
return "";
|
||||
}
|
||||
String value = metadata.get(key);
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
/**
|
||||
* 实时数据载荷(扁平 record,字段用 {@code Double} / {@code Integer} 允许 null,表达"未上报")。
|
||||
*
|
||||
* <p>不再复刻旧服务 {@code VehicleDataActual} 的 273 字段巨型类 —— 协议特有细节通过
|
||||
* {@link VehicleEvent#metadata()} 传递。此处只保留跨协议通用字段。
|
||||
*/
|
||||
public record RealtimePayload(
|
||||
// ===== 动力 & 能源 =====
|
||||
Double speedKmh,
|
||||
Double totalMileageKm,
|
||||
Double batterySoc,
|
||||
Double batteryVoltageV,
|
||||
Double batteryCurrentA,
|
||||
|
||||
// ===== 燃料电池 / 氢能 =====
|
||||
Double fcVoltageV,
|
||||
Double fcCurrentA,
|
||||
Double fcTempC,
|
||||
Double hydrogenRemainingKg,
|
||||
Double hydrogenHighPressureMpa,
|
||||
Double hydrogenLowPressureMpa,
|
||||
|
||||
// ===== 车辆状态 =====
|
||||
VehicleState vehicleState,
|
||||
ChargingState chargingState,
|
||||
RunningMode runningMode,
|
||||
Integer gearLevel,
|
||||
Double acceleratorPedal,
|
||||
Double brakePedal,
|
||||
|
||||
// ===== 位置(冗余一份便于单主题消费)=====
|
||||
Double longitude,
|
||||
Double latitude,
|
||||
Double altitudeM,
|
||||
Double directionDeg,
|
||||
|
||||
// ===== 环境 =====
|
||||
Double ambientTempC,
|
||||
Double coolantTempC
|
||||
) {
|
||||
|
||||
public enum VehicleState { STARTED, SHUTDOWN, OTHER, INVALID }
|
||||
|
||||
public enum ChargingState { UNCHARGED, PARKED_CHARGING, DRIVING_CHARGING, CHARGED, OTHER, INVALID }
|
||||
|
||||
public enum RunningMode { ELECTRIC, HYBRID, FUEL, OTHER, INVALID }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
/**
|
||||
* One normalized telemetry field value.
|
||||
*
|
||||
* <p>Protocol mappers use stable internal field keys so Kafka, Parquet, Redis,
|
||||
* and statistics do not depend on protocol-specific names.
|
||||
*/
|
||||
public record TelemetryFieldValue(
|
||||
String key,
|
||||
ValueType valueType,
|
||||
String value,
|
||||
String unit,
|
||||
Quality quality,
|
||||
String sourcePath
|
||||
) {
|
||||
|
||||
public TelemetryFieldValue {
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new IllegalArgumentException("key must not be blank");
|
||||
}
|
||||
if (valueType == null) {
|
||||
throw new IllegalArgumentException("valueType must not be null");
|
||||
}
|
||||
value = value == null ? "" : value;
|
||||
unit = unit == null ? "" : unit;
|
||||
quality = quality == null ? Quality.GOOD : quality;
|
||||
sourcePath = sourcePath == null ? "" : sourcePath;
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue stringValue(String key, String value, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.STRING, value, unit, Quality.GOOD, sourcePath);
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue doubleValue(String key, double value, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.DOUBLE, Double.toString(value), unit, Quality.GOOD, sourcePath);
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue longValue(String key, long value, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.LONG, Long.toString(value), unit, Quality.GOOD, sourcePath);
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue booleanValue(String key, boolean value, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.BOOLEAN, Boolean.toString(value), unit, Quality.GOOD, sourcePath);
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue instantValue(String key, String value, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.INSTANT, value, unit, Quality.GOOD, sourcePath);
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue jsonValue(String key, String value, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.JSON, value, unit, Quality.GOOD, sourcePath);
|
||||
}
|
||||
|
||||
public static TelemetryFieldValue missing(String key, String unit, String sourcePath) {
|
||||
return new TelemetryFieldValue(key, ValueType.STRING, "", unit, Quality.MISSING, sourcePath);
|
||||
}
|
||||
|
||||
public enum ValueType {
|
||||
STRING,
|
||||
DOUBLE,
|
||||
LONG,
|
||||
BOOLEAN,
|
||||
INSTANT,
|
||||
JSON
|
||||
}
|
||||
|
||||
public enum Quality {
|
||||
GOOD,
|
||||
ESTIMATED,
|
||||
INVALID,
|
||||
MISSING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Normalized full-field telemetry snapshot for one parsed vehicle event.
|
||||
*
|
||||
* <p>This is the shared contract for Kafka, Parquet history, Redis hot state,
|
||||
* and statistics. It intentionally lives in {@code ingest-api} and has no
|
||||
* Spring, Kafka, Redis, or DuckDB dependency.
|
||||
*/
|
||||
public record TelemetrySnapshot(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId protocol,
|
||||
String eventType,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String rawArchiveUri,
|
||||
Map<String, String> metadata,
|
||||
List<TelemetryFieldValue> fields
|
||||
) {
|
||||
|
||||
public TelemetrySnapshot {
|
||||
if (eventId == null || eventId.isBlank()) {
|
||||
throw new IllegalArgumentException("eventId must not be blank");
|
||||
}
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
if (protocol == null) {
|
||||
throw new IllegalArgumentException("protocol must not be null");
|
||||
}
|
||||
if (eventType == null || eventType.isBlank()) {
|
||||
throw new IllegalArgumentException("eventType must not be blank");
|
||||
}
|
||||
if (eventTime == null) {
|
||||
throw new IllegalArgumentException("eventTime must not be null");
|
||||
}
|
||||
if (ingestTime == null) {
|
||||
throw new IllegalArgumentException("ingestTime must not be null");
|
||||
}
|
||||
rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri;
|
||||
metadata = Map.copyOf(metadata == null ? Map.of() : metadata);
|
||||
fields = List.copyOf(fields == null ? List.of() : fields);
|
||||
validateUniqueKeys(fields);
|
||||
}
|
||||
|
||||
public Optional<TelemetryFieldValue> field(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
if (field.key().equals(key)) {
|
||||
return Optional.of(field);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public Map<String, TelemetryFieldValue> fieldsByKey() {
|
||||
Map<String, TelemetryFieldValue> index = new LinkedHashMap<>();
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
index.put(field.key(), field);
|
||||
}
|
||||
return Map.copyOf(index);
|
||||
}
|
||||
|
||||
public static Builder builder(String eventId,
|
||||
String vin,
|
||||
ProtocolId protocol,
|
||||
String eventType,
|
||||
Instant eventTime,
|
||||
Instant ingestTime) {
|
||||
return new Builder(eventId, vin, protocol, eventType, eventTime, ingestTime);
|
||||
}
|
||||
|
||||
private static void validateUniqueKeys(List<TelemetryFieldValue> fields) {
|
||||
Map<String, TelemetryFieldValue> seen = new LinkedHashMap<>();
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
if (field == null) {
|
||||
throw new IllegalArgumentException("field must not be null");
|
||||
}
|
||||
TelemetryFieldValue previous = seen.put(field.key(), field);
|
||||
if (previous != null) {
|
||||
throw new IllegalArgumentException("duplicate telemetry field key: " + field.key());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
private final String eventId;
|
||||
private final String vin;
|
||||
private final ProtocolId protocol;
|
||||
private final String eventType;
|
||||
private final Instant eventTime;
|
||||
private final Instant ingestTime;
|
||||
private String rawArchiveUri = "";
|
||||
private Map<String, String> metadata = Map.of();
|
||||
private final List<TelemetryFieldValue> fields = new ArrayList<>();
|
||||
|
||||
private Builder(String eventId,
|
||||
String vin,
|
||||
ProtocolId protocol,
|
||||
String eventType,
|
||||
Instant eventTime,
|
||||
Instant ingestTime) {
|
||||
this.eventId = eventId;
|
||||
this.vin = vin;
|
||||
this.protocol = protocol;
|
||||
this.eventType = eventType;
|
||||
this.eventTime = eventTime;
|
||||
this.ingestTime = ingestTime;
|
||||
}
|
||||
|
||||
public Builder rawArchiveUri(String rawArchiveUri) {
|
||||
this.rawArchiveUri = rawArchiveUri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder metadata(Map<String, String> metadata) {
|
||||
this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder addField(TelemetryFieldValue field) {
|
||||
this.fields.add(field);
|
||||
return this;
|
||||
}
|
||||
|
||||
public TelemetrySnapshot build() {
|
||||
return new TelemetrySnapshot(
|
||||
eventId, vin, protocol, eventType, eventTime, ingestTime,
|
||||
rawArchiveUri, metadata, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 领域事件根类型。所有协议归一到这里。
|
||||
*
|
||||
* <p>使用 sealed + record 表达有界闭合的事件集合,下游消费方可用 {@code switch} 模式匹配穷尽处理。
|
||||
*
|
||||
* <p>字段约定:
|
||||
* <ul>
|
||||
* <li>{@link #vin} 作为 Kafka 分区 key,保证单车顺序
|
||||
* <li>{@link #eventTime} 设备上报时间(而非服务器接收时间)
|
||||
* <li>{@link #ingestTime} 服务器接收时间,用于延迟监控
|
||||
* <li>{@link #traceId} W3C traceparent 上下文
|
||||
* </ul>
|
||||
*/
|
||||
public sealed interface VehicleEvent
|
||||
permits VehicleEvent.Realtime,
|
||||
VehicleEvent.Location,
|
||||
VehicleEvent.Alarm,
|
||||
VehicleEvent.Login,
|
||||
VehicleEvent.Logout,
|
||||
VehicleEvent.Heartbeat,
|
||||
VehicleEvent.MediaMeta,
|
||||
VehicleEvent.Passthrough,
|
||||
VehicleEvent.RawArchive {
|
||||
|
||||
String eventId();
|
||||
String vin();
|
||||
ProtocolId source();
|
||||
Instant eventTime();
|
||||
Instant ingestTime();
|
||||
String traceId();
|
||||
|
||||
/** 通用元数据:协议版本、终端手机号、网关 IP 等。 */
|
||||
Map<String, String> metadata();
|
||||
|
||||
// ===== 具体事件类型 =====
|
||||
|
||||
/** 整车实时数据(32960 实时上报 / MQTT 宇通 / JT808 位置扩展等都归一到这里)。 */
|
||||
record Realtime(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
RealtimePayload payload
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 纯位置事件(808 的 0x0200 / Xinda 0200 映射到此)。 */
|
||||
record Location(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
LocationPayload payload
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 报警事件。 */
|
||||
record Alarm(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
AlarmPayload payload
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 设备登入 / 车辆登入。 */
|
||||
record Login(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
String iccid,
|
||||
String protocolVersion
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 设备登出。 */
|
||||
record Logout(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 心跳 / 链路保活。 */
|
||||
record Heartbeat(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 多媒体元数据(大文件本体走对象存储,通过 archiveRef 引用)。 */
|
||||
record MediaMeta(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
String mediaId,
|
||||
String mediaType,
|
||||
long sizeBytes,
|
||||
String archiveRef
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/** 透传 / 自定义扩展消息。 */
|
||||
record Passthrough(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
int passthroughType,
|
||||
byte[] data
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/**
|
||||
* 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节交
|
||||
* {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型
|
||||
* (见 {@code KafkaEventSink.accepts})。
|
||||
*
|
||||
* <p>key 组装建议:{@code yyyy/MM/dd/<source>/<vin>/<eventId>.bin},具体由 sink 实现决定。
|
||||
*
|
||||
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
|
||||
* @param infoType 协议子类型(可为 0),同上
|
||||
* @param rawBytes 原始入站字节,不可为 null
|
||||
*/
|
||||
record RawArchive(
|
||||
String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
int command,
|
||||
int infoType,
|
||||
byte[] rawBytes
|
||||
) implements VehicleEvent {}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Converts current {@link VehicleEvent} variants into normalized full-field
|
||||
* telemetry snapshots.
|
||||
*
|
||||
* <p>Protocol-specific mappers can add richer fields later, but all downstream
|
||||
* modules should consume {@link TelemetrySnapshot} instead of protocol objects.
|
||||
*/
|
||||
public final class VehicleEventTelemetrySnapshotMapper {
|
||||
|
||||
private VehicleEventTelemetrySnapshotMapper() {
|
||||
}
|
||||
|
||||
public static Optional<TelemetrySnapshot> toSnapshot(VehicleEvent event) {
|
||||
if (event == null || event instanceof VehicleEvent.RawArchive) {
|
||||
return Optional.empty();
|
||||
}
|
||||
TelemetrySnapshot.Builder builder = TelemetrySnapshot.builder(
|
||||
event.eventId(),
|
||||
event.vin(),
|
||||
event.source(),
|
||||
eventType(event),
|
||||
event.eventTime(),
|
||||
event.ingestTime())
|
||||
.metadata(event.metadata())
|
||||
.rawArchiveUri(rawArchiveUri(event));
|
||||
|
||||
switch (event) {
|
||||
case VehicleEvent.Realtime realtime -> addRealtimeFields(builder, realtime.payload());
|
||||
case VehicleEvent.Location location -> addLocationFields(builder, location.payload());
|
||||
case VehicleEvent.Alarm alarm -> addAlarmFields(builder, alarm.payload());
|
||||
case VehicleEvent.Login login -> {
|
||||
addString(builder, "iccid", login.iccid(), "", "event.login.iccid");
|
||||
addString(builder, "protocol_version", login.protocolVersion(), "", "event.login.protocolVersion");
|
||||
}
|
||||
case VehicleEvent.Logout ignored -> {
|
||||
}
|
||||
case VehicleEvent.Heartbeat ignored -> {
|
||||
}
|
||||
case VehicleEvent.MediaMeta media -> {
|
||||
addString(builder, "media_id", media.mediaId(), "", "event.media.mediaId");
|
||||
addString(builder, "media_type", media.mediaType(), "", "event.media.mediaType");
|
||||
builder.addField(TelemetryFieldValue.longValue(
|
||||
"media_size_bytes", media.sizeBytes(), "bytes", "event.media.sizeBytes"));
|
||||
addString(builder, "media_archive_ref", media.archiveRef(), "", "event.media.archiveRef");
|
||||
}
|
||||
case VehicleEvent.Passthrough passthrough -> {
|
||||
builder.addField(TelemetryFieldValue.longValue(
|
||||
"passthrough_type", passthrough.passthroughType(), "", "event.passthrough.type"));
|
||||
builder.addField(TelemetryFieldValue.longValue(
|
||||
"passthrough_size_bytes",
|
||||
passthrough.data() == null ? 0 : passthrough.data().length,
|
||||
"bytes",
|
||||
"event.passthrough.data"));
|
||||
}
|
||||
case VehicleEvent.RawArchive ignored -> {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
return Optional.of(builder.build());
|
||||
}
|
||||
|
||||
private static void addRealtimeFields(TelemetrySnapshot.Builder builder, RealtimePayload p) {
|
||||
if (p == null) return;
|
||||
addDouble(builder, "speed_kmh", p.speedKmh(), "km/h", "event.realtime.speedKmh");
|
||||
addDouble(builder, "total_mileage_km", p.totalMileageKm(), "km", "event.realtime.totalMileageKm");
|
||||
addDouble(builder, "battery_soc", p.batterySoc(), "%", "event.realtime.batterySoc");
|
||||
addDouble(builder, "battery_voltage_v", p.batteryVoltageV(), "V", "event.realtime.batteryVoltageV");
|
||||
addDouble(builder, "battery_current_a", p.batteryCurrentA(), "A", "event.realtime.batteryCurrentA");
|
||||
addDouble(builder, "fc_voltage_v", p.fcVoltageV(), "V", "event.realtime.fcVoltageV");
|
||||
addDouble(builder, "fc_current_a", p.fcCurrentA(), "A", "event.realtime.fcCurrentA");
|
||||
addDouble(builder, "fc_temp_c", p.fcTempC(), "C", "event.realtime.fcTempC");
|
||||
addDouble(builder, "hydrogen_remaining_kg", p.hydrogenRemainingKg(), "kg", "event.realtime.hydrogenRemainingKg");
|
||||
addDouble(builder, "hydrogen_high_pressure_mpa", p.hydrogenHighPressureMpa(), "MPa", "event.realtime.hydrogenHighPressureMpa");
|
||||
addDouble(builder, "hydrogen_low_pressure_mpa", p.hydrogenLowPressureMpa(), "MPa", "event.realtime.hydrogenLowPressureMpa");
|
||||
addEnum(builder, "vehicle_state", p.vehicleState(), "", "event.realtime.vehicleState");
|
||||
addEnum(builder, "charging_state", p.chargingState(), "", "event.realtime.chargingState");
|
||||
addEnum(builder, "running_mode", p.runningMode(), "", "event.realtime.runningMode");
|
||||
addLong(builder, "gear_level", p.gearLevel(), "", "event.realtime.gearLevel");
|
||||
addDouble(builder, "accelerator_pedal", p.acceleratorPedal(), "%", "event.realtime.acceleratorPedal");
|
||||
addDouble(builder, "brake_pedal", p.brakePedal(), "%", "event.realtime.brakePedal");
|
||||
addDouble(builder, "longitude", p.longitude(), "deg", "event.realtime.longitude");
|
||||
addDouble(builder, "latitude", p.latitude(), "deg", "event.realtime.latitude");
|
||||
addDouble(builder, "altitude_m", p.altitudeM(), "m", "event.realtime.altitudeM");
|
||||
addDouble(builder, "direction_deg", p.directionDeg(), "deg", "event.realtime.directionDeg");
|
||||
addDouble(builder, "ambient_temp_c", p.ambientTempC(), "C", "event.realtime.ambientTempC");
|
||||
addDouble(builder, "coolant_temp_c", p.coolantTempC(), "C", "event.realtime.coolantTempC");
|
||||
}
|
||||
|
||||
private static void addLocationFields(TelemetrySnapshot.Builder builder, LocationPayload p) {
|
||||
if (p == null) return;
|
||||
builder.addField(TelemetryFieldValue.doubleValue("longitude", p.longitude(), "deg", "event.location.longitude"));
|
||||
builder.addField(TelemetryFieldValue.doubleValue("latitude", p.latitude(), "deg", "event.location.latitude"));
|
||||
builder.addField(TelemetryFieldValue.doubleValue("altitude_m", p.altitudeM(), "m", "event.location.altitudeM"));
|
||||
builder.addField(TelemetryFieldValue.doubleValue("speed_kmh", p.speedKmh(), "km/h", "event.location.speedKmh"));
|
||||
addDouble(builder, "total_mileage_km", p.totalMileageKm(), "km", "event.location.totalMileageKm");
|
||||
builder.addField(TelemetryFieldValue.doubleValue("direction_deg", p.directionDeg(), "deg", "event.location.directionDeg"));
|
||||
builder.addField(TelemetryFieldValue.longValue("location_alarm_flag", p.alarmFlag(), "", "event.location.alarmFlag"));
|
||||
builder.addField(TelemetryFieldValue.longValue("location_status_raw", p.statusFlag(), "", "event.location.statusFlag"));
|
||||
}
|
||||
|
||||
private static void addAlarmFields(TelemetrySnapshot.Builder builder, AlarmPayload p) {
|
||||
if (p == null) return;
|
||||
addEnum(builder, "alarm_level", p.level(), "", "event.alarm.level");
|
||||
builder.addField(TelemetryFieldValue.longValue("alarm_type_code", p.alarmTypeCode(), "", "event.alarm.alarmTypeCode"));
|
||||
addString(builder, "alarm_type_name", p.alarmTypeName(), "", "event.alarm.alarmTypeName");
|
||||
if (p.faultCodes() != null && !p.faultCodes().isEmpty()) {
|
||||
builder.addField(TelemetryFieldValue.stringValue(
|
||||
"fault_codes", String.join(",", p.faultCodes()), "", "event.alarm.faultCodes"));
|
||||
}
|
||||
if (p.activeBits() != null && !p.activeBits().isEmpty()) {
|
||||
builder.addField(TelemetryFieldValue.stringValue(
|
||||
"active_alarm_bits", String.join(",", p.activeBits()), "", "event.alarm.activeBits"));
|
||||
}
|
||||
addDouble(builder, "longitude", p.longitude(), "deg", "event.alarm.longitude");
|
||||
addDouble(builder, "latitude", p.latitude(), "deg", "event.alarm.latitude");
|
||||
addEnum(builder, "safety_category", p.safetyCategory(), "", "event.alarm.safetyCategory");
|
||||
builder.addField(TelemetryFieldValue.booleanValue(
|
||||
"hydrogen_leak_detected", p.hydrogenLeakDetected(), "", "event.alarm.hydrogenLeakDetected"));
|
||||
addEnum(builder, "hydrogen_leak_level", p.hydrogenLeakLevel(), "", "event.alarm.hydrogenLeakLevel");
|
||||
builder.addField(TelemetryFieldValue.booleanValue(
|
||||
"hydrogen_leak_action_required",
|
||||
p.hydrogenLeakActionRequired(),
|
||||
"",
|
||||
"event.alarm.hydrogenLeakActionRequired"));
|
||||
}
|
||||
|
||||
private static String eventType(VehicleEvent event) {
|
||||
if (event instanceof VehicleEvent.Realtime) return "REALTIME";
|
||||
if (event instanceof VehicleEvent.Location) return "LOCATION";
|
||||
if (event instanceof VehicleEvent.Alarm) return "ALARM";
|
||||
if (event instanceof VehicleEvent.Login) return "LOGIN";
|
||||
if (event instanceof VehicleEvent.Logout) return "LOGOUT";
|
||||
if (event instanceof VehicleEvent.Heartbeat) return "HEARTBEAT";
|
||||
if (event instanceof VehicleEvent.MediaMeta) return "MEDIA_META";
|
||||
if (event instanceof VehicleEvent.Passthrough) return "PASSTHROUGH";
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
private static String rawArchiveUri(VehicleEvent event) {
|
||||
Map<String, String> metadata = event.metadata();
|
||||
if (metadata == null) {
|
||||
return mediaArchiveRef(event);
|
||||
}
|
||||
String uri = metadata.getOrDefault(RawArchiveKeys.META_URI, "");
|
||||
if (uri != null && !uri.isBlank()) {
|
||||
return uri;
|
||||
}
|
||||
String key = metadata.getOrDefault(RawArchiveKeys.META_KEY, "");
|
||||
if (key != null && !key.isBlank()) {
|
||||
return RawArchiveKeys.logicalUri(key);
|
||||
}
|
||||
return mediaArchiveRef(event);
|
||||
}
|
||||
|
||||
private static String mediaArchiveRef(VehicleEvent event) {
|
||||
if (event instanceof VehicleEvent.MediaMeta media && media.archiveRef() != null) {
|
||||
return media.archiveRef();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static void addDouble(TelemetrySnapshot.Builder builder,
|
||||
String key,
|
||||
Double value,
|
||||
String unit,
|
||||
String sourcePath) {
|
||||
if (value != null) {
|
||||
builder.addField(TelemetryFieldValue.doubleValue(key, value, unit, sourcePath));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addLong(TelemetrySnapshot.Builder builder,
|
||||
String key,
|
||||
Integer value,
|
||||
String unit,
|
||||
String sourcePath) {
|
||||
if (value != null) {
|
||||
builder.addField(TelemetryFieldValue.longValue(key, value, unit, sourcePath));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addString(TelemetrySnapshot.Builder builder,
|
||||
String key,
|
||||
String value,
|
||||
String unit,
|
||||
String sourcePath) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
builder.addField(TelemetryFieldValue.stringValue(key, value, unit, sourcePath));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addEnum(TelemetrySnapshot.Builder builder,
|
||||
String key,
|
||||
Enum<?> value,
|
||||
String unit,
|
||||
String sourcePath) {
|
||||
if (value != null) {
|
||||
builder.addField(TelemetryFieldValue.stringValue(key, value.name(), unit, sourcePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* lingniu-vehicle-ingest 公共 API 模块。零 Spring 依赖,只定义 SPI、sealed 领域事件、注解。
|
||||
*
|
||||
* <p>分包:
|
||||
* <ul>
|
||||
* <li>{@code annotation} - Handler 注解体系
|
||||
* <li>{@code event} - sealed {@code VehicleEvent} + payload records
|
||||
* <li>{@code pipeline} - RawFrame / IngestContext / IngestInterceptor
|
||||
* <li>{@code sink} - EventSink SPI
|
||||
* <li>{@code spi} - ProtocolPlugin / FrameDecoder / FrameEncoder / EventMapper
|
||||
* </ul>
|
||||
*/
|
||||
package com.lingniu.ingest.api;
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.lingniu.ingest.api.pipeline;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享,不需要同步。
|
||||
*/
|
||||
public final class IngestContext {
|
||||
|
||||
private final String traceId;
|
||||
private final Map<String, Object> attributes = new HashMap<>(8);
|
||||
private volatile boolean aborted;
|
||||
private volatile String abortReason;
|
||||
|
||||
public IngestContext(String traceId) {
|
||||
this.traceId = traceId;
|
||||
}
|
||||
|
||||
public String traceId() {
|
||||
return traceId;
|
||||
}
|
||||
|
||||
public <T> T attr(String key) {
|
||||
@SuppressWarnings("unchecked")
|
||||
T v = (T) attributes.get(key);
|
||||
return v;
|
||||
}
|
||||
|
||||
public void attr(String key, Object value) {
|
||||
attributes.put(key, value);
|
||||
}
|
||||
|
||||
public void abort(String reason) {
|
||||
this.aborted = true;
|
||||
this.abortReason = reason;
|
||||
}
|
||||
|
||||
public boolean aborted() {
|
||||
return aborted;
|
||||
}
|
||||
|
||||
public String abortReason() {
|
||||
return abortReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.lingniu.ingest.api.pipeline;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
/**
|
||||
* 拦截器 SPI。内置实现:Auth / Dedup / RateLimit / CoordTransform / Tracing。
|
||||
* 实现 {@link org.springframework.core.Ordered}(或使用 {@code @Order})控制顺序。
|
||||
*/
|
||||
public interface IngestInterceptor {
|
||||
|
||||
/** 解码后、分发到 Handler 之前触发。返回 false 表示终止后续处理。 */
|
||||
default boolean before(RawFrame frame, IngestContext ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Handler 成功产出事件后触发,允许修饰事件或取消投递。 */
|
||||
default void after(VehicleEvent event, IngestContext ctx) {}
|
||||
|
||||
/** 处理链任一环节抛出异常时触发。 */
|
||||
default void onError(Throwable error, IngestContext ctx) {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.api.pipeline;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 进入 Pipeline 的原始帧描述。
|
||||
*
|
||||
* @param protocolId 来源协议
|
||||
* @param command 协议主命令 / 消息 ID(由各协议自行定义)
|
||||
* @param infoType 可选子类型(如 32960 的信息体 ID)
|
||||
* @param payload 已经被 {@link com.lingniu.ingest.api.spi.FrameDecoder} 解析出的协议对象
|
||||
* @param rawBytes 原始字节,用于冷存与排障(可能为 null,按配置开关)
|
||||
* @param sourceMeta 来源元数据:peer ip、端口、session id、topic 等
|
||||
* @param receivedAt 服务器接收时刻
|
||||
*/
|
||||
public record RawFrame(
|
||||
ProtocolId protocolId,
|
||||
int command,
|
||||
int infoType,
|
||||
Object payload,
|
||||
byte[] rawBytes,
|
||||
Map<String, String> sourceMeta,
|
||||
Instant receivedAt
|
||||
) {}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.lingniu.ingest.api.sink;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* 事件出口 SPI。Kafka、本地归档、指标打点都是一种 Sink。
|
||||
*
|
||||
* <p>实现应当线程安全、非阻塞(内部异步 IO)。由 Disruptor EventBus 扇出调用。
|
||||
*/
|
||||
public interface EventSink {
|
||||
|
||||
String name();
|
||||
|
||||
CompletableFuture<Void> publish(VehicleEvent event);
|
||||
|
||||
default CompletableFuture<Void> publishBatch(List<VehicleEvent> batch) {
|
||||
CompletableFuture<?>[] all = batch.stream().map(this::publish).toArray(CompletableFuture[]::new);
|
||||
return CompletableFuture.allOf(all);
|
||||
}
|
||||
|
||||
/** 是否接受此事件类型。默认全部接受。 */
|
||||
default boolean accepts(VehicleEvent event) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
/** 解码阶段异常,指示数据体本身违反协议。应被 Dispatcher 路由到 DLQ。 */
|
||||
public class DecodeException extends RuntimeException {
|
||||
public DecodeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public DecodeException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 协议消息 → 领域事件的映射器(Adapter 模式)。
|
||||
*
|
||||
* <p>实现类负责把"我这个协议特有的 Java 对象"翻译成统一的 {@link VehicleEvent}。
|
||||
* 允许一条协议消息展开为多条领域事件(例如 32960 的一次实时上报同时产出 Realtime + Location)。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface EventMapper<I> {
|
||||
List<VehicleEvent> toEvents(I message);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* 协议帧解码器:字节流 → 协议消息对象。
|
||||
*
|
||||
* <p>实现类应是无状态的(拆包/粘包由上游 Netty 的帧解码器处理),此处只负责一条完整帧的解析。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FrameDecoder<I> {
|
||||
I decode(ByteBuffer buffer) throws DecodeException;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** 下行帧编码器。无下行的协议返回 null 即可。 */
|
||||
@FunctionalInterface
|
||||
public interface FrameEncoder<O> {
|
||||
ByteBuffer encode(O message);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 编程式 Handler 接口。推荐使用 {@code @ProtocolHandler + @MessageMapping} 注解方式代替。
|
||||
* 此接口保留是为了 SPI 场景下的脚本式扩展(例如动态加载的 groovy handler)。
|
||||
*/
|
||||
public interface MessageHandler<I> {
|
||||
|
||||
boolean supports(I message);
|
||||
|
||||
List<VehicleEvent> handle(I message);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.lingniu.ingest.api.spi;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 协议插件 SPI。每个协议模块提供一个实现,由 {@code ProtocolPluginRegistry} 通过 ServiceLoader
|
||||
* 或 Spring Bean 方式发现并装配。
|
||||
*
|
||||
* @param <I> 协议解码后的消息类型
|
||||
* @param <O> 下行消息类型(无下行的协议用 {@link Void})
|
||||
*/
|
||||
public interface ProtocolPlugin<I, O> {
|
||||
|
||||
ProtocolId id();
|
||||
|
||||
FrameDecoder<I> decoder();
|
||||
|
||||
FrameEncoder<O> encoder();
|
||||
|
||||
EventMapper<I> eventMapper();
|
||||
|
||||
/**
|
||||
* 插件自带的协议特定 Handler 列表(可选)。
|
||||
* 通常推荐使用 {@code @ProtocolHandler} + Spring Bean 的方式注册,此处返回空列表。
|
||||
*/
|
||||
default List<MessageHandler<I>> builtinHandlers() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EnvelopeConsumerProcessorTest {
|
||||
|
||||
@Test
|
||||
void invalidEnvelopePublishesDeadLetterRecordWithKafkaPosition() {
|
||||
CapturingDeadLetterSink deadLetters = new CapturingDeadLetterSink();
|
||||
EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor(
|
||||
"event-history", ignored -> EnvelopeIngestResult.invalid("VehicleEnvelope bytes are invalid"), deadLetters);
|
||||
|
||||
EnvelopeIngestResult result = processor.process(
|
||||
new EnvelopeConsumerRecord("vehicle.realtime", 2, 42L, "VIN001", new byte[]{0x01, 0x02}));
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
|
||||
assertThat(deadLetters.records).singleElement().satisfies(record -> {
|
||||
assertThat(record.service()).isEqualTo("event-history");
|
||||
assertThat(record.topic()).isEqualTo("vehicle.realtime");
|
||||
assertThat(record.partition()).isEqualTo(2);
|
||||
assertThat(record.offset()).isEqualTo(42L);
|
||||
assertThat(record.key()).isEqualTo("VIN001");
|
||||
assertThat(record.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
|
||||
assertThat(record.message()).contains("VehicleEnvelope");
|
||||
assertThat(record.payload()).containsExactly(0x01, 0x02);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void processedEnvelopeDoesNotPublishDeadLetter() {
|
||||
CapturingDeadLetterSink deadLetters = new CapturingDeadLetterSink();
|
||||
EnvelopeConsumerProcessor processor = new EnvelopeConsumerProcessor(
|
||||
"vehicle-state", ignored -> EnvelopeIngestResult.processed("event-1", "VIN001"), deadLetters);
|
||||
|
||||
EnvelopeIngestResult result = processor.process(
|
||||
new EnvelopeConsumerRecord("vehicle.realtime", 0, 7L, "VIN001", new byte[]{0x0a}));
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.PROCESSED);
|
||||
assertThat(deadLetters.records).isEmpty();
|
||||
}
|
||||
|
||||
private static final class CapturingDeadLetterSink implements EnvelopeDeadLetterSink {
|
||||
private final List<EnvelopeDeadLetterRecord> records = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void publish(EnvelopeDeadLetterRecord record) {
|
||||
records.add(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RawArchiveKeysTest {
|
||||
|
||||
@Test
|
||||
void keyDateUsesShanghaiBusinessDayInsteadOfUtcDay() {
|
||||
String key = RawArchiveKeys.key(
|
||||
Instant.parse("2026-06-22T16:30:00Z"),
|
||||
ProtocolId.GB32960,
|
||||
"VIN001",
|
||||
"event-1");
|
||||
|
||||
assertThat(key).isEqualTo("2026/06/23/GB32960/VIN001/event-1.bin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyForRawArchiveUsesPrecomputedMetadataKey() {
|
||||
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
|
||||
"event-1",
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
Instant.parse("2026-06-22T16:30:00Z"),
|
||||
Instant.parse("2026-06-22T16:30:00Z"),
|
||||
"trace-1",
|
||||
Map.of(RawArchiveKeys.META_KEY, "precomputed/key.bin"),
|
||||
0x02,
|
||||
0,
|
||||
new byte[]{0x23, 0x23});
|
||||
|
||||
assertThat(RawArchiveKeys.key(raw)).isEqualTo("precomputed/key.bin");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class TelemetryFieldValueTest {
|
||||
|
||||
@Test
|
||||
void createsTypedDoubleFieldWithStableMetadata() {
|
||||
TelemetryFieldValue field = TelemetryFieldValue.doubleValue(
|
||||
"total_mileage_km", 12345.6, "km", "gb32960.vehicle.totalMileageKm");
|
||||
|
||||
assertThat(field.key()).isEqualTo("total_mileage_km");
|
||||
assertThat(field.valueType()).isEqualTo(TelemetryFieldValue.ValueType.DOUBLE);
|
||||
assertThat(field.value()).isEqualTo("12345.6");
|
||||
assertThat(field.unit()).isEqualTo("km");
|
||||
assertThat(field.quality()).isEqualTo(TelemetryFieldValue.Quality.GOOD);
|
||||
assertThat(field.sourcePath()).isEqualTo("gb32960.vehicle.totalMileageKm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankFieldKey() {
|
||||
assertThatThrownBy(() -> TelemetryFieldValue.stringValue(" ", "STARTED", "", "gb32960.vehicle.state"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksMissingValueExplicitly() {
|
||||
TelemetryFieldValue field = TelemetryFieldValue.missing(
|
||||
"hydrogen_remaining_kg", "kg", "gb32960.vendor.hydrogenRemaining");
|
||||
|
||||
assertThat(field.valueType()).isEqualTo(TelemetryFieldValue.ValueType.STRING);
|
||||
assertThat(field.value()).isEmpty();
|
||||
assertThat(field.quality()).isEqualTo(TelemetryFieldValue.Quality.MISSING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class TelemetrySnapshotTest {
|
||||
|
||||
@Test
|
||||
void indexesFieldsByInternalKey() {
|
||||
TelemetrySnapshot snapshot = new TelemetrySnapshot(
|
||||
"event-1",
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
"REALTIME",
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"archive://raw/event-1.bin",
|
||||
Map.of("protocolVersion", "V2016"),
|
||||
List.of(
|
||||
TelemetryFieldValue.doubleValue("total_mileage_km", 120.5, "km", "gb32960.vehicle.totalMileageKm"),
|
||||
TelemetryFieldValue.booleanValue("hydrogen_leak_detected", true, "", "gb32960.alarm.HYDROGEN_LEAK")
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(snapshot.field("total_mileage_km")).isPresent();
|
||||
assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("120.5");
|
||||
assertThat(snapshot.field("hydrogen_leak_detected").orElseThrow().value()).isEqualTo("true");
|
||||
assertThat(snapshot.field("unknown")).isEmpty();
|
||||
assertThat(snapshot.metadata()).containsEntry("protocolVersion", "V2016");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateFieldKeys() {
|
||||
assertThatThrownBy(() -> new TelemetrySnapshot(
|
||||
"event-1",
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
"REALTIME",
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"",
|
||||
Map.of(),
|
||||
List.of(
|
||||
TelemetryFieldValue.doubleValue("speed_kmh", 1.0, "km/h", "a"),
|
||||
TelemetryFieldValue.doubleValue("speed_kmh", 2.0, "km/h", "b")
|
||||
)
|
||||
))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.lingniu.ingest.api.event;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VehicleEventTelemetrySnapshotMapperTest {
|
||||
|
||||
@Test
|
||||
void mapsRealtimeEventToInternalTelemetryFields() {
|
||||
VehicleEvent.Realtime event = new VehicleEvent.Realtime(
|
||||
"event-1",
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-1",
|
||||
Map.of("protocolVersion", "V2016", "rawArchiveUri", "archive://raw/event-1.bin"),
|
||||
new RealtimePayload(
|
||||
80.5,
|
||||
12345.6,
|
||||
55.0,
|
||||
610.0,
|
||||
-20.5,
|
||||
220.0,
|
||||
100.0,
|
||||
65.0,
|
||||
8.2,
|
||||
35.0,
|
||||
1.1,
|
||||
RealtimePayload.VehicleState.STARTED,
|
||||
RealtimePayload.ChargingState.UNCHARGED,
|
||||
RealtimePayload.RunningMode.FUEL,
|
||||
3,
|
||||
20.0,
|
||||
0.0,
|
||||
113.12,
|
||||
23.45,
|
||||
10.0,
|
||||
180.0,
|
||||
30.0,
|
||||
70.0
|
||||
)
|
||||
);
|
||||
|
||||
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
|
||||
|
||||
assertThat(snapshot.eventId()).isEqualTo("event-1");
|
||||
assertThat(snapshot.rawArchiveUri()).isEqualTo("archive://raw/event-1.bin");
|
||||
assertThat(snapshot.field("speed_kmh").orElseThrow().value()).isEqualTo("80.5");
|
||||
assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("12345.6");
|
||||
assertThat(snapshot.field("vehicle_state").orElseThrow().value()).isEqualTo("STARTED");
|
||||
assertThat(snapshot.field("hydrogen_high_pressure_mpa").orElseThrow().value()).isEqualTo("35.0");
|
||||
assertThat(snapshot.field("longitude").orElseThrow().value()).isEqualTo("113.12");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsHydrogenLeakAlarmToInternalSafetyFields() {
|
||||
VehicleEvent.Alarm event = new VehicleEvent.Alarm(
|
||||
"event-2",
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-2",
|
||||
Map.of("protocolVersion", "V2016"),
|
||||
new AlarmPayload(
|
||||
AlarmPayload.AlarmLevel.CRITICAL,
|
||||
1,
|
||||
"GB32960_ALARM",
|
||||
java.util.List.of("OTH-1"),
|
||||
java.util.Set.of("HYDROGEN_LEAK"),
|
||||
113.12,
|
||||
23.45,
|
||||
AlarmPayload.SafetyCategory.HYDROGEN_LEAK,
|
||||
true,
|
||||
AlarmPayload.HydrogenLeakLevel.CRITICAL,
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
|
||||
|
||||
assertThat(snapshot.eventType()).isEqualTo("ALARM");
|
||||
assertThat(snapshot.field("hydrogen_leak_detected").orElseThrow().value()).isEqualTo("true");
|
||||
assertThat(snapshot.field("hydrogen_leak_level").orElseThrow().value()).isEqualTo("CRITICAL");
|
||||
assertThat(snapshot.field("hydrogen_leak_action_required").orElseThrow().value()).isEqualTo("true");
|
||||
assertThat(snapshot.field("safety_category").orElseThrow().value()).isEqualTo("HYDROGEN_LEAK");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsLocationMileageToInternalTelemetryField() {
|
||||
VehicleEvent.Location event = new VehicleEvent.Location(
|
||||
"event-location-1",
|
||||
"VIN001",
|
||||
ProtocolId.JT808,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-location-1",
|
||||
Map.of("messageId", "0x200"),
|
||||
new LocationPayload(113.12, 23.45, 10.0, 60.0, 90.0, 0, 1, 1234.5)
|
||||
);
|
||||
|
||||
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
|
||||
|
||||
assertThat(snapshot.field("total_mileage_km").orElseThrow().value()).isEqualTo("1234.5");
|
||||
assertThat(snapshot.field("total_mileage_km").orElseThrow().sourcePath())
|
||||
.isEqualTo("event.location.totalMileageKm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mediaMetaUsesArchiveRefAsQueryableRawArchiveUriFallback() {
|
||||
VehicleEvent.MediaMeta event = new VehicleEvent.MediaMeta(
|
||||
"media-1",
|
||||
"VIN001",
|
||||
ProtocolId.JT1078,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-4",
|
||||
Map.of("channel", "1"),
|
||||
"media-001",
|
||||
"jt1078-video-h264-channel-1",
|
||||
1024,
|
||||
"file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp"
|
||||
);
|
||||
|
||||
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
|
||||
|
||||
assertThat(snapshot.eventType()).isEqualTo("MEDIA_META");
|
||||
assertThat(snapshot.rawArchiveUri())
|
||||
.isEqualTo("file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp");
|
||||
assertThat(snapshot.field("media_archive_ref").orElseThrow().value())
|
||||
.isEqualTo("file:///archive/jt1078/2026/06/22/VIN001/media-001.rtp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesRawArchiveUriFromRawArchiveKeyMetadata() {
|
||||
VehicleEvent.Heartbeat event = new VehicleEvent.Heartbeat(
|
||||
"heartbeat-1",
|
||||
"VIN001",
|
||||
ProtocolId.JT808,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-5",
|
||||
Map.of("rawArchiveKey", "2026/06/22/JT808/VIN001/raw-1.bin")
|
||||
);
|
||||
|
||||
TelemetrySnapshot snapshot = VehicleEventTelemetrySnapshotMapper.toSnapshot(event).orElseThrow();
|
||||
|
||||
assertThat(snapshot.rawArchiveUri())
|
||||
.isEqualTo("archive://2026/06/22/JT808/VIN001/raw-1.bin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsRawArchiveBecauseItIsStoredByArchiveSink() {
|
||||
VehicleEvent.RawArchive raw = new VehicleEvent.RawArchive(
|
||||
"raw-1",
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
Instant.parse("2026-06-22T08:00:00Z"),
|
||||
Instant.parse("2026-06-22T08:00:01Z"),
|
||||
"trace-3",
|
||||
Map.of(),
|
||||
0x02,
|
||||
0,
|
||||
new byte[]{0x23, 0x23}
|
||||
);
|
||||
|
||||
assertThat(VehicleEventTelemetrySnapshotMapper.toSnapshot(raw)).isEmpty();
|
||||
}
|
||||
}
|
||||
30
modules/core/ingest-codec-common/pom.xml
Normal file
30
modules/core/ingest-codec-common/pom.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>ingest-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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
62
modules/core/ingest-core/pom.xml
Normal file
62
modules/core/ingest-core/pom.xml
Normal 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>
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 Adapter(Netty / MQTT / PushClient)都调用 {@link #dispatch(RawFrame)},
|
||||
* 协议差异在这里被彻底抹平。
|
||||
*
|
||||
* <p>对于带 {@code @AsyncBatch} 的 Handler,Dispatcher 不直接反射调用,而是把消息交给
|
||||
* {@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.core.config.IngestCoreAutoConfiguration
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
32
modules/core/observability/pom.xml
Normal file
32
modules/core/observability/pom.xml
Normal 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>
|
||||
@@ -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; // 尽早介入,晚于 Tracing(order=0)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.observability.ObservabilityAutoConfiguration
|
||||
56
modules/core/session-core/pom.xml
Normal file
56
modules/core/session-core/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.session.config.SessionCoreAutoConfiguration
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
47
modules/core/vehicle-identity/pom.xml
Normal file
47
modules/core/vehicle-identity/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public interface VehicleIdentityRegistry {
|
||||
|
||||
void bind(VehicleIdentityBinding binding);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public interface VehicleIdentityResolver {
|
||||
|
||||
VehicleIdentity resolve(VehicleIdentityLookup lookup);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration
|
||||
@@ -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\"");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user