docs: add detailed 32960 pipeline comments
This commit is contained in:
@@ -8,15 +8,23 @@ import java.lang.annotation.Target;
|
||||
/**
|
||||
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
|
||||
*
|
||||
* <p>处理方法签名需为 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>}。
|
||||
* <p>处理方法签名需接收 {@code List<T>},返回 {@code List<VehicleEvent>} 或单个
|
||||
* {@code VehicleEvent}。Dispatcher 不直接调用目标方法,而是交给
|
||||
* {@code AsyncBatchExecutor} 按 size/waitMs 聚合后调用。
|
||||
*
|
||||
* <p>注意:当调用方需要给每条事件补不同的 rawArchiveUri 等逐条元数据时,执行器会按单条 flush,
|
||||
* 以保证事件与原始帧一一对应。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface AsyncBatch {
|
||||
|
||||
/** 达到该批量大小后立即 flush。 */
|
||||
int size() default 1000;
|
||||
|
||||
/** 第一条消息入队后最多等待毫秒数,避免低流量车辆长期不出批。 */
|
||||
long waitMs() default 500;
|
||||
|
||||
/** 每个 Handler 方法对应的批处理 worker 数。 */
|
||||
int poolSize() default 2;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.lang.annotation.Target;
|
||||
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
|
||||
*
|
||||
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
|
||||
*
|
||||
* <p>当前内置去重主要基于 RawFrame sourceMeta/raw bytes;该注解保留给更细粒度 Handler 级去重。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
|
||||
@@ -14,14 +14,20 @@ import java.lang.annotation.Target;
|
||||
* <li>JT/T 808:{@code command} = 消息 ID(0x0100 / 0x0200 / ...);{@code infoType} 留空
|
||||
* <li>MQTT:{@code command} 可为 topic hash 或忽略
|
||||
* </ul>
|
||||
*
|
||||
* <p>同一个方法可以声明多个 command/infoType。Dispatcher 会按协议、command、infoType 精确路由;
|
||||
* 32960 实时上报解析后通常由 0x02/0x03 主命令加信息体 ID 决定落到哪个 Handler。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface MessageMapping {
|
||||
|
||||
/** 协议主命令或消息 ID,空数组表示该维度不限制。 */
|
||||
int[] command() default {};
|
||||
|
||||
/** 协议子类型,GB32960 中对应信息体 ID;空数组表示该维度不限制。 */
|
||||
int[] infoType() default {};
|
||||
|
||||
/** 仅用于日志/Swagger/排障展示,不参与路由。 */
|
||||
String desc() default "";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点。
|
||||
* 单 VIN 速率限制声明。
|
||||
*
|
||||
* <p>当前内置限流器在 Pipeline before 阶段按 VIN 中止处理,超限帧不会进入 Handler 和下游存储。
|
||||
* 如果需要把超限帧写入 DLQ,应在拦截器或入口层显式增加对应 sink。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
|
||||
@@ -33,6 +33,8 @@ public final class EnvelopeConsumerProcessor {
|
||||
throw new IllegalArgumentException("record must not be null");
|
||||
}
|
||||
EnvelopeIngestResult result = ingestor.tryIngest(record.payload());
|
||||
// 派生消费者不在这里抛出业务异常给 Kafka worker;坏消息统一进入 DLQ,
|
||||
// worker 看到 process 正常返回后才能提交 offset,避免同一坏消息无限阻塞消费组。
|
||||
if (DEAD_LETTER_STATUSES.contains(result.status())) {
|
||||
deadLetterSink.publish(toDeadLetter(record, result));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared raw-archive key conventions used by Dispatcher, archive sink, and query services.
|
||||
*
|
||||
* <p>32960 生产链路里,RAW .bin 文件本体按 {@code 日期/协议/VIN/eventId.bin} 落在 archive 根目录,
|
||||
* DuckDB 只保存 {@code archive://...} 引用和查询索引。这个类集中维护 key 规则,避免接收端、落盘端、
|
||||
* snapshot 查询端对目录分区的理解不一致。</p>
|
||||
*/
|
||||
public final class RawArchiveKeys {
|
||||
|
||||
@@ -23,6 +27,7 @@ public final class RawArchiveKeys {
|
||||
}
|
||||
|
||||
public static String key(Instant ingestTime, ProtocolId source, String vin, String eventId) {
|
||||
// 分区日期使用 ingestTime 的东八区自然日,和磁盘目录保持一致;eventTime 只代表车端上报时间。
|
||||
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()
|
||||
|
||||
@@ -5,6 +5,10 @@ package com.lingniu.ingest.api.event;
|
||||
*
|
||||
* <p>Protocol mappers use stable internal field keys so Kafka, Parquet, Redis,
|
||||
* and statistics do not depend on protocol-specific names.
|
||||
*
|
||||
* <p>{@code value} 统一用字符串承载,真实类型由 {@code valueType} 表达。这样 Kafka
|
||||
* protobuf、CSV 导出、DuckDB JSON 字段和前端展示可以共用同一份字段结构;数值精度/格式化
|
||||
* 由查询层或前端按 {@code valueType + unit} 决定。
|
||||
*/
|
||||
public record TelemetryFieldValue(
|
||||
String key,
|
||||
@@ -24,6 +28,7 @@ public record TelemetryFieldValue(
|
||||
}
|
||||
value = value == null ? "" : value;
|
||||
unit = unit == null ? "" : unit;
|
||||
// 默认 GOOD,只有解析器明确知道异常/无效/缺失时才降级,避免调用方到处补质量字段。
|
||||
quality = quality == null ? Quality.GOOD : quality;
|
||||
sourcePath = sourcePath == null ? "" : sourcePath;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ public record TelemetrySnapshot(
|
||||
rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri;
|
||||
metadata = Map.copyOf(metadata == null ? Map.of() : metadata);
|
||||
fields = List.copyOf(fields == null ? List.of() : fields);
|
||||
// snapshot/fields 查询按 key 投影和导出,重复 key 会导致前端列和 CSV 表头不稳定,构造期直接拒绝。
|
||||
validateUniqueKeys(fields);
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ public record TelemetrySnapshot(
|
||||
if (key == null || key.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// fields 保持插入顺序,单字段查询走线性查找;批量查询应使用 fieldsByKey 建索引。
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
if (field.key().equals(key)) {
|
||||
return Optional.of(field);
|
||||
@@ -70,6 +72,7 @@ public record TelemetrySnapshot(
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
index.put(field.key(), field);
|
||||
}
|
||||
// LinkedHashMap 保留原始字段顺序,CSV 导出和 Swagger 示例能稳定复现解析器顺序。
|
||||
return Map.copyOf(index);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,11 +141,11 @@ public sealed interface VehicleEvent
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/**
|
||||
* 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节交
|
||||
* {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型
|
||||
* (见 {@code KafkaEventSink.accepts})。
|
||||
* 原始报文归档事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节和归档索引信息。
|
||||
* 32960 历史全字段查询依赖它对应的 {@code archive://...} 文件能够被回读。
|
||||
*
|
||||
* <p>key 组装建议:{@code yyyy/MM/dd/<source>/<vin>/<eventId>.bin},具体由 sink 实现决定。
|
||||
* <p>当前 {@code KafkaEventSink} 默认不发送本类型,{@code EventFileStoreSink} 只保存索引不保存
|
||||
* bytes;因此启用新 raw 落盘/转发实现时,必须同时保证 {@link RawArchiveKeys} 的 key 规则一致。
|
||||
*
|
||||
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
|
||||
* @param infoType 协议子类型(可为 0),同上
|
||||
|
||||
@@ -5,6 +5,9 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享,不需要同步。
|
||||
*
|
||||
* <p>attributes 用于在入口、拦截器、Dispatcher 之间传递轻量元数据,例如 traceId、归档 URI、
|
||||
* 鉴权结果等;不要放大对象或长期缓存,避免高频上报时放大内存占用。
|
||||
*/
|
||||
public final class IngestContext {
|
||||
|
||||
@@ -32,6 +35,7 @@ public final class IngestContext {
|
||||
}
|
||||
|
||||
public void abort(String reason) {
|
||||
// reason 会进入日志/诊断信息,保持短小、机器可读,方便定位是去重、限流还是鉴权失败。
|
||||
this.aborted = true;
|
||||
this.abortReason = reason;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.util.Map;
|
||||
* @param rawBytes 原始字节,用于冷存与排障(可能为 null,按配置开关)
|
||||
* @param sourceMeta 来源元数据:peer ip、端口、session id、topic 等
|
||||
* @param receivedAt 服务器接收时刻
|
||||
*
|
||||
* <p>RawFrame 是所有协议进入统一 Pipeline 的边界对象。32960 TCP 入口会把 VIN、seq、
|
||||
* rawArchiveUri 等关键索引放进 sourceMeta,后续去重、快照和历史查询都依赖这些字段保持稳定。
|
||||
*/
|
||||
public record RawFrame(
|
||||
ProtocolId protocolId,
|
||||
|
||||
@@ -135,6 +135,8 @@ public final class AsyncBatchExecutor implements AutoCloseable {
|
||||
|
||||
private void flush(List<BatchItem> buf) {
|
||||
if (requiresPerItemTransform(buf)) {
|
||||
// rawArchiveUri/rawSize 等元数据是每帧唯一的。为了不把第一条帧的元数据错误复制给整批事件,
|
||||
// 一旦存在逐条 transformer,就按单条调用 Handler,再逐条发布。
|
||||
for (BatchItem item : buf) {
|
||||
flushTransformedItem(item);
|
||||
}
|
||||
@@ -161,6 +163,7 @@ public final class AsyncBatchExecutor implements AutoCloseable {
|
||||
private List<VehicleEvent> invoke(List<Object> messages) {
|
||||
List<VehicleEvent> events;
|
||||
try {
|
||||
// Handler 仍然只暴露一个批量签名;是否批满或超时由 Batcher 统一处理。
|
||||
Object result = def.method().invoke(def.bean(), messages);
|
||||
events = switch (result) {
|
||||
case null -> List.of();
|
||||
|
||||
@@ -43,6 +43,7 @@ public final class DisruptorEventBus implements AutoCloseable {
|
||||
EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
|
||||
.map(this::toHandler)
|
||||
.toArray(EventHandler[]::new);
|
||||
// 每个 sink 一个独立 handler,事件按扇出模式同时写 Kafka、event-file-store 等目标。
|
||||
disruptor.handleEventsWith(handlers);
|
||||
disruptor.start();
|
||||
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
|
||||
@@ -66,6 +67,7 @@ public final class DisruptorEventBus implements AutoCloseable {
|
||||
if (e == null || !sink.accepts(e)) return;
|
||||
try {
|
||||
sink.publish(e).exceptionally(ex -> {
|
||||
// sink 异步失败只计数和打日志,不反向阻塞入站连接;生产侧靠 sink 自身重试/DLQ 保证可观测。
|
||||
failed.incrementAndGet();
|
||||
log.warn("sink {} publish failed", sink.name(), ex);
|
||||
return null;
|
||||
|
||||
@@ -21,6 +21,10 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
|
||||
*
|
||||
* <p>生产链路顺序:入口收到 {@code RawFrame} → {@link InterceptorChain} 做准入 →
|
||||
* {@link Dispatcher} 定位协议 Handler → Handler 产出 {@code VehicleEvent} →
|
||||
* {@link DisruptorEventBus} 扇出到 Archive、DuckDB、Kafka 等 {@code EventSink}。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(IngestCoreProperties.class)
|
||||
@@ -46,6 +50,7 @@ public class IngestCoreAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
|
||||
// 去重尽量放在限流前,重复包不应消耗后续每 VIN QPS 配额。
|
||||
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ public class IngestCoreProperties {
|
||||
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
|
||||
|
||||
public static class Disruptor {
|
||||
/** 事件总线 ring buffer 大小;生产环境应保持 2 的幂,避免 Disruptor 初始化失败。 */
|
||||
private int ringBufferSize = 131072;
|
||||
/** waiting 策略直接影响延迟和 CPU 占用,默认 yielding 偏低延迟。 */
|
||||
private String waitStrategy = "yielding";
|
||||
private String producerType = "multi";
|
||||
|
||||
@@ -33,7 +35,9 @@ public class IngestCoreProperties {
|
||||
|
||||
public static class Dedup {
|
||||
private boolean enabled = true;
|
||||
/** 单实例缓存的幂等键数量上限,按 VIN 数和重发窗口估算。 */
|
||||
private int cacheSize = 200000;
|
||||
/** 幂等键保留窗口;过短会放过重发包,过长会增加内存压力。 */
|
||||
private long ttlSeconds = 600;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
@@ -45,7 +49,9 @@ public class IngestCoreProperties {
|
||||
}
|
||||
|
||||
public static class RateLimit {
|
||||
/** 每 VIN 每秒进入 Handler 的最大帧数,默认按 32960 高频实时上报预留。 */
|
||||
private int perVinQps = 50;
|
||||
/** VIN limiter 缓存上限;覆盖活跃车辆数即可,淘汰后会重新创建 limiter。 */
|
||||
private int maxVins = 100000;
|
||||
|
||||
public int getPerVinQps() { return perVinQps; }
|
||||
|
||||
@@ -43,6 +43,7 @@ public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
|
||||
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
|
||||
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
|
||||
|
||||
// 这些注解只声明策略,实际限流、幂等、异步批处理由 Dispatcher/Interceptor 层解释执行。
|
||||
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
|
||||
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
|
||||
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);
|
||||
|
||||
@@ -122,6 +122,7 @@ public final class Dispatcher {
|
||||
}
|
||||
|
||||
private static String nextRawArchiveEventId(Instant ingestTime) {
|
||||
// 文件名需要可排序且单进程内唯一:毫秒时间放大到微秒量级,再用 AtomicLong 递增兜底。
|
||||
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);
|
||||
@@ -141,6 +142,7 @@ public final class Dispatcher {
|
||||
if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) {
|
||||
return event;
|
||||
}
|
||||
// 派生事件携带 rawArchiveUri,便于后续服务从 Kafka 事件回查原始报文。
|
||||
Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key());
|
||||
return switch (event) {
|
||||
case VehicleEvent.Realtime e -> new VehicleEvent.Realtime(
|
||||
|
||||
@@ -20,6 +20,7 @@ public class HandlerInvoker {
|
||||
return switch (result) {
|
||||
case null -> Collections.emptyList();
|
||||
case VehicleEvent e -> List.of(e);
|
||||
// Handler 可以一次产出多个领域事件,例如 GB32960 0x02 同时产出 Realtime/Location/Alarm。
|
||||
case List<?> list -> (List<VehicleEvent>) list;
|
||||
default -> throw new IllegalStateException(
|
||||
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());
|
||||
|
||||
@@ -11,6 +11,11 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
|
||||
*
|
||||
* <p>{@link #before(RawFrame, IngestContext)} 是接入链路的准入闸门:任何拦截器返回
|
||||
* {@code false} 或调用 {@link IngestContext#abort(String)} 都会停止后续协议解析/Handler 调用。
|
||||
* {@link #after(VehicleEvent, IngestContext)} 与 {@link #onError(Throwable, IngestContext)}
|
||||
* 则保持广播语义,让指标、审计等横切逻辑都能观察到同一条处理结果。
|
||||
*/
|
||||
public final class InterceptorChain {
|
||||
|
||||
@@ -24,6 +29,7 @@ public final class InterceptorChain {
|
||||
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
for (IngestInterceptor i : interceptors) {
|
||||
// before 阶段短路是有意的:去重、限流失败后不应继续消耗解析和存储资源。
|
||||
if (!i.before(frame, ctx) || ctx.aborted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,12 @@ import java.time.Duration;
|
||||
/**
|
||||
* 基于 Caffeine 的本地幂等去重。
|
||||
*
|
||||
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性,
|
||||
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。
|
||||
* <p>Key 构造:{@code protocolId + vin + command + seq/fingerprint}。如果上游已经在
|
||||
* {@link RawFrame#sourceMeta()} 里带了 {@code seq},优先用真实流水号;否则退化为 raw bytes
|
||||
* fingerprint,避免设备重连或 TCP 重发导致同一帧重复进入 DuckDB/Archive。
|
||||
*
|
||||
* <p>当前缓存是进程内的,只能保证单实例去重。32960 若做多副本水平扩容,需要把这个位置替换成
|
||||
* Redis/集中式幂等键,否则不同实例仍可能各自接收一次相同原始包。
|
||||
*/
|
||||
public class DedupInterceptor implements IngestInterceptor, Ordered {
|
||||
|
||||
@@ -32,6 +36,7 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
|
||||
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;
|
||||
|
||||
@@ -13,6 +13,9 @@ import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter,由 Caffeine 按 LRU 管理。
|
||||
*
|
||||
* <p>这里限的是进入业务 Handler 前的原始帧,不区分命令类型;32960 生产侧如需允许登入/登出穿透,
|
||||
* 应在此处扩展白名单或改为按命令配置,而不是在存储层丢弃。
|
||||
*/
|
||||
public class RateLimitInterceptor implements IngestInterceptor, Ordered {
|
||||
|
||||
@@ -34,6 +37,7 @@ public class RateLimitInterceptor implements IngestInterceptor, Ordered {
|
||||
@Override
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
|
||||
// VIN 粒度隔离,避免某一辆车的高频/异常上报挤占其他车辆处理额度。
|
||||
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
|
||||
if (!rl.acquirePermission()) {
|
||||
ctx.abort("rate-limited:" + vin);
|
||||
|
||||
@@ -35,6 +35,7 @@ public final class InMemorySessionStore implements SessionStore {
|
||||
@Override
|
||||
public void put(DeviceSession session) {
|
||||
byId.put(session.sessionId(), session);
|
||||
// 三索引用同一个 sessionId 汇聚;HTTP 下行可以拿 sessionId/VIN/phone 任意一种查在线连接。
|
||||
if (session.vin() != null) vinToId.put(session.vin(), session.sessionId());
|
||||
if (session.phone() != null) phoneToId.put(session.phone(), session.sessionId());
|
||||
}
|
||||
@@ -61,6 +62,7 @@ public final class InMemorySessionStore implements SessionStore {
|
||||
DeviceSession old = byId.getIfPresent(sessionId);
|
||||
if (old == null) return Optional.empty();
|
||||
DeviceSession next = updater.apply(old);
|
||||
// update 复用 put,保证 VIN/phone 二级索引和主会话对象一起刷新。
|
||||
put(next);
|
||||
return Optional.of(next);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
|
||||
@Override
|
||||
public void put(DeviceSession session) {
|
||||
// 同一个 sessionId 重新写入时先清理旧 VIN/phone 索引,避免旧外部号继续指向新会话。
|
||||
findBySessionId(session.sessionId()).ifPresent(old -> deleteIndexes(old));
|
||||
redis.opsForHash().putAll(sessionKey(session.sessionId()), toHash(session));
|
||||
redis.expire(sessionKey(session.sessionId()), ttl);
|
||||
@@ -67,6 +68,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
String sessionId = redis.opsForValue().get(vinIndexKey(vin));
|
||||
Optional<DeviceSession> session = findBySessionId(sessionId);
|
||||
if (session.isEmpty() && hasText(sessionId)) {
|
||||
// 二级索引可能比主 hash 晚过期;发现悬挂索引时立即清理。
|
||||
redis.delete(vinIndexKey(vin));
|
||||
}
|
||||
return session;
|
||||
@@ -80,6 +82,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
String sessionId = redis.opsForValue().get(phoneIndexKey(phone));
|
||||
Optional<DeviceSession> session = findBySessionId(sessionId);
|
||||
if (session.isEmpty() && hasText(sessionId)) {
|
||||
// 二级索引可能比主 hash 晚过期;发现悬挂索引时立即清理。
|
||||
redis.delete(phoneIndexKey(phone));
|
||||
}
|
||||
return session;
|
||||
@@ -124,6 +127,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
|
||||
private static Map<String, String> toHash(DeviceSession session) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
// attr.* 保留协议侧附加字段,后续扩展不需要改 Redis schema。
|
||||
put(values, "sessionId", session.sessionId());
|
||||
put(values, "protocol", session.protocol() == null ? null : session.protocol().name());
|
||||
put(values, "vin", session.vin());
|
||||
|
||||
@@ -23,6 +23,7 @@ public class SessionCoreAutoConfiguration {
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis")
|
||||
@ConditionalOnMissingBean
|
||||
public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) {
|
||||
// Redis 只保存会话元数据索引;真实 Netty Channel 仍在各协议进程本地内存中。
|
||||
return new RedisSessionStore(redis, properties.getTtl());
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ public class SessionCoreAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public CommandDispatcher commandDispatcher() {
|
||||
// 没有协议模块提供真实下行实现时使用 Noop,避免仅接收/查询部署因为缺 Bean 启动失败。
|
||||
return new NoopCommandDispatcher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public final class FileVehicleIdentityService implements VehicleIdentityResolver
|
||||
Files.createDirectories(path.getParent());
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) {
|
||||
// append-only 允许运行时新增绑定;重启时按文件顺序 replay,后写覆盖内存索引中的旧映射。
|
||||
writer.write(mapper.writeValueAsString(BindingLine.from(binding)));
|
||||
writer.newLine();
|
||||
}
|
||||
@@ -72,6 +73,7 @@ public final class FileVehicleIdentityService implements VehicleIdentityResolver
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -31,8 +31,10 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
|
||||
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
|
||||
}
|
||||
if (!lookup.vin().isBlank()) {
|
||||
// 协议报文已经携带可信 VIN 时直接采用;绑定表主要服务 JT808/MQTT/信达这类外部 ID。
|
||||
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
|
||||
}
|
||||
// 绑定优先级:phone > deviceId > plate。顺序要稳定,否则同一车辆多外部号会产生不同内部 VIN。
|
||||
VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
|
||||
if (phone != null) return phone;
|
||||
|
||||
@@ -43,6 +45,7 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
|
||||
if (plate != null) return plate;
|
||||
|
||||
if (!lookup.deviceId().isBlank()) {
|
||||
// fallback 不是已确认绑定,只用于不中断接收和冷存;metadata.identityResolved=false 会保留这个事实。
|
||||
return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
|
||||
}
|
||||
if (!lookup.phone().isBlank()) {
|
||||
@@ -70,6 +73,7 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
|
||||
|
||||
private static String key(ProtocolId protocol, String value) {
|
||||
String p = protocol == null ? "UNKNOWN" : protocol.name();
|
||||
// 绑定 key 带协议前缀,避免 JT808 phone 与 MQTT deviceId 数值相同时互相覆盖。
|
||||
return p + ":" + value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public class VehicleIdentityAutoConfiguration {
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file")
|
||||
public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
// 文件实现同时提供 resolver 和 registry,协议层既能查绑定,也能在注册/鉴权时补写绑定。
|
||||
return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user