docs: add detailed 32960 pipeline comments

This commit is contained in:
kkfluous
2026-06-23 13:17:37 +08:00
parent ba68ffe061
commit a096e4ce0e
125 changed files with 493 additions and 14 deletions

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -14,14 +14,20 @@ import java.lang.annotation.Target;
* <li>JT/T 808{@code command} = 消息 ID0x0100 / 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 "";
}

View File

@@ -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)

View File

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

View File

@@ -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()

View File

@@ -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;
}

View File

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

View File

@@ -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同上

View File

@@ -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;
}

View File

@@ -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,

View File

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

View File

@@ -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;

View File

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

View File

@@ -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; }

View File

@@ -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);

View File

@@ -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(

View File

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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -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);

View File

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

View File

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