diff --git a/modules/apps/bootstrap-all/src/main/resources/application.yml b/modules/apps/bootstrap-all/src/main/resources/application.yml index 7109ef7c..dc8b5ecd 100644 --- a/modules/apps/bootstrap-all/src/main/resources/application.yml +++ b/modules/apps/bootstrap-all/src/main/resources/application.yml @@ -202,6 +202,8 @@ lingniu: path: ./archive/ event-file-store: enabled: ${EVENT_FILE_STORE_ENABLED:true} + # 32960 历史索引库根路径。这里保存 Parquet 分区和 DuckDB sidecar 索引; + # 全字段查询仍会按 rawArchiveUri 回读 archive 根目录下的 .bin 原始包。 path: ${EVENT_FILE_STORE_PATH:./target/event-store/} zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai} batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500} diff --git a/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java index e2449f30..7a12d56b 100644 --- a/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java +++ b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/CommandGatewayAutoConfiguration.java @@ -11,4 +11,5 @@ import org.springframework.web.servlet.DispatcherServlet; @ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true) @ComponentScan(basePackageClasses = TerminalCommandController.class) public class CommandGatewayAutoConfiguration { + // 只扫描 REST 下行命令网关;不参与 32960 接收、RAW 冷存或历史查询链路。 } diff --git a/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java index 1aee3351..5215c4d1 100644 --- a/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java +++ b/modules/apps/command-gateway/src/main/java/com/lingniu/ingest/gateway/TerminalCommandController.java @@ -205,12 +205,14 @@ public class TerminalCommandController { // ===== helpers ===== private Optional resolveSession(String clientId) { + // 运维侧常拿到 VIN、手机号或 sessionId 中任意一个;这里按三索引兜底解析到在线连接。 return sessions.findBySessionId(clientId) .or(() -> sessions.findByPhone(clientId)) .or(() -> sessions.findByVin(clientId)); } private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { + // 需要终端应答的命令走 request-response,超时时间由网关统一兜底,避免 HTTP 线程无限等待。 CompletableFuture future = dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT); try { @@ -229,6 +231,7 @@ public class TerminalCommandController { private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { try { + // ACK 类命令不等待终端回包;只确认写入在线 session 对应的 channel。 dispatcher.notify(clientId, cmd).join(); return CommandResult.ok(Map.of("op", op)); } catch (Exception e) { diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java index 6f28febf..6cea65df 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/AsyncBatch.java @@ -8,15 +8,23 @@ import java.lang.annotation.Target; /** * 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。 * - *

处理方法签名需为 {@code void foo(List list)} 或返回 {@code List}。 + *

处理方法签名需接收 {@code List},返回 {@code List} 或单个 + * {@code VehicleEvent}。Dispatcher 不直接调用目标方法,而是交给 + * {@code AsyncBatchExecutor} 按 size/waitMs 聚合后调用。 + * + *

注意:当调用方需要给每条事件补不同的 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; } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java index d8a1d36c..24badc4d 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/IdempotentKey.java @@ -9,6 +9,8 @@ import java.lang.annotation.Target; * 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。 * *

示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")} + * + *

当前内置去重主要基于 RawFrame sourceMeta/raw bytes;该注解保留给更细粒度 Handler 级去重。 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java index ed24a145..aa53a873 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/MessageMapping.java @@ -14,14 +14,20 @@ import java.lang.annotation.Target; *

  • JT/T 808:{@code command} = 消息 ID(0x0100 / 0x0200 / ...);{@code infoType} 留空 *
  • MQTT:{@code command} 可为 topic hash 或忽略 * + * + *

    同一个方法可以声明多个 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 ""; } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java index 73c47a25..77855f91 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/annotation/RateLimited.java @@ -6,7 +6,10 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * 单 VIN 速率限制。超限消息直接进 DLQ 并打点。 + * 单 VIN 速率限制声明。 + * + *

    当前内置限流器在 Pipeline before 阶段按 VIN 中止处理,超限帧不会进入 Handler 和下游存储。 + * 如果需要把超限帧写入 DLQ,应在拦截器或入口层显式增加对应 sink。 */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java index 95db7358..68b4041b 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/consumer/EnvelopeConsumerProcessor.java @@ -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)); } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java index ae88ffb4..bcc36a0e 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/RawArchiveKeys.java @@ -9,6 +9,10 @@ import java.util.Map; /** * Shared raw-archive key conventions used by Dispatcher, archive sink, and query services. + * + *

    32960 生产链路里,RAW .bin 文件本体按 {@code 日期/协议/VIN/eventId.bin} 落在 archive 根目录, + * DuckDB 只保存 {@code archive://...} 引用和查询索引。这个类集中维护 key 规则,避免接收端、落盘端、 + * snapshot 查询端对目录分区的理解不一致。

    */ 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() diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java index a88ee6aa..b6fec6b5 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetryFieldValue.java @@ -5,6 +5,10 @@ package com.lingniu.ingest.api.event; * *

    Protocol mappers use stable internal field keys so Kafka, Parquet, Redis, * and statistics do not depend on protocol-specific names. + * + *

    {@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; } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java index 0739620f..555b5227 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/TelemetrySnapshot.java @@ -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); } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java index 58fb23a3..c3c425ef 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/event/VehicleEvent.java @@ -141,11 +141,11 @@ public sealed interface VehicleEvent ) implements VehicleEvent {} /** - * 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节交 - * {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型 - * (见 {@code KafkaEventSink.accepts})。 + * 原始报文归档事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节和归档索引信息。 + * 32960 历史全字段查询依赖它对应的 {@code archive://...} 文件能够被回读。 * - *

    key 组装建议:{@code yyyy/MM/dd///.bin},具体由 sink 实现决定。 + *

    当前 {@code KafkaEventSink} 默认不发送本类型,{@code EventFileStoreSink} 只保存索引不保存 + * bytes;因此启用新 raw 落盘/转发实现时,必须同时保证 {@link RawArchiveKeys} 的 key 规则一致。 * * @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档 * @param infoType 协议子类型(可为 0),同上 diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java index 6cb22f0a..6b54d829 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/IngestContext.java @@ -5,6 +5,9 @@ import java.util.Map; /** * 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享,不需要同步。 + * + *

    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; } diff --git a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java index 081621ca..d292ce19 100644 --- a/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java +++ b/modules/core/ingest-api/src/main/java/com/lingniu/ingest/api/pipeline/RawFrame.java @@ -15,6 +15,9 @@ import java.util.Map; * @param rawBytes 原始字节,用于冷存与排障(可能为 null,按配置开关) * @param sourceMeta 来源元数据:peer ip、端口、session id、topic 等 * @param receivedAt 服务器接收时刻 + * + *

    RawFrame 是所有协议进入统一 Pipeline 的边界对象。32960 TCP 入口会把 VIN、seq、 + * rawArchiveUri 等关键索引放进 sourceMeta,后续去重、快照和历史查询都依赖这些字段保持稳定。 */ public record RawFrame( ProtocolId protocolId, diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java index 6e2839f6..0399e506 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/AsyncBatchExecutor.java @@ -135,6 +135,8 @@ public final class AsyncBatchExecutor implements AutoCloseable { private void flush(List 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 invoke(List messages) { List events; try { + // Handler 仍然只暴露一个批量签名;是否批满或超时由 Batcher 统一处理。 Object result = def.method().invoke(def.bean(), messages); events = switch (result) { case null -> List.of(); diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java index 54a34c51..6e567e14 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/concurrency/DisruptorEventBus.java @@ -43,6 +43,7 @@ public final class DisruptorEventBus implements AutoCloseable { EventHandler[] 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; diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java index 43076a91..3249e85f 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreAutoConfiguration.java @@ -21,6 +21,10 @@ import java.util.List; /** * ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。 + * + *

    生产链路顺序:入口收到 {@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()); } diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java index 0d3e11be..dfec3e99 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/config/IngestCoreProperties.java @@ -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; } diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java index c6e1a0f8..f4df2bbf 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/AnnotationHandlerBeanPostProcessor.java @@ -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); diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java index 754fcf3f..32ab96c2 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/Dispatcher.java @@ -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 metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key()); return switch (event) { case VehicleEvent.Realtime e -> new VehicleEvent.Realtime( diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java index 9587828e..f6ec962c 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/dispatcher/HandlerInvoker.java @@ -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) list; default -> throw new IllegalStateException( "Handler return type must be VehicleEvent or List: " + def.method()); diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java index 37d468d2..f3109787 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/InterceptorChain.java @@ -11,6 +11,11 @@ import java.util.List; /** * 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。 + * + *

    {@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; } diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java index 7816fba9..4148a7fe 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/DedupInterceptor.java @@ -13,8 +13,12 @@ import java.time.Duration; /** * 基于 Caffeine 的本地幂等去重。 * - *

    Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性, - * 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。 + *

    Key 构造:{@code protocolId + vin + command + seq/fingerprint}。如果上游已经在 + * {@link RawFrame#sourceMeta()} 里带了 {@code seq},优先用真实流水号;否则退化为 raw bytes + * fingerprint,避免设备重连或 TCP 重发导致同一帧重复进入 DuckDB/Archive。 + * + *

    当前缓存是进程内的,只能保证单实例去重。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; diff --git a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java index 70945c79..12efe9b4 100644 --- a/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java +++ b/modules/core/ingest-core/src/main/java/com/lingniu/ingest/core/pipeline/builtin/RateLimitInterceptor.java @@ -13,6 +13,9 @@ import java.time.Duration; /** * 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter,由 Caffeine 按 LRU 管理。 + * + *

    这里限的是进入业务 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); diff --git a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java index 3b59d0c2..1b022b9d 100644 --- a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java +++ b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/InMemorySessionStore.java @@ -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); } diff --git a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java index 7e222c15..90a86457 100644 --- a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java +++ b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/RedisSessionStore.java @@ -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 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 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 toHash(DeviceSession session) { Map 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()); diff --git a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java index 63f32893..71224cc2 100644 --- a/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java +++ b/modules/core/session-core/src/main/java/com/lingniu/ingest/session/config/SessionCoreAutoConfiguration.java @@ -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(); } } diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java index f1805e41..119d5f1b 100644 --- a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/FileVehicleIdentityService.java @@ -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); diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java index 80bec208..fa71a2da 100644 --- a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/InMemoryVehicleIdentityService.java @@ -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); } } diff --git a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java index dc6b0c45..94ab994c 100644 --- a/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java +++ b/modules/core/vehicle-identity/src/main/java/com/lingniu/ingest/identity/config/VehicleIdentityAutoConfiguration.java @@ -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); } diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java index 50940d46..282a911b 100644 --- a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/client/MqttEndpointManager.java @@ -64,6 +64,7 @@ public final class MqttEndpointManager implements AutoCloseable { public void start() { for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) { try { + // 多 endpoint 互不影响:单个连接初始化失败会派发 operationalError,但不会阻止其他 endpoint。 Mqtt3AsyncClient client = buildClient(ep); clients.add(client); connectAndSubscribe(client, ep); @@ -146,6 +147,7 @@ public final class MqttEndpointManager implements AutoCloseable { String externalDeviceId = firstNonBlank(parsed.deviceId(), parsed.vin()); IdentityResolution identity = resolveIdentity(parsed); Map meta = new HashMap<>(4); + // metadata 同时保留外部设备号和内部 VIN,便于排查绑定表错误导致的车辆归属问题。 meta.put("vin", identity.identity().vin()); meta.put("externalVin", parsed.externalVin() == null ? "" : parsed.externalVin()); meta.put("phone", parsed.phone() == null ? "" : parsed.phone()); @@ -173,6 +175,7 @@ public final class MqttEndpointManager implements AutoCloseable { } catch (Exception e) { log.warn("mqtt endpoint [{}] message handling failed topic={} len={}", ep.getName(), topic, payload.length, e); + // 单条消息异常也转成 RawFrame 错误事件,避免 MQTT 回调线程吞掉现场。 publishMessageError(ep, topic, payload, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); } } @@ -215,6 +218,7 @@ public final class MqttEndpointManager implements AutoCloseable { } private void publishOperationalError(MqttInboundProperties.Endpoint ep, String phase, String reason) { + // 连接/订阅级别错误也进入 Dispatcher,这样监控和历史查询能看到接入端本身的问题。 String endpointName = ep == null ? "" : ep.getName(); String profile = ep == null ? "" : ep.getProfile(); String topic = ep == null ? "" : ep.getTopic(); diff --git a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java index abfd5e30..e39e646c 100644 --- a/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java +++ b/modules/inbound/inbound-mqtt/src/main/java/com/lingniu/ingest/inbound/mqtt/config/MqttInboundProperties.java @@ -7,11 +7,16 @@ import java.util.List; /** * MQTT 接入配置。支持多 endpoint(每个 endpoint 可对应一个车厂 / 一个 broker)。 + * + *

    这是 JSON/MQTT 厂商入口,不参与 GB32960 TCP 32960 端口接入;如果同一辆车同时存在 + * MQTT 和 32960 数据,需要在下游按 protocol/source 做归因。 */ @ConfigurationProperties(prefix = "lingniu.ingest.mqtt") public class MqttInboundProperties { + /** 总开关。关闭时不会创建任何 MQTT 客户端连接。 */ private boolean enabled = false; + /** 多 broker/多 topic 配置;每个 endpoint 独立重连、订阅并转成内部 VehicleEvent。 */ private List endpoints = new ArrayList<>(); public boolean isEnabled() { return enabled; } @@ -31,6 +36,7 @@ public class MqttInboundProperties { private String clientId; private String username; private String password; + /** false 表示保留 broker 会话,重连后可继续接收离线期间的 QoS1/2 消息。 */ private boolean cleanSession = false; private int keepAliveSeconds = 20; private int connectionTimeoutSeconds = 10; diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java index 639b95c2..60a81896 100644 --- a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushClient.java @@ -116,6 +116,7 @@ public class XindaPushClient extends PushClient { String cmd = msg.getCmd(); if (cmd == null) return; try { + // 信达只把业务消息派发到 Dispatcher;登录/心跳/订阅 ACK 只影响连接状态和日志。 switch (cmd) { case "8001" -> handleLoginAck(msg); case "8002" -> log.debug("[xinda-push] heartbeat ack"); @@ -128,6 +129,7 @@ public class XindaPushClient extends PushClient { } } catch (Exception e) { log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e); + // 推送回调异常也生成错误 RawFrame,保证原始 JSON 不丢。 publishMessageError(msg, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); } } @@ -193,6 +195,7 @@ public class XindaPushClient extends PushClient { JSONObject identityFields = parsed.json(); IdentityResolution identity = resolveIdentity(identityFields); Map meta = new HashMap<>(); + // 保留外部字段和内部 VIN 的双轨元数据,方便定位绑定错误或第三方字段变更。 meta.put("source", "xinda-push"); meta.put("cmd", payload.command()); meta.put("vin", identity.identity().vin()); diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java index 4c66cbd2..49ff8213 100644 --- a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/XindaPushEventMapper.java @@ -41,10 +41,12 @@ public final class XindaPushEventMapper implements EventMapper try { json = parse(message.json()); } catch (Exception e) { + // JSON 解析失败不丢弃,转 passthrough 保留 rawJson,后续可通过历史库回查。 return List.of(passthrough(message, new JSONObject(), true, false, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); } if (json.getBooleanValue("operationalError")) { + // 接入侧运行错误也统一作为 passthrough 事件进入管线,便于监控和审计。 return List.of(passthrough(message, json, false, false)); } return switch (message.command()) { diff --git a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java index e168222a..92838d03 100644 --- a/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java +++ b/modules/inbound/inbound-xinda-push/src/main/java/com/lingniu/ingest/inbound/xinda/config/XindaPushProperties.java @@ -7,16 +7,21 @@ import java.util.List; /** * 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。 + * + *

    该入口属于第三方推送协议,与 GB32960 TCP 接收链路并列。生产排查 32960 时, + * 应先确认本开关是否关闭,避免把信达推送事件误认为 32960 原始包。 */ @ConfigurationProperties(prefix = "lingniu.ingest.xinda-push") public class XindaPushProperties { + /** 总开关。关闭时不会向信达平台建连/订阅。 */ private boolean enabled = false; private String host; private int port = 10100; private String username; private String password; private String clientDesc = "lingniu-ingest"; + /** 默认订阅常见定位/轨迹类消息,新增业务消息需要先在 mapper 中确认字段映射。 */ private List subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401")); /** 断线重连间隔(秒)。 */ private int reconnectIntervalSec = 5; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java index 4901e3fe..ed99cec3 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960PlatformAuthorizer.java @@ -20,6 +20,9 @@ import java.util.Set; *

  • 平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单 * * + *

    平台账号不仅用于 0x05 鉴权,也会绑定到 channel attribute,后续 0x02/0x03 上报会用它选择 + * vendor profile。账号配错时,可能表现为 0x30+ 厂商字段全部落 Raw。 + * *

    匹配规则: *

      *
    1. 配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权) diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java index 9a3d777e..5c26b817 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/auth/Gb32960VinAuthorizer.java @@ -13,6 +13,9 @@ import java.util.concurrent.atomic.AtomicReference; * *

      线程安全:内部持有 {@code AtomicReference},支持运行时热替换白名单 * (未来可扩展为从 Nacos / 数据库定时刷新)。 + * + *

      该校验只决定是否允许设备继续进入接收链路,不负责车辆主数据注册。生产环境若打开白名单, + * 需要确保新增车辆先同步到这里,否则终端会收到 VIN_NOT_EXIST 应答并断开。 */ public final class Gb32960VinAuthorizer { @@ -38,6 +41,7 @@ public final class Gb32960VinAuthorizer { public boolean isAllowed(String vin) { if (!enabled) return true; if (vin == null || vin.isBlank()) return false; + // 默认大小写不敏感,以容忍部分平台把 VIN 小写转发;规范 VIN 本身仍应为大写。 String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase(); return whitelist.get().contains(key); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java index ab637d37..b9809441 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java @@ -97,6 +97,7 @@ public final class Gb32960BodyParser { /** 主入口:根据上下文按 selector 选 vendor profile,然后照旧解析循环。 */ public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) { ProtocolVersion version = ctx.version(); + // selector 只决定“标准 parser 集合上是否叠加某个 vendor 扩展集合”;标准 typeCode 始终可解析。 InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx)); List blocks = new ArrayList<>(8); byte[] signature = null; @@ -121,6 +122,7 @@ public final class Gb32960BodyParser { if (parser == null) { // lenient:未知类型无法安全跳过,吞剩余字节作为 Raw 后终止。 // 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。 + // 如果该未知块实际是 TLV,应优先新增专用 TLV parser,让主循环可以继续解析后续块。 int remaining = body.remaining(); byte[] rest = new byte[remaining]; body.get(rest); @@ -149,6 +151,7 @@ public final class Gb32960BodyParser { "info block 0x" + Integer.toHexString(typeCode) + " needs " + fixedLen + " bytes but got " + body.remaining()); } + // 固定长度块尾部缺字节时不猜测字段;保留剩余 bytes,方便后续用 RAW 回放复盘。 int remaining = body.remaining(); byte[] tail = new byte[remaining]; body.get(tail); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java index 981a1105..0cf027bb 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960CommandParser.java @@ -83,6 +83,8 @@ public final class Gb32960CommandParser { Instant time = readTime(buf); int serial = buf.getShort() & 0xFFFF; String user = readAscii(buf, 12); + // 标准密码字段为 20B,但现场存在扩展到 32B 的平台登入包;这里按剩余长度-1 读取, + // 最后一字节仍保留给加密规则,兼容两种形态。 int passwordLength = Math.max(0, buf.remaining() - 1); String pwd = readAscii(buf, passwordLength); EncryptType enc = EncryptType.of(buf.get() & 0xFF); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java index 8265b0d6..62fca57b 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameDecoder.java @@ -76,6 +76,8 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder { if (command != 0x05 || dataLen != 41) { return standardLen; } + // 现场平台登入包存在"声明 41B、实际 53B"的兼容形态;只有扩展长度 BCC 成立时才按 53B 分帧, + // 否则回退标准长度,避免误吞下一条帧。 int extendedLen = HEADER_LEN + 53 + 1; if (in.readableBytes() < extendedLen) { return standardLen; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java index 81f1ce51..64eaae75 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960FrameEncoder.java @@ -17,6 +17,9 @@ import java.time.ZoneId; * *

      应答规则(§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、 * 重新计算校验位。 + * + *

      该类只做协议编码,不写 Channel。是否立即关闭连接由 {@code Gb32960AckService} + * 根据鉴权结果决定。 */ public final class Gb32960FrameEncoder { diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java index fcfcad36..ecc2884b 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960MessageDecoder.java @@ -139,6 +139,7 @@ public final class Gb32960MessageDecoder implements FrameDecoder } private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) { + // 与 FrameDecoder 的兼容规则保持一致:部分平台登录帧声明长度 41B,但真实账号/密码体为 53B。 return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53; } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java index 994b50a7..addcd7e2 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960ParserContext.java @@ -11,6 +11,8 @@ import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion; * @param version 协议版本(来自帧起始符) * @param vin 17 字节 VIN(trim 后),平台登入 0x05 等帧 VIN 全 0 时为空串 * @param platformAccount 平台登入用户名;非平台对接场景为 null + * + *

      广东燃料电池扩展等厂商字段通常不是靠 VIN 就能可靠判断,上游平台账号是更稳定的路由线索。 */ public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) { diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java index ad9a1718..563b96b6 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/InfoBlockParserRegistry.java @@ -18,6 +18,7 @@ public final class InfoBlockParserRegistry { public InfoBlockParserRegistry(List list) { for (InfoBlockParser p : list) { + // 后注册覆盖前注册:vendor profile 可以用同一 typeCode 替换默认解析器。 parsers.put(new Key(p.version(), p.typeCode()), p); } } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java index fc4f22e6..4a7b39bf 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/ValueDecoder.java @@ -33,6 +33,7 @@ public final class ValueDecoder { } public static int u16raw(ByteBuffer buf) { + // 仅用于长度、数量、bitmask 等协议控制字段;业务测量值应优先走 u16/scaledU16。 return buf.getShort() & 0xFFFF; } @@ -45,6 +46,7 @@ public final class ValueDecoder { } public static long u32raw(ByteBuffer buf) { + // 仅用于故障码、标志位等需要保留全部 bit 的字段。 return buf.getInt() & 0xFFFFFFFFL; } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java index 9b8d06ae..60f719db 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/AlarmV2016BlockParser.java @@ -38,6 +38,7 @@ public final class AlarmV2016BlockParser implements InfoBlockParser { int count = buf.get() & 0xFF; List out = new ArrayList<>(count); for (int i = 0; i < count; i++) { + // 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。 out.add(buf.getInt() & 0xFFFFFFFFL); } return out; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java index b6c0250c..9980b4a2 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/DriveMotorV2016BlockParser.java @@ -27,6 +27,7 @@ public final class DriveMotorV2016BlockParser implements InfoBlockParser { int count = buf.get() & 0xFF; List motors = new ArrayList<>(count); for (int i = 0; i < count; i++) { + // rpm/torque 在标准里是带偏移的无符号数,先按原始无符号读,再统一减偏移。 Integer serialNo = ValueDecoder.u8(buf); Integer state = ValueDecoder.u8(buf); Integer ctrlTempC = ValueDecoder.tempOffset40(buf); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java index c5361903..d3c2df23 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/ExtremeValueV2016BlockParser.java @@ -19,6 +19,7 @@ public final class ExtremeValueV2016BlockParser implements InfoBlockParser { @Override public InfoBlock parse(ByteBuffer buf) { + // 极值块只给出子系统号/单体号/探针号及对应值,不包含完整单体数组。 Integer maxVSub = ValueDecoder.u8(buf); Integer maxVCell = ValueDecoder.u8(buf); Double maxV = ValueDecoder.scaledU16(buf, 0.001); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java index 527657d0..b47fa280 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/FuelCellV2016BlockParser.java @@ -30,6 +30,7 @@ public final class FuelCellV2016BlockParser implements InfoBlockParser { // 防御:probeCount 异常时回退为空列表避免吞 buffer List probes = new ArrayList<>(); int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节 + // probeCount 来自车端报文,错误值会导致尾部固定段错位;这里先确保尾部还能完整解析。 if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) { for (int i = 0; i < probeCount; i++) { Integer t = ValueDecoder.tempOffset40(buf); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java index a543f7fb..f69fbf46 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/PositionV2016BlockParser.java @@ -25,6 +25,7 @@ public final class PositionV2016BlockParser implements InfoBlockParser { long latRaw = buf.getInt() & 0xFFFFFFFFL; double longitude = lonRaw / 1_000_000.0; double latitude = latRaw / 1_000_000.0; + // 标准用状态位表达南纬/西经;原始经纬度本身始终按正数传输。 if ((statusFlag & 0b100) != 0) longitude = -longitude; if ((statusFlag & 0b010) != 0) latitude = -latitude; return new InfoBlock.Gb32960V2016.Position(statusFlag, longitude, latitude); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java index 375bc7b3..939ef2a4 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/TemperatureV2016BlockParser.java @@ -26,6 +26,7 @@ public final class TemperatureV2016BlockParser implements InfoBlockParser { for (int i = 0; i < subCount; i++) { buf.get(); // batteryIndex int probes = buf.getShort() & 0xFFFF; + // 与电压块类似,这里暂不保存每个探针,只输出可查询的最高/最低温摘要。 for (int p = 0; p < probes; p++) { int t = (buf.get() & 0xFF) - 40; if (t > maxT) maxT = t; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java index df2558bc..a0df6f7c 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VehicleV2016BlockParser.java @@ -13,6 +13,8 @@ import java.nio.ByteBuffer; * + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2) * + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2) * + accelPedal(1) + brakePedal(1)。 + * + *

      该块是实时事件和 snapshot 的核心公共字段来源,字段名变更会影响前端常用查询。 */ public final class VehicleV2016BlockParser implements InfoBlockParser { diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java index e6d5fdf8..1aa5f9ba 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2016/VoltageV2016BlockParser.java @@ -34,12 +34,14 @@ public final class VoltageV2016BlockParser implements InfoBlockParser { int cellCount = buf.getShort() & 0xFFFF; buf.getShort(); // frameCellStart int frameCellCount = buf.get() & 0xFF; + // 报文可能只上传本帧范围内的单体电压;聚合层只保存最高/最低/总压摘要。 int actual = Math.min(frameCellCount, cellCount); for (int c = 0; c < actual; c++) { double cellV = (buf.getShort() & 0xFFFF) * 0.001; if (cellV > maxCell) maxCell = cellV; if (cellV < minCell) minCell = cellV; } + // 如果 frameCellCount 大于 cellCount,剩余字节仍要消费掉,避免影响后续子系统解析。 for (int c = actual; c < frameCellCount; c++) buf.getShort(); } if (maxCell == Double.MIN_VALUE) maxCell = 0; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java index 8a3b0b78..0817f17b 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/AlarmV2025BlockParser.java @@ -30,6 +30,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser { int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0; List levels = new ArrayList<>(generalAlarmCount); + // 2025 新增通用报警等级列表;尾部不完整时保留已读条目,避免整帧丢弃。 for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) { int bit = buf.get() & 0xFF; int level = buf.get() & 0xFF; @@ -44,6 +45,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser { int count = buf.get() & 0xFF; List out = new ArrayList<>(count); for (int i = 0; i < count; i++) { + // 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。 out.add(buf.getInt() & 0xFFFFFFFFL); } return out; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java index 002a64c8..eda41c16 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/BatteryTemperatureV2025BlockParser.java @@ -26,6 +26,7 @@ public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser int packNo = buf.get() & 0xFF; int probeCount = buf.getShort() & 0xFFFF; List temps = new ArrayList<>(probeCount); + // probeCount 是车端声明值;实际数组按剩余字节裁剪,防止坏包读穿。 int safeCount = Math.min(probeCount, buf.remaining()); for (int k = 0; k < safeCount; k++) { temps.add((buf.get() & 0xFF) - 40); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java index bcf49e03..16c13ec8 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/DriveMotorV2025BlockParser.java @@ -29,6 +29,7 @@ public final class DriveMotorV2025BlockParser implements InfoBlockParser { int count = buf.get() & 0xFF; List motors = new ArrayList<>(count); for (int i = 0; i < count; i++) { + // 2025 版 torque 扩为 4 字节,不能沿用 2016 的 u16 解析。 Integer serialNo = ValueDecoder.u8(buf); Integer state = ValueDecoder.u8(buf); Integer ctrlTempC = ValueDecoder.tempOffset40(buf); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java index 64d42a21..b7bba34f 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/FuelCellStackV2025BlockParser.java @@ -33,6 +33,7 @@ public final class FuelCellStackV2025BlockParser implements InfoBlockParser { Integer airInletTemp = ValueDecoder.tempOffset40(buf); int probeCount = buf.getShort() & 0xFFFF; List temps = new ArrayList<>(probeCount); + // 坏包 probeCount 过大时只消费剩余可用字节;声明数量仍保存在 stack 对象外层上下文中。 int safeCount = Math.min(probeCount, buf.remaining()); for (int k = 0; k < safeCount; k++) { temps.add((buf.get() & 0xFF) - 40); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java index 665e67f1..54f9a638 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/MinParallelVoltageV2025BlockParser.java @@ -29,6 +29,7 @@ public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0); int totalUnits = buf.getShort() & 0xFFFF; List frameVoltages = new ArrayList<>(totalUnits); + // 每个最小并联单元电压占 2 字节;不足时只读完整单元,避免半个值污染后续解析。 int safeCount = Math.min(totalUnits, buf.remaining() / 2); for (int k = 0; k < safeCount; k++) { frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java index 657a825c..919e6c56 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorExtremeV2025BlockParser.java @@ -18,6 +18,7 @@ public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockPar @Override public InfoBlock parse(ByteBuffer buf) { + // 2025 超级电容极值的单体/探针编号为 2 字节,区别于 2016 蓄电池极值的 1 字节编号。 Integer maxVSys = ValueDecoder.u8(buf); Integer maxVCell = ValueDecoder.u16(buf); Double maxV = ValueDecoder.scaledU16(buf, 0.001); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java index b1e65c07..96655812 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/SuperCapacitorV2025BlockParser.java @@ -25,12 +25,14 @@ public final class SuperCapacitorV2025BlockParser implements InfoBlockParser { Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0); int cellCount = buf.getShort() & 0xFFFF; List cells = new ArrayList<>(cellCount); + // 电压数组按 2 字节一个值裁剪,避免声明数量异常时读穿温度段。 int safeCellCount = Math.min(cellCount, buf.remaining() / 2); for (int i = 0; i < safeCellCount; i++) { cells.add((buf.getShort() & 0xFFFF) * 0.001); } int probeCount = buf.hasRemaining() ? (buf.getShort() & 0xFFFF) : 0; List temps = new ArrayList<>(probeCount); + // 温度探针每个 1 字节,按剩余字节裁剪。 int safeProbeCount = Math.min(probeCount, buf.remaining()); for (int i = 0; i < safeProbeCount; i++) { temps.add((buf.get() & 0xFF) - 40); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java index d36348e6..e6ee6a40 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/v2025/VehicleV2025BlockParser.java @@ -10,6 +10,8 @@ import java.nio.ByteBuffer; /** * 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。 * 相比 2016 版删除加速踏板和制动踏板。 + * + *

      为保持查询接口稳定,后续转换层会把 2016/2025 两版整车字段映射到同一组逻辑字段。 */ public final class VehicleV2025BlockParser implements InfoBlockParser { diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java index 71f7c170..7d088197 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcDcDcBlockParser.java @@ -18,6 +18,8 @@ import java.nio.ByteBuffer; * outputCurrentA(2, 0.1A, 0~600A) * controllerTempC(1, -40 offset) * + * + *

      只有 vendor profile 命中 {@code guangdong-fc} 时才会解析到该结构;否则同一 typeCode 会落 Raw。 */ public final class GdFcDcDcBlockParser implements InfoBlockParser { diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java index d597897e..1e6d4c43 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcStackBlockParser.java @@ -61,6 +61,8 @@ public final class GdFcStackBlockParser implements InfoBlockParser { Integer frameStart = ValueDecoder.u16(buf); int frameCells = buf.get() & 0xFF; List frameVoltages = new ArrayList<>(frameCells); + // 对端会把单体电压按 frameCellStart/frameCellCount 分帧上送;这里只解析当前帧片段, + // 完整电压数组由查询层按同一 vin+eventTime 合并,避免入站侧持有跨帧状态。 int safe = Math.min(frameCells, buf.remaining() / 2); for (int k = 0; k < safe; k++) { frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java index 50ffec99..f2df20b3 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVehicleInfoBlockParser.java @@ -28,6 +28,7 @@ public final class GdFcVehicleInfoBlockParser implements InfoBlockParser { public InfoBlock parse(ByteBuffer buf) { Integer collision = ValueDecoder.u8raw(buf); int rawTemp = buf.getShort() & 0xFFFF; + // 规范保留值按缺失处理,避免 snapshot 中出现 65494/65495 这种不可读温度。 Integer tempC = (rawTemp == 0xFFFE || rawTemp == 0xFFFF) ? null : rawTemp - 40; Double pressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0); Double h2Mass = ValueDecoder.scaledU16(buf, 0.1); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java index 2ca9628e..16e22ced 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/parser/vendor/guangdong/GdFcVendorTlvBlockParser.java @@ -48,6 +48,7 @@ public final class GdFcVendorTlvBlockParser implements InfoBlockParser { public InfoBlock parse(ByteBuffer buf) { int len = buf.getShort() & 0xFFFF; // 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException) + // declaredLength 仍保留原值,后续可以在 API/日志里看出对端长度声明是否异常。 int safe = Math.min(len, buf.remaining()); byte[] payload = new byte[safe]; buf.get(payload); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java index e71cdefd..881029ef 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/Gb32960ProfileRegistry.java @@ -45,6 +45,7 @@ public class Gb32960ProfileRegistry { } List combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size()); combined.addAll(defaultParsers); + // vendor parser 只新增扩展 typeCode,不应覆盖标准 parser;若未来需覆盖,必须显式设计优先级。 combined.addAll(vendorParsers); map.put(name, new InfoBlockParserRegistry(combined)); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java index 0472c810..83561ed9 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/RuleBasedVendorExtensionSelector.java @@ -49,6 +49,7 @@ public final class RuleBasedVendorExtensionSelector implements VendorExtensionSe CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin())); Optional cached = cache.get(key); if (cached != null) return cached.orElse(null); + // 32960 高频上报通常同一连接/同一 VIN 连续上报,缓存可避免每帧扫描所有规则。 String hit = scan(key); cache.put(key, Optional.ofNullable(hit)); return hit; diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java index 9ec37994..3d89a9da 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionCatalog.java @@ -24,6 +24,7 @@ public final class VendorExtensionCatalog { private final Map> extensions; public VendorExtensionCatalog(Map> extensions) { + // 只冻结顶层 map;parser 实例本身按无状态单例使用,不应在运行期修改。 this.extensions = Map.copyOf(extensions); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java index 5838784a..cbfd408d 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/profile/VendorExtensionSelector.java @@ -6,6 +6,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext; * 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile)。 * *

      实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。 + * 命中结果会直接影响 0x30+ 等厂商字段是否解析成结构化字段,还是退化为 Raw。 */ public interface VendorExtensionSelector { diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java index 41d57c0f..d1411089 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java @@ -58,6 +58,9 @@ import java.util.List; * *

      每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按 * {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。 + * + *

      只有 {@code lingniu.ingest.gb32960.enabled=true} 时才会启动 32960 TCP 服务。 + * 解析、鉴权、ACK、RAW 入库都从 {@link Gb32960NettyServer} 建立的 Netty pipeline 进入。 */ @AutoConfiguration @EnableConfigurationProperties(Gb32960Properties.class) @@ -114,6 +117,7 @@ public class Gb32960AutoConfiguration { new GdFcVehicleInfoBlockParser(), new GdFcDemoExtensionBlockParser(), // 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展) + // 这里只注册已经在线上见过的 typeCode;新增 typeCode 应先确认长度字段格式。 new GdFcVendorTlvBlockParser(0x83)))); } @@ -133,6 +137,7 @@ public class Gb32960AutoConfiguration { for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) { if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName()); } + // enabled 只来自配置里实际引用过的扩展,避免无关 vendor parser 进入默认解析路径。 return new Gb32960ProfileRegistry(parsers, catalog, enabled); } @@ -142,6 +147,7 @@ public class Gb32960AutoConfiguration { public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props, VendorExtensionCatalog catalog) { if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) { + // 没有 vendor 配置时强制 NONE,0x30+ 在 2016 帧里会 Raw 兜底,防止误套扩展解析。 return VendorExtensionSelector.NONE; } return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names()); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java index fd5166c6..920b5ecd 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java @@ -10,8 +10,11 @@ import java.util.Set; @ConfigurationProperties(prefix = "lingniu.ingest.gb32960") public class Gb32960Properties { + /** 是否启动 32960 TCP 监听。生产 32960 线路打开后,此开关决定端口是否真正 bind。 */ private boolean enabled = false; + /** 32960 TCP 监听端口;当前生产验证线使用 32960。 */ private int port = 9000; + /** Netty worker 线程数;0 表示按 Netty/CPU 默认策略分配。 */ private int workerThreads = 0; /** diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java index e622988d..87d25919 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/handler/Gb32960RealtimeHandler.java @@ -17,7 +17,11 @@ import java.util.List; * *

      这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有 * GB32960 帧产出的事件丢失。构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制 - * 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor → Kafka。 + * 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor; + * 下游 sink 再决定写 raw archive、DuckDB/Parquet 历史库或转发 Kafka。 + * + *

      当前 32960 专用 snapshot 查询不依赖 Realtime/Location 事件表,而是通过 raw archive URI + * 回读原始 .bin 并即时解码合并,避免不同车辆或不同子包之间做大范围 join。 */ @ProtocolHandler(protocol = ProtocolId.GB32960) public class Gb32960RealtimeHandler { @@ -33,6 +37,7 @@ public class Gb32960RealtimeHandler { @RateLimited(perVin = 50) @EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class}) public List onReport(Gb32960Message msg) { + // 这里保留 Realtime/Location 事件供实时消费者使用;历史全字段查询走 raw archive 解码。 return mapper.toEvents(msg); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java index c807e439..ed9097ea 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AccessService.java @@ -12,6 +12,9 @@ import java.net.InetSocketAddress; * *

      Keeps authentication and channel-scoped platform identity out of the Netty * handler so the hot path can stay small and testable. + * + *

      平台登入成功后,username 会绑定到 Netty channel attribute。后续实时上报帧没有账号字段, + * 只能通过同一连接上的 attribute 找回平台账号,用于 Hyundai/广东扩展等 vendor profile 选择。 */ public final class Gb32960AccessService { @@ -50,10 +53,12 @@ public final class Gb32960AccessService { public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username, String password, Channel channel) { + // 鉴权策略可以按来源 IP 做限制,因此这里把远端地址从 Netty channel 中抽出。 return platformAuthorizer.authenticate(username, password, peerIp(channel)); } public void bindPlatformAccount(Channel channel, String username) { + // 只绑定到当前连接,断线重连后必须重新平台登入,避免跨连接复用旧账号。 channel.attr(PLATFORM_ACCOUNT_ATTR).set(username); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java index 3fadf881..8ac4918d 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960AckService.java @@ -15,6 +15,9 @@ import java.time.Instant; /** * Builds and writes GB/T 32960 response frames. + * + *

      ACK 需要尽量复用请求帧里的原始 17 字节 VIN。平台登入这类命令可能没有真实 VIN, + * 如果把 trim 后的字符串重新 padding,部分上级平台会认为应答 VIN 与请求不一致。 */ public final class Gb32960AckService { @@ -39,6 +42,7 @@ public final class Gb32960AckService { Instant eventTime, byte[] data, String tag) { + // rawVin 版本用于严格回显请求帧 VIN 字节,优先给入站 handler 使用。 byte[] ack = Gb32960FrameEncoder.buildResponse( version, command, responseFlag, rawVin, eventTime, data); write(channel, ack, tag); @@ -86,6 +90,7 @@ public final class Gb32960AckService { channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> { if (f.isSuccess()) { if (highFrequency) { + // 实时上报 ACK 频率高,默认降到 DEBUG,避免生产日志被正常心跳/上报刷满。 if (log.isDebugEnabled()) { log.debug("[gb32960] {} flushed peer={} len={} hex={}", tag, addr(channel), ack.length, hex); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java index f1d7fc79..55ff1c3d 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960ChannelHandler.java @@ -27,6 +27,10 @@ import java.util.Map; /** * Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。 * + *

      这是 32960 生产链路的入口边界。它只做协议层职责:解码、鉴权、应答、组装 + * {@link RawFrame} 并投递 Dispatcher;原始包归档、DuckDB/Parquet 索引、Kafka 转发都在 + * 下游 sink 中完成,避免 Netty EventLoop 被存储 IO 阻塞。 + * *

      认证逻辑: *

        *
      • {@link Gb32960VinAuthorizer#isEnforcing()} = false:放行所有 VIN @@ -99,6 +103,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { String platformAccount = accessService.platformAccount(ctx.channel()); msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount); } catch (Exception e) { + // 解码失败只丢当前帧,不主动断开连接。真实设备偶发脏字节时,FrameDecoder 会继续寻找下一帧头。 log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e); return; } @@ -138,6 +143,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { if (platformAccount != null && !platformAccount.isBlank()) { sourceMeta.put("platformAccount", platformAccount); } + // RawFrame.rawBytes 是后续 raw archive 和按 rawArchiveUri 回查的源头;不要在这里裁剪或重编码。 RawFrame rf = new RawFrame( ProtocolId.GB32960, cmd.code(), @@ -244,6 +250,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { * 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。 * 部分车端实现严格按规范,没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。 * + *

        注意:应答成功不代表下游已完成持久化,只表示平台已接收并通过 Dispatcher 投递。 + * 生产排查存储问题时,应继续检查 raw archive sink、EventFileStoreSink 和 Kafka sink 的日志。 + * *

        日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏): *

          *
        • 0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。 @@ -326,6 +335,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler { String vin = msg.header().vin(); CommandType cmd = msg.header().command(); if (cmd == CommandType.VEHICLE_LOGIN) { + // 规范要求登入失败要回 ACK,让终端能明确知道 VIN 不存在,而不是当作网络超时重试。 ackService.writeAndClose( ctx, msg.header().protocolVersion(), diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java index c622a4a6..d4d67363 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960FrameDiagnostics.java @@ -15,6 +15,9 @@ import java.util.Set; /** * Channel-scoped diagnostics for high-frequency GB/T 32960 reports. + * + *

          每个 TCP channel 只记录有限个首次出现的 (vin, rawTypeSignature),用于发现厂商 profile + * 没命中、字段解析退化为 Raw 的问题。它不参与业务判断,也不会影响 raw archive 和历史查询。 */ public final class Gb32960FrameDiagnostics { @@ -76,6 +79,7 @@ public final class Gb32960FrameDiagnostics { private void trimToLimit(Set seen) { while (seen.size() > maxKeysPerChannel) { + // LinkedHashSet 保持插入顺序,超限时淘汰最早的签名,避免异常设备导致 attribute 无界增长。 String first = seen.iterator().next(); seen.remove(first); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java index 0810ebfb..50df20b5 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/inbound/Gb32960NettyServer.java @@ -32,6 +32,8 @@ import java.util.concurrent.ThreadFactory; * GB/T 32960 Netty Server 启动器。 * *

          EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。 + * 端口是否启动由 {@code lingniu.ingest.gb32960.enabled} 控制,生产 32960 线应确认该开关、 + * 端口、防火墙和上游平台账号同时匹配。 * *

          支持可选 TLS:{@link Gb32960Properties.Tls#isEnabled()} = true 时,pipeline 前置 * {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA,默认要求客户端证书(双向 TLS)。 @@ -97,6 +99,7 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean { .childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) { + // Pipeline 顺序不能调换:TLS 先解密,FrameDecoder 再做拆包,ChannelHandler 最后做业务应答和派发。 if (sslContext != null) { ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc())); } diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java index 07fcaa29..a92b6f45 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/mapper/Gb32960EventMapper.java @@ -26,6 +26,9 @@ import java.util.stream.Collectors; * *

          实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选); * 车辆登入/登出/心跳产出对应的会话事件。 + * + *

          该 Mapper 只生成统一领域事件,字段是面向实时总线的精简视图。32960 的完整包字段、 + * 厂商扩展字段和 snapshot 合并字段由历史查询服务从 raw archive 重新解码,不在这里展开。 */ public final class Gb32960EventMapper implements EventMapper { @@ -42,6 +45,7 @@ public final class Gb32960EventMapper implements EventMapper { CommandType cmd = header.command(); if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) { + // 补发包与实时包进入同一套实时事件模型,差异保留在 metadata.command 里供下游判断。 buildRealtime(message, header, baseMeta, eventTime, ingestTime, out); return out; } @@ -155,6 +159,7 @@ public final class Gb32960EventMapper implements EventMapper { null, meta, payload)); } if (p != null) { + // 位置事件只从 POSITION 子包生成。没有 POSITION 时不从整车数据推断经纬度,避免制造假定位。 LocationPayload loc = new LocationPayload( p.longitude, p.latitude, 0.0, v != null && v.speedKmh != null ? v.speedKmh : 0.0, @@ -165,6 +170,7 @@ public final class Gb32960EventMapper implements EventMapper { null, meta, loc)); } if (alarm != null && shouldEmitAlarm(alarm)) { + // 只在存在告警等级或氢安全关键位时发 Alarm,减少正常高频帧对告警消费者的噪声。 List faultCodes = new ArrayList<>(); addFaultCodes(faultCodes, "BAT", alarm.batteryFaults); addFaultCodes(faultCodes, "MOT", alarm.motorFaults); diff --git a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java index cc241d1a..e775eb26 100644 --- a/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java +++ b/modules/protocols/protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/model/InfoBlock.java @@ -274,6 +274,7 @@ public sealed interface InfoBlock record MinParallelVoltage(int subSystemCount, List packs) implements Gb32960V2025 { @Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; } + /** totalUnits 是车端声明数量;frameVoltages 是本帧实际解析出的完整电压值。 */ public record Pack( int packNo, Double packVoltageV, @@ -287,6 +288,7 @@ public sealed interface InfoBlock record BatteryTemperature(int subSystemCount, List packs) implements Gb32960V2025 { @Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; } + /** probeCount 是车端声明数量;probeTempsC 是本帧实际解析出的温度值。 */ public record Pack(int packNo, int probeCount, List probeTempsC) {} } @@ -298,6 +300,7 @@ public sealed interface InfoBlock int stackNo, Double voltageV, Double currentA, + // 标准字段名写 pressure,协议比例按 kPa 命名,避免与 2016 Mpa 字段混淆。 Double hydrogenInletPressureKpa, Double airInletPressureKpa, Integer airInletTempC, diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java index bec745d6..770c5a55 100644 --- a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/config/Jt1078Properties.java @@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "lingniu.ingest.jt1078") public class Jt1078Properties { + /** 是否启用 JT/T 1078 音视频能力;它不参与 32960 快照/历史字段查询。 */ private boolean enabled = false; private MediaStream mediaStream = new MediaStream(); @@ -14,13 +15,18 @@ public class Jt1078Properties { public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; } public static class MediaStream { + /** 媒体流子模块开关,便于只启用信令、不落媒体文件。 */ private boolean enabled = true; + /** TCP 媒体流监听,适合生产默认路径。 */ private boolean tcpEnabled = true; + /** UDP 媒体流监听默认关闭,开启前需要确认网络和包乱序处理能力。 */ private boolean udpEnabled = false; private int port = 11078; private int udpPort = 11078; private int workerThreads = 0; + /** 单包最大长度保护,防止异常媒体流耗尽内存。 */ private int maxPacketLength = 1024 * 1024; + /** 媒体文件分段秒数,影响归档文件粒度。 */ private int segmentSeconds = 300; public boolean isEnabled() { return enabled; } diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java index 541387d4..352b72c0 100644 --- a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078MediaArchiveService.java @@ -54,6 +54,7 @@ public final class Jt1078MediaArchiveService { long segment = segment(packet.timestamp()); String key = key(vin, packet, segment); try { + // JT1078 是 RTP 分片流,同一分段追加写入 .media;只发布一次 MediaMeta 作为索引事件。 String uri = archive.append(key, packet.payload()); long segmentSizeBytes = archiveSize(key, packet.payload().length); publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes); @@ -103,6 +104,7 @@ public final class Jt1078MediaArchiveService { : archiveKey; String old = publishedSegmentRefs.getIfPresent(segmentKey); if (old != null) { + // 同一个分段只发一次索引事件,避免每个 RTP 包都在历史库产生一条 media meta。 return; } publishedSegmentRefs.put(segmentKey, uri); @@ -166,6 +168,7 @@ public final class Jt1078MediaArchiveService { private long segment(long timestamp) { long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp; + // 按配置秒数对齐分段,保证同一路通道同一时间窗落到同一个 archive key。 return seconds - Math.floorMod(seconds, segmentSeconds); } diff --git a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java index 048480c6..639f1691 100644 --- a/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java +++ b/modules/protocols/protocol-jt1078/src/main/java/com/lingniu/ingest/protocol/jt1078/media/Jt1078RtpPacketParser.java @@ -19,6 +19,7 @@ final class Jt1078RtpPacketParser { String sim = readBcd(in, 6); int channelId = in.readUnsignedByte(); int dataAndPacketType = in.readUnsignedByte(); + // 高 4 位是数据类型,低 4 位是分包类型;归档 key 需要二者共同区分。 int dataType = (dataAndPacketType >>> 4) & 0x0F; int packetType = dataAndPacketType & 0x0F; long timestamp = in.readLong(); @@ -37,6 +38,7 @@ final class Jt1078RtpPacketParser { if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) { return ""; } + // UDP/TCP 解码器在完整 parse 前先取 SIM,用于日志和初步身份定位;不移动 readerIndex。 StringBuilder sb = new StringBuilder(12); int simOffset = in.readerIndex() + 6; for (int i = 0; i < 6; i++) { diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java index fa1d199e..ee57bedd 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/codec/Jt808MessageDecoder.java @@ -47,6 +47,7 @@ public final class Jt808MessageDecoder implements FrameDecoder { int bodyLength = attrs & 0x03FF; int encryptType = (attrs >> 10) & 0x07; boolean subpacket = (attrs & 0x2000) != 0; + // JT/T 808-2019 通过属性 bit14 标识,终端手机号从 6B BCD 扩展到 10B BCD。 boolean v2019 = (attrs & 0x4000) != 0; Jt808Header.ProtocolVersion version = v2019 @@ -64,6 +65,7 @@ public final class Jt808MessageDecoder implements FrameDecoder { int totalPkgs = 0, pkgSeq = 0; int bodyStart = headerLen; if (subpacket) { + // 分包头紧跟消息流水号之后;当前只暴露包总数/序号,重组由上游或后续模块处理。 totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF); pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF); bodyStart += 4; @@ -82,6 +84,7 @@ public final class Jt808MessageDecoder implements FrameDecoder { BodyParser parser = registry.find(messageId); Jt808Body body; if (parser == null) { + // 未实现的消息体保持 Raw,仍可进入 archive/event-history,避免协议扩展导致数据丢失。 byte[] raw = new byte[bodyLength]; bodyBuf.get(raw); body = new Jt808Body.Raw(messageId, raw); diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java index 53502be8..74532723 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/config/Jt808Properties.java @@ -4,8 +4,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "lingniu.ingest.jt808") public class Jt808Properties { + /** 是否启动 JT/T 808 TCP 监听。与 GB32960 独立,通常用于部标终端。 */ private boolean enabled = false; + /** JT808 默认监听端口;不要与 32960 端口共用。 */ private int port = 10808; + /** Netty worker 线程数;0 表示使用框架默认值。 */ private int workerThreads = 0; public boolean isEnabled() { return enabled; } diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java index c43bb434..7ba34622 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/downlink/Jt808CommandDispatcher.java @@ -67,6 +67,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher { CompletableFuture pending = pendingRequests.await(ctx.phone, ctx.serial); writeFrames(ctx, true).whenComplete((unused, ex) -> { if (ex != null) { + // 写 channel 失败时必须移除 pending,否则后续同 phone/serial 重用会匹配到旧 future。 pendingRequests.cancel(ctx.phone, ctx.serial); pending.completeExceptionally(ex); } @@ -74,6 +75,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher { return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) .whenComplete((r, ex) -> { + // 超时/取消同样清理 pending,避免长期运行后内存泄漏。 if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial); }) .thenApply(msg -> { @@ -122,6 +124,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher { Optional s = sessions.findBySessionId(sessionId) .or(() -> sessions.findByPhone(sessionId)) .or(() -> sessions.findByVin(sessionId)); + // HTTP 调用方可以传 sessionId/VIN/phone;最终写 Netty Channel 必须落到 JT808 phone。 return s.map(DeviceSession::phone) .filter(p -> p != null && !p.isBlank()) .orElse(sessionId); // 回落:直接把 sessionId 当 phone diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java index eeafbdbf..1057136a 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/inbound/Jt808ChannelHandler.java @@ -105,6 +105,8 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler { try { processDecodedFrame(ctx, frame, msg); } catch (RuntimeException e) { + // 解码成功但业务处理失败时仍派发一条 malformed RawFrame, + // 保证原始帧能被 archive/event-file-store 留痕,方便事后排查。 log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}", addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage()); log.debug("[jt808] processing failed stack peer={}", addr(ctx), e); @@ -175,11 +177,13 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler { private IdentityResolution resolveIdentity(Jt808Message msg) { String phone = msg.header().phone(); if (identityResolver == null) { + // 没有绑定表时用手机号兜底为 VIN,保证会话/下行仍可按 phone 找到连接。 return new IdentityResolution( new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE), null); } try { + // 优先把 JT808 phone/device/plate 映射到内部 VIN,后续所有事件都用内部 VIN 做主键。 return new IdentityResolution( identityResolver.resolve(new VehicleIdentityLookup( ProtocolId.JT808, @@ -206,6 +210,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler { private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) { String message = errorMessage == null ? "" : errorMessage; + // 无法解出 header 的坏帧也进入 Dispatcher,后续 archive sink 可以保留 raw bytes。 Jt808Message malformed = new Jt808Message( new Jt808Header(0, frame == null ? 0 : frame.length, 0, false, Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0), diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java index a767233c..afbd58aa 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/mapper/Jt808EventMapper.java @@ -168,6 +168,7 @@ public final class Jt808EventMapper implements EventMapper { private IdentityResolution resolveIdentity(Jt808Message message) { if (message.body() instanceof Jt808Body.Malformed) { + // 坏帧没有可信 header/body,统一 unknown,原始字节仍由 passthrough 事件保留。 return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN)); } var header = message.header(); @@ -212,6 +213,7 @@ public final class Jt808EventMapper implements EventMapper { Instant ingestTime) { List out = new ArrayList<>(batch.locations().size()); Map batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType())); + // 批量位置拆成多条 Location 事件,方便历史库按单点时间查询和导出。 for (Jt808Body.Location loc : batch.locations()) { out.add(locationEvent(vin, batchMeta, loc, ingestTime)); } @@ -224,6 +226,7 @@ public final class Jt808EventMapper implements EventMapper { Instant ingestTime) { List out = new ArrayList<>(2); if (media.location() != null) { + // 多媒体上传消息内嵌位置时同时产出 Location,避免只看到媒体元数据看不到拍摄位置。 out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())), media.location(), ingestTime)); } diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java index a7d62d55..98130f7c 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808ChannelRegistry.java @@ -31,6 +31,7 @@ public final class Jt808ChannelRegistry { reverse.put(channel, phone); if (old != null && old != channel) { reverse.remove(old); + // 同一 phone 新连接上线时关闭旧 channel,避免下行命令写到已被终端替换的连接。 old.close(); log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id()); } @@ -55,6 +56,7 @@ public final class Jt808ChannelRegistry { } public int nextSerial(String phone) { + // JT808 流水号 16 bit,无符号回绕到 0;pending 匹配也使用这个序号。 return serials.computeIfAbsent(phone, k -> new AtomicInteger()) .updateAndGet(i -> (i + 1) & 0xFFFF); } diff --git a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java index 58b57f93..8d03b852 100644 --- a/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java +++ b/modules/protocols/protocol-jt808/src/main/java/com/lingniu/ingest/protocol/jt808/session/Jt808PendingRequests.java @@ -23,6 +23,7 @@ public final class Jt808PendingRequests { public CompletableFuture await(String phone, int serial) { CompletableFuture cf = new CompletableFuture<>(); + // JT808 平台下行流水号按终端 phone 独立递增,因此 phone+serial 才能唯一定位一次请求。 pending.put(key(phone, serial), cf); return cf; } diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java index 821921fa..e6afa433 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/ApiExceptionHandler.java @@ -13,6 +13,13 @@ import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeParseException; +/** + * 统一 API 错误响应。 + * + *

          业务接口抛出的参数错误、时间格式错误和 Spring 参数绑定错误都会转成包含具体原因的 + * JSON,而不是让前端只能看到默认 400/500。这里的 timestamp 仅表示错误发生时间, + * 按东八区展示;业务数据里的 eventTime/ingestTime 不在这里改时区。 + */ @RestControllerAdvice public final class ApiExceptionHandler { @@ -65,6 +72,7 @@ public final class ApiExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity generic(Exception ex, HttpServletRequest request) { + // 保留异常 message 方便联调定位;底层如果没有 message,则退回通用文案。 return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request); } diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java index c958ce36..3d41105f 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryController.java @@ -19,6 +19,12 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +/** + * 通用历史事件查询接口。 + * + *

          这是跨协议的低层记录查询,返回的是 EventFileStore 中的 envelope/snapshot 记录。 + * GB32960 业务查询应优先使用 {@link Gb32960FrameController},因为它会回读 RAW 并按协议展开全部字段。 + */ @RestController @ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") @ConditionalOnBean(EventFileStore.class) @@ -158,6 +164,7 @@ public class EventHistoryController { if (!fields.isArray()) { return Map.of(); } + // 通用导出只识别 telemetry snapshot 的扁平 fields;复杂 GB32960 block 字段用专用 CSV 接口。 Map out = new LinkedHashMap<>(); for (JsonNode field : fields) { String key = field.path("key").asText(""); diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java index 2d5fca22..3b79d8bf 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestor.java @@ -12,6 +12,9 @@ import java.io.IOException; /** * Consumer-side entry point that stores one Kafka envelope value into the * historical detail file store. + * + *

          当前 32960 本机运行配置没有启用 Kafka consumer;这个类保留给“其他服务把 envelope + * 写回历史库”的解耦部署方式。失败时返回结构化结果,由 Kafka worker 决定是否进 DLQ。 */ public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor { @@ -44,10 +47,12 @@ public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor { store.append(record); return EnvelopeIngestResult.stored(record.eventId(), record.vin()); } catch (IllegalArgumentException ex) { + // 协议不可解析或缺少必要字段属于不可重试问题,避免 Kafka consumer 无限重放。 return envelope == null ? EnvelopeIngestResult.invalid(ex.getMessage()) : EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage()); } catch (IOException ex) { + // 文件库写入失败可能是临时 I/O 问题,交给上层 worker 按失败处理。 return EnvelopeIngestResult.failed( envelope == null ? "" : envelope.getEventId(), envelope == null ? "" : envelope.getVin(), diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java index 717e4404..53653315 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameService.java @@ -27,6 +27,16 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +/** + * GB32960 RAW 历史帧查询和业务快照组装服务。 + * + *

          历史库里保存的是 RAW_ARCHIVE 索引,不保存完整解码后的宽表。查询时先从 + * {@link EventFileStore} 找到 archive:// URI,再读取本地 RAW .bin,用当前协议解析器即时解码。 + * 这种设计让写入路径保持轻量,也允许后续修复解析器后直接重放历史 RAW 得到新字段。 + * + *

          snapshot 的聚合单位是 {@code vin + eventTime}。同一时刻可能有多个 32960 子包, + * 比如广东燃料电池堆电压分帧;这里会把这些子包合并成前端可消费的单个逻辑快照。 + */ public final class Gb32960DecodedFrameService { private static final Logger log = LoggerFactory.getLogger(Gb32960DecodedFrameService.class); @@ -76,6 +86,7 @@ public final class Gb32960DecodedFrameService { String vin, String platformAccount) throws IOException { int frameLimit = Math.max(1, Math.min(limit, 1000)); + // 只查 RAW_ARCHIVE:REALTIME/LOCATION 等派生事件不再作为 32960 历史查询的数据源。 List records = store.query(new EventFileQuery( ProtocolId.GB32960, dateFrom, @@ -91,6 +102,7 @@ public final class Gb32960DecodedFrameService { LinkedHashSet seenUris = new LinkedHashSet<>(); for (EventFileRecord record : records) { String rawArchiveUri = rawArchiveUri(record); + // 同一个 RAW 可能因为索引重建或事件补偿被看到多次,按 URI 去重保证返回帧唯一。 if (rawArchiveUri == null || rawArchiveUri.isBlank() || !seenUris.add(rawArchiveUri)) { continue; } @@ -201,6 +213,7 @@ public final class Gb32960DecodedFrameService { throw new IllegalArgumentException("vin is required for gb32960 snapshots"); } int snapshotLimit = Math.max(1, Math.min(limit, 1000)); + // 经验上一个完整 snapshot 可能由多个 RAW 子包组成,查询帧数需要放大后再按 snapshot 截断。 List frames = query(dateFrom, dateTo, eventTimeFrom, eventTimeTo, order, snapshotLimit * 30, vin, platformAccount); LinkedHashMap builders = new LinkedHashMap<>(); @@ -227,6 +240,7 @@ public final class Gb32960DecodedFrameService { try { return decode(record, rawArchiveUri, platformAccount); } catch (NoSuchFileException e) { + // 索引存在但 RAW 文件被人工清理时不中断整次查询,返回仍可用的其他帧。 log.warn("skip gb32960 frame because raw archive is missing eventId={} rawArchiveUri={}", record.eventId(), rawArchiveUri); return null; @@ -402,6 +416,7 @@ public final class Gb32960DecodedFrameService { if (!Double.isFinite(value)) { return BigDecimal.ZERO; } + // 协议缩放后的 double 容易出现 12.300000000000002;对外统一保留 6 位并去掉尾零。 BigDecimal normalized = BigDecimal.valueOf(value) .setScale(OUTPUT_DOUBLE_SCALE, RoundingMode.HALF_UP) .stripTrailingZeros(); @@ -418,6 +433,7 @@ public final class Gb32960DecodedFrameService { ? rawArchiveUri.substring("archive://".length()) : rawArchiveUri; Path path = archiveRoot.resolve(key.replace("..", "_").replaceAll("^/+", "")).normalize(); + // archive:// 是外部输入,必须限制在 archiveRoot 内,避免通过 ../ 读取任意文件。 if (!path.startsWith(archiveRoot)) { throw new IOException("raw archive path escapes root: " + rawArchiveUri); } @@ -562,6 +578,7 @@ public final class Gb32960DecodedFrameService { int index = Integer.parseInt(part); return index >= 0 && index < list.size() ? list.get(index) : null; } + // 对数组字段支持 "stacks.cellVoltages" 这类投影,返回每个元素上对应属性的列表。 List out = new ArrayList<>(list.size()); for (Object item : list) { if (item instanceof Map itemMap) { @@ -593,8 +610,10 @@ public final class Gb32960DecodedFrameService { for (Map block : frame.blocks()) { String type = String.valueOf(block.get("type")); if ("GD_FC_STACK".equals(type)) { + // 广东燃料电池堆电压可能按帧号拆成多个 RAW,这里按 cell 起始序号合并。 mergeStack(block); } else { + // 非分帧块同一 snapshot 内只保留第一次出现的块,避免后到空值覆盖前面完整值。 blocks.putIfAbsent(type, deepCopy(block)); } } @@ -625,6 +644,7 @@ public final class Gb32960DecodedFrameService { int incomingStart = intValue(incoming.get("frameCellStart"), 1); List targetVoltages = doubleList(target.get("frameCellVoltagesV")); List incomingVoltages = doubleList(incoming.get("frameCellVoltagesV")); + // GB/T 分帧的起始序号是 1-based;内部 List 是 0-based,所以写入时统一减一。 int cellCount = Math.max(intValue(target.get("cellCount"), 0), intValue(incoming.get("cellCount"), 0)); int needed = Math.max(cellCount, Math.max( targetStart - 1 + targetVoltages.size(), @@ -693,6 +713,7 @@ public final class Gb32960DecodedFrameService { normalized.removeLast(); } long missing = normalized.stream().filter(item -> item == null).count(); + // 给前端明确的完整性标记,避免只能靠数组长度猜测是否缺帧。 target.put("frameCellStart", 1); target.put("frameCellCount", normalized.size()); target.put("frameCellVoltagesV", normalized); diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java index f362bfbe..f131a1d0 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FieldDictionary.java @@ -2,6 +2,13 @@ package com.lingniu.ingest.eventhistory; import java.util.List; +/** + * GB32960 字段字典。 + * + *

          字典是前端字段选择器、中文表头、Tooltip、枚举/位图展示的单一来源。 + * 字段 key 必须和 {@link Gb32960DecodedFrameService} 输出的 block data 路径一致; + * 支持点号路径和数组投影路径,例如 {@code GD_FC_STACK.stacks.frameCellVoltagesV}。 + */ public final class Gb32960FieldDictionary { private Gb32960FieldDictionary() { @@ -12,6 +19,7 @@ public final class Gb32960FieldDictionary { } private static final List PACKETS = List.of( + // 标准 32960 字段优先放前面;广东燃料电池扩展字段放后面,方便前端按常用程度展示。 packet("VEHICLE", "整车数据", "GB/T 32960 整车运行状态、车速、里程、电压、电流、SOC 等核心字段。", field("vehicleState", "车辆状态", "", "车辆运行、停止等状态编码。", mappings( @@ -178,13 +186,22 @@ public final class Gb32960FieldDictionary { return new ValueMapping(code, value, nameZh, description); } + /** + * 一个数据包/信息体类型的字段集合,例如 VEHICLE 或 GD_FC_DCDC。 + */ public record Packet(String code, String nameZh, String description, List fields) { } + /** + * 可查询字段定义。key 是接口查询参数使用的英文路径,nameZh/unit/description 用于前端展示。 + */ public record Field(String key, String nameZh, String unit, String description, List valueMappings) { } + /** + * 协议枚举值或位图值的展示映射。code 保留原始协议表示,value 是解析后的业务值。 + */ public record ValueMapping(String code, String value, String nameZh, String description) { } } diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java index afaddb24..9f6e971a 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/Gb32960FrameController.java @@ -16,6 +16,13 @@ import org.springframework.web.server.ResponseStatusException; import java.io.IOException; import java.util.List; +/** + * GB32960 历史查询 HTTP 边界。 + * + *

          这个 Controller 只做参数校验、时间范围解析和 Swagger 说明,真正的 RAW 读取、 + * 协议解码、snapshot 合并、字段投影都放在 {@link Gb32960DecodedFrameService}。 + * 这样可以保证接口层足够薄,后续如果要把查询服务拆出去,只需要迁移 service 和 store。 + */ @RestController @ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") @ConditionalOnBean(Gb32960DecodedFrameService.class) @@ -90,6 +97,7 @@ public final class Gb32960FrameController { @RequestParam String vin, @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") @RequestParam(required = false) String platformAccount) throws IOException { + // snapshot 是按同一 VIN + eventTime 合并多个 RAW 子包,跨车查询会造成错误合并和低效扫描。 if (vin == null || vin.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshots"); } @@ -115,6 +123,7 @@ public final class Gb32960FrameController { @RequestParam String fields, @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") @RequestParam(required = false) String platformAccount) throws IOException { + // 字段投影接口是前端高频接口,强制单车查询可以命中 VIN 分区,避免全库扫描后再 join。 if (vin == null || vin.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field query"); } @@ -140,6 +149,7 @@ public final class Gb32960FrameController { @RequestParam String fields, @Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai") @RequestParam(required = false) String platformAccount) throws IOException { + // CSV 复用字段投影逻辑,表头中文化由 dictionary 提供,避免导出和页面展示出现两套字段定义。 if (vin == null || vin.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field csv export"); } diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java index f16b400a..f71f2dc4 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/QueryTimeRange.java @@ -6,6 +6,13 @@ import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneId; +/** + * 查询时间范围解析器。 + * + *

          HTTP 查询允许传日期、带时区时间或不带时区的本地时间: + * 日期用于分区裁剪,不带时区的时间统一按 Asia/Shanghai 转成 UTC Instant。 + * 返回值同时包含 LocalDate 分区边界和精确到秒/毫秒的 eventTime 边界。 + */ final class QueryTimeRange { private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai"); @@ -56,6 +63,7 @@ final class QueryTimeRange { } String value = raw.trim(); if (value.length() == 10) { + // 纯日期查询覆盖东八区自然日;结束日期取当天最后一毫秒,兼容 dateTo=2026-06-23。 LocalDate date = LocalDate.parse(value); Instant instant = endOfRange ? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1) @@ -63,9 +71,11 @@ final class QueryTimeRange { return new Boundary(date, instant); } try { + // 带 Z 或 +08:00 的时间按调用方显式时区解析。 Instant instant = OffsetDateTime.parse(value).toInstant(); return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant); } catch (RuntimeException ignored) { + // 不带时区的前端输入按东八区解释,接口返回的数据时间本身仍保持原始 UTC 字符串。 LocalDateTime local = LocalDateTime.parse(value); Instant instant = local.atZone(DEFAULT_ZONE).toInstant(); return new Boundary(local.toLocalDate(), instant); diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java index e38d466d..fc660e0e 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/TelemetryEnvelopeRecordMapper.java @@ -13,6 +13,10 @@ import java.util.Map; /** * Maps Kafka protobuf envelopes into records stored by the historical detail * file store. + * + *

          这是跨协议 Kafka envelope 回灌历史库的通用适配器。它保存的是 protobuf + * telemetry_snapshot 的 JSON 视图,不负责 GB32960 原始包解码;32960 全字段 snapshot + * 查询仍以 rawArchiveUri 指向的 .bin 为准。 */ public final class TelemetryEnvelopeRecordMapper { @@ -28,6 +32,7 @@ public final class TelemetryEnvelopeRecordMapper { ProtocolId protocol = protocol(envelope.getSource()); Map metadata = new LinkedHashMap<>(envelope.getMetadataMap()); if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) { + // 保留未知 source 原文,避免历史库标准化成 UNKNOWN 后丢失排障线索。 metadata.putIfAbsent("originalSource", envelope.getSource()); } return new EventFileRecord( diff --git a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java index c16fef72..530d5b51 100644 --- a/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java +++ b/modules/services/event-history-service/src/main/java/com/lingniu/ingest/eventhistory/config/EventHistoryAutoConfiguration.java @@ -22,6 +22,18 @@ import org.springframework.context.annotation.Bean; import java.net.URI; import java.nio.file.Path; +/** + * Event History 查询/消费服务自动装配。 + * + *

          该模块有两种入口: + *

            + *
          • HTTP 查询入口:直接查询 {@link EventFileStore},GB32960 专用接口还会回读 RAW archive + *
          • Kafka consumer 入口:把其他服务投递的 envelope 再写入 {@link EventFileStore} + *
          + * + *

          当前 32960 单体运行模式通常只启用 HTTP 查询和本地 event-file-store; + * Kafka consumer 是否启动还取决于 {@code lingniu.ingest.sink.mq.consumer.enabled=true}。 + */ @AutoConfiguration @AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class}) @ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") @@ -46,6 +58,7 @@ public class EventHistoryAutoConfiguration { @ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor") public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor, EnvelopeDeadLetterSink deadLetterSink) { + // 只注册 processor;真正拉 Kafka 的 runner 在 sink-mq 模块按 consumer.enabled 决定是否创建。 return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink); } @@ -62,6 +75,7 @@ public class EventHistoryAutoConfiguration { public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store, Gb32960MessageDecoder decoder, SinkArchiveProperties archiveProperties) { + // snapshot/frame 查询通过 EventFileStore 找到 rawArchiveUri,再从 archiveRoot 读取 .bin 即时解码。 return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null); } @@ -76,6 +90,7 @@ public class EventHistoryAutoConfiguration { if (value == null || value.isBlank()) { return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive"); } + // 支持 file:// URI,便于部署配置里统一用 URI 风格表达 archive 根路径。 if (value.startsWith("file://")) { return Path.of(URI.create(value)); } diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java index 4b2da3bf..6a275c08 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageCalculator.java @@ -31,6 +31,8 @@ public final class DailyMileageCalculator { return OptionalDouble.empty(); } + // 两种策略都只基于同一 VIN 的里程点计算,不能跨车辆 join; + // 上层 repository 负责按 VIN 过滤,避免大车队查询时扩大扫描面。 double value = switch (strategy) { case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points); case DAY_MAX_MINUS_DAY_MIN -> dayMaxMinusDayMin(statDate, points); @@ -39,6 +41,7 @@ public final class DailyMileageCalculator { } private double currentLastMinusPreviousLast(LocalDate statDate, List points) { + // 适用于累计里程单调递增的设备:当天最后一帧减前一日最后一帧。 Optional previousLast = points.stream() .filter(point -> localDate(point).isBefore(statDate)) .max(Comparator.comparing(MileagePoint::eventTime)); @@ -53,6 +56,7 @@ public final class DailyMileageCalculator { } private double dayMaxMinusDayMin(LocalDate statDate, List points) { + // 兜底策略:仅使用当天数据,适合缺少前一日最后点但当天点足够的场景。 List currentDay = points.stream() .filter(point -> localDate(point).isEqual(statDate)) .toList(); diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java index 44875507..6999a188 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyVehicleStatService.java @@ -29,6 +29,7 @@ public final class DailyVehicleStatService { public Optional calculateAndSave(String vin, LocalDate statDate) { VehicleStatRule rule = ruleRepository.ruleFor(vin); + // 统计是从里程点二次计算出来的派生数据,不作为 32960 全字段历史查询的数据源。 OptionalDouble dailyMileage = mileageCalculator.calculate( statDate, rule.dailyMileageStrategy(), diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java index 7869461b..963c3598 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/FileVehicleStatRepository.java @@ -22,6 +22,7 @@ public final class FileVehicleStatRepository implements VehicleStatRepository { throw new IllegalArgumentException("root must not be null"); } Path absoluteRoot = root.toAbsolutePath(); + // 当前实现是轻量本地 TSV,适合开发和小规模派生统计;生产历史全字段查询走 DuckDB RAW 索引。 this.pointsFile = absoluteRoot.resolve("mileage-points.tsv"); this.dailyStatsFile = absoluteRoot.resolve("daily-stats.tsv"); } @@ -44,6 +45,7 @@ public final class FileVehicleStatRepository implements VehicleStatRepository { continue; } try { + // statDate 由计算器按策略判断;这里返回该 VIN 的点,避免仓储层混入业务口径。 out.add(new MileagePoint(Instant.parse(parts[1]), Double.parseDouble(parts[2]))); } catch (RuntimeException ignored) { // Ignore corrupt rows instead of making the whole statistics API unavailable. @@ -118,6 +120,7 @@ public final class FileVehicleStatRepository implements VehicleStatRepository { if (value == null || value.isBlank()) { throw new IllegalArgumentException("vin must not be blank"); } + // TSV 文件没有转义层,VIN 中的控制字符统一替换,防止破坏行结构。 return value.trim().replace('\t', '_').replace('\n', '_').replace('\r', '_'); } } diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java index 0f310dd0..a47f5529 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventProcessor.java @@ -27,6 +27,7 @@ public final class VehicleStatEventProcessor { return; } + // 统计消费的是已经标准化后的 telemetry_snapshot,不重新解析 RAW .bin。 OptionalDouble totalMileage = totalMileage(envelope); if (totalMileage.isEmpty()) { return; diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java index f9f4cb00..5e9d1719 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatEventSink.java @@ -34,6 +34,7 @@ public final class VehicleStatEventSink implements EventSink { @Override public boolean accepts(VehicleEvent event) { + // 直接 EventSink 路径只服务老的 Realtime 派生统计;32960 RAW 历史链路不依赖它。 return event instanceof VehicleEvent.Realtime realtime && realtime.payload() != null && realtime.payload().totalMileageKm() != null; diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java index 06d4d1d2..af098c28 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java @@ -31,6 +31,7 @@ public class VehicleStatAutoConfiguration { @Bean @ConditionalOnMissingBean public VehicleStatRepository vehicleStatRepository(VehicleStatProperties props) { + // 派生统计默认落本地文件,便于独立开关;主历史库仍由 event-file-store 管理。 return new FileVehicleStatRepository(Path.of(props.getFilePath())); } @@ -91,6 +92,7 @@ public class VehicleStatAutoConfiguration { @ConditionalOnMissingBean(name = "vehicleStatEnvelopeConsumerProcessor") public EnvelopeConsumerProcessor vehicleStatEnvelopeConsumerProcessor(VehicleStatEnvelopeIngestor ingestor, EnvelopeDeadLetterSink deadLetterSink) { + // Bean 名必须和 sink-mq 默认 binding 对齐,KafkaEnvelopeConsumerFactory 才能自动创建 worker。 return new EnvelopeConsumerProcessor("vehicle-stat", ingestor, deadLetterSink); } } diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java index d45d8c21..9cde4bb1 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java @@ -5,8 +5,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "lingniu.ingest.vehicle-stat") public class VehicleStatProperties { + /** 派生统计本地文件根目录;不是 32960 RAW archive 或 DuckDB 历史库目录。 */ private String filePath = "./target/vehicle-stat/"; + /** 统计自然日口径,默认按国内业务使用东八区。 */ private String zoneId = "Asia/Shanghai"; public String getFilePath() { diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java index d77bf688..e22888af 100644 --- a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/RedisVehicleStateRepository.java @@ -17,6 +17,7 @@ public final class RedisVehicleStateRepository implements VehicleStateRepository @Override public void putState(String vin, String json) { + // 每辆车一个 Redis key,读最新状态时不需要扫描或 join 历史表。 put(key("vehicle:state:", vin), json); } diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java index 11b0534f..ecb9f973 100644 --- a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateController.java @@ -28,6 +28,7 @@ public final class VehicleStateController { @GetMapping("/{vin}") public ResponseEntity state(@PathVariable String vin) { + // 查询的是 Redis 最新状态快照,不是 event-file-store 的历史 snapshot。 return json(repository.getState(vin)); } diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java index 2f25144a..8251ce3d 100644 --- a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateEnvelopeIngestor.java @@ -25,6 +25,7 @@ public final class VehicleStateEnvelopeIngestor implements EnvelopeIngestor { VehicleEnvelope envelope = null; try { envelope = parse(kafkaValue); + // Kafka 消费路径只接受标准 VehicleEnvelope;坏消息返回明确结果给处理器写 DLQ。 updater.update(envelope); return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin()); } catch (IllegalArgumentException ex) { diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java index 4dc49444..699e36c8 100644 --- a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/VehicleStateUpdater.java @@ -27,6 +27,7 @@ public final class VehicleStateUpdater { Map fields = fields(envelope); String vin = envelope.getVin(); + // vehicle-state 只维护“最新状态”缓存,覆盖写 Redis,不承担历史查询或 RAW 回放职责。 repository.putState(vin, json(base(envelope, fields))); repository.putLastEvent(vin, json(lastEvent(envelope))); @@ -41,6 +42,7 @@ public final class VehicleStateUpdater { private static Map fields(VehicleEnvelope envelope) { Map fields = new LinkedHashMap<>(); for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) { + // 同名字段以后到者为准,和最新状态语义一致。 fields.put(field.getKey(), field.getValue()); } return fields; @@ -89,6 +91,7 @@ public final class VehicleStateUpdater { } private static boolean hasSafetyFields(Map fields) { + // safety 是从标准字段中筛出来的窄视图,字段缺失时不写对应 Redis key。 return fields.containsKey("safety_category") || fields.containsKey("hydrogen_leak_detected") || fields.containsKey("hydrogen_leak_level") diff --git a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java index 52f22df7..d65dd316 100644 --- a/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java +++ b/modules/services/vehicle-state-service/src/main/java/com/lingniu/ingest/vehiclestate/config/VehicleStateAutoConfiguration.java @@ -22,6 +22,7 @@ public class VehicleStateAutoConfiguration { @ConditionalOnBean(StringRedisTemplate.class) @ConditionalOnMissingBean public VehicleStateRepository vehicleStateRepository(StringRedisTemplate redis) { + // 车辆状态模块依赖 Redis;没有 Redis Bean 时不自动启用,避免误以为它是历史库。 return new RedisVehicleStateRepository(redis); } @@ -44,6 +45,7 @@ public class VehicleStateAutoConfiguration { @ConditionalOnMissingBean(name = "vehicleStateEnvelopeConsumerProcessor") public EnvelopeConsumerProcessor vehicleStateEnvelopeConsumerProcessor(VehicleStateEnvelopeIngestor ingestor, EnvelopeDeadLetterSink deadLetterSink) { + // Bean 名必须和 sink-mq 默认 binding 对齐,KafkaEnvelopeConsumerFactory 才能自动创建 worker。 return new EnvelopeConsumerProcessor("vehicle-state", ingestor, deadLetterSink); } diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java index 1127275e..f583916e 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStore.java @@ -24,6 +24,13 @@ import java.util.Map; import java.util.UUID; import java.util.stream.Stream; +/** + * 基于 Parquet 文件 + DuckDB sidecar 索引的历史事件库。 + * + *

          写入时按 {@code protocol/date/vehicle/header} 分区保存 Parquet,便于按单车、按天直接读取。 + * 同时维护一个轻量 DuckDB 索引文件 {@code events.duckdb},用于无 VIN 查询、rawArchiveUri + * 精确定位和索引重建。设计目标是把 32960 查询的热路径限制在“单车若干天的文件”内。 + */ public final class DuckDbParquetEventFileStore implements EventFileStore { private static final TypeReference> STRING_MAP = @@ -68,9 +75,11 @@ public final class DuckDbParquetEventFileStore implements EventFileStore { @Override public List query(EventFileQuery query) throws IOException { + // 有 VIN 时直接扫该车分区 parquet,避免先查总索引再和车辆文件 join。 if (query.vin() != null) { return queryParquet(query); } + // 无 VIN 的管理类查询走 DuckDB 索引,代价更高但不影响高频单车查询路径。 ensureIndexInitialized(); return queryIndex(query); } @@ -121,6 +130,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore { if (query.eventTimeTo() != null) { predicates.add("event_time_ms <= " + query.eventTimeTo().toEpochMilli()); } + // 这里的 SQL 只拼接经过 escape 的内部值;外部 rawArchiveUri 查询使用 PreparedStatement。 String where = predicates.isEmpty() ? "" : "WHERE " + String.join(" AND ", predicates) + "\n"; String sql = """ SELECT event_id, protocol, event_type, vin, @@ -255,6 +265,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore { ps.executeBatch(); } if (Files.exists(partFile)) { + // DuckDB 不能原地追加 parquet;先 UNION 旧文件和本批数据,再原子替换分区文件。 statement.execute(""" CREATE TEMPORARY TABLE combined_records AS SELECT event_id, protocol, event_type, vin, @@ -301,6 +312,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore { Statement statement = connection.createStatement()) { createIndexSchema(statement); if (isIndexEmpty(statement)) { + // 新部署或删除 index 后,可从现有 parquet 自动回填索引,避免历史数据不可查。 backfillIndexFromParquet(statement); } indexInitialized = true; @@ -381,6 +393,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore { Path dir = query.vin() == null ? partitionDir(query.protocol(), date) : partitionDir(query.protocol(), date).resolve("vehicle=" + storageName(query.vin())); + // 目录结构把 VIN 放在 date 下,单车多日查询只需要遍历目标日期内的目标车辆目录。 if (Files.isDirectory(dir)) { try (Stream stream = Files.walk(dir)) { stream.filter(path -> path.getFileName().toString().endsWith(".parquet")) @@ -471,6 +484,7 @@ public final class DuckDbParquetEventFileStore implements EventFileStore { if (vin == null || vin.isBlank()) { return "_unknown"; } + // VIN 会进入文件路径,保守替换特殊字符,避免路径穿越和跨平台文件名问题。 return vin.replaceAll("[^A-Za-z0-9._-]", "_"); } diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java index e152aa2a..f0e52f6b 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileQuery.java @@ -5,6 +5,12 @@ import com.lingniu.ingest.api.ProtocolId; import java.time.Instant; import java.time.LocalDate; +/** + * 文件历史库查询条件。 + * + *

          {@code dateFrom/dateTo} 用于定位分区目录,{@code eventTimeFrom/eventTimeTo} + * 用于在分区内做精确时间过滤。这样既支持按天快速裁剪,也支持接口精确到秒的查询。 + */ public record EventFileQuery( ProtocolId protocol, LocalDate dateFrom, @@ -34,6 +40,7 @@ public record EventFileQuery( if (eventTimeFrom != null && eventTimeTo != null && eventTimeTo.isBefore(eventTimeFrom)) { throw new IllegalArgumentException("eventTimeTo must not be before eventTimeFrom"); } + // 归一化可选字段,避免空字符串进入 SQL 谓词或文件分区路径。 order = order == null ? Order.ASC : order; limit = limit <= 0 ? 100 : limit; vin = vin == null || vin.isBlank() ? null : vin.trim(); diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java index d7287b1e..98c0ad4c 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileRecord.java @@ -10,6 +10,9 @@ import java.util.Map; * *

          {@code payloadJson} 保存统一 telemetry snapshot JSON;公共列只保留排序、分区、 * 追溯和通用展示需要的最小字段。 + * + *

          对 GB32960 来说,{@code rawArchiveUri} 是最关键的追溯列:通用查询可以直接展示 + * payloadJson,专用全字段查询则通过 rawArchiveUri 回读原始包重新解码。 */ public record EventFileRecord( String eventId, @@ -35,6 +38,7 @@ public record EventFileRecord( if (ingestTime == null) { throw new IllegalArgumentException("ingestTime must not be null"); } + // 允许 eventType/vin/rawArchiveUri 为空字符串,便于保存平台登录、RAW 索引等非车辆事件。 eventType = eventType == null ? "" : eventType; vin = vin == null ? "" : vin; rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri; diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java index ab12be84..cfe9300b 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStore.java @@ -3,16 +3,29 @@ package com.lingniu.ingest.eventfilestore; import java.io.IOException; import java.util.List; +/** + * 历史明细文件库抽象。 + * + *

          写入侧只接受已经标准化的 {@link EventFileRecord};具体实现可以落 Parquet、维护 DuckDB + * sidecar 索引,或在测试中用内存实现。32960 专用 snapshot 查询会先用这里按 VIN/日期找到 + * rawArchiveUri,再回读原始 .bin 解码完整字段。 + */ public interface EventFileStore { + /** 单条追加的便捷方法,最终仍走批量写入路径,保证实现只维护一种落盘语义。 */ default void append(EventFileRecord record) throws IOException { appendAll(List.of(record)); } void appendAll(List records) throws IOException; + /** 按协议、日期、VIN、事件类型等条件查询标准化历史记录。 */ List query(EventFileQuery query) throws IOException; + /** + * 按 rawArchiveUri 回查索引记录。默认实现返回 null,允许轻量测试实现不维护该索引。 + * 生产 DuckDB/Parquet 实现必须覆盖,用于 snapshots 的 sourceFrames 反查。 + */ default EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException { return null; } diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java index 7eac04b9..10d79ae4 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/EventFileStoreSink.java @@ -22,8 +22,12 @@ import org.slf4j.LoggerFactory; /** * EventBus 出口:把解析后的 {@link VehicleEvent} 明细写入 Parquet 文件库。 * - *

          {@link VehicleEvent.RawArchive} 只写可查询索引,不写原始 bytes;原始 bytes 本体仍由 - * sink-archive 独立冷存。 + *

          {@link VehicleEvent.RawArchive} 在这里仅写可查询索引和 {@code archive://...} 引用,不写 + * 原始 bytes。本体必须已经由上游归档链路落成 .bin;否则 snapshot/frame 查询只能找到索引, + * 但回读原始包时会报缺失。 + * + *

          32960 生产链路当前只把 RAW_ARCHIVE 作为历史查询事实源;REALTIME 和 LOCATION + * 是从 RAW 派生出来的轻量事件,不再重复写入 event-history,避免历史库里出现两套含义相近的数据。 */ public final class EventFileStoreSink implements EventSink, AutoCloseable { @@ -73,6 +77,7 @@ public final class EventFileStoreSink implements EventSink, AutoCloseable { @Override public boolean accepts(VehicleEvent event) { + // REALTIME/LOCATION 可以由 RAW 重放得到;历史库只保留 RAW_ARCHIVE 和少量非遥测事件索引。 return event != null && !(event instanceof VehicleEvent.Realtime) && !(event instanceof VehicleEvent.Location); @@ -134,6 +139,7 @@ public final class EventFileStoreSink implements EventSink, AutoCloseable { synchronized (buffer) { buffer.add(record); if (buffer.size() >= batchSize) { + // 拷贝后释放锁,避免慢 I/O 阻塞 EventBus 后续写入线程。 toFlush = new ArrayList<>(buffer); buffer.clear(); } @@ -189,6 +195,7 @@ public final class EventFileStoreSink implements EventSink, AutoCloseable { if (raw.metadata() != null) { metadata.putAll(raw.metadata()); } + // metadata 是 HTTP 查询和 raw 文件回查共用的关联字段,缺失时在这里补齐。 metadata.putIfAbsent(RawArchiveKeys.META_EVENT_ID, raw.eventId()); metadata.putIfAbsent(RawArchiveKeys.META_KEY, rawArchiveKey); metadata.putIfAbsent(RawArchiveKeys.META_URI, rawArchiveUri); diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java index b10c86f0..de3863d6 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java @@ -15,6 +15,18 @@ import org.springframework.context.annotation.Bean; import java.nio.file.Path; import java.time.ZoneId; +/** + * EventFileStore 自动装配。 + * + *

          开启 {@code lingniu.ingest.event-file-store.enabled=true} 后会创建两类 Bean: + *

            + *
          • {@link EventFileStore}:DuckDB sidecar index + Parquet 文件库,用于历史查询 + *
          • {@link EventFileStoreSink}:EventBus sink,把可接收事件转换为 {@code EventFileRecord} + *
          + * + *

          32960 当前生产路径里,event-file-store 主要保存 RAW_ARCHIVE 的可查询索引; + * 原始 bytes 本体由 sink-archive 写入 archive 根目录。 + */ @AutoConfiguration @EnableConfigurationProperties(EventFileStoreProperties.class) @ConditionalOnProperty( @@ -28,6 +40,7 @@ public class EventFileStoreAutoConfiguration { public EventFileStore eventFileStore(EventFileStoreProperties properties, ObjectProvider objectMapper) { ObjectMapper mapper = mapper(objectMapper); + // zoneId 只影响文件分区日期;payload 中 eventTime/ingestTime 仍保持 Instant/UTC 表示。 return new DuckDbParquetEventFileStore( Path.of(properties.getPath()), ZoneId.of(properties.getZoneId()), @@ -48,6 +61,7 @@ public class EventFileStoreAutoConfiguration { private static ObjectMapper mapper(ObjectProvider provider) { ObjectMapper base = provider.getIfAvailable(ObjectMapper::new); + // 使用 copy,避免全局 ObjectMapper 被本模块注册 JavaTimeModule 时产生隐式副作用。 return base.copy().registerModule(new JavaTimeModule()); } } diff --git a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java index 0d4e568f..b95043a0 100644 --- a/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java +++ b/modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java @@ -12,21 +12,30 @@ public class EventFileStoreProperties { /** * Parquet 文件库根路径。 + * + *

          实际结构为 {@code protocol=GB32960/date=yyyy-MM-dd/vehicle=VIN/header=event-records-v1/events.parquet}, + * 另有 {@code events.duckdb} sidecar index 放在根路径下。 */ private String path = "./event-store"; /** * 按事件时间分区时使用的业务时区。 + * + *

          该配置只决定“某条事件落入哪一天目录”,不改变接口响应里的 UTC 时间字符串。 */ private String zoneId = "Asia/Shanghai"; /** * Sink 缓冲多少条事件后批量写一个 Parquet part 文件。 + * + *

          值越大写入吞吐越好,但异常退出时内存缓冲里尚未 flush 的事件越多。 */ private int batchSize = 500; /** * 未达到 batchSize 时的最长刷盘间隔。 + * + *

          用于低流量车辆:即使没有攒满 batch,也会定期把缓冲写入文件,降低查询延迟。 */ private long flushIntervalMillis = 1000; diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java index 5b2fbcf5..bf716ee2 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/EnvelopeMapper.java @@ -36,6 +36,7 @@ public final class EnvelopeMapper { .setIngestTimeMs(event.ingestTime().toEpochMilli()) .setIngestNodeId(nodeId); if (event.metadata() != null) b.putAllMetadata(event.metadata()); + // telemetrySnapshot 是跨事件类型的统一字段视图,消费者可先读它,必要时再读具体 payload。 VehicleEventTelemetrySnapshotMapper.toSnapshot(event) .map(EnvelopeMapper::buildTelemetrySnapshot) .ifPresent(b::setTelemetrySnapshot); @@ -70,6 +71,8 @@ public final class EnvelopeMapper { int size = ra.rawBytes() == null ? 0 : ra.rawBytes().length; String key = rawArchiveKey(ra); String uri = rawArchiveUri(ra, key); + // RAW envelope 只携带引用信息,不把完整原始字节塞进 Kafka,避免 topic 膨胀。 + // 注意 KafkaEventSink 当前仍过滤 RawArchive;这段用于未来放开 raw 引用转发或消费者测试。 b.putMetadata(RawArchiveKeys.META_KEY, key); b.putMetadata(RawArchiveKeys.META_URI, uri); b.putMetadata(RawArchiveKeys.META_EVENT_ID, ra.eventId()); diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java index fd1dc871..d85e37e0 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerFactory.java @@ -33,6 +33,8 @@ public final class KafkaEnvelopeConsumerFactory { List workers = new ArrayList<>(); for (Map.Entry entry : bindings.entrySet()) { String processorBeanName = entry.getKey(); + // binding 的 key 必须和 Spring Bean 名一致;这样配置只声明 topic/group, + // 实际处理逻辑仍由各业务模块自己的 EnvelopeConsumerProcessor 承接。 EnvelopeConsumerProcessor processor = processors.get(processorBeanName); SinkMqProperties.Binding binding = entry.getValue(); if (processor == null || binding == null || !binding.isEnabled()) { @@ -56,6 +58,8 @@ public final class KafkaEnvelopeConsumerFactory { if (configured != null && !configured.isEmpty()) { return configured; } + // 默认绑定保留老派生模块的消费能力;32960 主链路不依赖这些派生表, + // 历史查询以 event-file-store 的 RAW 索引和 archive .bin 为准。 SinkMqProperties.Topics topics = props.getTopics(); Map defaults = new LinkedHashMap<>(); defaults.put("eventHistoryEnvelopeConsumerProcessor", binding( @@ -108,6 +112,8 @@ public final class KafkaEnvelopeConsumerFactory { p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ByteArrayDeserializer.class.getName()); p.put(ConsumerConfig.GROUP_ID_CONFIG, groupId(binding, processorBeanName)); p.put(ConsumerConfig.CLIENT_ID_CONFIG, consumer.getClientIdPrefix() + "-" + processorBeanName); + // 手动提交 offset:只有本批次至少有一条记录被处理器接收后才 commit, + // 避免轮询到空批次或未绑定 topic 时推进消费位点。 p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, consumer.getAutoOffsetReset()); p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, consumer.getMaxPollRecords()); diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java index 694452a2..a9833c89 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerRunner.java @@ -45,6 +45,7 @@ public final class KafkaEnvelopeConsumerRunner implements SmartLifecycle, AutoCl if (!running.compareAndSet(false, true)) { return; } + // 每个 worker 一条后台线程,避免某个处理器阻塞时拖慢其他消费组。 executor = Executors.newFixedThreadPool(workers.size(), r -> { Thread thread = new Thread(r, "kafka-envelope-consumer"); thread.setDaemon(true); @@ -60,6 +61,8 @@ public final class KafkaEnvelopeConsumerRunner implements SmartLifecycle, AutoCl try { worker.pollOnce(pollTimeout); } catch (RuntimeException ex) { + // Kafka/处理器异常不让 Spring 生命周期退出;退避后继续消费, + // 具体坏消息由 EnvelopeConsumerProcessor 写入 DLQ。 log.warn("Kafka envelope consumer poll failed; the worker will retry after backoff", ex); sleepBackoff(); } diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java index ad38138d..874c1ca1 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeConsumerWorker.java @@ -37,8 +37,11 @@ public final class KafkaEnvelopeConsumerWorker implements AutoCloseable { for (ConsumerRecord record : records) { EnvelopeConsumerProcessor processor = processorsByTopic.get(record.topic()); if (processor == null) { + // worker 可能订阅多个 topic;没有显式绑定处理器的 topic 不参与提交语义。 continue; } + // EnvelopeConsumerProcessor 内部会把解析或业务错误转成 DLQ 记录, + // 这里保持 Kafka worker 的职责单一:轮询、分发、成功后提交 offset。 processor.process(new EnvelopeConsumerRecord( record.topic(), record.partition(), @@ -48,6 +51,7 @@ public final class KafkaEnvelopeConsumerWorker implements AutoCloseable { processed++; } if (processed > 0) { + // commitSync 放在批次末尾,保证同一个 poll 批次内的消息按 Kafka offset 一起确认。 consumer.commitSync(); } return processed; diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java index 04e1627c..e3043f68 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEnvelopeDeadLetterSink.java @@ -34,6 +34,7 @@ public final class KafkaEnvelopeDeadLetterSink implements EnvelopeDeadLetterSink throw new IllegalArgumentException("record must not be null"); } ProducerRecord out = new ProducerRecord<>(topic, record.key(), record.payload()); + // DLQ payload 保持原始 Kafka value;定位信息全部放 header,便于后续重放或人工排查。 header(out, "dlq-service", record.service()); header(out, "dlq-source-topic", record.topic()); header(out, "dlq-source-partition", Integer.toString(record.partition())); diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java index 4a05df81..e58c798d 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/KafkaEventSink.java @@ -43,9 +43,9 @@ public final class KafkaEventSink implements EventSink, AutoCloseable { } /** - * 拒绝 {@link VehicleEvent.RawArchive} —— 原始报文体积大、属于冷存域,本期由 - * {@code ArchiveEventSink} 独占处理。未来若需要将 raw archive URI 回填到 Kafka 的 - * {@code vehicle.raw.archive} topic,再放开此过滤并在 Envelope 里填 uri/checksum。 + * 拒绝 {@link VehicleEvent.RawArchive} —— 当前生产 Kafka sink 只投递实时、会话、告警等轻量事件。 + * {@link EnvelopeMapper} 和 {@link TopicRouter} 已具备 RAW envelope/topic 的映射能力,但这里仍 + * 显式过滤;如需把 32960 raw 引用转发到 {@code vehicle.raw.archive},应先移除此过滤并补充测试。 */ @Override public boolean accepts(VehicleEvent event) { diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java index 4e4fb5db..53185392 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqAutoConfiguration.java @@ -30,6 +30,10 @@ import java.util.Properties; *

        • {@code lingniu.ingest.sink.mq.type=kafka}(默认 kafka)—— 后端选择,预留 * rocketmq/pulsar 等扩展。 * + * + *

          Producer 和 Consumer 是两个独立开关:{@code sink.mq.enabled=true} 只表示可以创建 + * Kafka producer/sink;是否从 Kafka 拉 envelope 还要单独开启 + * {@code lingniu.ingest.sink.mq.consumer.enabled=true} 并配置 bindings。 */ @AutoConfiguration @EnableConfigurationProperties(SinkMqProperties.class) @@ -84,6 +88,7 @@ public class SinkMqAutoConfiguration { TopicRouter router, SinkMqProperties props, CircuitBreaker breaker) { + // KafkaEventSink 是 EventBus 的生产端出口;它不会启动任何 Kafka 消费线程。 return new KafkaEventSink(producer, mapper, router, props.getTopics().getDlq(), breaker); } @@ -101,6 +106,7 @@ public class SinkMqAutoConfiguration { @ConditionalOnProperty(prefix = "lingniu.ingest.sink.mq.consumer", name = "enabled", havingValue = "true") public KafkaEnvelopeConsumerRunner kafkaEnvelopeConsumerRunner(Map processors, SinkMqProperties props) { + // Consumer runner 根据 processor bean 名和配置 binding 生成 worker;没有 binding 时直接失败,避免静默不消费。 List workers = new KafkaEnvelopeConsumerFactory().createWorkers(processors, props); if (workers.isEmpty()) { throw new IllegalStateException("no kafka envelope consumer workers created; check consumer bindings"); diff --git a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java index 7829a6f3..632be968 100644 --- a/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java +++ b/modules/sinks/sink-mq/src/main/java/com/lingniu/ingest/sink/mq/SinkMqProperties.java @@ -20,12 +20,14 @@ public class SinkMqProperties { /** MQ 后端类型。目前仅支持 kafka,预留 rocketmq/pulsar 等。 */ private String type = "kafka"; + /** Kafka bootstrap servers;生产环境应通过环境变量覆盖,不建议使用默认开发地址。 */ private String bootstrapServers = "114.55.58.251:9092"; private String compressionType = "zstd"; private int lingerMs = 20; private int batchSize = 65536; private String acks = "all"; private boolean enableIdempotence = true; + /** 写入 envelope 的节点标识,进入 Protobuf 字段 ingest_node_id,便于追踪多实例来源。 */ private String nodeId = "ingest-local"; private Topics topics = new Topics(); private Consumer consumer = new Consumer(); @@ -54,12 +56,16 @@ public class SinkMqProperties { public void setConsumer(Consumer consumer) { this.consumer = consumer; } public static class Topics { + /** 实时遥测事件 topic;GB32960 RAW-only 架构下可逐步弱化该 topic。 */ private String realtime = "vehicle.realtime"; + /** 位置事件 topic;通常可由 realtime/RAW 派生。 */ private String location = "vehicle.location"; private String alarm = "vehicle.alarm"; private String session = "vehicle.session"; private String mediaMeta = "vehicle.media.meta"; + /** RAW 归档引用 topic,payload 应携带 archive URI/size,不建议携带完整 raw bytes。 */ private String rawArchive = "vehicle.raw.archive"; + /** producer 熔断或 consumer 处理失败时的死信 topic。 */ private String dlq = "vehicle.dlq"; public String getRealtime() { return realtime; } @@ -79,6 +85,7 @@ public class SinkMqProperties { } public static class Consumer { + /** Kafka 消费总开关。与 producer 总开关分离,默认 false,避免单体服务意外自消费。 */ private boolean enabled = false; private boolean autoStartup = true; private String clientIdPrefix = "lingniu-envelope-consumer"; @@ -86,6 +93,12 @@ public class SinkMqProperties { private int loopBackoffMillis = 1000; private String autoOffsetReset = "earliest"; private int maxPollRecords = 500; + /** + * Processor bean name -> Kafka binding。 + * + *

          示例 key:{@code eventHistoryEnvelopeConsumerProcessor}、 + * {@code vehicleStateEnvelopeConsumerProcessor}、{@code vehicleStatEnvelopeConsumerProcessor}。 + */ private Map bindings = new LinkedHashMap<>(); public boolean isEnabled() { return enabled; } @@ -107,8 +120,11 @@ public class SinkMqProperties { } public static class Binding { + /** 单个 processor binding 开关,用于临时停某个下游消费者而不关整个 consumer runner。 */ private boolean enabled = true; + /** Kafka consumer group id。不同服务要独立消费同一 topic 时必须使用不同 group。 */ private String groupId; + /** 该 processor 订阅的 topic 列表。 */ private List topics = new ArrayList<>(); public boolean isEnabled() { return enabled; }