docs: add detailed 32960 pipeline comments

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

View File

@@ -202,6 +202,8 @@ lingniu:
path: ./archive/ path: ./archive/
event-file-store: event-file-store:
enabled: ${EVENT_FILE_STORE_ENABLED:true} enabled: ${EVENT_FILE_STORE_ENABLED:true}
# 32960 历史索引库根路径。这里保存 Parquet 分区和 DuckDB sidecar 索引;
# 全字段查询仍会按 rawArchiveUri 回读 archive 根目录下的 .bin 原始包。
path: ${EVENT_FILE_STORE_PATH:./target/event-store/} path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai} zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500} batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500}

View File

@@ -11,4 +11,5 @@ import org.springframework.web.servlet.DispatcherServlet;
@ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true)
@ComponentScan(basePackageClasses = TerminalCommandController.class) @ComponentScan(basePackageClasses = TerminalCommandController.class)
public class CommandGatewayAutoConfiguration { public class CommandGatewayAutoConfiguration {
// 只扫描 REST 下行命令网关;不参与 32960 接收、RAW 冷存或历史查询链路。
} }

View File

@@ -205,12 +205,14 @@ public class TerminalCommandController {
// ===== helpers ===== // ===== helpers =====
private Optional<DeviceSession> resolveSession(String clientId) { private Optional<DeviceSession> resolveSession(String clientId) {
// 运维侧常拿到 VIN、手机号或 sessionId 中任意一个;这里按三索引兜底解析到在线连接。
return sessions.findBySessionId(clientId) return sessions.findBySessionId(clientId)
.or(() -> sessions.findByPhone(clientId)) .or(() -> sessions.findByPhone(clientId))
.or(() -> sessions.findByVin(clientId)); .or(() -> sessions.findByVin(clientId));
} }
private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
// 需要终端应答的命令走 request-response超时时间由网关统一兜底避免 HTTP 线程无限等待。
CompletableFuture<Jt808Message> future = CompletableFuture<Jt808Message> future =
dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT); dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT);
try { try {
@@ -229,6 +231,7 @@ public class TerminalCommandController {
private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) { private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
try { try {
// ACK 类命令不等待终端回包;只确认写入在线 session 对应的 channel。
dispatcher.notify(clientId, cmd).join(); dispatcher.notify(clientId, cmd).join();
return CommandResult.ok(Map.of("op", op)); return CommandResult.ok(Map.of("op", op));
} catch (Exception e) { } catch (Exception e) {

View File

@@ -8,15 +8,23 @@ import java.lang.annotation.Target;
/** /**
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。 * 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
* *
* <p>处理方法签名需 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>} * <p>处理方法签名需接收 {@code List<T>}返回 {@code List<VehicleEvent>} 或单个
* {@code VehicleEvent}。Dispatcher 不直接调用目标方法,而是交给
* {@code AsyncBatchExecutor} 按 size/waitMs 聚合后调用。
*
* <p>注意:当调用方需要给每条事件补不同的 rawArchiveUri 等逐条元数据时,执行器会按单条 flush
* 以保证事件与原始帧一一对应。
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD) @Target(ElementType.METHOD)
public @interface AsyncBatch { public @interface AsyncBatch {
/** 达到该批量大小后立即 flush。 */
int size() default 1000; int size() default 1000;
/** 第一条消息入队后最多等待毫秒数,避免低流量车辆长期不出批。 */
long waitMs() default 500; long waitMs() default 500;
/** 每个 Handler 方法对应的批处理 worker 数。 */
int poolSize() default 2; int poolSize() default 2;
} }

View File

@@ -9,6 +9,8 @@ import java.lang.annotation.Target;
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。 * 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
* *
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")} * <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
*
* <p>当前内置去重主要基于 RawFrame sourceMeta/raw bytes该注解保留给更细粒度 Handler 级去重。
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@@ -14,14 +14,20 @@ import java.lang.annotation.Target;
* <li>JT/T 808{@code command} = 消息 ID0x0100 / 0x0200 / ...{@code infoType} 留空 * <li>JT/T 808{@code command} = 消息 ID0x0100 / 0x0200 / ...{@code infoType} 留空
* <li>MQTT{@code command} 可为 topic hash 或忽略 * <li>MQTT{@code command} 可为 topic hash 或忽略
* </ul> * </ul>
*
* <p>同一个方法可以声明多个 command/infoType。Dispatcher 会按协议、command、infoType 精确路由;
* 32960 实时上报解析后通常由 0x02/0x03 主命令加信息体 ID 决定落到哪个 Handler。
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD) @Target(ElementType.METHOD)
public @interface MessageMapping { public @interface MessageMapping {
/** 协议主命令或消息 ID空数组表示该维度不限制。 */
int[] command() default {}; int[] command() default {};
/** 协议子类型GB32960 中对应信息体 ID空数组表示该维度不限制。 */
int[] infoType() default {}; int[] infoType() default {};
/** 仅用于日志/Swagger/排障展示,不参与路由。 */
String desc() default ""; String desc() default "";
} }

View File

@@ -6,7 +6,10 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/** /**
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点 * 单 VIN 速率限制声明
*
* <p>当前内置限流器在 Pipeline before 阶段按 VIN 中止处理,超限帧不会进入 Handler 和下游存储。
* 如果需要把超限帧写入 DLQ应在拦截器或入口层显式增加对应 sink。
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD) @Target(ElementType.METHOD)

View File

@@ -33,6 +33,8 @@ public final class EnvelopeConsumerProcessor {
throw new IllegalArgumentException("record must not be null"); throw new IllegalArgumentException("record must not be null");
} }
EnvelopeIngestResult result = ingestor.tryIngest(record.payload()); EnvelopeIngestResult result = ingestor.tryIngest(record.payload());
// 派生消费者不在这里抛出业务异常给 Kafka worker坏消息统一进入 DLQ
// worker 看到 process 正常返回后才能提交 offset避免同一坏消息无限阻塞消费组。
if (DEAD_LETTER_STATUSES.contains(result.status())) { if (DEAD_LETTER_STATUSES.contains(result.status())) {
deadLetterSink.publish(toDeadLetter(record, result)); deadLetterSink.publish(toDeadLetter(record, result));
} }

View File

@@ -9,6 +9,10 @@ import java.util.Map;
/** /**
* Shared raw-archive key conventions used by Dispatcher, archive sink, and query services. * Shared raw-archive key conventions used by Dispatcher, archive sink, and query services.
*
* <p>32960 生产链路里RAW .bin 文件本体按 {@code 日期/协议/VIN/eventId.bin} 落在 archive 根目录,
* DuckDB 只保存 {@code archive://...} 引用和查询索引。这个类集中维护 key 规则,避免接收端、落盘端、
* snapshot 查询端对目录分区的理解不一致。</p>
*/ */
public final class RawArchiveKeys { public final class RawArchiveKeys {
@@ -23,6 +27,7 @@ public final class RawArchiveKeys {
} }
public static String key(Instant ingestTime, ProtocolId source, String vin, String eventId) { 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 dateKey = DATE_KEY.format(ingestTime == null ? Instant.EPOCH : ingestTime);
String safeVin = vin == null || vin.isBlank() ? "unknown-vin" : vin; String safeVin = vin == null || vin.isBlank() ? "unknown-vin" : vin;
String safeEventId = eventId == null || eventId.isBlank() String safeEventId = eventId == null || eventId.isBlank()

View File

@@ -5,6 +5,10 @@ package com.lingniu.ingest.api.event;
* *
* <p>Protocol mappers use stable internal field keys so Kafka, Parquet, Redis, * <p>Protocol mappers use stable internal field keys so Kafka, Parquet, Redis,
* and statistics do not depend on protocol-specific names. * and statistics do not depend on protocol-specific names.
*
* <p>{@code value} 统一用字符串承载,真实类型由 {@code valueType} 表达。这样 Kafka
* protobuf、CSV 导出、DuckDB JSON 字段和前端展示可以共用同一份字段结构;数值精度/格式化
* 由查询层或前端按 {@code valueType + unit} 决定。
*/ */
public record TelemetryFieldValue( public record TelemetryFieldValue(
String key, String key,
@@ -24,6 +28,7 @@ public record TelemetryFieldValue(
} }
value = value == null ? "" : value; value = value == null ? "" : value;
unit = unit == null ? "" : unit; unit = unit == null ? "" : unit;
// 默认 GOOD只有解析器明确知道异常/无效/缺失时才降级,避免调用方到处补质量字段。
quality = quality == null ? Quality.GOOD : quality; quality = quality == null ? Quality.GOOD : quality;
sourcePath = sourcePath == null ? "" : sourcePath; sourcePath = sourcePath == null ? "" : sourcePath;
} }

View File

@@ -50,6 +50,7 @@ public record TelemetrySnapshot(
rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri; rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri;
metadata = Map.copyOf(metadata == null ? Map.of() : metadata); metadata = Map.copyOf(metadata == null ? Map.of() : metadata);
fields = List.copyOf(fields == null ? List.of() : fields); fields = List.copyOf(fields == null ? List.of() : fields);
// snapshot/fields 查询按 key 投影和导出,重复 key 会导致前端列和 CSV 表头不稳定,构造期直接拒绝。
validateUniqueKeys(fields); validateUniqueKeys(fields);
} }
@@ -57,6 +58,7 @@ public record TelemetrySnapshot(
if (key == null || key.isBlank()) { if (key == null || key.isBlank()) {
return Optional.empty(); return Optional.empty();
} }
// fields 保持插入顺序,单字段查询走线性查找;批量查询应使用 fieldsByKey 建索引。
for (TelemetryFieldValue field : fields) { for (TelemetryFieldValue field : fields) {
if (field.key().equals(key)) { if (field.key().equals(key)) {
return Optional.of(field); return Optional.of(field);
@@ -70,6 +72,7 @@ public record TelemetrySnapshot(
for (TelemetryFieldValue field : fields) { for (TelemetryFieldValue field : fields) {
index.put(field.key(), field); index.put(field.key(), field);
} }
// LinkedHashMap 保留原始字段顺序CSV 导出和 Swagger 示例能稳定复现解析器顺序。
return Map.copyOf(index); return Map.copyOf(index);
} }

View File

@@ -141,11 +141,11 @@ public sealed interface VehicleEvent
) implements VehicleEvent {} ) implements VehicleEvent {}
/** /**
* 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节 * 原始报文归档事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节和归档索引信息。
* {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型 * 32960 历史全字段查询依赖它对应的 {@code archive://...} 文件能够被回读。
* (见 {@code KafkaEventSink.accepts})。
* *
* <p>key 组装建议:{@code yyyy/MM/dd/<source>/<vin>/<eventId>.bin},具体由 sink 实现决定。 * <p>当前 {@code KafkaEventSink} 默认不发送本类型,{@code EventFileStoreSink} 只保存索引不保存
* bytes因此启用新 raw 落盘/转发实现时,必须同时保证 {@link RawArchiveKeys} 的 key 规则一致。
* *
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档 * @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
* @param infoType 协议子类型(可为 0同上 * @param infoType 协议子类型(可为 0同上

View File

@@ -5,6 +5,9 @@ import java.util.Map;
/** /**
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享不需要同步。 * 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享不需要同步。
*
* <p>attributes 用于在入口、拦截器、Dispatcher 之间传递轻量元数据,例如 traceId、归档 URI、
* 鉴权结果等;不要放大对象或长期缓存,避免高频上报时放大内存占用。
*/ */
public final class IngestContext { public final class IngestContext {
@@ -32,6 +35,7 @@ public final class IngestContext {
} }
public void abort(String reason) { public void abort(String reason) {
// reason 会进入日志/诊断信息,保持短小、机器可读,方便定位是去重、限流还是鉴权失败。
this.aborted = true; this.aborted = true;
this.abortReason = reason; this.abortReason = reason;
} }

View File

@@ -15,6 +15,9 @@ import java.util.Map;
* @param rawBytes 原始字节,用于冷存与排障(可能为 null按配置开关 * @param rawBytes 原始字节,用于冷存与排障(可能为 null按配置开关
* @param sourceMeta 来源元数据peer ip、端口、session id、topic 等 * @param sourceMeta 来源元数据peer ip、端口、session id、topic 等
* @param receivedAt 服务器接收时刻 * @param receivedAt 服务器接收时刻
*
* <p>RawFrame 是所有协议进入统一 Pipeline 的边界对象。32960 TCP 入口会把 VIN、seq、
* rawArchiveUri 等关键索引放进 sourceMeta后续去重、快照和历史查询都依赖这些字段保持稳定。
*/ */
public record RawFrame( public record RawFrame(
ProtocolId protocolId, ProtocolId protocolId,

View File

@@ -135,6 +135,8 @@ public final class AsyncBatchExecutor implements AutoCloseable {
private void flush(List<BatchItem> buf) { private void flush(List<BatchItem> buf) {
if (requiresPerItemTransform(buf)) { if (requiresPerItemTransform(buf)) {
// rawArchiveUri/rawSize 等元数据是每帧唯一的。为了不把第一条帧的元数据错误复制给整批事件,
// 一旦存在逐条 transformer就按单条调用 Handler再逐条发布。
for (BatchItem item : buf) { for (BatchItem item : buf) {
flushTransformedItem(item); flushTransformedItem(item);
} }
@@ -161,6 +163,7 @@ public final class AsyncBatchExecutor implements AutoCloseable {
private List<VehicleEvent> invoke(List<Object> messages) { private List<VehicleEvent> invoke(List<Object> messages) {
List<VehicleEvent> events; List<VehicleEvent> events;
try { try {
// Handler 仍然只暴露一个批量签名;是否批满或超时由 Batcher 统一处理。
Object result = def.method().invoke(def.bean(), messages); Object result = def.method().invoke(def.bean(), messages);
events = switch (result) { events = switch (result) {
case null -> List.of(); case null -> List.of();

View File

@@ -43,6 +43,7 @@ public final class DisruptorEventBus implements AutoCloseable {
EventHandler<VehicleEventSlot>[] handlers = sinks.stream() EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
.map(this::toHandler) .map(this::toHandler)
.toArray(EventHandler[]::new); .toArray(EventHandler[]::new);
// 每个 sink 一个独立 handler事件按扇出模式同时写 Kafka、event-file-store 等目标。
disruptor.handleEventsWith(handlers); disruptor.handleEventsWith(handlers);
disruptor.start(); disruptor.start();
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}", log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
@@ -66,6 +67,7 @@ public final class DisruptorEventBus implements AutoCloseable {
if (e == null || !sink.accepts(e)) return; if (e == null || !sink.accepts(e)) return;
try { try {
sink.publish(e).exceptionally(ex -> { sink.publish(e).exceptionally(ex -> {
// sink 异步失败只计数和打日志,不反向阻塞入站连接;生产侧靠 sink 自身重试/DLQ 保证可观测。
failed.incrementAndGet(); failed.incrementAndGet();
log.warn("sink {} publish failed", sink.name(), ex); log.warn("sink {} publish failed", sink.name(), ex);
return null; return null;

View File

@@ -21,6 +21,10 @@ import java.util.List;
/** /**
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。 * ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
*
* <p>生产链路顺序:入口收到 {@code RawFrame} → {@link InterceptorChain} 做准入 →
* {@link Dispatcher} 定位协议 Handler → Handler 产出 {@code VehicleEvent} →
* {@link DisruptorEventBus} 扇出到 Archive、DuckDB、Kafka 等 {@code EventSink}。
*/ */
@AutoConfiguration @AutoConfiguration
@EnableConfigurationProperties(IngestCoreProperties.class) @EnableConfigurationProperties(IngestCoreProperties.class)
@@ -46,6 +50,7 @@ public class IngestCoreAutoConfiguration {
@Bean @Bean
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) { public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
// 去重尽量放在限流前,重复包不应消耗后续每 VIN QPS 配额。
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds()); return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
} }

View File

@@ -19,7 +19,9 @@ public class IngestCoreProperties {
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; } public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
public static class Disruptor { public static class Disruptor {
/** 事件总线 ring buffer 大小;生产环境应保持 2 的幂,避免 Disruptor 初始化失败。 */
private int ringBufferSize = 131072; private int ringBufferSize = 131072;
/** waiting 策略直接影响延迟和 CPU 占用,默认 yielding 偏低延迟。 */
private String waitStrategy = "yielding"; private String waitStrategy = "yielding";
private String producerType = "multi"; private String producerType = "multi";
@@ -33,7 +35,9 @@ public class IngestCoreProperties {
public static class Dedup { public static class Dedup {
private boolean enabled = true; private boolean enabled = true;
/** 单实例缓存的幂等键数量上限,按 VIN 数和重发窗口估算。 */
private int cacheSize = 200000; private int cacheSize = 200000;
/** 幂等键保留窗口;过短会放过重发包,过长会增加内存压力。 */
private long ttlSeconds = 600; private long ttlSeconds = 600;
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
@@ -45,7 +49,9 @@ public class IngestCoreProperties {
} }
public static class RateLimit { public static class RateLimit {
/** 每 VIN 每秒进入 Handler 的最大帧数,默认按 32960 高频实时上报预留。 */
private int perVinQps = 50; private int perVinQps = 50;
/** VIN limiter 缓存上限;覆盖活跃车辆数即可,淘汰后会重新创建 limiter。 */
private int maxVins = 100000; private int maxVins = 100000;
public int getPerVinQps() { return perVinQps; } public int getPerVinQps() { return perVinQps; }

View File

@@ -43,6 +43,7 @@ public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command(); int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType(); int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
// 这些注解只声明策略,实际限流、幂等、异步批处理由 Dispatcher/Interceptor 层解释执行。
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class); RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class); IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class); AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);

View File

@@ -122,6 +122,7 @@ public final class Dispatcher {
} }
private static String nextRawArchiveEventId(Instant ingestTime) { private static String nextRawArchiveEventId(Instant ingestTime) {
// 文件名需要可排序且单进程内唯一:毫秒时间放大到微秒量级,再用 AtomicLong 递增兜底。
long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L; long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L;
long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base)); long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base));
return Long.toString(next); return Long.toString(next);
@@ -141,6 +142,7 @@ public final class Dispatcher {
if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) { if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) {
return event; return event;
} }
// 派生事件携带 rawArchiveUri便于后续服务从 Kafka 事件回查原始报文。
Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key()); Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key());
return switch (event) { return switch (event) {
case VehicleEvent.Realtime e -> new VehicleEvent.Realtime( case VehicleEvent.Realtime e -> new VehicleEvent.Realtime(

View File

@@ -20,6 +20,7 @@ public class HandlerInvoker {
return switch (result) { return switch (result) {
case null -> Collections.emptyList(); case null -> Collections.emptyList();
case VehicleEvent e -> List.of(e); case VehicleEvent e -> List.of(e);
// Handler 可以一次产出多个领域事件,例如 GB32960 0x02 同时产出 Realtime/Location/Alarm。
case List<?> list -> (List<VehicleEvent>) list; case List<?> list -> (List<VehicleEvent>) list;
default -> throw new IllegalStateException( default -> throw new IllegalStateException(
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method()); "Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());

View File

@@ -11,6 +11,11 @@ import java.util.List;
/** /**
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。 * 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
*
* <p>{@link #before(RawFrame, IngestContext)} 是接入链路的准入闸门:任何拦截器返回
* {@code false} 或调用 {@link IngestContext#abort(String)} 都会停止后续协议解析/Handler 调用。
* {@link #after(VehicleEvent, IngestContext)} 与 {@link #onError(Throwable, IngestContext)}
* 则保持广播语义,让指标、审计等横切逻辑都能观察到同一条处理结果。
*/ */
public final class InterceptorChain { public final class InterceptorChain {
@@ -24,6 +29,7 @@ public final class InterceptorChain {
public boolean before(RawFrame frame, IngestContext ctx) { public boolean before(RawFrame frame, IngestContext ctx) {
for (IngestInterceptor i : interceptors) { for (IngestInterceptor i : interceptors) {
// before 阶段短路是有意的:去重、限流失败后不应继续消耗解析和存储资源。
if (!i.before(frame, ctx) || ctx.aborted()) { if (!i.before(frame, ctx) || ctx.aborted()) {
return false; return false;
} }

View File

@@ -13,8 +13,12 @@ import java.time.Duration;
/** /**
* 基于 Caffeine 的本地幂等去重。 * 基于 Caffeine 的本地幂等去重。
* *
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性, * <p>Key 构造:{@code protocolId + vin + command + seq/fingerprint}。如果上游已经在
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。 * {@link RawFrame#sourceMeta()} 里带了 {@code seq},优先用真实流水号;否则退化为 raw bytes
* fingerprint避免设备重连或 TCP 重发导致同一帧重复进入 DuckDB/Archive。
*
* <p>当前缓存是进程内的只能保证单实例去重。32960 若做多副本水平扩容,需要把这个位置替换成
* Redis/集中式幂等键,否则不同实例仍可能各自接收一次相同原始包。
*/ */
public class DedupInterceptor implements IngestInterceptor, Ordered { public class DedupInterceptor implements IngestInterceptor, Ordered {
@@ -32,6 +36,7 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown"); String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
String seq = frame.sourceMeta().get("seq"); String seq = frame.sourceMeta().get("seq");
if (seq == null || seq.isBlank()) { if (seq == null || seq.isBlank()) {
// 某些入口没有硬件流水号,使用原始字节哈希作为保底键,至少能挡住同连接内的重复帧。
seq = rawFingerprint(frame); seq = rawFingerprint(frame);
} }
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq; String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;

View File

@@ -13,6 +13,9 @@ import java.time.Duration;
/** /**
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter由 Caffeine 按 LRU 管理。 * 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter由 Caffeine 按 LRU 管理。
*
* <p>这里限的是进入业务 Handler 前的原始帧不区分命令类型32960 生产侧如需允许登入/登出穿透,
* 应在此处扩展白名单或改为按命令配置,而不是在存储层丢弃。
*/ */
public class RateLimitInterceptor implements IngestInterceptor, Ordered { public class RateLimitInterceptor implements IngestInterceptor, Ordered {
@@ -34,6 +37,7 @@ public class RateLimitInterceptor implements IngestInterceptor, Ordered {
@Override @Override
public boolean before(RawFrame frame, IngestContext ctx) { public boolean before(RawFrame frame, IngestContext ctx) {
String vin = frame.sourceMeta().getOrDefault("vin", "unknown"); String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
// VIN 粒度隔离,避免某一辆车的高频/异常上报挤占其他车辆处理额度。
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig)); RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
if (!rl.acquirePermission()) { if (!rl.acquirePermission()) {
ctx.abort("rate-limited:" + vin); ctx.abort("rate-limited:" + vin);

View File

@@ -35,6 +35,7 @@ public final class InMemorySessionStore implements SessionStore {
@Override @Override
public void put(DeviceSession session) { public void put(DeviceSession session) {
byId.put(session.sessionId(), session); byId.put(session.sessionId(), session);
// 三索引用同一个 sessionId 汇聚HTTP 下行可以拿 sessionId/VIN/phone 任意一种查在线连接。
if (session.vin() != null) vinToId.put(session.vin(), session.sessionId()); if (session.vin() != null) vinToId.put(session.vin(), session.sessionId());
if (session.phone() != null) phoneToId.put(session.phone(), 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); DeviceSession old = byId.getIfPresent(sessionId);
if (old == null) return Optional.empty(); if (old == null) return Optional.empty();
DeviceSession next = updater.apply(old); DeviceSession next = updater.apply(old);
// update 复用 put保证 VIN/phone 二级索引和主会话对象一起刷新。
put(next); put(next);
return Optional.of(next); return Optional.of(next);
} }

View File

@@ -33,6 +33,7 @@ public final class RedisSessionStore implements SessionStore {
@Override @Override
public void put(DeviceSession session) { public void put(DeviceSession session) {
// 同一个 sessionId 重新写入时先清理旧 VIN/phone 索引,避免旧外部号继续指向新会话。
findBySessionId(session.sessionId()).ifPresent(old -> deleteIndexes(old)); findBySessionId(session.sessionId()).ifPresent(old -> deleteIndexes(old));
redis.opsForHash().putAll(sessionKey(session.sessionId()), toHash(session)); redis.opsForHash().putAll(sessionKey(session.sessionId()), toHash(session));
redis.expire(sessionKey(session.sessionId()), ttl); redis.expire(sessionKey(session.sessionId()), ttl);
@@ -67,6 +68,7 @@ public final class RedisSessionStore implements SessionStore {
String sessionId = redis.opsForValue().get(vinIndexKey(vin)); String sessionId = redis.opsForValue().get(vinIndexKey(vin));
Optional<DeviceSession> session = findBySessionId(sessionId); Optional<DeviceSession> session = findBySessionId(sessionId);
if (session.isEmpty() && hasText(sessionId)) { if (session.isEmpty() && hasText(sessionId)) {
// 二级索引可能比主 hash 晚过期;发现悬挂索引时立即清理。
redis.delete(vinIndexKey(vin)); redis.delete(vinIndexKey(vin));
} }
return session; return session;
@@ -80,6 +82,7 @@ public final class RedisSessionStore implements SessionStore {
String sessionId = redis.opsForValue().get(phoneIndexKey(phone)); String sessionId = redis.opsForValue().get(phoneIndexKey(phone));
Optional<DeviceSession> session = findBySessionId(sessionId); Optional<DeviceSession> session = findBySessionId(sessionId);
if (session.isEmpty() && hasText(sessionId)) { if (session.isEmpty() && hasText(sessionId)) {
// 二级索引可能比主 hash 晚过期;发现悬挂索引时立即清理。
redis.delete(phoneIndexKey(phone)); redis.delete(phoneIndexKey(phone));
} }
return session; return session;
@@ -124,6 +127,7 @@ public final class RedisSessionStore implements SessionStore {
private static Map<String, String> toHash(DeviceSession session) { private static Map<String, String> toHash(DeviceSession session) {
Map<String, String> values = new LinkedHashMap<>(); Map<String, String> values = new LinkedHashMap<>();
// attr.* 保留协议侧附加字段,后续扩展不需要改 Redis schema。
put(values, "sessionId", session.sessionId()); put(values, "sessionId", session.sessionId());
put(values, "protocol", session.protocol() == null ? null : session.protocol().name()); put(values, "protocol", session.protocol() == null ? null : session.protocol().name());
put(values, "vin", session.vin()); put(values, "vin", session.vin());

View File

@@ -23,6 +23,7 @@ public class SessionCoreAutoConfiguration {
@ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis") @ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis")
@ConditionalOnMissingBean @ConditionalOnMissingBean
public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) { public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) {
// Redis 只保存会话元数据索引;真实 Netty Channel 仍在各协议进程本地内存中。
return new RedisSessionStore(redis, properties.getTtl()); return new RedisSessionStore(redis, properties.getTtl());
} }
@@ -35,6 +36,7 @@ public class SessionCoreAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
public CommandDispatcher commandDispatcher() { public CommandDispatcher commandDispatcher() {
// 没有协议模块提供真实下行实现时使用 Noop避免仅接收/查询部署因为缺 Bean 启动失败。
return new NoopCommandDispatcher(); return new NoopCommandDispatcher();
} }
} }

View File

@@ -46,6 +46,7 @@ public final class FileVehicleIdentityService implements VehicleIdentityResolver
Files.createDirectories(path.getParent()); Files.createDirectories(path.getParent());
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) { StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) {
// append-only 允许运行时新增绑定;重启时按文件顺序 replay后写覆盖内存索引中的旧映射。
writer.write(mapper.writeValueAsString(BindingLine.from(binding))); writer.write(mapper.writeValueAsString(BindingLine.from(binding)));
writer.newLine(); writer.newLine();
} }
@@ -72,6 +73,7 @@ public final class FileVehicleIdentityService implements VehicleIdentityResolver
} }
try { try {
BindingLine binding = mapper.readValue(line, BindingLine.class); BindingLine binding = mapper.readValue(line, BindingLine.class);
// 单行损坏不影响其它绑定加载,避免一个坏配置拖垮全部接入。
delegate.bind(binding.toBinding()); delegate.bind(binding.toBinding());
} catch (Exception e) { } catch (Exception e) {
log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e); log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e);

View File

@@ -31,8 +31,10 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN); return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
} }
if (!lookup.vin().isBlank()) { if (!lookup.vin().isBlank()) {
// 协议报文已经携带可信 VIN 时直接采用;绑定表主要服务 JT808/MQTT/信达这类外部 ID。
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN); return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
} }
// 绑定优先级phone > deviceId > plate。顺序要稳定否则同一车辆多外部号会产生不同内部 VIN。
VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE); VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
if (phone != null) return phone; if (phone != null) return phone;
@@ -43,6 +45,7 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
if (plate != null) return plate; if (plate != null) return plate;
if (!lookup.deviceId().isBlank()) { if (!lookup.deviceId().isBlank()) {
// fallback 不是已确认绑定只用于不中断接收和冷存metadata.identityResolved=false 会保留这个事实。
return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID); return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
} }
if (!lookup.phone().isBlank()) { if (!lookup.phone().isBlank()) {
@@ -70,6 +73,7 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
private static String key(ProtocolId protocol, String value) { private static String key(ProtocolId protocol, String value) {
String p = protocol == null ? "UNKNOWN" : protocol.name(); String p = protocol == null ? "UNKNOWN" : protocol.name();
// 绑定 key 带协议前缀,避免 JT808 phone 与 MQTT deviceId 数值相同时互相覆盖。
return p + ":" + value.trim().toUpperCase(Locale.ROOT); return p + ":" + value.trim().toUpperCase(Locale.ROOT);
} }
} }

View File

@@ -28,6 +28,7 @@ public class VehicleIdentityAutoConfiguration {
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file") @ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file")
public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties, public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
// 文件实现同时提供 resolver 和 registry协议层既能查绑定也能在注册/鉴权时补写绑定。
return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper); return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper);
} }

View File

@@ -64,6 +64,7 @@ public final class MqttEndpointManager implements AutoCloseable {
public void start() { public void start() {
for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) { for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) {
try { try {
// 多 endpoint 互不影响:单个连接初始化失败会派发 operationalError但不会阻止其他 endpoint。
Mqtt3AsyncClient client = buildClient(ep); Mqtt3AsyncClient client = buildClient(ep);
clients.add(client); clients.add(client);
connectAndSubscribe(client, ep); connectAndSubscribe(client, ep);
@@ -146,6 +147,7 @@ public final class MqttEndpointManager implements AutoCloseable {
String externalDeviceId = firstNonBlank(parsed.deviceId(), parsed.vin()); String externalDeviceId = firstNonBlank(parsed.deviceId(), parsed.vin());
IdentityResolution identity = resolveIdentity(parsed); IdentityResolution identity = resolveIdentity(parsed);
Map<String, String> meta = new HashMap<>(4); Map<String, String> meta = new HashMap<>(4);
// metadata 同时保留外部设备号和内部 VIN便于排查绑定表错误导致的车辆归属问题。
meta.put("vin", identity.identity().vin()); meta.put("vin", identity.identity().vin());
meta.put("externalVin", parsed.externalVin() == null ? "" : parsed.externalVin()); meta.put("externalVin", parsed.externalVin() == null ? "" : parsed.externalVin());
meta.put("phone", parsed.phone() == null ? "" : parsed.phone()); meta.put("phone", parsed.phone() == null ? "" : parsed.phone());
@@ -173,6 +175,7 @@ public final class MqttEndpointManager implements AutoCloseable {
} catch (Exception e) { } catch (Exception e) {
log.warn("mqtt endpoint [{}] message handling failed topic={} len={}", log.warn("mqtt endpoint [{}] message handling failed topic={} len={}",
ep.getName(), topic, payload.length, e); ep.getName(), topic, payload.length, e);
// 单条消息异常也转成 RawFrame 错误事件,避免 MQTT 回调线程吞掉现场。
publishMessageError(ep, topic, payload, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); 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) { private void publishOperationalError(MqttInboundProperties.Endpoint ep, String phase, String reason) {
// 连接/订阅级别错误也进入 Dispatcher这样监控和历史查询能看到接入端本身的问题。
String endpointName = ep == null ? "" : ep.getName(); String endpointName = ep == null ? "" : ep.getName();
String profile = ep == null ? "" : ep.getProfile(); String profile = ep == null ? "" : ep.getProfile();
String topic = ep == null ? "" : ep.getTopic(); String topic = ep == null ? "" : ep.getTopic();

View File

@@ -7,11 +7,16 @@ import java.util.List;
/** /**
* MQTT 接入配置。支持多 endpoint每个 endpoint 可对应一个车厂 / 一个 broker * MQTT 接入配置。支持多 endpoint每个 endpoint 可对应一个车厂 / 一个 broker
*
* <p>这是 JSON/MQTT 厂商入口,不参与 GB32960 TCP 32960 端口接入;如果同一辆车同时存在
* MQTT 和 32960 数据,需要在下游按 protocol/source 做归因。
*/ */
@ConfigurationProperties(prefix = "lingniu.ingest.mqtt") @ConfigurationProperties(prefix = "lingniu.ingest.mqtt")
public class MqttInboundProperties { public class MqttInboundProperties {
/** 总开关。关闭时不会创建任何 MQTT 客户端连接。 */
private boolean enabled = false; private boolean enabled = false;
/** 多 broker/多 topic 配置;每个 endpoint 独立重连、订阅并转成内部 VehicleEvent。 */
private List<Endpoint> endpoints = new ArrayList<>(); private List<Endpoint> endpoints = new ArrayList<>();
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }
@@ -31,6 +36,7 @@ public class MqttInboundProperties {
private String clientId; private String clientId;
private String username; private String username;
private String password; private String password;
/** false 表示保留 broker 会话,重连后可继续接收离线期间的 QoS1/2 消息。 */
private boolean cleanSession = false; private boolean cleanSession = false;
private int keepAliveSeconds = 20; private int keepAliveSeconds = 20;
private int connectionTimeoutSeconds = 10; private int connectionTimeoutSeconds = 10;

View File

@@ -116,6 +116,7 @@ public class XindaPushClient extends PushClient {
String cmd = msg.getCmd(); String cmd = msg.getCmd();
if (cmd == null) return; if (cmd == null) return;
try { try {
// 信达只把业务消息派发到 Dispatcher登录/心跳/订阅 ACK 只影响连接状态和日志。
switch (cmd) { switch (cmd) {
case "8001" -> handleLoginAck(msg); case "8001" -> handleLoginAck(msg);
case "8002" -> log.debug("[xinda-push] heartbeat ack"); case "8002" -> log.debug("[xinda-push] heartbeat ack");
@@ -128,6 +129,7 @@ public class XindaPushClient extends PushClient {
} }
} catch (Exception e) { } catch (Exception e) {
log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), 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()); publishMessageError(msg, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
} }
} }
@@ -193,6 +195,7 @@ public class XindaPushClient extends PushClient {
JSONObject identityFields = parsed.json(); JSONObject identityFields = parsed.json();
IdentityResolution identity = resolveIdentity(identityFields); IdentityResolution identity = resolveIdentity(identityFields);
Map<String, String> meta = new HashMap<>(); Map<String, String> meta = new HashMap<>();
// 保留外部字段和内部 VIN 的双轨元数据,方便定位绑定错误或第三方字段变更。
meta.put("source", "xinda-push"); meta.put("source", "xinda-push");
meta.put("cmd", payload.command()); meta.put("cmd", payload.command());
meta.put("vin", identity.identity().vin()); meta.put("vin", identity.identity().vin());

View File

@@ -41,10 +41,12 @@ public final class XindaPushEventMapper implements EventMapper<XindaPushMessage>
try { try {
json = parse(message.json()); json = parse(message.json());
} catch (Exception e) { } catch (Exception e) {
// JSON 解析失败不丢弃,转 passthrough 保留 rawJson后续可通过历史库回查。
return List.of(passthrough(message, new JSONObject(), true, false, return List.of(passthrough(message, new JSONObject(), true, false,
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
} }
if (json.getBooleanValue("operationalError")) { if (json.getBooleanValue("operationalError")) {
// 接入侧运行错误也统一作为 passthrough 事件进入管线,便于监控和审计。
return List.of(passthrough(message, json, false, false)); return List.of(passthrough(message, json, false, false));
} }
return switch (message.command()) { return switch (message.command()) {

View File

@@ -7,16 +7,21 @@ import java.util.List;
/** /**
* 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。 * 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。
*
* <p>该入口属于第三方推送协议,与 GB32960 TCP 接收链路并列。生产排查 32960 时,
* 应先确认本开关是否关闭,避免把信达推送事件误认为 32960 原始包。
*/ */
@ConfigurationProperties(prefix = "lingniu.ingest.xinda-push") @ConfigurationProperties(prefix = "lingniu.ingest.xinda-push")
public class XindaPushProperties { public class XindaPushProperties {
/** 总开关。关闭时不会向信达平台建连/订阅。 */
private boolean enabled = false; private boolean enabled = false;
private String host; private String host;
private int port = 10100; private int port = 10100;
private String username; private String username;
private String password; private String password;
private String clientDesc = "lingniu-ingest"; private String clientDesc = "lingniu-ingest";
/** 默认订阅常见定位/轨迹类消息,新增业务消息需要先在 mapper 中确认字段映射。 */
private List<String> subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401")); private List<String> subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401"));
/** 断线重连间隔(秒)。 */ /** 断线重连间隔(秒)。 */
private int reconnectIntervalSec = 5; private int reconnectIntervalSec = 5;

View File

@@ -20,6 +20,9 @@ import java.util.Set;
* <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单 * <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单
* </ul> * </ul>
* *
* <p>平台账号不仅用于 0x05 鉴权,也会绑定到 channel attribute后续 0x02/0x03 上报会用它选择
* vendor profile。账号配错时可能表现为 0x30+ 厂商字段全部落 Raw。
*
* <p>匹配规则: * <p>匹配规则:
* <ol> * <ol>
* <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权) * <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权)

View File

@@ -13,6 +13,9 @@ import java.util.concurrent.atomic.AtomicReference;
* *
* <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单 * <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单
* (未来可扩展为从 Nacos / 数据库定时刷新)。 * (未来可扩展为从 Nacos / 数据库定时刷新)。
*
* <p>该校验只决定是否允许设备继续进入接收链路,不负责车辆主数据注册。生产环境若打开白名单,
* 需要确保新增车辆先同步到这里,否则终端会收到 VIN_NOT_EXIST 应答并断开。
*/ */
public final class Gb32960VinAuthorizer { public final class Gb32960VinAuthorizer {
@@ -38,6 +41,7 @@ public final class Gb32960VinAuthorizer {
public boolean isAllowed(String vin) { public boolean isAllowed(String vin) {
if (!enabled) return true; if (!enabled) return true;
if (vin == null || vin.isBlank()) return false; if (vin == null || vin.isBlank()) return false;
// 默认大小写不敏感,以容忍部分平台把 VIN 小写转发;规范 VIN 本身仍应为大写。
String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase(); String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase();
return whitelist.get().contains(key); return whitelist.get().contains(key);
} }

View File

@@ -97,6 +97,7 @@ public final class Gb32960BodyParser {
/** 主入口:根据上下文按 selector 选 vendor profile然后照旧解析循环。 */ /** 主入口:根据上下文按 selector 选 vendor profile然后照旧解析循环。 */
public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) { public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) {
ProtocolVersion version = ctx.version(); ProtocolVersion version = ctx.version();
// selector 只决定“标准 parser 集合上是否叠加某个 vendor 扩展集合”;标准 typeCode 始终可解析。
InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx)); InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx));
List<InfoBlock> blocks = new ArrayList<>(8); List<InfoBlock> blocks = new ArrayList<>(8);
byte[] signature = null; byte[] signature = null;
@@ -121,6 +122,7 @@ public final class Gb32960BodyParser {
if (parser == null) { if (parser == null) {
// lenient未知类型无法安全跳过吞剩余字节作为 Raw 后终止。 // lenient未知类型无法安全跳过吞剩余字节作为 Raw 后终止。
// 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。 // 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。
// 如果该未知块实际是 TLV应优先新增专用 TLV parser让主循环可以继续解析后续块。
int remaining = body.remaining(); int remaining = body.remaining();
byte[] rest = new byte[remaining]; byte[] rest = new byte[remaining];
body.get(rest); body.get(rest);
@@ -149,6 +151,7 @@ public final class Gb32960BodyParser {
"info block 0x" + Integer.toHexString(typeCode) "info block 0x" + Integer.toHexString(typeCode)
+ " needs " + fixedLen + " bytes but got " + body.remaining()); + " needs " + fixedLen + " bytes but got " + body.remaining());
} }
// 固定长度块尾部缺字节时不猜测字段;保留剩余 bytes方便后续用 RAW 回放复盘。
int remaining = body.remaining(); int remaining = body.remaining();
byte[] tail = new byte[remaining]; byte[] tail = new byte[remaining];
body.get(tail); body.get(tail);

View File

@@ -83,6 +83,8 @@ public final class Gb32960CommandParser {
Instant time = readTime(buf); Instant time = readTime(buf);
int serial = buf.getShort() & 0xFFFF; int serial = buf.getShort() & 0xFFFF;
String user = readAscii(buf, 12); String user = readAscii(buf, 12);
// 标准密码字段为 20B但现场存在扩展到 32B 的平台登入包;这里按剩余长度-1 读取,
// 最后一字节仍保留给加密规则,兼容两种形态。
int passwordLength = Math.max(0, buf.remaining() - 1); int passwordLength = Math.max(0, buf.remaining() - 1);
String pwd = readAscii(buf, passwordLength); String pwd = readAscii(buf, passwordLength);
EncryptType enc = EncryptType.of(buf.get() & 0xFF); EncryptType enc = EncryptType.of(buf.get() & 0xFF);

View File

@@ -76,6 +76,8 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder {
if (command != 0x05 || dataLen != 41) { if (command != 0x05 || dataLen != 41) {
return standardLen; return standardLen;
} }
// 现场平台登入包存在"声明 41B、实际 53B"的兼容形态;只有扩展长度 BCC 成立时才按 53B 分帧,
// 否则回退标准长度,避免误吞下一条帧。
int extendedLen = HEADER_LEN + 53 + 1; int extendedLen = HEADER_LEN + 53 + 1;
if (in.readableBytes() < extendedLen) { if (in.readableBytes() < extendedLen) {
return standardLen; return standardLen;

View File

@@ -17,6 +17,9 @@ import java.time.ZoneId;
* *
* <p>应答规则§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、 * <p>应答规则§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
* 重新计算校验位。 * 重新计算校验位。
*
* <p>该类只做协议编码,不写 Channel。是否立即关闭连接由 {@code Gb32960AckService}
* 根据鉴权结果决定。
*/ */
public final class Gb32960FrameEncoder { public final class Gb32960FrameEncoder {

View File

@@ -139,6 +139,7 @@ public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message>
} }
private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) { private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) {
// 与 FrameDecoder 的兼容规则保持一致:部分平台登录帧声明长度 41B但真实账号/密码体为 53B。
return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53; return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53;
} }

View File

@@ -11,6 +11,8 @@ import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
* @param version 协议版本(来自帧起始符) * @param version 协议版本(来自帧起始符)
* @param vin 17 字节 VINtrim 后),平台登入 0x05 等帧 VIN 全 0 时为空串 * @param vin 17 字节 VINtrim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
* @param platformAccount 平台登入用户名;非平台对接场景为 null * @param platformAccount 平台登入用户名;非平台对接场景为 null
*
* <p>广东燃料电池扩展等厂商字段通常不是靠 VIN 就能可靠判断,上游平台账号是更稳定的路由线索。
*/ */
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) { public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {

View File

@@ -18,6 +18,7 @@ public final class InfoBlockParserRegistry {
public InfoBlockParserRegistry(List<InfoBlockParser> list) { public InfoBlockParserRegistry(List<InfoBlockParser> list) {
for (InfoBlockParser p : list) { for (InfoBlockParser p : list) {
// 后注册覆盖前注册vendor profile 可以用同一 typeCode 替换默认解析器。
parsers.put(new Key(p.version(), p.typeCode()), p); parsers.put(new Key(p.version(), p.typeCode()), p);
} }
} }

View File

@@ -33,6 +33,7 @@ public final class ValueDecoder {
} }
public static int u16raw(ByteBuffer buf) { public static int u16raw(ByteBuffer buf) {
// 仅用于长度、数量、bitmask 等协议控制字段;业务测量值应优先走 u16/scaledU16。
return buf.getShort() & 0xFFFF; return buf.getShort() & 0xFFFF;
} }
@@ -45,6 +46,7 @@ public final class ValueDecoder {
} }
public static long u32raw(ByteBuffer buf) { public static long u32raw(ByteBuffer buf) {
// 仅用于故障码、标志位等需要保留全部 bit 的字段。
return buf.getInt() & 0xFFFFFFFFL; return buf.getInt() & 0xFFFFFFFFL;
} }

View File

@@ -38,6 +38,7 @@ public final class AlarmV2016BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF; int count = buf.get() & 0xFF;
List<Long> out = new ArrayList<>(count); List<Long> out = new ArrayList<>(count);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
out.add(buf.getInt() & 0xFFFFFFFFL); out.add(buf.getInt() & 0xFFFFFFFFL);
} }
return out; return out;

View File

@@ -27,6 +27,7 @@ public final class DriveMotorV2016BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF; int count = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2016.DriveMotor.Motor> motors = new ArrayList<>(count); List<InfoBlock.Gb32960V2016.DriveMotor.Motor> motors = new ArrayList<>(count);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
// rpm/torque 在标准里是带偏移的无符号数,先按原始无符号读,再统一减偏移。
Integer serialNo = ValueDecoder.u8(buf); Integer serialNo = ValueDecoder.u8(buf);
Integer state = ValueDecoder.u8(buf); Integer state = ValueDecoder.u8(buf);
Integer ctrlTempC = ValueDecoder.tempOffset40(buf); Integer ctrlTempC = ValueDecoder.tempOffset40(buf);

View File

@@ -19,6 +19,7 @@ public final class ExtremeValueV2016BlockParser implements InfoBlockParser {
@Override @Override
public InfoBlock parse(ByteBuffer buf) { public InfoBlock parse(ByteBuffer buf) {
// 极值块只给出子系统号/单体号/探针号及对应值,不包含完整单体数组。
Integer maxVSub = ValueDecoder.u8(buf); Integer maxVSub = ValueDecoder.u8(buf);
Integer maxVCell = ValueDecoder.u8(buf); Integer maxVCell = ValueDecoder.u8(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001); Double maxV = ValueDecoder.scaledU16(buf, 0.001);

View File

@@ -30,6 +30,7 @@ public final class FuelCellV2016BlockParser implements InfoBlockParser {
// 防御probeCount 异常时回退为空列表避免吞 buffer // 防御probeCount 异常时回退为空列表避免吞 buffer
List<Integer> probes = new ArrayList<>(); List<Integer> probes = new ArrayList<>();
int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节 int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节
// probeCount 来自车端报文,错误值会导致尾部固定段错位;这里先确保尾部还能完整解析。
if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) { if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) {
for (int i = 0; i < probeCount; i++) { for (int i = 0; i < probeCount; i++) {
Integer t = ValueDecoder.tempOffset40(buf); Integer t = ValueDecoder.tempOffset40(buf);

View File

@@ -25,6 +25,7 @@ public final class PositionV2016BlockParser implements InfoBlockParser {
long latRaw = buf.getInt() & 0xFFFFFFFFL; long latRaw = buf.getInt() & 0xFFFFFFFFL;
double longitude = lonRaw / 1_000_000.0; double longitude = lonRaw / 1_000_000.0;
double latitude = latRaw / 1_000_000.0; double latitude = latRaw / 1_000_000.0;
// 标准用状态位表达南纬/西经;原始经纬度本身始终按正数传输。
if ((statusFlag & 0b100) != 0) longitude = -longitude; if ((statusFlag & 0b100) != 0) longitude = -longitude;
if ((statusFlag & 0b010) != 0) latitude = -latitude; if ((statusFlag & 0b010) != 0) latitude = -latitude;
return new InfoBlock.Gb32960V2016.Position(statusFlag, longitude, latitude); return new InfoBlock.Gb32960V2016.Position(statusFlag, longitude, latitude);

View File

@@ -26,6 +26,7 @@ public final class TemperatureV2016BlockParser implements InfoBlockParser {
for (int i = 0; i < subCount; i++) { for (int i = 0; i < subCount; i++) {
buf.get(); // batteryIndex buf.get(); // batteryIndex
int probes = buf.getShort() & 0xFFFF; int probes = buf.getShort() & 0xFFFF;
// 与电压块类似,这里暂不保存每个探针,只输出可查询的最高/最低温摘要。
for (int p = 0; p < probes; p++) { for (int p = 0; p < probes; p++) {
int t = (buf.get() & 0xFF) - 40; int t = (buf.get() & 0xFF) - 40;
if (t > maxT) maxT = t; if (t > maxT) maxT = t;

View File

@@ -13,6 +13,8 @@ import java.nio.ByteBuffer;
* + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2) * + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2)
* + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2) * + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2)
* + accelPedal(1) + brakePedal(1)。 * + accelPedal(1) + brakePedal(1)。
*
* <p>该块是实时事件和 snapshot 的核心公共字段来源,字段名变更会影响前端常用查询。
*/ */
public final class VehicleV2016BlockParser implements InfoBlockParser { public final class VehicleV2016BlockParser implements InfoBlockParser {

View File

@@ -34,12 +34,14 @@ public final class VoltageV2016BlockParser implements InfoBlockParser {
int cellCount = buf.getShort() & 0xFFFF; int cellCount = buf.getShort() & 0xFFFF;
buf.getShort(); // frameCellStart buf.getShort(); // frameCellStart
int frameCellCount = buf.get() & 0xFF; int frameCellCount = buf.get() & 0xFF;
// 报文可能只上传本帧范围内的单体电压;聚合层只保存最高/最低/总压摘要。
int actual = Math.min(frameCellCount, cellCount); int actual = Math.min(frameCellCount, cellCount);
for (int c = 0; c < actual; c++) { for (int c = 0; c < actual; c++) {
double cellV = (buf.getShort() & 0xFFFF) * 0.001; double cellV = (buf.getShort() & 0xFFFF) * 0.001;
if (cellV > maxCell) maxCell = cellV; if (cellV > maxCell) maxCell = cellV;
if (cellV < minCell) minCell = cellV; if (cellV < minCell) minCell = cellV;
} }
// 如果 frameCellCount 大于 cellCount剩余字节仍要消费掉避免影响后续子系统解析。
for (int c = actual; c < frameCellCount; c++) buf.getShort(); for (int c = actual; c < frameCellCount; c++) buf.getShort();
} }
if (maxCell == Double.MIN_VALUE) maxCell = 0; if (maxCell == Double.MIN_VALUE) maxCell = 0;

View File

@@ -30,6 +30,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0; int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
List<InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount); List<InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount);
// 2025 新增通用报警等级列表;尾部不完整时保留已读条目,避免整帧丢弃。
for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) { for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) {
int bit = buf.get() & 0xFF; int bit = buf.get() & 0xFF;
int level = buf.get() & 0xFF; int level = buf.get() & 0xFF;
@@ -44,6 +45,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF; int count = buf.get() & 0xFF;
List<Long> out = new ArrayList<>(count); List<Long> out = new ArrayList<>(count);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
out.add(buf.getInt() & 0xFFFFFFFFL); out.add(buf.getInt() & 0xFFFFFFFFL);
} }
return out; return out;

View File

@@ -26,6 +26,7 @@ public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser
int packNo = buf.get() & 0xFF; int packNo = buf.get() & 0xFF;
int probeCount = buf.getShort() & 0xFFFF; int probeCount = buf.getShort() & 0xFFFF;
List<Integer> temps = new ArrayList<>(probeCount); List<Integer> temps = new ArrayList<>(probeCount);
// probeCount 是车端声明值;实际数组按剩余字节裁剪,防止坏包读穿。
int safeCount = Math.min(probeCount, buf.remaining()); int safeCount = Math.min(probeCount, buf.remaining());
for (int k = 0; k < safeCount; k++) { for (int k = 0; k < safeCount; k++) {
temps.add((buf.get() & 0xFF) - 40); temps.add((buf.get() & 0xFF) - 40);

View File

@@ -29,6 +29,7 @@ public final class DriveMotorV2025BlockParser implements InfoBlockParser {
int count = buf.get() & 0xFF; int count = buf.get() & 0xFF;
List<InfoBlock.Gb32960V2025.DriveMotor.Motor> motors = new ArrayList<>(count); List<InfoBlock.Gb32960V2025.DriveMotor.Motor> motors = new ArrayList<>(count);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
// 2025 版 torque 扩为 4 字节,不能沿用 2016 的 u16 解析。
Integer serialNo = ValueDecoder.u8(buf); Integer serialNo = ValueDecoder.u8(buf);
Integer state = ValueDecoder.u8(buf); Integer state = ValueDecoder.u8(buf);
Integer ctrlTempC = ValueDecoder.tempOffset40(buf); Integer ctrlTempC = ValueDecoder.tempOffset40(buf);

View File

@@ -33,6 +33,7 @@ public final class FuelCellStackV2025BlockParser implements InfoBlockParser {
Integer airInletTemp = ValueDecoder.tempOffset40(buf); Integer airInletTemp = ValueDecoder.tempOffset40(buf);
int probeCount = buf.getShort() & 0xFFFF; int probeCount = buf.getShort() & 0xFFFF;
List<Integer> temps = new ArrayList<>(probeCount); List<Integer> temps = new ArrayList<>(probeCount);
// 坏包 probeCount 过大时只消费剩余可用字节;声明数量仍保存在 stack 对象外层上下文中。
int safeCount = Math.min(probeCount, buf.remaining()); int safeCount = Math.min(probeCount, buf.remaining());
for (int k = 0; k < safeCount; k++) { for (int k = 0; k < safeCount; k++) {
temps.add((buf.get() & 0xFF) - 40); temps.add((buf.get() & 0xFF) - 40);

View File

@@ -29,6 +29,7 @@ public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser
Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0); Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
int totalUnits = buf.getShort() & 0xFFFF; int totalUnits = buf.getShort() & 0xFFFF;
List<Double> frameVoltages = new ArrayList<>(totalUnits); List<Double> frameVoltages = new ArrayList<>(totalUnits);
// 每个最小并联单元电压占 2 字节;不足时只读完整单元,避免半个值污染后续解析。
int safeCount = Math.min(totalUnits, buf.remaining() / 2); int safeCount = Math.min(totalUnits, buf.remaining() / 2);
for (int k = 0; k < safeCount; k++) { for (int k = 0; k < safeCount; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001); frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);

View File

@@ -18,6 +18,7 @@ public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockPar
@Override @Override
public InfoBlock parse(ByteBuffer buf) { public InfoBlock parse(ByteBuffer buf) {
// 2025 超级电容极值的单体/探针编号为 2 字节,区别于 2016 蓄电池极值的 1 字节编号。
Integer maxVSys = ValueDecoder.u8(buf); Integer maxVSys = ValueDecoder.u8(buf);
Integer maxVCell = ValueDecoder.u16(buf); Integer maxVCell = ValueDecoder.u16(buf);
Double maxV = ValueDecoder.scaledU16(buf, 0.001); Double maxV = ValueDecoder.scaledU16(buf, 0.001);

View File

@@ -25,12 +25,14 @@ public final class SuperCapacitorV2025BlockParser implements InfoBlockParser {
Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0); Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
int cellCount = buf.getShort() & 0xFFFF; int cellCount = buf.getShort() & 0xFFFF;
List<Double> cells = new ArrayList<>(cellCount); List<Double> cells = new ArrayList<>(cellCount);
// 电压数组按 2 字节一个值裁剪,避免声明数量异常时读穿温度段。
int safeCellCount = Math.min(cellCount, buf.remaining() / 2); int safeCellCount = Math.min(cellCount, buf.remaining() / 2);
for (int i = 0; i < safeCellCount; i++) { for (int i = 0; i < safeCellCount; i++) {
cells.add((buf.getShort() & 0xFFFF) * 0.001); cells.add((buf.getShort() & 0xFFFF) * 0.001);
} }
int probeCount = buf.hasRemaining() ? (buf.getShort() & 0xFFFF) : 0; int probeCount = buf.hasRemaining() ? (buf.getShort() & 0xFFFF) : 0;
List<Integer> temps = new ArrayList<>(probeCount); List<Integer> temps = new ArrayList<>(probeCount);
// 温度探针每个 1 字节,按剩余字节裁剪。
int safeProbeCount = Math.min(probeCount, buf.remaining()); int safeProbeCount = Math.min(probeCount, buf.remaining());
for (int i = 0; i < safeProbeCount; i++) { for (int i = 0; i < safeProbeCount; i++) {
temps.add((buf.get() & 0xFF) - 40); temps.add((buf.get() & 0xFF) - 40);

View File

@@ -10,6 +10,8 @@ import java.nio.ByteBuffer;
/** /**
* 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。 * 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。
* 相比 2016 版删除加速踏板和制动踏板。 * 相比 2016 版删除加速踏板和制动踏板。
*
* <p>为保持查询接口稳定,后续转换层会把 2016/2025 两版整车字段映射到同一组逻辑字段。
*/ */
public final class VehicleV2025BlockParser implements InfoBlockParser { public final class VehicleV2025BlockParser implements InfoBlockParser {

View File

@@ -18,6 +18,8 @@ import java.nio.ByteBuffer;
* outputCurrentA(2, 0.1A, 0~600A) * outputCurrentA(2, 0.1A, 0~600A)
* controllerTempC(1, -40 offset) * controllerTempC(1, -40 offset)
* </pre> * </pre>
*
* <p>只有 vendor profile 命中 {@code guangdong-fc} 时才会解析到该结构;否则同一 typeCode 会落 Raw。
*/ */
public final class GdFcDcDcBlockParser implements InfoBlockParser { public final class GdFcDcDcBlockParser implements InfoBlockParser {

View File

@@ -61,6 +61,8 @@ public final class GdFcStackBlockParser implements InfoBlockParser {
Integer frameStart = ValueDecoder.u16(buf); Integer frameStart = ValueDecoder.u16(buf);
int frameCells = buf.get() & 0xFF; int frameCells = buf.get() & 0xFF;
List<Double> frameVoltages = new ArrayList<>(frameCells); List<Double> frameVoltages = new ArrayList<>(frameCells);
// 对端会把单体电压按 frameCellStart/frameCellCount 分帧上送;这里只解析当前帧片段,
// 完整电压数组由查询层按同一 vin+eventTime 合并,避免入站侧持有跨帧状态。
int safe = Math.min(frameCells, buf.remaining() / 2); int safe = Math.min(frameCells, buf.remaining() / 2);
for (int k = 0; k < safe; k++) { for (int k = 0; k < safe; k++) {
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001); frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);

View File

@@ -28,6 +28,7 @@ public final class GdFcVehicleInfoBlockParser implements InfoBlockParser {
public InfoBlock parse(ByteBuffer buf) { public InfoBlock parse(ByteBuffer buf) {
Integer collision = ValueDecoder.u8raw(buf); Integer collision = ValueDecoder.u8raw(buf);
int rawTemp = buf.getShort() & 0xFFFF; int rawTemp = buf.getShort() & 0xFFFF;
// 规范保留值按缺失处理,避免 snapshot 中出现 65494/65495 这种不可读温度。
Integer tempC = (rawTemp == 0xFFFE || rawTemp == 0xFFFF) ? null : rawTemp - 40; Integer tempC = (rawTemp == 0xFFFE || rawTemp == 0xFFFF) ? null : rawTemp - 40;
Double pressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0); Double pressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
Double h2Mass = ValueDecoder.scaledU16(buf, 0.1); Double h2Mass = ValueDecoder.scaledU16(buf, 0.1);

View File

@@ -48,6 +48,7 @@ public final class GdFcVendorTlvBlockParser implements InfoBlockParser {
public InfoBlock parse(ByteBuffer buf) { public InfoBlock parse(ByteBuffer buf) {
int len = buf.getShort() & 0xFFFF; int len = buf.getShort() & 0xFFFF;
// 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException // 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException
// declaredLength 仍保留原值,后续可以在 API/日志里看出对端长度声明是否异常。
int safe = Math.min(len, buf.remaining()); int safe = Math.min(len, buf.remaining());
byte[] payload = new byte[safe]; byte[] payload = new byte[safe];
buf.get(payload); buf.get(payload);

View File

@@ -45,6 +45,7 @@ public class Gb32960ProfileRegistry {
} }
List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size()); List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size());
combined.addAll(defaultParsers); combined.addAll(defaultParsers);
// vendor parser 只新增扩展 typeCode不应覆盖标准 parser若未来需覆盖必须显式设计优先级。
combined.addAll(vendorParsers); combined.addAll(vendorParsers);
map.put(name, new InfoBlockParserRegistry(combined)); map.put(name, new InfoBlockParserRegistry(combined));
} }

View File

@@ -49,6 +49,7 @@ public final class RuleBasedVendorExtensionSelector implements VendorExtensionSe
CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin())); CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin()));
Optional<String> cached = cache.get(key); Optional<String> cached = cache.get(key);
if (cached != null) return cached.orElse(null); if (cached != null) return cached.orElse(null);
// 32960 高频上报通常同一连接/同一 VIN 连续上报,缓存可避免每帧扫描所有规则。
String hit = scan(key); String hit = scan(key);
cache.put(key, Optional.ofNullable(hit)); cache.put(key, Optional.ofNullable(hit));
return hit; return hit;

View File

@@ -24,6 +24,7 @@ public final class VendorExtensionCatalog {
private final Map<String, List<InfoBlockParser>> extensions; private final Map<String, List<InfoBlockParser>> extensions;
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) { public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
// 只冻结顶层 mapparser 实例本身按无状态单例使用,不应在运行期修改。
this.extensions = Map.copyOf(extensions); this.extensions = Map.copyOf(extensions);
} }

View File

@@ -6,6 +6,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile * 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile
* *
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。 * <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
* 命中结果会直接影响 0x30+ 等厂商字段是否解析成结构化字段,还是退化为 Raw。
*/ */
public interface VendorExtensionSelector { public interface VendorExtensionSelector {

View File

@@ -58,6 +58,9 @@ import java.util.List;
* *
* <p>每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按 * <p>每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按
* {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。 * {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。
*
* <p>只有 {@code lingniu.ingest.gb32960.enabled=true} 时才会启动 32960 TCP 服务。
* 解析、鉴权、ACK、RAW 入库都从 {@link Gb32960NettyServer} 建立的 Netty pipeline 进入。
*/ */
@AutoConfiguration @AutoConfiguration
@EnableConfigurationProperties(Gb32960Properties.class) @EnableConfigurationProperties(Gb32960Properties.class)
@@ -114,6 +117,7 @@ public class Gb32960AutoConfiguration {
new GdFcVehicleInfoBlockParser(), new GdFcVehicleInfoBlockParser(),
new GdFcDemoExtensionBlockParser(), new GdFcDemoExtensionBlockParser(),
// 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展) // 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展)
// 这里只注册已经在线上见过的 typeCode新增 typeCode 应先确认长度字段格式。
new GdFcVendorTlvBlockParser(0x83)))); new GdFcVendorTlvBlockParser(0x83))));
} }
@@ -133,6 +137,7 @@ public class Gb32960AutoConfiguration {
for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) { for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) {
if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName()); if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName());
} }
// enabled 只来自配置里实际引用过的扩展,避免无关 vendor parser 进入默认解析路径。
return new Gb32960ProfileRegistry(parsers, catalog, enabled); return new Gb32960ProfileRegistry(parsers, catalog, enabled);
} }
@@ -142,6 +147,7 @@ public class Gb32960AutoConfiguration {
public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props, public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props,
VendorExtensionCatalog catalog) { VendorExtensionCatalog catalog) {
if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) { if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) {
// 没有 vendor 配置时强制 NONE0x30+ 在 2016 帧里会 Raw 兜底,防止误套扩展解析。
return VendorExtensionSelector.NONE; return VendorExtensionSelector.NONE;
} }
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names()); return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());

View File

@@ -10,8 +10,11 @@ import java.util.Set;
@ConfigurationProperties(prefix = "lingniu.ingest.gb32960") @ConfigurationProperties(prefix = "lingniu.ingest.gb32960")
public class Gb32960Properties { public class Gb32960Properties {
/** 是否启动 32960 TCP 监听。生产 32960 线路打开后,此开关决定端口是否真正 bind。 */
private boolean enabled = false; private boolean enabled = false;
/** 32960 TCP 监听端口;当前生产验证线使用 32960。 */
private int port = 9000; private int port = 9000;
/** Netty worker 线程数0 表示按 Netty/CPU 默认策略分配。 */
private int workerThreads = 0; private int workerThreads = 0;
/** /**

View File

@@ -17,7 +17,11 @@ import java.util.List;
* *
* <p>这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有 * <p>这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有
* GB32960 帧产出的事件丢失。构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制 * GB32960 帧产出的事件丢失。构造器注入 {@link Gb32960EventMapper},全部业务逻辑限制
* 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor → Kafka。 * 在纯函数里,不触碰数据库 / 不做 IO。产出的事件由 Dispatcher 统一投递到 Disruptor
* 下游 sink 再决定写 raw archive、DuckDB/Parquet 历史库或转发 Kafka。
*
* <p>当前 32960 专用 snapshot 查询不依赖 Realtime/Location 事件表,而是通过 raw archive URI
* 回读原始 .bin 并即时解码合并,避免不同车辆或不同子包之间做大范围 join。
*/ */
@ProtocolHandler(protocol = ProtocolId.GB32960) @ProtocolHandler(protocol = ProtocolId.GB32960)
public class Gb32960RealtimeHandler { public class Gb32960RealtimeHandler {
@@ -33,6 +37,7 @@ public class Gb32960RealtimeHandler {
@RateLimited(perVin = 50) @RateLimited(perVin = 50)
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class}) @EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class})
public List<VehicleEvent> onReport(Gb32960Message msg) { public List<VehicleEvent> onReport(Gb32960Message msg) {
// 这里保留 Realtime/Location 事件供实时消费者使用;历史全字段查询走 raw archive 解码。
return mapper.toEvents(msg); return mapper.toEvents(msg);
} }

View File

@@ -12,6 +12,9 @@ import java.net.InetSocketAddress;
* *
* <p>Keeps authentication and channel-scoped platform identity out of the Netty * <p>Keeps authentication and channel-scoped platform identity out of the Netty
* handler so the hot path can stay small and testable. * handler so the hot path can stay small and testable.
*
* <p>平台登入成功后username 会绑定到 Netty channel attribute。后续实时上报帧没有账号字段
* 只能通过同一连接上的 attribute 找回平台账号,用于 Hyundai/广东扩展等 vendor profile 选择。
*/ */
public final class Gb32960AccessService { public final class Gb32960AccessService {
@@ -50,10 +53,12 @@ public final class Gb32960AccessService {
public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username, public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username,
String password, String password,
Channel channel) { Channel channel) {
// 鉴权策略可以按来源 IP 做限制,因此这里把远端地址从 Netty channel 中抽出。
return platformAuthorizer.authenticate(username, password, peerIp(channel)); return platformAuthorizer.authenticate(username, password, peerIp(channel));
} }
public void bindPlatformAccount(Channel channel, String username) { public void bindPlatformAccount(Channel channel, String username) {
// 只绑定到当前连接,断线重连后必须重新平台登入,避免跨连接复用旧账号。
channel.attr(PLATFORM_ACCOUNT_ATTR).set(username); channel.attr(PLATFORM_ACCOUNT_ATTR).set(username);
} }

View File

@@ -15,6 +15,9 @@ import java.time.Instant;
/** /**
* Builds and writes GB/T 32960 response frames. * Builds and writes GB/T 32960 response frames.
*
* <p>ACK 需要尽量复用请求帧里的原始 17 字节 VIN。平台登入这类命令可能没有真实 VIN
* 如果把 trim 后的字符串重新 padding部分上级平台会认为应答 VIN 与请求不一致。
*/ */
public final class Gb32960AckService { public final class Gb32960AckService {
@@ -39,6 +42,7 @@ public final class Gb32960AckService {
Instant eventTime, Instant eventTime,
byte[] data, byte[] data,
String tag) { String tag) {
// rawVin 版本用于严格回显请求帧 VIN 字节,优先给入站 handler 使用。
byte[] ack = Gb32960FrameEncoder.buildResponse( byte[] ack = Gb32960FrameEncoder.buildResponse(
version, command, responseFlag, rawVin, eventTime, data); version, command, responseFlag, rawVin, eventTime, data);
write(channel, ack, tag); write(channel, ack, tag);
@@ -86,6 +90,7 @@ public final class Gb32960AckService {
channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> { channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> {
if (f.isSuccess()) { if (f.isSuccess()) {
if (highFrequency) { if (highFrequency) {
// 实时上报 ACK 频率高,默认降到 DEBUG避免生产日志被正常心跳/上报刷满。
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("[gb32960] {} flushed peer={} len={} hex={}", log.debug("[gb32960] {} flushed peer={} len={} hex={}",
tag, addr(channel), ack.length, hex); tag, addr(channel), ack.length, hex);

View File

@@ -27,6 +27,10 @@ import java.util.Map;
/** /**
* Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。 * Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。
* *
* <p>这是 32960 生产链路的入口边界。它只做协议层职责:解码、鉴权、应答、组装
* {@link RawFrame} 并投递 Dispatcher原始包归档、DuckDB/Parquet 索引、Kafka 转发都在
* 下游 sink 中完成,避免 Netty EventLoop 被存储 IO 阻塞。
*
* <p>认证逻辑: * <p>认证逻辑:
* <ul> * <ul>
* <li>{@link Gb32960VinAuthorizer#isEnforcing()} = false放行所有 VIN * <li>{@link Gb32960VinAuthorizer#isEnforcing()} = false放行所有 VIN
@@ -99,6 +103,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
String platformAccount = accessService.platformAccount(ctx.channel()); String platformAccount = accessService.platformAccount(ctx.channel());
msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount); msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount);
} catch (Exception e) { } catch (Exception e) {
// 解码失败只丢当前帧不主动断开连接。真实设备偶发脏字节时FrameDecoder 会继续寻找下一帧头。
log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e); log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e);
return; return;
} }
@@ -138,6 +143,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
if (platformAccount != null && !platformAccount.isBlank()) { if (platformAccount != null && !platformAccount.isBlank()) {
sourceMeta.put("platformAccount", platformAccount); sourceMeta.put("platformAccount", platformAccount);
} }
// RawFrame.rawBytes 是后续 raw archive 和按 rawArchiveUri 回查的源头;不要在这里裁剪或重编码。
RawFrame rf = new RawFrame( RawFrame rf = new RawFrame(
ProtocolId.GB32960, ProtocolId.GB32960,
cmd.code(), cmd.code(),
@@ -244,6 +250,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
* 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。 * 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。
* 部分车端实现严格按规范没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。 * 部分车端实现严格按规范没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
* *
* <p>注意:应答成功不代表下游已完成持久化,只表示平台已接收并通过 Dispatcher 投递。
* 生产排查存储问题时,应继续检查 raw archive sink、EventFileStoreSink 和 Kafka sink 的日志。
*
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏): * <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
* <ul> * <ul>
* <li>0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。 * <li>0x02 / 0x03 首次见到某 (vin, normal) → INFO 一条,含 peer/vin/platformAccount/version/parsedBlocks。
@@ -326,6 +335,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
String vin = msg.header().vin(); String vin = msg.header().vin();
CommandType cmd = msg.header().command(); CommandType cmd = msg.header().command();
if (cmd == CommandType.VEHICLE_LOGIN) { if (cmd == CommandType.VEHICLE_LOGIN) {
// 规范要求登入失败要回 ACK让终端能明确知道 VIN 不存在,而不是当作网络超时重试。
ackService.writeAndClose( ackService.writeAndClose(
ctx, ctx,
msg.header().protocolVersion(), msg.header().protocolVersion(),

View File

@@ -15,6 +15,9 @@ import java.util.Set;
/** /**
* Channel-scoped diagnostics for high-frequency GB/T 32960 reports. * Channel-scoped diagnostics for high-frequency GB/T 32960 reports.
*
* <p>每个 TCP channel 只记录有限个首次出现的 (vin, rawTypeSignature),用于发现厂商 profile
* 没命中、字段解析退化为 Raw 的问题。它不参与业务判断,也不会影响 raw archive 和历史查询。
*/ */
public final class Gb32960FrameDiagnostics { public final class Gb32960FrameDiagnostics {
@@ -76,6 +79,7 @@ public final class Gb32960FrameDiagnostics {
private void trimToLimit(Set<String> seen) { private void trimToLimit(Set<String> seen) {
while (seen.size() > maxKeysPerChannel) { while (seen.size() > maxKeysPerChannel) {
// LinkedHashSet 保持插入顺序,超限时淘汰最早的签名,避免异常设备导致 attribute 无界增长。
String first = seen.iterator().next(); String first = seen.iterator().next();
seen.remove(first); seen.remove(first);
} }

View File

@@ -32,6 +32,8 @@ import java.util.concurrent.ThreadFactory;
* GB/T 32960 Netty Server 启动器。 * GB/T 32960 Netty Server 启动器。
* *
* <p>EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。 * <p>EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。
* 端口是否启动由 {@code lingniu.ingest.gb32960.enabled} 控制,生产 32960 线应确认该开关、
* 端口、防火墙和上游平台账号同时匹配。
* *
* <p>支持可选 TLS{@link Gb32960Properties.Tls#isEnabled()} = true 时pipeline 前置 * <p>支持可选 TLS{@link Gb32960Properties.Tls#isEnabled()} = true 时pipeline 前置
* {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA默认要求客户端证书双向 TLS * {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA默认要求客户端证书双向 TLS
@@ -97,6 +99,7 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean {
.childHandler(new ChannelInitializer<SocketChannel>() { .childHandler(new ChannelInitializer<SocketChannel>() {
@Override @Override
protected void initChannel(SocketChannel ch) { protected void initChannel(SocketChannel ch) {
// Pipeline 顺序不能调换TLS 先解密FrameDecoder 再做拆包ChannelHandler 最后做业务应答和派发。
if (sslContext != null) { if (sslContext != null) {
ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc())); ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc()));
} }

View File

@@ -26,6 +26,9 @@ import java.util.stream.Collectors;
* *
* <p>实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选); * <p>实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选);
* 车辆登入/登出/心跳产出对应的会话事件。 * 车辆登入/登出/心跳产出对应的会话事件。
*
* <p>该 Mapper 只生成统一领域事件字段是面向实时总线的精简视图。32960 的完整包字段、
* 厂商扩展字段和 snapshot 合并字段由历史查询服务从 raw archive 重新解码,不在这里展开。
*/ */
public final class Gb32960EventMapper implements EventMapper<Gb32960Message> { public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
@@ -42,6 +45,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
CommandType cmd = header.command(); CommandType cmd = header.command();
if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) { if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) {
// 补发包与实时包进入同一套实时事件模型,差异保留在 metadata.command 里供下游判断。
buildRealtime(message, header, baseMeta, eventTime, ingestTime, out); buildRealtime(message, header, baseMeta, eventTime, ingestTime, out);
return out; return out;
} }
@@ -155,6 +159,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
null, meta, payload)); null, meta, payload));
} }
if (p != null) { if (p != null) {
// 位置事件只从 POSITION 子包生成。没有 POSITION 时不从整车数据推断经纬度,避免制造假定位。
LocationPayload loc = new LocationPayload( LocationPayload loc = new LocationPayload(
p.longitude, p.latitude, 0.0, p.longitude, p.latitude, 0.0,
v != null && v.speedKmh != null ? v.speedKmh : 0.0, v != null && v.speedKmh != null ? v.speedKmh : 0.0,
@@ -165,6 +170,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
null, meta, loc)); null, meta, loc));
} }
if (alarm != null && shouldEmitAlarm(alarm)) { if (alarm != null && shouldEmitAlarm(alarm)) {
// 只在存在告警等级或氢安全关键位时发 Alarm减少正常高频帧对告警消费者的噪声。
List<String> faultCodes = new ArrayList<>(); List<String> faultCodes = new ArrayList<>();
addFaultCodes(faultCodes, "BAT", alarm.batteryFaults); addFaultCodes(faultCodes, "BAT", alarm.batteryFaults);
addFaultCodes(faultCodes, "MOT", alarm.motorFaults); addFaultCodes(faultCodes, "MOT", alarm.motorFaults);

View File

@@ -274,6 +274,7 @@ public sealed interface InfoBlock
record MinParallelVoltage(int subSystemCount, List<Pack> packs) implements Gb32960V2025 { record MinParallelVoltage(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; } @Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; }
/** totalUnits 是车端声明数量frameVoltages 是本帧实际解析出的完整电压值。 */
public record Pack( public record Pack(
int packNo, int packNo,
Double packVoltageV, Double packVoltageV,
@@ -287,6 +288,7 @@ public sealed interface InfoBlock
record BatteryTemperature(int subSystemCount, List<Pack> packs) implements Gb32960V2025 { record BatteryTemperature(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
@Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; } @Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; }
/** probeCount 是车端声明数量probeTempsC 是本帧实际解析出的温度值。 */
public record Pack(int packNo, int probeCount, List<Integer> probeTempsC) {} public record Pack(int packNo, int probeCount, List<Integer> probeTempsC) {}
} }
@@ -298,6 +300,7 @@ public sealed interface InfoBlock
int stackNo, int stackNo,
Double voltageV, Double voltageV,
Double currentA, Double currentA,
// 标准字段名写 pressure协议比例按 kPa 命名,避免与 2016 Mpa 字段混淆。
Double hydrogenInletPressureKpa, Double hydrogenInletPressureKpa,
Double airInletPressureKpa, Double airInletPressureKpa,
Integer airInletTempC, Integer airInletTempC,

View File

@@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.jt1078") @ConfigurationProperties(prefix = "lingniu.ingest.jt1078")
public class Jt1078Properties { public class Jt1078Properties {
/** 是否启用 JT/T 1078 音视频能力;它不参与 32960 快照/历史字段查询。 */
private boolean enabled = false; private boolean enabled = false;
private MediaStream mediaStream = new MediaStream(); private MediaStream mediaStream = new MediaStream();
@@ -14,13 +15,18 @@ public class Jt1078Properties {
public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; } public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; }
public static class MediaStream { public static class MediaStream {
/** 媒体流子模块开关,便于只启用信令、不落媒体文件。 */
private boolean enabled = true; private boolean enabled = true;
/** TCP 媒体流监听,适合生产默认路径。 */
private boolean tcpEnabled = true; private boolean tcpEnabled = true;
/** UDP 媒体流监听默认关闭,开启前需要确认网络和包乱序处理能力。 */
private boolean udpEnabled = false; private boolean udpEnabled = false;
private int port = 11078; private int port = 11078;
private int udpPort = 11078; private int udpPort = 11078;
private int workerThreads = 0; private int workerThreads = 0;
/** 单包最大长度保护,防止异常媒体流耗尽内存。 */
private int maxPacketLength = 1024 * 1024; private int maxPacketLength = 1024 * 1024;
/** 媒体文件分段秒数,影响归档文件粒度。 */
private int segmentSeconds = 300; private int segmentSeconds = 300;
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }

View File

@@ -54,6 +54,7 @@ public final class Jt1078MediaArchiveService {
long segment = segment(packet.timestamp()); long segment = segment(packet.timestamp());
String key = key(vin, packet, segment); String key = key(vin, packet, segment);
try { try {
// JT1078 是 RTP 分片流,同一分段追加写入 .media只发布一次 MediaMeta 作为索引事件。
String uri = archive.append(key, packet.payload()); String uri = archive.append(key, packet.payload());
long segmentSizeBytes = archiveSize(key, packet.payload().length); long segmentSizeBytes = archiveSize(key, packet.payload().length);
publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes); publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes);
@@ -103,6 +104,7 @@ public final class Jt1078MediaArchiveService {
: archiveKey; : archiveKey;
String old = publishedSegmentRefs.getIfPresent(segmentKey); String old = publishedSegmentRefs.getIfPresent(segmentKey);
if (old != null) { if (old != null) {
// 同一个分段只发一次索引事件,避免每个 RTP 包都在历史库产生一条 media meta。
return; return;
} }
publishedSegmentRefs.put(segmentKey, uri); publishedSegmentRefs.put(segmentKey, uri);
@@ -166,6 +168,7 @@ public final class Jt1078MediaArchiveService {
private long segment(long timestamp) { private long segment(long timestamp) {
long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp; long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp;
// 按配置秒数对齐分段,保证同一路通道同一时间窗落到同一个 archive key。
return seconds - Math.floorMod(seconds, segmentSeconds); return seconds - Math.floorMod(seconds, segmentSeconds);
} }

View File

@@ -19,6 +19,7 @@ final class Jt1078RtpPacketParser {
String sim = readBcd(in, 6); String sim = readBcd(in, 6);
int channelId = in.readUnsignedByte(); int channelId = in.readUnsignedByte();
int dataAndPacketType = in.readUnsignedByte(); int dataAndPacketType = in.readUnsignedByte();
// 高 4 位是数据类型,低 4 位是分包类型;归档 key 需要二者共同区分。
int dataType = (dataAndPacketType >>> 4) & 0x0F; int dataType = (dataAndPacketType >>> 4) & 0x0F;
int packetType = dataAndPacketType & 0x0F; int packetType = dataAndPacketType & 0x0F;
long timestamp = in.readLong(); long timestamp = in.readLong();
@@ -37,6 +38,7 @@ final class Jt1078RtpPacketParser {
if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) { if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) {
return ""; return "";
} }
// UDP/TCP 解码器在完整 parse 前先取 SIM用于日志和初步身份定位不移动 readerIndex。
StringBuilder sb = new StringBuilder(12); StringBuilder sb = new StringBuilder(12);
int simOffset = in.readerIndex() + 6; int simOffset = in.readerIndex() + 6;
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {

View File

@@ -47,6 +47,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
int bodyLength = attrs & 0x03FF; int bodyLength = attrs & 0x03FF;
int encryptType = (attrs >> 10) & 0x07; int encryptType = (attrs >> 10) & 0x07;
boolean subpacket = (attrs & 0x2000) != 0; boolean subpacket = (attrs & 0x2000) != 0;
// JT/T 808-2019 通过属性 bit14 标识,终端手机号从 6B BCD 扩展到 10B BCD。
boolean v2019 = (attrs & 0x4000) != 0; boolean v2019 = (attrs & 0x4000) != 0;
Jt808Header.ProtocolVersion version = v2019 Jt808Header.ProtocolVersion version = v2019
@@ -64,6 +65,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
int totalPkgs = 0, pkgSeq = 0; int totalPkgs = 0, pkgSeq = 0;
int bodyStart = headerLen; int bodyStart = headerLen;
if (subpacket) { if (subpacket) {
// 分包头紧跟消息流水号之后;当前只暴露包总数/序号,重组由上游或后续模块处理。
totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF); totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF);
pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF); pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF);
bodyStart += 4; bodyStart += 4;
@@ -82,6 +84,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
BodyParser parser = registry.find(messageId); BodyParser parser = registry.find(messageId);
Jt808Body body; Jt808Body body;
if (parser == null) { if (parser == null) {
// 未实现的消息体保持 Raw仍可进入 archive/event-history避免协议扩展导致数据丢失。
byte[] raw = new byte[bodyLength]; byte[] raw = new byte[bodyLength];
bodyBuf.get(raw); bodyBuf.get(raw);
body = new Jt808Body.Raw(messageId, raw); body = new Jt808Body.Raw(messageId, raw);

View File

@@ -4,8 +4,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "lingniu.ingest.jt808") @ConfigurationProperties(prefix = "lingniu.ingest.jt808")
public class Jt808Properties { public class Jt808Properties {
/** 是否启动 JT/T 808 TCP 监听。与 GB32960 独立,通常用于部标终端。 */
private boolean enabled = false; private boolean enabled = false;
/** JT808 默认监听端口;不要与 32960 端口共用。 */
private int port = 10808; private int port = 10808;
/** Netty worker 线程数0 表示使用框架默认值。 */
private int workerThreads = 0; private int workerThreads = 0;
public boolean isEnabled() { return enabled; } public boolean isEnabled() { return enabled; }

View File

@@ -67,6 +67,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
CompletableFuture<Jt808Message> pending = pendingRequests.await(ctx.phone, ctx.serial); CompletableFuture<Jt808Message> pending = pendingRequests.await(ctx.phone, ctx.serial);
writeFrames(ctx, true).whenComplete((unused, ex) -> { writeFrames(ctx, true).whenComplete((unused, ex) -> {
if (ex != null) { if (ex != null) {
// 写 channel 失败时必须移除 pending否则后续同 phone/serial 重用会匹配到旧 future。
pendingRequests.cancel(ctx.phone, ctx.serial); pendingRequests.cancel(ctx.phone, ctx.serial);
pending.completeExceptionally(ex); pending.completeExceptionally(ex);
} }
@@ -74,6 +75,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
.whenComplete((r, ex) -> { .whenComplete((r, ex) -> {
// 超时/取消同样清理 pending避免长期运行后内存泄漏。
if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial); if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial);
}) })
.thenApply(msg -> { .thenApply(msg -> {
@@ -122,6 +124,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
Optional<DeviceSession> s = sessions.findBySessionId(sessionId) Optional<DeviceSession> s = sessions.findBySessionId(sessionId)
.or(() -> sessions.findByPhone(sessionId)) .or(() -> sessions.findByPhone(sessionId))
.or(() -> sessions.findByVin(sessionId)); .or(() -> sessions.findByVin(sessionId));
// HTTP 调用方可以传 sessionId/VIN/phone最终写 Netty Channel 必须落到 JT808 phone。
return s.map(DeviceSession::phone) return s.map(DeviceSession::phone)
.filter(p -> p != null && !p.isBlank()) .filter(p -> p != null && !p.isBlank())
.orElse(sessionId); // 回落:直接把 sessionId 当 phone .orElse(sessionId); // 回落:直接把 sessionId 当 phone

View File

@@ -105,6 +105,8 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
try { try {
processDecodedFrame(ctx, frame, msg); processDecodedFrame(ctx, frame, msg);
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 解码成功但业务处理失败时仍派发一条 malformed RawFrame
// 保证原始帧能被 archive/event-file-store 留痕,方便事后排查。
log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}", log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}",
addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage()); addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage());
log.debug("[jt808] processing failed stack peer={}", addr(ctx), e); log.debug("[jt808] processing failed stack peer={}", addr(ctx), e);
@@ -175,11 +177,13 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
private IdentityResolution resolveIdentity(Jt808Message msg) { private IdentityResolution resolveIdentity(Jt808Message msg) {
String phone = msg.header().phone(); String phone = msg.header().phone();
if (identityResolver == null) { if (identityResolver == null) {
// 没有绑定表时用手机号兜底为 VIN保证会话/下行仍可按 phone 找到连接。
return new IdentityResolution( return new IdentityResolution(
new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE), new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE),
null); null);
} }
try { try {
// 优先把 JT808 phone/device/plate 映射到内部 VIN后续所有事件都用内部 VIN 做主键。
return new IdentityResolution( return new IdentityResolution(
identityResolver.resolve(new VehicleIdentityLookup( identityResolver.resolve(new VehicleIdentityLookup(
ProtocolId.JT808, ProtocolId.JT808,
@@ -206,6 +210,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) { private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) {
String message = errorMessage == null ? "" : errorMessage; String message = errorMessage == null ? "" : errorMessage;
// 无法解出 header 的坏帧也进入 Dispatcher后续 archive sink 可以保留 raw bytes。
Jt808Message malformed = new Jt808Message( Jt808Message malformed = new Jt808Message(
new Jt808Header(0, frame == null ? 0 : frame.length, 0, false, new Jt808Header(0, frame == null ? 0 : frame.length, 0, false,
Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0), Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0),

View File

@@ -168,6 +168,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
private IdentityResolution resolveIdentity(Jt808Message message) { private IdentityResolution resolveIdentity(Jt808Message message) {
if (message.body() instanceof Jt808Body.Malformed) { if (message.body() instanceof Jt808Body.Malformed) {
// 坏帧没有可信 header/body统一 unknown原始字节仍由 passthrough 事件保留。
return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN)); return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN));
} }
var header = message.header(); var header = message.header();
@@ -212,6 +213,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
Instant ingestTime) { Instant ingestTime) {
List<VehicleEvent> out = new ArrayList<>(batch.locations().size()); List<VehicleEvent> out = new ArrayList<>(batch.locations().size());
Map<String, String> batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType())); Map<String, String> batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType()));
// 批量位置拆成多条 Location 事件,方便历史库按单点时间查询和导出。
for (Jt808Body.Location loc : batch.locations()) { for (Jt808Body.Location loc : batch.locations()) {
out.add(locationEvent(vin, batchMeta, loc, ingestTime)); out.add(locationEvent(vin, batchMeta, loc, ingestTime));
} }
@@ -224,6 +226,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
Instant ingestTime) { Instant ingestTime) {
List<VehicleEvent> out = new ArrayList<>(2); List<VehicleEvent> out = new ArrayList<>(2);
if (media.location() != null) { if (media.location() != null) {
// 多媒体上传消息内嵌位置时同时产出 Location避免只看到媒体元数据看不到拍摄位置。
out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())), out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())),
media.location(), ingestTime)); media.location(), ingestTime));
} }

View File

@@ -31,6 +31,7 @@ public final class Jt808ChannelRegistry {
reverse.put(channel, phone); reverse.put(channel, phone);
if (old != null && old != channel) { if (old != null && old != channel) {
reverse.remove(old); reverse.remove(old);
// 同一 phone 新连接上线时关闭旧 channel避免下行命令写到已被终端替换的连接。
old.close(); old.close();
log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id()); 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) { public int nextSerial(String phone) {
// JT808 流水号 16 bit无符号回绕到 0pending 匹配也使用这个序号。
return serials.computeIfAbsent(phone, k -> new AtomicInteger()) return serials.computeIfAbsent(phone, k -> new AtomicInteger())
.updateAndGet(i -> (i + 1) & 0xFFFF); .updateAndGet(i -> (i + 1) & 0xFFFF);
} }

View File

@@ -23,6 +23,7 @@ public final class Jt808PendingRequests {
public CompletableFuture<Jt808Message> await(String phone, int serial) { public CompletableFuture<Jt808Message> await(String phone, int serial) {
CompletableFuture<Jt808Message> cf = new CompletableFuture<>(); CompletableFuture<Jt808Message> cf = new CompletableFuture<>();
// JT808 平台下行流水号按终端 phone 独立递增,因此 phone+serial 才能唯一定位一次请求。
pending.put(key(phone, serial), cf); pending.put(key(phone, serial), cf);
return cf; return cf;
} }

View File

@@ -13,6 +13,13 @@ import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
/**
* 统一 API 错误响应。
*
* <p>业务接口抛出的参数错误、时间格式错误和 Spring 参数绑定错误都会转成包含具体原因的
* JSON而不是让前端只能看到默认 400/500。这里的 timestamp 仅表示错误发生时间,
* 按东八区展示;业务数据里的 eventTime/ingestTime 不在这里改时区。
*/
@RestControllerAdvice @RestControllerAdvice
public final class ApiExceptionHandler { public final class ApiExceptionHandler {
@@ -65,6 +72,7 @@ public final class ApiExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> generic(Exception ex, public ResponseEntity<ApiError> generic(Exception ex,
HttpServletRequest request) { HttpServletRequest request) {
// 保留异常 message 方便联调定位;底层如果没有 message则退回通用文案。
return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request); return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request);
} }

View File

@@ -19,6 +19,12 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 通用历史事件查询接口。
*
* <p>这是跨协议的低层记录查询,返回的是 EventFileStore 中的 envelope/snapshot 记录。
* GB32960 业务查询应优先使用 {@link Gb32960FrameController},因为它会回读 RAW 并按协议展开全部字段。
*/
@RestController @RestController
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") @ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
@ConditionalOnBean(EventFileStore.class) @ConditionalOnBean(EventFileStore.class)
@@ -158,6 +164,7 @@ public class EventHistoryController {
if (!fields.isArray()) { if (!fields.isArray()) {
return Map.of(); return Map.of();
} }
// 通用导出只识别 telemetry snapshot 的扁平 fields复杂 GB32960 block 字段用专用 CSV 接口。
Map<String, String> out = new LinkedHashMap<>(); Map<String, String> out = new LinkedHashMap<>();
for (JsonNode field : fields) { for (JsonNode field : fields) {
String key = field.path("key").asText(""); String key = field.path("key").asText("");

View File

@@ -12,6 +12,9 @@ import java.io.IOException;
/** /**
* Consumer-side entry point that stores one Kafka envelope value into the * Consumer-side entry point that stores one Kafka envelope value into the
* historical detail file store. * historical detail file store.
*
* <p>当前 32960 本机运行配置没有启用 Kafka consumer这个类保留给“其他服务把 envelope
* 写回历史库”的解耦部署方式。失败时返回结构化结果,由 Kafka worker 决定是否进 DLQ。
*/ */
public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor { public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
@@ -44,10 +47,12 @@ public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
store.append(record); store.append(record);
return EnvelopeIngestResult.stored(record.eventId(), record.vin()); return EnvelopeIngestResult.stored(record.eventId(), record.vin());
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
// 协议不可解析或缺少必要字段属于不可重试问题,避免 Kafka consumer 无限重放。
return envelope == null return envelope == null
? EnvelopeIngestResult.invalid(ex.getMessage()) ? EnvelopeIngestResult.invalid(ex.getMessage())
: EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage()); : EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage());
} catch (IOException ex) { } catch (IOException ex) {
// 文件库写入失败可能是临时 I/O 问题,交给上层 worker 按失败处理。
return EnvelopeIngestResult.failed( return EnvelopeIngestResult.failed(
envelope == null ? "" : envelope.getEventId(), envelope == null ? "" : envelope.getEventId(),
envelope == null ? "" : envelope.getVin(), envelope == null ? "" : envelope.getVin(),

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,13 @@ import java.time.LocalDateTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
/**
* 查询时间范围解析器。
*
* <p>HTTP 查询允许传日期、带时区时间或不带时区的本地时间:
* 日期用于分区裁剪,不带时区的时间统一按 Asia/Shanghai 转成 UTC Instant。
* 返回值同时包含 LocalDate 分区边界和精确到秒/毫秒的 eventTime 边界。
*/
final class QueryTimeRange { final class QueryTimeRange {
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai"); private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");
@@ -56,6 +63,7 @@ final class QueryTimeRange {
} }
String value = raw.trim(); String value = raw.trim();
if (value.length() == 10) { if (value.length() == 10) {
// 纯日期查询覆盖东八区自然日;结束日期取当天最后一毫秒,兼容 dateTo=2026-06-23。
LocalDate date = LocalDate.parse(value); LocalDate date = LocalDate.parse(value);
Instant instant = endOfRange Instant instant = endOfRange
? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1) ? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1)
@@ -63,9 +71,11 @@ final class QueryTimeRange {
return new Boundary(date, instant); return new Boundary(date, instant);
} }
try { try {
// 带 Z 或 +08:00 的时间按调用方显式时区解析。
Instant instant = OffsetDateTime.parse(value).toInstant(); Instant instant = OffsetDateTime.parse(value).toInstant();
return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant); return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant);
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// 不带时区的前端输入按东八区解释,接口返回的数据时间本身仍保持原始 UTC 字符串。
LocalDateTime local = LocalDateTime.parse(value); LocalDateTime local = LocalDateTime.parse(value);
Instant instant = local.atZone(DEFAULT_ZONE).toInstant(); Instant instant = local.atZone(DEFAULT_ZONE).toInstant();
return new Boundary(local.toLocalDate(), instant); return new Boundary(local.toLocalDate(), instant);

View File

@@ -13,6 +13,10 @@ import java.util.Map;
/** /**
* Maps Kafka protobuf envelopes into records stored by the historical detail * Maps Kafka protobuf envelopes into records stored by the historical detail
* file store. * file store.
*
* <p>这是跨协议 Kafka envelope 回灌历史库的通用适配器。它保存的是 protobuf
* telemetry_snapshot 的 JSON 视图,不负责 GB32960 原始包解码32960 全字段 snapshot
* 查询仍以 rawArchiveUri 指向的 .bin 为准。
*/ */
public final class TelemetryEnvelopeRecordMapper { public final class TelemetryEnvelopeRecordMapper {
@@ -28,6 +32,7 @@ public final class TelemetryEnvelopeRecordMapper {
ProtocolId protocol = protocol(envelope.getSource()); ProtocolId protocol = protocol(envelope.getSource());
Map<String, String> metadata = new LinkedHashMap<>(envelope.getMetadataMap()); Map<String, String> metadata = new LinkedHashMap<>(envelope.getMetadataMap());
if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) { if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) {
// 保留未知 source 原文,避免历史库标准化成 UNKNOWN 后丢失排障线索。
metadata.putIfAbsent("originalSource", envelope.getSource()); metadata.putIfAbsent("originalSource", envelope.getSource());
} }
return new EventFileRecord( return new EventFileRecord(

View File

@@ -22,6 +22,18 @@ import org.springframework.context.annotation.Bean;
import java.net.URI; import java.net.URI;
import java.nio.file.Path; import java.nio.file.Path;
/**
* Event History 查询/消费服务自动装配。
*
* <p>该模块有两种入口:
* <ul>
* <li>HTTP 查询入口:直接查询 {@link EventFileStore}GB32960 专用接口还会回读 RAW archive
* <li>Kafka consumer 入口:把其他服务投递的 envelope 再写入 {@link EventFileStore}
* </ul>
*
* <p>当前 32960 单体运行模式通常只启用 HTTP 查询和本地 event-file-store
* Kafka consumer 是否启动还取决于 {@code lingniu.ingest.sink.mq.consumer.enabled=true}。
*/
@AutoConfiguration @AutoConfiguration
@AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class}) @AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class})
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true") @ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
@@ -46,6 +58,7 @@ public class EventHistoryAutoConfiguration {
@ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor") @ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor")
public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor, public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor,
EnvelopeDeadLetterSink deadLetterSink) { EnvelopeDeadLetterSink deadLetterSink) {
// 只注册 processor真正拉 Kafka 的 runner 在 sink-mq 模块按 consumer.enabled 决定是否创建。
return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink); return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink);
} }
@@ -62,6 +75,7 @@ public class EventHistoryAutoConfiguration {
public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store, public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store,
Gb32960MessageDecoder decoder, Gb32960MessageDecoder decoder,
SinkArchiveProperties archiveProperties) { SinkArchiveProperties archiveProperties) {
// snapshot/frame 查询通过 EventFileStore 找到 rawArchiveUri再从 archiveRoot 读取 .bin 即时解码。
return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null); return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null);
} }
@@ -76,6 +90,7 @@ public class EventHistoryAutoConfiguration {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive"); return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive");
} }
// 支持 file:// URI便于部署配置里统一用 URI 风格表达 archive 根路径。
if (value.startsWith("file://")) { if (value.startsWith("file://")) {
return Path.of(URI.create(value)); return Path.of(URI.create(value));
} }

View File

@@ -31,6 +31,8 @@ public final class DailyMileageCalculator {
return OptionalDouble.empty(); return OptionalDouble.empty();
} }
// 两种策略都只基于同一 VIN 的里程点计算,不能跨车辆 join
// 上层 repository 负责按 VIN 过滤,避免大车队查询时扩大扫描面。
double value = switch (strategy) { double value = switch (strategy) {
case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points); case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points);
case DAY_MAX_MINUS_DAY_MIN -> dayMaxMinusDayMin(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<MileagePoint> points) { private double currentLastMinusPreviousLast(LocalDate statDate, List<MileagePoint> points) {
// 适用于累计里程单调递增的设备:当天最后一帧减前一日最后一帧。
Optional<MileagePoint> previousLast = points.stream() Optional<MileagePoint> previousLast = points.stream()
.filter(point -> localDate(point).isBefore(statDate)) .filter(point -> localDate(point).isBefore(statDate))
.max(Comparator.comparing(MileagePoint::eventTime)); .max(Comparator.comparing(MileagePoint::eventTime));
@@ -53,6 +56,7 @@ public final class DailyMileageCalculator {
} }
private double dayMaxMinusDayMin(LocalDate statDate, List<MileagePoint> points) { private double dayMaxMinusDayMin(LocalDate statDate, List<MileagePoint> points) {
// 兜底策略:仅使用当天数据,适合缺少前一日最后点但当天点足够的场景。
List<MileagePoint> currentDay = points.stream() List<MileagePoint> currentDay = points.stream()
.filter(point -> localDate(point).isEqual(statDate)) .filter(point -> localDate(point).isEqual(statDate))
.toList(); .toList();

View File

@@ -29,6 +29,7 @@ public final class DailyVehicleStatService {
public Optional<VehicleDailyStatResult> calculateAndSave(String vin, LocalDate statDate) { public Optional<VehicleDailyStatResult> calculateAndSave(String vin, LocalDate statDate) {
VehicleStatRule rule = ruleRepository.ruleFor(vin); VehicleStatRule rule = ruleRepository.ruleFor(vin);
// 统计是从里程点二次计算出来的派生数据,不作为 32960 全字段历史查询的数据源。
OptionalDouble dailyMileage = mileageCalculator.calculate( OptionalDouble dailyMileage = mileageCalculator.calculate(
statDate, statDate,
rule.dailyMileageStrategy(), rule.dailyMileageStrategy(),

Some files were not shown because too many files have changed in this diff Show More