docs: add detailed 32960 pipeline comments
This commit is contained in:
@@ -202,6 +202,8 @@ lingniu:
|
||||
path: ./archive/
|
||||
event-file-store:
|
||||
enabled: ${EVENT_FILE_STORE_ENABLED:true}
|
||||
# 32960 历史索引库根路径。这里保存 Parquet 分区和 DuckDB sidecar 索引;
|
||||
# 全字段查询仍会按 rawArchiveUri 回读 archive 根目录下的 .bin 原始包。
|
||||
path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
|
||||
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
|
||||
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:500}
|
||||
|
||||
@@ -11,4 +11,5 @@ import org.springframework.web.servlet.DispatcherServlet;
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
@ComponentScan(basePackageClasses = TerminalCommandController.class)
|
||||
public class CommandGatewayAutoConfiguration {
|
||||
// 只扫描 REST 下行命令网关;不参与 32960 接收、RAW 冷存或历史查询链路。
|
||||
}
|
||||
|
||||
@@ -205,12 +205,14 @@ public class TerminalCommandController {
|
||||
// ===== helpers =====
|
||||
|
||||
private Optional<DeviceSession> resolveSession(String clientId) {
|
||||
// 运维侧常拿到 VIN、手机号或 sessionId 中任意一个;这里按三索引兜底解析到在线连接。
|
||||
return sessions.findBySessionId(clientId)
|
||||
.or(() -> sessions.findByPhone(clientId))
|
||||
.or(() -> sessions.findByVin(clientId));
|
||||
}
|
||||
|
||||
private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
|
||||
// 需要终端应答的命令走 request-response,超时时间由网关统一兜底,避免 HTTP 线程无限等待。
|
||||
CompletableFuture<Jt808Message> future =
|
||||
dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT);
|
||||
try {
|
||||
@@ -229,6 +231,7 @@ public class TerminalCommandController {
|
||||
|
||||
private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
|
||||
try {
|
||||
// ACK 类命令不等待终端回包;只确认写入在线 session 对应的 channel。
|
||||
dispatcher.notify(clientId, cmd).join();
|
||||
return CommandResult.ok(Map.of("op", op));
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -8,15 +8,23 @@ import java.lang.annotation.Target;
|
||||
/**
|
||||
* 批量异步处理:框架把多次同类调用聚合成 {@code List} 交给方法。
|
||||
*
|
||||
* <p>处理方法签名需为 {@code void foo(List<T> list)} 或返回 {@code List<VehicleEvent>}。
|
||||
* <p>处理方法签名需接收 {@code List<T>},返回 {@code List<VehicleEvent>} 或单个
|
||||
* {@code VehicleEvent}。Dispatcher 不直接调用目标方法,而是交给
|
||||
* {@code AsyncBatchExecutor} 按 size/waitMs 聚合后调用。
|
||||
*
|
||||
* <p>注意:当调用方需要给每条事件补不同的 rawArchiveUri 等逐条元数据时,执行器会按单条 flush,
|
||||
* 以保证事件与原始帧一一对应。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface AsyncBatch {
|
||||
|
||||
/** 达到该批量大小后立即 flush。 */
|
||||
int size() default 1000;
|
||||
|
||||
/** 第一条消息入队后最多等待毫秒数,避免低流量车辆长期不出批。 */
|
||||
long waitMs() default 500;
|
||||
|
||||
/** 每个 Handler 方法对应的批处理 worker 数。 */
|
||||
int poolSize() default 2;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import java.lang.annotation.Target;
|
||||
* 声明幂等键,由 Dedup 拦截器使用。支持 SpEL 表达式,上下文根对象为 Handler 参数。
|
||||
*
|
||||
* <p>示例:{@code @IdempotentKey("#msg.vin + ':' + #msg.seq + ':' + #msg.eventTime")}
|
||||
*
|
||||
* <p>当前内置去重主要基于 RawFrame sourceMeta/raw bytes;该注解保留给更细粒度 Handler 级去重。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
|
||||
@@ -14,14 +14,20 @@ import java.lang.annotation.Target;
|
||||
* <li>JT/T 808:{@code command} = 消息 ID(0x0100 / 0x0200 / ...);{@code infoType} 留空
|
||||
* <li>MQTT:{@code command} 可为 topic hash 或忽略
|
||||
* </ul>
|
||||
*
|
||||
* <p>同一个方法可以声明多个 command/infoType。Dispatcher 会按协议、command、infoType 精确路由;
|
||||
* 32960 实时上报解析后通常由 0x02/0x03 主命令加信息体 ID 决定落到哪个 Handler。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
public @interface MessageMapping {
|
||||
|
||||
/** 协议主命令或消息 ID,空数组表示该维度不限制。 */
|
||||
int[] command() default {};
|
||||
|
||||
/** 协议子类型,GB32960 中对应信息体 ID;空数组表示该维度不限制。 */
|
||||
int[] infoType() default {};
|
||||
|
||||
/** 仅用于日志/Swagger/排障展示,不参与路由。 */
|
||||
String desc() default "";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。超限消息直接进 DLQ 并打点。
|
||||
* 单 VIN 速率限制声明。
|
||||
*
|
||||
* <p>当前内置限流器在 Pipeline before 阶段按 VIN 中止处理,超限帧不会进入 Handler 和下游存储。
|
||||
* 如果需要把超限帧写入 DLQ,应在拦截器或入口层显式增加对应 sink。
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.METHOD)
|
||||
|
||||
@@ -33,6 +33,8 @@ public final class EnvelopeConsumerProcessor {
|
||||
throw new IllegalArgumentException("record must not be null");
|
||||
}
|
||||
EnvelopeIngestResult result = ingestor.tryIngest(record.payload());
|
||||
// 派生消费者不在这里抛出业务异常给 Kafka worker;坏消息统一进入 DLQ,
|
||||
// worker 看到 process 正常返回后才能提交 offset,避免同一坏消息无限阻塞消费组。
|
||||
if (DEAD_LETTER_STATUSES.contains(result.status())) {
|
||||
deadLetterSink.publish(toDeadLetter(record, result));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared raw-archive key conventions used by Dispatcher, archive sink, and query services.
|
||||
*
|
||||
* <p>32960 生产链路里,RAW .bin 文件本体按 {@code 日期/协议/VIN/eventId.bin} 落在 archive 根目录,
|
||||
* DuckDB 只保存 {@code archive://...} 引用和查询索引。这个类集中维护 key 规则,避免接收端、落盘端、
|
||||
* snapshot 查询端对目录分区的理解不一致。</p>
|
||||
*/
|
||||
public final class RawArchiveKeys {
|
||||
|
||||
@@ -23,6 +27,7 @@ public final class RawArchiveKeys {
|
||||
}
|
||||
|
||||
public static String key(Instant ingestTime, ProtocolId source, String vin, String eventId) {
|
||||
// 分区日期使用 ingestTime 的东八区自然日,和磁盘目录保持一致;eventTime 只代表车端上报时间。
|
||||
String dateKey = DATE_KEY.format(ingestTime == null ? Instant.EPOCH : ingestTime);
|
||||
String safeVin = vin == null || vin.isBlank() ? "unknown-vin" : vin;
|
||||
String safeEventId = eventId == null || eventId.isBlank()
|
||||
|
||||
@@ -5,6 +5,10 @@ package com.lingniu.ingest.api.event;
|
||||
*
|
||||
* <p>Protocol mappers use stable internal field keys so Kafka, Parquet, Redis,
|
||||
* and statistics do not depend on protocol-specific names.
|
||||
*
|
||||
* <p>{@code value} 统一用字符串承载,真实类型由 {@code valueType} 表达。这样 Kafka
|
||||
* protobuf、CSV 导出、DuckDB JSON 字段和前端展示可以共用同一份字段结构;数值精度/格式化
|
||||
* 由查询层或前端按 {@code valueType + unit} 决定。
|
||||
*/
|
||||
public record TelemetryFieldValue(
|
||||
String key,
|
||||
@@ -24,6 +28,7 @@ public record TelemetryFieldValue(
|
||||
}
|
||||
value = value == null ? "" : value;
|
||||
unit = unit == null ? "" : unit;
|
||||
// 默认 GOOD,只有解析器明确知道异常/无效/缺失时才降级,避免调用方到处补质量字段。
|
||||
quality = quality == null ? Quality.GOOD : quality;
|
||||
sourcePath = sourcePath == null ? "" : sourcePath;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ public record TelemetrySnapshot(
|
||||
rawArchiveUri = rawArchiveUri == null ? "" : rawArchiveUri;
|
||||
metadata = Map.copyOf(metadata == null ? Map.of() : metadata);
|
||||
fields = List.copyOf(fields == null ? List.of() : fields);
|
||||
// snapshot/fields 查询按 key 投影和导出,重复 key 会导致前端列和 CSV 表头不稳定,构造期直接拒绝。
|
||||
validateUniqueKeys(fields);
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ public record TelemetrySnapshot(
|
||||
if (key == null || key.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
// fields 保持插入顺序,单字段查询走线性查找;批量查询应使用 fieldsByKey 建索引。
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
if (field.key().equals(key)) {
|
||||
return Optional.of(field);
|
||||
@@ -70,6 +72,7 @@ public record TelemetrySnapshot(
|
||||
for (TelemetryFieldValue field : fields) {
|
||||
index.put(field.key(), field);
|
||||
}
|
||||
// LinkedHashMap 保留原始字段顺序,CSV 导出和 Swagger 示例能稳定复现解析器顺序。
|
||||
return Map.copyOf(index);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,11 +141,11 @@ public sealed interface VehicleEvent
|
||||
) implements VehicleEvent {}
|
||||
|
||||
/**
|
||||
* 原始报文冷存事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节交
|
||||
* {@code ArchiveEventSink} 写入 ArchiveStore。Kafka sink 默认不处理本类型
|
||||
* (见 {@code KafkaEventSink.accepts})。
|
||||
* 原始报文归档事件:每条成功解码的入站帧由 Dispatcher 产出一条,携带原始字节和归档索引信息。
|
||||
* 32960 历史全字段查询依赖它对应的 {@code archive://...} 文件能够被回读。
|
||||
*
|
||||
* <p>key 组装建议:{@code yyyy/MM/dd/<source>/<vin>/<eventId>.bin},具体由 sink 实现决定。
|
||||
* <p>当前 {@code KafkaEventSink} 默认不发送本类型,{@code EventFileStoreSink} 只保存索引不保存
|
||||
* bytes;因此启用新 raw 落盘/转发实现时,必须同时保证 {@link RawArchiveKeys} 的 key 规则一致。
|
||||
*
|
||||
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
|
||||
* @param infoType 协议子类型(可为 0),同上
|
||||
|
||||
@@ -5,6 +5,9 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次消息处理的上下文,贯穿整个拦截链与 Handler。线程不共享,不需要同步。
|
||||
*
|
||||
* <p>attributes 用于在入口、拦截器、Dispatcher 之间传递轻量元数据,例如 traceId、归档 URI、
|
||||
* 鉴权结果等;不要放大对象或长期缓存,避免高频上报时放大内存占用。
|
||||
*/
|
||||
public final class IngestContext {
|
||||
|
||||
@@ -32,6 +35,7 @@ public final class IngestContext {
|
||||
}
|
||||
|
||||
public void abort(String reason) {
|
||||
// reason 会进入日志/诊断信息,保持短小、机器可读,方便定位是去重、限流还是鉴权失败。
|
||||
this.aborted = true;
|
||||
this.abortReason = reason;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.util.Map;
|
||||
* @param rawBytes 原始字节,用于冷存与排障(可能为 null,按配置开关)
|
||||
* @param sourceMeta 来源元数据:peer ip、端口、session id、topic 等
|
||||
* @param receivedAt 服务器接收时刻
|
||||
*
|
||||
* <p>RawFrame 是所有协议进入统一 Pipeline 的边界对象。32960 TCP 入口会把 VIN、seq、
|
||||
* rawArchiveUri 等关键索引放进 sourceMeta,后续去重、快照和历史查询都依赖这些字段保持稳定。
|
||||
*/
|
||||
public record RawFrame(
|
||||
ProtocolId protocolId,
|
||||
|
||||
@@ -135,6 +135,8 @@ public final class AsyncBatchExecutor implements AutoCloseable {
|
||||
|
||||
private void flush(List<BatchItem> buf) {
|
||||
if (requiresPerItemTransform(buf)) {
|
||||
// rawArchiveUri/rawSize 等元数据是每帧唯一的。为了不把第一条帧的元数据错误复制给整批事件,
|
||||
// 一旦存在逐条 transformer,就按单条调用 Handler,再逐条发布。
|
||||
for (BatchItem item : buf) {
|
||||
flushTransformedItem(item);
|
||||
}
|
||||
@@ -161,6 +163,7 @@ public final class AsyncBatchExecutor implements AutoCloseable {
|
||||
private List<VehicleEvent> invoke(List<Object> messages) {
|
||||
List<VehicleEvent> events;
|
||||
try {
|
||||
// Handler 仍然只暴露一个批量签名;是否批满或超时由 Batcher 统一处理。
|
||||
Object result = def.method().invoke(def.bean(), messages);
|
||||
events = switch (result) {
|
||||
case null -> List.of();
|
||||
|
||||
@@ -43,6 +43,7 @@ public final class DisruptorEventBus implements AutoCloseable {
|
||||
EventHandler<VehicleEventSlot>[] handlers = sinks.stream()
|
||||
.map(this::toHandler)
|
||||
.toArray(EventHandler[]::new);
|
||||
// 每个 sink 一个独立 handler,事件按扇出模式同时写 Kafka、event-file-store 等目标。
|
||||
disruptor.handleEventsWith(handlers);
|
||||
disruptor.start();
|
||||
log.info("DisruptorEventBus started ringBuffer={} wait={} sinks={}",
|
||||
@@ -66,6 +67,7 @@ public final class DisruptorEventBus implements AutoCloseable {
|
||||
if (e == null || !sink.accepts(e)) return;
|
||||
try {
|
||||
sink.publish(e).exceptionally(ex -> {
|
||||
// sink 异步失败只计数和打日志,不反向阻塞入站连接;生产侧靠 sink 自身重试/DLQ 保证可观测。
|
||||
failed.incrementAndGet();
|
||||
log.warn("sink {} publish failed", sink.name(), ex);
|
||||
return null;
|
||||
|
||||
@@ -21,6 +21,10 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* ingest-core 的自动装配入口。所有 Bean 都是 {@code @ConditionalOnMissingBean},便于下游替换。
|
||||
*
|
||||
* <p>生产链路顺序:入口收到 {@code RawFrame} → {@link InterceptorChain} 做准入 →
|
||||
* {@link Dispatcher} 定位协议 Handler → Handler 产出 {@code VehicleEvent} →
|
||||
* {@link DisruptorEventBus} 扇出到 Archive、DuckDB、Kafka 等 {@code EventSink}。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(IngestCoreProperties.class)
|
||||
@@ -46,6 +50,7 @@ public class IngestCoreAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.pipeline.dedup", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public DedupInterceptor dedupInterceptor(IngestCoreProperties props) {
|
||||
// 去重尽量放在限流前,重复包不应消耗后续每 VIN QPS 配额。
|
||||
return new DedupInterceptor(props.getDedup().getCacheSize(), props.getDedup().getTtlSeconds());
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ public class IngestCoreProperties {
|
||||
public void setRateLimit(RateLimit rateLimit) { this.rateLimit = rateLimit; }
|
||||
|
||||
public static class Disruptor {
|
||||
/** 事件总线 ring buffer 大小;生产环境应保持 2 的幂,避免 Disruptor 初始化失败。 */
|
||||
private int ringBufferSize = 131072;
|
||||
/** waiting 策略直接影响延迟和 CPU 占用,默认 yielding 偏低延迟。 */
|
||||
private String waitStrategy = "yielding";
|
||||
private String producerType = "multi";
|
||||
|
||||
@@ -33,7 +35,9 @@ public class IngestCoreProperties {
|
||||
|
||||
public static class Dedup {
|
||||
private boolean enabled = true;
|
||||
/** 单实例缓存的幂等键数量上限,按 VIN 数和重发窗口估算。 */
|
||||
private int cacheSize = 200000;
|
||||
/** 幂等键保留窗口;过短会放过重发包,过长会增加内存压力。 */
|
||||
private long ttlSeconds = 600;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
@@ -45,7 +49,9 @@ public class IngestCoreProperties {
|
||||
}
|
||||
|
||||
public static class RateLimit {
|
||||
/** 每 VIN 每秒进入 Handler 的最大帧数,默认按 32960 高频实时上报预留。 */
|
||||
private int perVinQps = 50;
|
||||
/** VIN limiter 缓存上限;覆盖活跃车辆数即可,淘汰后会重新创建 limiter。 */
|
||||
private int maxVins = 100000;
|
||||
|
||||
public int getPerVinQps() { return perVinQps; }
|
||||
|
||||
@@ -43,6 +43,7 @@ public class AnnotationHandlerBeanPostProcessor implements BeanPostProcessor {
|
||||
int[] commands = mapping.command().length == 0 ? new int[]{0} : mapping.command();
|
||||
int[] infoTypes = mapping.infoType().length == 0 ? new int[]{0} : mapping.infoType();
|
||||
|
||||
// 这些注解只声明策略,实际限流、幂等、异步批处理由 Dispatcher/Interceptor 层解释执行。
|
||||
RateLimited rl = AnnotatedElementUtils.findMergedAnnotation(method, RateLimited.class);
|
||||
IdempotentKey ik = AnnotatedElementUtils.findMergedAnnotation(method, IdempotentKey.class);
|
||||
AsyncBatch ab = AnnotatedElementUtils.findMergedAnnotation(method, AsyncBatch.class);
|
||||
|
||||
@@ -122,6 +122,7 @@ public final class Dispatcher {
|
||||
}
|
||||
|
||||
private static String nextRawArchiveEventId(Instant ingestTime) {
|
||||
// 文件名需要可排序且单进程内唯一:毫秒时间放大到微秒量级,再用 AtomicLong 递增兜底。
|
||||
long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L;
|
||||
long next = RAW_ARCHIVE_SEQUENCE.updateAndGet(previous -> Math.max(previous + 1, base));
|
||||
return Long.toString(next);
|
||||
@@ -141,6 +142,7 @@ public final class Dispatcher {
|
||||
if (event == null || rawArchive.isEmpty() || event instanceof VehicleEvent.RawArchive) {
|
||||
return event;
|
||||
}
|
||||
// 派生事件携带 rawArchiveUri,便于后续服务从 Kafka 事件回查原始报文。
|
||||
Map<String, String> metadata = addRawArchiveMetadata(event.metadata(), rawArchive.eventId(), rawArchive.key());
|
||||
return switch (event) {
|
||||
case VehicleEvent.Realtime e -> new VehicleEvent.Realtime(
|
||||
|
||||
@@ -20,6 +20,7 @@ public class HandlerInvoker {
|
||||
return switch (result) {
|
||||
case null -> Collections.emptyList();
|
||||
case VehicleEvent e -> List.of(e);
|
||||
// Handler 可以一次产出多个领域事件,例如 GB32960 0x02 同时产出 Realtime/Location/Alarm。
|
||||
case List<?> list -> (List<VehicleEvent>) list;
|
||||
default -> throw new IllegalStateException(
|
||||
"Handler return type must be VehicleEvent or List<VehicleEvent>: " + def.method());
|
||||
|
||||
@@ -11,6 +11,11 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* 顺序执行的拦截器链,通过 Spring {@code @Order} 或 {@code Ordered} 排序。
|
||||
*
|
||||
* <p>{@link #before(RawFrame, IngestContext)} 是接入链路的准入闸门:任何拦截器返回
|
||||
* {@code false} 或调用 {@link IngestContext#abort(String)} 都会停止后续协议解析/Handler 调用。
|
||||
* {@link #after(VehicleEvent, IngestContext)} 与 {@link #onError(Throwable, IngestContext)}
|
||||
* 则保持广播语义,让指标、审计等横切逻辑都能观察到同一条处理结果。
|
||||
*/
|
||||
public final class InterceptorChain {
|
||||
|
||||
@@ -24,6 +29,7 @@ public final class InterceptorChain {
|
||||
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
for (IngestInterceptor i : interceptors) {
|
||||
// before 阶段短路是有意的:去重、限流失败后不应继续消耗解析和存储资源。
|
||||
if (!i.before(frame, ctx) || ctx.aborted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,12 @@ import java.time.Duration;
|
||||
/**
|
||||
* 基于 Caffeine 的本地幂等去重。
|
||||
*
|
||||
* <p>Key 构造:{@code protocolId + command + sourceMeta.seq} —— 真实实现可以接入 Redis 做多节点一致性,
|
||||
* 本实现只覆盖单节点场景,满足第一阶段 PoC 需求。
|
||||
* <p>Key 构造:{@code protocolId + vin + command + seq/fingerprint}。如果上游已经在
|
||||
* {@link RawFrame#sourceMeta()} 里带了 {@code seq},优先用真实流水号;否则退化为 raw bytes
|
||||
* fingerprint,避免设备重连或 TCP 重发导致同一帧重复进入 DuckDB/Archive。
|
||||
*
|
||||
* <p>当前缓存是进程内的,只能保证单实例去重。32960 若做多副本水平扩容,需要把这个位置替换成
|
||||
* Redis/集中式幂等键,否则不同实例仍可能各自接收一次相同原始包。
|
||||
*/
|
||||
public class DedupInterceptor implements IngestInterceptor, Ordered {
|
||||
|
||||
@@ -32,6 +36,7 @@ public class DedupInterceptor implements IngestInterceptor, Ordered {
|
||||
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
|
||||
String seq = frame.sourceMeta().get("seq");
|
||||
if (seq == null || seq.isBlank()) {
|
||||
// 某些入口没有硬件流水号,使用原始字节哈希作为保底键,至少能挡住同连接内的重复帧。
|
||||
seq = rawFingerprint(frame);
|
||||
}
|
||||
String key = frame.protocolId() + ":" + vin + ":" + frame.command() + ":" + seq;
|
||||
|
||||
@@ -13,6 +13,9 @@ import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 单 VIN 速率限制。每个 VIN 一个独立的 Resilience4j RateLimiter,由 Caffeine 按 LRU 管理。
|
||||
*
|
||||
* <p>这里限的是进入业务 Handler 前的原始帧,不区分命令类型;32960 生产侧如需允许登入/登出穿透,
|
||||
* 应在此处扩展白名单或改为按命令配置,而不是在存储层丢弃。
|
||||
*/
|
||||
public class RateLimitInterceptor implements IngestInterceptor, Ordered {
|
||||
|
||||
@@ -34,6 +37,7 @@ public class RateLimitInterceptor implements IngestInterceptor, Ordered {
|
||||
@Override
|
||||
public boolean before(RawFrame frame, IngestContext ctx) {
|
||||
String vin = frame.sourceMeta().getOrDefault("vin", "unknown");
|
||||
// VIN 粒度隔离,避免某一辆车的高频/异常上报挤占其他车辆处理额度。
|
||||
RateLimiter rl = limiters.get(vin, k -> RateLimiter.of("vin-" + k, defaultConfig));
|
||||
if (!rl.acquirePermission()) {
|
||||
ctx.abort("rate-limited:" + vin);
|
||||
|
||||
@@ -35,6 +35,7 @@ public final class InMemorySessionStore implements SessionStore {
|
||||
@Override
|
||||
public void put(DeviceSession session) {
|
||||
byId.put(session.sessionId(), session);
|
||||
// 三索引用同一个 sessionId 汇聚;HTTP 下行可以拿 sessionId/VIN/phone 任意一种查在线连接。
|
||||
if (session.vin() != null) vinToId.put(session.vin(), session.sessionId());
|
||||
if (session.phone() != null) phoneToId.put(session.phone(), session.sessionId());
|
||||
}
|
||||
@@ -61,6 +62,7 @@ public final class InMemorySessionStore implements SessionStore {
|
||||
DeviceSession old = byId.getIfPresent(sessionId);
|
||||
if (old == null) return Optional.empty();
|
||||
DeviceSession next = updater.apply(old);
|
||||
// update 复用 put,保证 VIN/phone 二级索引和主会话对象一起刷新。
|
||||
put(next);
|
||||
return Optional.of(next);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
|
||||
@Override
|
||||
public void put(DeviceSession session) {
|
||||
// 同一个 sessionId 重新写入时先清理旧 VIN/phone 索引,避免旧外部号继续指向新会话。
|
||||
findBySessionId(session.sessionId()).ifPresent(old -> deleteIndexes(old));
|
||||
redis.opsForHash().putAll(sessionKey(session.sessionId()), toHash(session));
|
||||
redis.expire(sessionKey(session.sessionId()), ttl);
|
||||
@@ -67,6 +68,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
String sessionId = redis.opsForValue().get(vinIndexKey(vin));
|
||||
Optional<DeviceSession> session = findBySessionId(sessionId);
|
||||
if (session.isEmpty() && hasText(sessionId)) {
|
||||
// 二级索引可能比主 hash 晚过期;发现悬挂索引时立即清理。
|
||||
redis.delete(vinIndexKey(vin));
|
||||
}
|
||||
return session;
|
||||
@@ -80,6 +82,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
String sessionId = redis.opsForValue().get(phoneIndexKey(phone));
|
||||
Optional<DeviceSession> session = findBySessionId(sessionId);
|
||||
if (session.isEmpty() && hasText(sessionId)) {
|
||||
// 二级索引可能比主 hash 晚过期;发现悬挂索引时立即清理。
|
||||
redis.delete(phoneIndexKey(phone));
|
||||
}
|
||||
return session;
|
||||
@@ -124,6 +127,7 @@ public final class RedisSessionStore implements SessionStore {
|
||||
|
||||
private static Map<String, String> toHash(DeviceSession session) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
// attr.* 保留协议侧附加字段,后续扩展不需要改 Redis schema。
|
||||
put(values, "sessionId", session.sessionId());
|
||||
put(values, "protocol", session.protocol() == null ? null : session.protocol().name());
|
||||
put(values, "vin", session.vin());
|
||||
|
||||
@@ -23,6 +23,7 @@ public class SessionCoreAutoConfiguration {
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.session", name = "store", havingValue = "redis")
|
||||
@ConditionalOnMissingBean
|
||||
public SessionStore redisSessionStore(StringRedisTemplate redis, SessionProperties properties) {
|
||||
// Redis 只保存会话元数据索引;真实 Netty Channel 仍在各协议进程本地内存中。
|
||||
return new RedisSessionStore(redis, properties.getTtl());
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ public class SessionCoreAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public CommandDispatcher commandDispatcher() {
|
||||
// 没有协议模块提供真实下行实现时使用 Noop,避免仅接收/查询部署因为缺 Bean 启动失败。
|
||||
return new NoopCommandDispatcher();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public final class FileVehicleIdentityService implements VehicleIdentityResolver
|
||||
Files.createDirectories(path.getParent());
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) {
|
||||
// append-only 允许运行时新增绑定;重启时按文件顺序 replay,后写覆盖内存索引中的旧映射。
|
||||
writer.write(mapper.writeValueAsString(BindingLine.from(binding)));
|
||||
writer.newLine();
|
||||
}
|
||||
@@ -72,6 +73,7 @@ public final class FileVehicleIdentityService implements VehicleIdentityResolver
|
||||
}
|
||||
try {
|
||||
BindingLine binding = mapper.readValue(line, BindingLine.class);
|
||||
// 单行损坏不影响其它绑定加载,避免一个坏配置拖垮全部接入。
|
||||
delegate.bind(binding.toBinding());
|
||||
} catch (Exception e) {
|
||||
log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e);
|
||||
|
||||
@@ -31,8 +31,10 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
|
||||
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
|
||||
}
|
||||
if (!lookup.vin().isBlank()) {
|
||||
// 协议报文已经携带可信 VIN 时直接采用;绑定表主要服务 JT808/MQTT/信达这类外部 ID。
|
||||
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
|
||||
}
|
||||
// 绑定优先级:phone > deviceId > plate。顺序要稳定,否则同一车辆多外部号会产生不同内部 VIN。
|
||||
VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
|
||||
if (phone != null) return phone;
|
||||
|
||||
@@ -43,6 +45,7 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
|
||||
if (plate != null) return plate;
|
||||
|
||||
if (!lookup.deviceId().isBlank()) {
|
||||
// fallback 不是已确认绑定,只用于不中断接收和冷存;metadata.identityResolved=false 会保留这个事实。
|
||||
return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
|
||||
}
|
||||
if (!lookup.phone().isBlank()) {
|
||||
@@ -70,6 +73,7 @@ public final class InMemoryVehicleIdentityService implements VehicleIdentityReso
|
||||
|
||||
private static String key(ProtocolId protocol, String value) {
|
||||
String p = protocol == null ? "UNKNOWN" : protocol.name();
|
||||
// 绑定 key 带协议前缀,避免 JT808 phone 与 MQTT deviceId 数值相同时互相覆盖。
|
||||
return p + ":" + value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public class VehicleIdentityAutoConfiguration {
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file")
|
||||
public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
// 文件实现同时提供 resolver 和 registry,协议层既能查绑定,也能在注册/鉴权时补写绑定。
|
||||
return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ public final class MqttEndpointManager implements AutoCloseable {
|
||||
public void start() {
|
||||
for (MqttInboundProperties.Endpoint ep : props.getEndpoints()) {
|
||||
try {
|
||||
// 多 endpoint 互不影响:单个连接初始化失败会派发 operationalError,但不会阻止其他 endpoint。
|
||||
Mqtt3AsyncClient client = buildClient(ep);
|
||||
clients.add(client);
|
||||
connectAndSubscribe(client, ep);
|
||||
@@ -146,6 +147,7 @@ public final class MqttEndpointManager implements AutoCloseable {
|
||||
String externalDeviceId = firstNonBlank(parsed.deviceId(), parsed.vin());
|
||||
IdentityResolution identity = resolveIdentity(parsed);
|
||||
Map<String, String> meta = new HashMap<>(4);
|
||||
// metadata 同时保留外部设备号和内部 VIN,便于排查绑定表错误导致的车辆归属问题。
|
||||
meta.put("vin", identity.identity().vin());
|
||||
meta.put("externalVin", parsed.externalVin() == null ? "" : parsed.externalVin());
|
||||
meta.put("phone", parsed.phone() == null ? "" : parsed.phone());
|
||||
@@ -173,6 +175,7 @@ public final class MqttEndpointManager implements AutoCloseable {
|
||||
} catch (Exception e) {
|
||||
log.warn("mqtt endpoint [{}] message handling failed topic={} len={}",
|
||||
ep.getName(), topic, payload.length, e);
|
||||
// 单条消息异常也转成 RawFrame 错误事件,避免 MQTT 回调线程吞掉现场。
|
||||
publishMessageError(ep, topic, payload, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -215,6 +218,7 @@ public final class MqttEndpointManager implements AutoCloseable {
|
||||
}
|
||||
|
||||
private void publishOperationalError(MqttInboundProperties.Endpoint ep, String phase, String reason) {
|
||||
// 连接/订阅级别错误也进入 Dispatcher,这样监控和历史查询能看到接入端本身的问题。
|
||||
String endpointName = ep == null ? "" : ep.getName();
|
||||
String profile = ep == null ? "" : ep.getProfile();
|
||||
String topic = ep == null ? "" : ep.getTopic();
|
||||
|
||||
@@ -7,11 +7,16 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* MQTT 接入配置。支持多 endpoint(每个 endpoint 可对应一个车厂 / 一个 broker)。
|
||||
*
|
||||
* <p>这是 JSON/MQTT 厂商入口,不参与 GB32960 TCP 32960 端口接入;如果同一辆车同时存在
|
||||
* MQTT 和 32960 数据,需要在下游按 protocol/source 做归因。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.mqtt")
|
||||
public class MqttInboundProperties {
|
||||
|
||||
/** 总开关。关闭时不会创建任何 MQTT 客户端连接。 */
|
||||
private boolean enabled = false;
|
||||
/** 多 broker/多 topic 配置;每个 endpoint 独立重连、订阅并转成内部 VehicleEvent。 */
|
||||
private List<Endpoint> endpoints = new ArrayList<>();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
@@ -31,6 +36,7 @@ public class MqttInboundProperties {
|
||||
private String clientId;
|
||||
private String username;
|
||||
private String password;
|
||||
/** false 表示保留 broker 会话,重连后可继续接收离线期间的 QoS1/2 消息。 */
|
||||
private boolean cleanSession = false;
|
||||
private int keepAliveSeconds = 20;
|
||||
private int connectionTimeoutSeconds = 10;
|
||||
|
||||
@@ -116,6 +116,7 @@ public class XindaPushClient extends PushClient {
|
||||
String cmd = msg.getCmd();
|
||||
if (cmd == null) return;
|
||||
try {
|
||||
// 信达只把业务消息派发到 Dispatcher;登录/心跳/订阅 ACK 只影响连接状态和日志。
|
||||
switch (cmd) {
|
||||
case "8001" -> handleLoginAck(msg);
|
||||
case "8002" -> log.debug("[xinda-push] heartbeat ack");
|
||||
@@ -128,6 +129,7 @@ public class XindaPushClient extends PushClient {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[xinda-push] dispatch failed cmd={} json={}", cmd, msg.getJson(), e);
|
||||
// 推送回调异常也生成错误 RawFrame,保证原始 JSON 不丢。
|
||||
publishMessageError(msg, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -193,6 +195,7 @@ public class XindaPushClient extends PushClient {
|
||||
JSONObject identityFields = parsed.json();
|
||||
IdentityResolution identity = resolveIdentity(identityFields);
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
// 保留外部字段和内部 VIN 的双轨元数据,方便定位绑定错误或第三方字段变更。
|
||||
meta.put("source", "xinda-push");
|
||||
meta.put("cmd", payload.command());
|
||||
meta.put("vin", identity.identity().vin());
|
||||
|
||||
@@ -41,10 +41,12 @@ public final class XindaPushEventMapper implements EventMapper<XindaPushMessage>
|
||||
try {
|
||||
json = parse(message.json());
|
||||
} catch (Exception e) {
|
||||
// JSON 解析失败不丢弃,转 passthrough 保留 rawJson,后续可通过历史库回查。
|
||||
return List.of(passthrough(message, new JSONObject(), true, false,
|
||||
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
|
||||
}
|
||||
if (json.getBooleanValue("operationalError")) {
|
||||
// 接入侧运行错误也统一作为 passthrough 事件进入管线,便于监控和审计。
|
||||
return List.of(passthrough(message, json, false, false));
|
||||
}
|
||||
return switch (message.command()) {
|
||||
|
||||
@@ -7,16 +7,21 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* 信达 Push 接入配置。所有敏感字段均可通过环境变量注入,彻底取代旧代码里的硬编码。
|
||||
*
|
||||
* <p>该入口属于第三方推送协议,与 GB32960 TCP 接收链路并列。生产排查 32960 时,
|
||||
* 应先确认本开关是否关闭,避免把信达推送事件误认为 32960 原始包。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.xinda-push")
|
||||
public class XindaPushProperties {
|
||||
|
||||
/** 总开关。关闭时不会向信达平台建连/订阅。 */
|
||||
private boolean enabled = false;
|
||||
private String host;
|
||||
private int port = 10100;
|
||||
private String username;
|
||||
private String password;
|
||||
private String clientDesc = "lingniu-ingest";
|
||||
/** 默认订阅常见定位/轨迹类消息,新增业务消息需要先在 mapper 中确认字段映射。 */
|
||||
private List<String> subscribeMsgIds = new ArrayList<>(List.of("0200", "0300", "0401"));
|
||||
/** 断线重连间隔(秒)。 */
|
||||
private int reconnectIntervalSec = 5;
|
||||
|
||||
@@ -20,6 +20,9 @@ import java.util.Set;
|
||||
* <li>平台登入有 12B 用户名 + 20B 密码 + 可选 IP 白名单
|
||||
* </ul>
|
||||
*
|
||||
* <p>平台账号不仅用于 0x05 鉴权,也会绑定到 channel attribute,后续 0x02/0x03 上报会用它选择
|
||||
* vendor profile。账号配错时,可能表现为 0x30+ 厂商字段全部落 Raw。
|
||||
*
|
||||
* <p>匹配规则:
|
||||
* <ol>
|
||||
* <li>配置列表为空 → 返回 {@link Result#ALLOW_NO_POLICY}(兼容老行为,放行但标记未鉴权)
|
||||
|
||||
@@ -13,6 +13,9 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
*
|
||||
* <p>线程安全:内部持有 {@code AtomicReference<Set>},支持运行时热替换白名单
|
||||
* (未来可扩展为从 Nacos / 数据库定时刷新)。
|
||||
*
|
||||
* <p>该校验只决定是否允许设备继续进入接收链路,不负责车辆主数据注册。生产环境若打开白名单,
|
||||
* 需要确保新增车辆先同步到这里,否则终端会收到 VIN_NOT_EXIST 应答并断开。
|
||||
*/
|
||||
public final class Gb32960VinAuthorizer {
|
||||
|
||||
@@ -38,6 +41,7 @@ public final class Gb32960VinAuthorizer {
|
||||
public boolean isAllowed(String vin) {
|
||||
if (!enabled) return true;
|
||||
if (vin == null || vin.isBlank()) return false;
|
||||
// 默认大小写不敏感,以容忍部分平台把 VIN 小写转发;规范 VIN 本身仍应为大写。
|
||||
String key = caseSensitive ? vin.trim() : vin.trim().toUpperCase();
|
||||
return whitelist.get().contains(key);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ public final class Gb32960BodyParser {
|
||||
/** 主入口:根据上下文按 selector 选 vendor profile,然后照旧解析循环。 */
|
||||
public Gb32960MessageDecoder.BodyParseResult parse(Gb32960ParserContext ctx, ByteBuffer body) {
|
||||
ProtocolVersion version = ctx.version();
|
||||
// selector 只决定“标准 parser 集合上是否叠加某个 vendor 扩展集合”;标准 typeCode 始终可解析。
|
||||
InfoBlockParserRegistry registry = profileRegistry.forProfile(selector.select(ctx));
|
||||
List<InfoBlock> blocks = new ArrayList<>(8);
|
||||
byte[] signature = null;
|
||||
@@ -121,6 +122,7 @@ public final class Gb32960BodyParser {
|
||||
if (parser == null) {
|
||||
// lenient:未知类型无法安全跳过,吞剩余字节作为 Raw 后终止。
|
||||
// 真实 typeCode 透传到 Raw 字段,便于在日志/JSON 里直接看到漂移落点。
|
||||
// 如果该未知块实际是 TLV,应优先新增专用 TLV parser,让主循环可以继续解析后续块。
|
||||
int remaining = body.remaining();
|
||||
byte[] rest = new byte[remaining];
|
||||
body.get(rest);
|
||||
@@ -149,6 +151,7 @@ public final class Gb32960BodyParser {
|
||||
"info block 0x" + Integer.toHexString(typeCode)
|
||||
+ " needs " + fixedLen + " bytes but got " + body.remaining());
|
||||
}
|
||||
// 固定长度块尾部缺字节时不猜测字段;保留剩余 bytes,方便后续用 RAW 回放复盘。
|
||||
int remaining = body.remaining();
|
||||
byte[] tail = new byte[remaining];
|
||||
body.get(tail);
|
||||
|
||||
@@ -83,6 +83,8 @@ public final class Gb32960CommandParser {
|
||||
Instant time = readTime(buf);
|
||||
int serial = buf.getShort() & 0xFFFF;
|
||||
String user = readAscii(buf, 12);
|
||||
// 标准密码字段为 20B,但现场存在扩展到 32B 的平台登入包;这里按剩余长度-1 读取,
|
||||
// 最后一字节仍保留给加密规则,兼容两种形态。
|
||||
int passwordLength = Math.max(0, buf.remaining() - 1);
|
||||
String pwd = readAscii(buf, passwordLength);
|
||||
EncryptType enc = EncryptType.of(buf.get() & 0xFF);
|
||||
|
||||
@@ -76,6 +76,8 @@ public class Gb32960FrameDecoder extends ByteToMessageDecoder {
|
||||
if (command != 0x05 || dataLen != 41) {
|
||||
return standardLen;
|
||||
}
|
||||
// 现场平台登入包存在"声明 41B、实际 53B"的兼容形态;只有扩展长度 BCC 成立时才按 53B 分帧,
|
||||
// 否则回退标准长度,避免误吞下一条帧。
|
||||
int extendedLen = HEADER_LEN + 53 + 1;
|
||||
if (in.readableBytes() < extendedLen) {
|
||||
return standardLen;
|
||||
|
||||
@@ -17,6 +17,9 @@ import java.time.ZoneId;
|
||||
*
|
||||
* <p>应答规则(§6.3.2):服务端发送应答时,变更应答标志、保留报文时间、删除其余报文内容、
|
||||
* 重新计算校验位。
|
||||
*
|
||||
* <p>该类只做协议编码,不写 Channel。是否立即关闭连接由 {@code Gb32960AckService}
|
||||
* 根据鉴权结果决定。
|
||||
*/
|
||||
public final class Gb32960FrameEncoder {
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ public final class Gb32960MessageDecoder implements FrameDecoder<Gb32960Message>
|
||||
}
|
||||
|
||||
private static boolean isExtendedPlatformLogin(int commandCode, int declaredDataLength, int actualDataLength) {
|
||||
// 与 FrameDecoder 的兼容规则保持一致:部分平台登录帧声明长度 41B,但真实账号/密码体为 53B。
|
||||
return commandCode == 0x05 && declaredDataLength == 41 && actualDataLength == 53;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
* @param version 协议版本(来自帧起始符)
|
||||
* @param vin 17 字节 VIN(trim 后),平台登入 0x05 等帧 VIN 全 0 时为空串
|
||||
* @param platformAccount 平台登入用户名;非平台对接场景为 null
|
||||
*
|
||||
* <p>广东燃料电池扩展等厂商字段通常不是靠 VIN 就能可靠判断,上游平台账号是更稳定的路由线索。
|
||||
*/
|
||||
public record Gb32960ParserContext(ProtocolVersion version, String vin, String platformAccount) {
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class InfoBlockParserRegistry {
|
||||
|
||||
public InfoBlockParserRegistry(List<InfoBlockParser> list) {
|
||||
for (InfoBlockParser p : list) {
|
||||
// 后注册覆盖前注册:vendor profile 可以用同一 typeCode 替换默认解析器。
|
||||
parsers.put(new Key(p.version(), p.typeCode()), p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class ValueDecoder {
|
||||
}
|
||||
|
||||
public static int u16raw(ByteBuffer buf) {
|
||||
// 仅用于长度、数量、bitmask 等协议控制字段;业务测量值应优先走 u16/scaledU16。
|
||||
return buf.getShort() & 0xFFFF;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ public final class ValueDecoder {
|
||||
}
|
||||
|
||||
public static long u32raw(ByteBuffer buf) {
|
||||
// 仅用于故障码、标志位等需要保留全部 bit 的字段。
|
||||
return buf.getInt() & 0xFFFFFFFFL;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ public final class AlarmV2016BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<Long> out = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
|
||||
out.add(buf.getInt() & 0xFFFFFFFFL);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -27,6 +27,7 @@ public final class DriveMotorV2016BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<InfoBlock.Gb32960V2016.DriveMotor.Motor> motors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// rpm/torque 在标准里是带偏移的无符号数,先按原始无符号读,再统一减偏移。
|
||||
Integer serialNo = ValueDecoder.u8(buf);
|
||||
Integer state = ValueDecoder.u8(buf);
|
||||
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class ExtremeValueV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
// 极值块只给出子系统号/单体号/探针号及对应值,不包含完整单体数组。
|
||||
Integer maxVSub = ValueDecoder.u8(buf);
|
||||
Integer maxVCell = ValueDecoder.u8(buf);
|
||||
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class FuelCellV2016BlockParser implements InfoBlockParser {
|
||||
// 防御:probeCount 异常时回退为空列表避免吞 buffer
|
||||
List<Integer> probes = new ArrayList<>();
|
||||
int tailFixed = 2 + 1 + 2 + 1 + 2 + 1 + 1; // 尾部固定 10 字节
|
||||
// probeCount 来自车端报文,错误值会导致尾部固定段错位;这里先确保尾部还能完整解析。
|
||||
if (probeCount >= 0 && probeCount <= buf.remaining() - tailFixed) {
|
||||
for (int i = 0; i < probeCount; i++) {
|
||||
Integer t = ValueDecoder.tempOffset40(buf);
|
||||
|
||||
@@ -25,6 +25,7 @@ public final class PositionV2016BlockParser implements InfoBlockParser {
|
||||
long latRaw = buf.getInt() & 0xFFFFFFFFL;
|
||||
double longitude = lonRaw / 1_000_000.0;
|
||||
double latitude = latRaw / 1_000_000.0;
|
||||
// 标准用状态位表达南纬/西经;原始经纬度本身始终按正数传输。
|
||||
if ((statusFlag & 0b100) != 0) longitude = -longitude;
|
||||
if ((statusFlag & 0b010) != 0) latitude = -latitude;
|
||||
return new InfoBlock.Gb32960V2016.Position(statusFlag, longitude, latitude);
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class TemperatureV2016BlockParser implements InfoBlockParser {
|
||||
for (int i = 0; i < subCount; i++) {
|
||||
buf.get(); // batteryIndex
|
||||
int probes = buf.getShort() & 0xFFFF;
|
||||
// 与电压块类似,这里暂不保存每个探针,只输出可查询的最高/最低温摘要。
|
||||
for (int p = 0; p < probes; p++) {
|
||||
int t = (buf.get() & 0xFF) - 40;
|
||||
if (t > maxT) maxT = t;
|
||||
|
||||
@@ -13,6 +13,8 @@ import java.nio.ByteBuffer;
|
||||
* + totalMileageKm(4) + totalVoltageV(2) + totalCurrentA(2)
|
||||
* + socPercent(1) + dcDcStatus(1) + gearRaw(1) + insulationKohm(2)
|
||||
* + accelPedal(1) + brakePedal(1)。
|
||||
*
|
||||
* <p>该块是实时事件和 snapshot 的核心公共字段来源,字段名变更会影响前端常用查询。
|
||||
*/
|
||||
public final class VehicleV2016BlockParser implements InfoBlockParser {
|
||||
|
||||
|
||||
@@ -34,12 +34,14 @@ public final class VoltageV2016BlockParser implements InfoBlockParser {
|
||||
int cellCount = buf.getShort() & 0xFFFF;
|
||||
buf.getShort(); // frameCellStart
|
||||
int frameCellCount = buf.get() & 0xFF;
|
||||
// 报文可能只上传本帧范围内的单体电压;聚合层只保存最高/最低/总压摘要。
|
||||
int actual = Math.min(frameCellCount, cellCount);
|
||||
for (int c = 0; c < actual; c++) {
|
||||
double cellV = (buf.getShort() & 0xFFFF) * 0.001;
|
||||
if (cellV > maxCell) maxCell = cellV;
|
||||
if (cellV < minCell) minCell = cellV;
|
||||
}
|
||||
// 如果 frameCellCount 大于 cellCount,剩余字节仍要消费掉,避免影响后续子系统解析。
|
||||
for (int c = actual; c < frameCellCount; c++) buf.getShort();
|
||||
}
|
||||
if (maxCell == Double.MIN_VALUE) maxCell = 0;
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
int generalAlarmCount = buf.hasRemaining() ? (buf.get() & 0xFF) : 0;
|
||||
List<InfoBlock.Gb32960V2025.Alarm.GeneralAlarmEntry> levels = new ArrayList<>(generalAlarmCount);
|
||||
// 2025 新增通用报警等级列表;尾部不完整时保留已读条目,避免整帧丢弃。
|
||||
for (int i = 0; i < generalAlarmCount && buf.remaining() >= 2; i++) {
|
||||
int bit = buf.get() & 0xFF;
|
||||
int level = buf.get() & 0xFF;
|
||||
@@ -44,6 +45,7 @@ public final class AlarmV2025BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<Long> out = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 故障码按 4 字节无符号值处理,避免高位为 1 时变成负数。
|
||||
out.add(buf.getInt() & 0xFFFFFFFFL);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class BatteryTemperatureV2025BlockParser implements InfoBlockParser
|
||||
int packNo = buf.get() & 0xFF;
|
||||
int probeCount = buf.getShort() & 0xFFFF;
|
||||
List<Integer> temps = new ArrayList<>(probeCount);
|
||||
// probeCount 是车端声明值;实际数组按剩余字节裁剪,防止坏包读穿。
|
||||
int safeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class DriveMotorV2025BlockParser implements InfoBlockParser {
|
||||
int count = buf.get() & 0xFF;
|
||||
List<InfoBlock.Gb32960V2025.DriveMotor.Motor> motors = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// 2025 版 torque 扩为 4 字节,不能沿用 2016 的 u16 解析。
|
||||
Integer serialNo = ValueDecoder.u8(buf);
|
||||
Integer state = ValueDecoder.u8(buf);
|
||||
Integer ctrlTempC = ValueDecoder.tempOffset40(buf);
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class FuelCellStackV2025BlockParser implements InfoBlockParser {
|
||||
Integer airInletTemp = ValueDecoder.tempOffset40(buf);
|
||||
int probeCount = buf.getShort() & 0xFFFF;
|
||||
List<Integer> temps = new ArrayList<>(probeCount);
|
||||
// 坏包 probeCount 过大时只消费剩余可用字节;声明数量仍保存在 stack 对象外层上下文中。
|
||||
int safeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class MinParallelVoltageV2025BlockParser implements InfoBlockParser
|
||||
Double current = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
|
||||
int totalUnits = buf.getShort() & 0xFFFF;
|
||||
List<Double> frameVoltages = new ArrayList<>(totalUnits);
|
||||
// 每个最小并联单元电压占 2 字节;不足时只读完整单元,避免半个值污染后续解析。
|
||||
int safeCount = Math.min(totalUnits, buf.remaining() / 2);
|
||||
for (int k = 0; k < safeCount; k++) {
|
||||
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class SuperCapacitorExtremeV2025BlockParser implements InfoBlockPar
|
||||
|
||||
@Override
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
// 2025 超级电容极值的单体/探针编号为 2 字节,区别于 2016 蓄电池极值的 1 字节编号。
|
||||
Integer maxVSys = ValueDecoder.u8(buf);
|
||||
Integer maxVCell = ValueDecoder.u16(buf);
|
||||
Double maxV = ValueDecoder.scaledU16(buf, 0.001);
|
||||
|
||||
@@ -25,12 +25,14 @@ public final class SuperCapacitorV2025BlockParser implements InfoBlockParser {
|
||||
Double totalCurrent = ValueDecoder.scaledU16WithOffset(buf, 0.1, -3000.0);
|
||||
int cellCount = buf.getShort() & 0xFFFF;
|
||||
List<Double> cells = new ArrayList<>(cellCount);
|
||||
// 电压数组按 2 字节一个值裁剪,避免声明数量异常时读穿温度段。
|
||||
int safeCellCount = Math.min(cellCount, buf.remaining() / 2);
|
||||
for (int i = 0; i < safeCellCount; i++) {
|
||||
cells.add((buf.getShort() & 0xFFFF) * 0.001);
|
||||
}
|
||||
int probeCount = buf.hasRemaining() ? (buf.getShort() & 0xFFFF) : 0;
|
||||
List<Integer> temps = new ArrayList<>(probeCount);
|
||||
// 温度探针每个 1 字节,按剩余字节裁剪。
|
||||
int safeProbeCount = Math.min(probeCount, buf.remaining());
|
||||
for (int i = 0; i < safeProbeCount; i++) {
|
||||
temps.add((buf.get() & 0xFF) - 40);
|
||||
|
||||
@@ -10,6 +10,8 @@ import java.nio.ByteBuffer;
|
||||
/**
|
||||
* 整车数据 (0x01) —— GB/T 32960.3-2025。固定 18 字节。
|
||||
* 相比 2016 版删除加速踏板和制动踏板。
|
||||
*
|
||||
* <p>为保持查询接口稳定,后续转换层会把 2016/2025 两版整车字段映射到同一组逻辑字段。
|
||||
*/
|
||||
public final class VehicleV2025BlockParser implements InfoBlockParser {
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import java.nio.ByteBuffer;
|
||||
* outputCurrentA(2, 0.1A, 0~600A)
|
||||
* controllerTempC(1, -40 offset)
|
||||
* </pre>
|
||||
*
|
||||
* <p>只有 vendor profile 命中 {@code guangdong-fc} 时才会解析到该结构;否则同一 typeCode 会落 Raw。
|
||||
*/
|
||||
public final class GdFcDcDcBlockParser implements InfoBlockParser {
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ public final class GdFcStackBlockParser implements InfoBlockParser {
|
||||
Integer frameStart = ValueDecoder.u16(buf);
|
||||
int frameCells = buf.get() & 0xFF;
|
||||
List<Double> frameVoltages = new ArrayList<>(frameCells);
|
||||
// 对端会把单体电压按 frameCellStart/frameCellCount 分帧上送;这里只解析当前帧片段,
|
||||
// 完整电压数组由查询层按同一 vin+eventTime 合并,避免入站侧持有跨帧状态。
|
||||
int safe = Math.min(frameCells, buf.remaining() / 2);
|
||||
for (int k = 0; k < safe; k++) {
|
||||
frameVoltages.add((buf.getShort() & 0xFFFF) * 0.001);
|
||||
|
||||
@@ -28,6 +28,7 @@ public final class GdFcVehicleInfoBlockParser implements InfoBlockParser {
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
Integer collision = ValueDecoder.u8raw(buf);
|
||||
int rawTemp = buf.getShort() & 0xFFFF;
|
||||
// 规范保留值按缺失处理,避免 snapshot 中出现 65494/65495 这种不可读温度。
|
||||
Integer tempC = (rawTemp == 0xFFFE || rawTemp == 0xFFFF) ? null : rawTemp - 40;
|
||||
Double pressure = ValueDecoder.scaledU16WithOffset(buf, 0.1, -100.0);
|
||||
Double h2Mass = ValueDecoder.scaledU16(buf, 0.1);
|
||||
|
||||
@@ -48,6 +48,7 @@ public final class GdFcVendorTlvBlockParser implements InfoBlockParser {
|
||||
public InfoBlock parse(ByteBuffer buf) {
|
||||
int len = buf.getShort() & 0xFFFF;
|
||||
// 防御:若 length 大于剩余可读字节,截短到剩余长度(避免 BufferUnderflowException)
|
||||
// declaredLength 仍保留原值,后续可以在 API/日志里看出对端长度声明是否异常。
|
||||
int safe = Math.min(len, buf.remaining());
|
||||
byte[] payload = new byte[safe];
|
||||
buf.get(payload);
|
||||
|
||||
@@ -45,6 +45,7 @@ public class Gb32960ProfileRegistry {
|
||||
}
|
||||
List<InfoBlockParser> combined = new ArrayList<>(defaultParsers.size() + vendorParsers.size());
|
||||
combined.addAll(defaultParsers);
|
||||
// vendor parser 只新增扩展 typeCode,不应覆盖标准 parser;若未来需覆盖,必须显式设计优先级。
|
||||
combined.addAll(vendorParsers);
|
||||
map.put(name, new InfoBlockParserRegistry(combined));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ public final class RuleBasedVendorExtensionSelector implements VendorExtensionSe
|
||||
CacheKey key = new CacheKey(normalize(ctx.platformAccount()), normalize(ctx.vin()));
|
||||
Optional<String> cached = cache.get(key);
|
||||
if (cached != null) return cached.orElse(null);
|
||||
// 32960 高频上报通常同一连接/同一 VIN 连续上报,缓存可避免每帧扫描所有规则。
|
||||
String hit = scan(key);
|
||||
cache.put(key, Optional.ofNullable(hit));
|
||||
return hit;
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class VendorExtensionCatalog {
|
||||
private final Map<String, List<InfoBlockParser>> extensions;
|
||||
|
||||
public VendorExtensionCatalog(Map<String, List<InfoBlockParser>> extensions) {
|
||||
// 只冻结顶层 map;parser 实例本身按无状态单例使用,不应在运行期修改。
|
||||
this.extensions = Map.copyOf(extensions);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
|
||||
* 给定上下文返回应用的 vendor 扩展套件名({@code null} 表示走默认 profile)。
|
||||
*
|
||||
* <p>实现需要线程安全且 O(1) 期望复杂度(建议内部缓存)。
|
||||
* 命中结果会直接影响 0x30+ 等厂商字段是否解析成结构化字段,还是退化为 Raw。
|
||||
*/
|
||||
public interface VendorExtensionSelector {
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ import java.util.List;
|
||||
*
|
||||
* <p>每个 Parser Bean 都会被 {@link InfoBlockParserRegistry} 按
|
||||
* {@code (ProtocolVersion, typeCode)} 注册。2016/2025 可同时加载。
|
||||
*
|
||||
* <p>只有 {@code lingniu.ingest.gb32960.enabled=true} 时才会启动 32960 TCP 服务。
|
||||
* 解析、鉴权、ACK、RAW 入库都从 {@link Gb32960NettyServer} 建立的 Netty pipeline 进入。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(Gb32960Properties.class)
|
||||
@@ -114,6 +117,7 @@ public class Gb32960AutoConfiguration {
|
||||
new GdFcVehicleInfoBlockParser(),
|
||||
new GdFcDemoExtensionBlockParser(),
|
||||
// 0x83 厂商私有 TLV 兜底(同 peer 在广东规范之外又下发的未公开扩展)
|
||||
// 这里只注册已经在线上见过的 typeCode;新增 typeCode 应先确认长度字段格式。
|
||||
new GdFcVendorTlvBlockParser(0x83))));
|
||||
}
|
||||
|
||||
@@ -133,6 +137,7 @@ public class Gb32960AutoConfiguration {
|
||||
for (Gb32960Properties.VendorExtension e : props.getVendorExtensions()) {
|
||||
if (e.getName() != null && !e.getName().isBlank()) enabled.add(e.getName());
|
||||
}
|
||||
// enabled 只来自配置里实际引用过的扩展,避免无关 vendor parser 进入默认解析路径。
|
||||
return new Gb32960ProfileRegistry(parsers, catalog, enabled);
|
||||
}
|
||||
|
||||
@@ -142,6 +147,7 @@ public class Gb32960AutoConfiguration {
|
||||
public VendorExtensionSelector gb32960VendorExtensionSelector(Gb32960Properties props,
|
||||
VendorExtensionCatalog catalog) {
|
||||
if (props.getVendorExtensions() == null || props.getVendorExtensions().isEmpty()) {
|
||||
// 没有 vendor 配置时强制 NONE,0x30+ 在 2016 帧里会 Raw 兜底,防止误套扩展解析。
|
||||
return VendorExtensionSelector.NONE;
|
||||
}
|
||||
return new RuleBasedVendorExtensionSelector(props.getVendorExtensions(), catalog.names());
|
||||
|
||||
@@ -10,8 +10,11 @@ import java.util.Set;
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.gb32960")
|
||||
public class Gb32960Properties {
|
||||
|
||||
/** 是否启动 32960 TCP 监听。生产 32960 线路打开后,此开关决定端口是否真正 bind。 */
|
||||
private boolean enabled = false;
|
||||
/** 32960 TCP 监听端口;当前生产验证线使用 32960。 */
|
||||
private int port = 9000;
|
||||
/** Netty worker 线程数;0 表示按 Netty/CPU 默认策略分配。 */
|
||||
private int workerThreads = 0;
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,11 @@ import java.util.List;
|
||||
*
|
||||
* <p>这是 GB32960 协议在通用 Dispatcher 管线里的**唯一入口** Bean——移除将导致所有
|
||||
* 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)
|
||||
public class Gb32960RealtimeHandler {
|
||||
@@ -33,6 +37,7 @@ public class Gb32960RealtimeHandler {
|
||||
@RateLimited(perVin = 50)
|
||||
@EventEmit({VehicleEvent.Realtime.class, VehicleEvent.Location.class})
|
||||
public List<VehicleEvent> onReport(Gb32960Message msg) {
|
||||
// 这里保留 Realtime/Location 事件供实时消费者使用;历史全字段查询走 raw archive 解码。
|
||||
return mapper.toEvents(msg);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.net.InetSocketAddress;
|
||||
*
|
||||
* <p>Keeps authentication and channel-scoped platform identity out of the Netty
|
||||
* handler so the hot path can stay small and testable.
|
||||
*
|
||||
* <p>平台登入成功后,username 会绑定到 Netty channel attribute。后续实时上报帧没有账号字段,
|
||||
* 只能通过同一连接上的 attribute 找回平台账号,用于 Hyundai/广东扩展等 vendor profile 选择。
|
||||
*/
|
||||
public final class Gb32960AccessService {
|
||||
|
||||
@@ -50,10 +53,12 @@ public final class Gb32960AccessService {
|
||||
public Gb32960PlatformAuthorizer.Result authenticatePlatformLogin(String username,
|
||||
String password,
|
||||
Channel channel) {
|
||||
// 鉴权策略可以按来源 IP 做限制,因此这里把远端地址从 Netty channel 中抽出。
|
||||
return platformAuthorizer.authenticate(username, password, peerIp(channel));
|
||||
}
|
||||
|
||||
public void bindPlatformAccount(Channel channel, String username) {
|
||||
// 只绑定到当前连接,断线重连后必须重新平台登入,避免跨连接复用旧账号。
|
||||
channel.attr(PLATFORM_ACCOUNT_ATTR).set(username);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Builds and writes GB/T 32960 response frames.
|
||||
*
|
||||
* <p>ACK 需要尽量复用请求帧里的原始 17 字节 VIN。平台登入这类命令可能没有真实 VIN,
|
||||
* 如果把 trim 后的字符串重新 padding,部分上级平台会认为应答 VIN 与请求不一致。
|
||||
*/
|
||||
public final class Gb32960AckService {
|
||||
|
||||
@@ -39,6 +42,7 @@ public final class Gb32960AckService {
|
||||
Instant eventTime,
|
||||
byte[] data,
|
||||
String tag) {
|
||||
// rawVin 版本用于严格回显请求帧 VIN 字节,优先给入站 handler 使用。
|
||||
byte[] ack = Gb32960FrameEncoder.buildResponse(
|
||||
version, command, responseFlag, rawVin, eventTime, data);
|
||||
write(channel, ack, tag);
|
||||
@@ -86,6 +90,7 @@ public final class Gb32960AckService {
|
||||
channel.writeAndFlush(Unpooled.wrappedBuffer(ack)).addListener(f -> {
|
||||
if (f.isSuccess()) {
|
||||
if (highFrequency) {
|
||||
// 实时上报 ACK 频率高,默认降到 DEBUG,避免生产日志被正常心跳/上报刷满。
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[gb32960] {} flushed peer={} len={} hex={}",
|
||||
tag, addr(channel), ack.length, hex);
|
||||
|
||||
@@ -27,6 +27,10 @@ import java.util.Map;
|
||||
/**
|
||||
* Netty 入站处理器:字节 → Gb32960Message → 认证 → RawFrame → Dispatcher。
|
||||
*
|
||||
* <p>这是 32960 生产链路的入口边界。它只做协议层职责:解码、鉴权、应答、组装
|
||||
* {@link RawFrame} 并投递 Dispatcher;原始包归档、DuckDB/Parquet 索引、Kafka 转发都在
|
||||
* 下游 sink 中完成,避免 Netty EventLoop 被存储 IO 阻塞。
|
||||
*
|
||||
* <p>认证逻辑:
|
||||
* <ul>
|
||||
* <li>{@link Gb32960VinAuthorizer#isEnforcing()} = false:放行所有 VIN
|
||||
@@ -99,6 +103,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
String platformAccount = accessService.platformAccount(ctx.channel());
|
||||
msg = decoder.decode(ByteBuffer.wrap(frame), platformAccount);
|
||||
} catch (Exception e) {
|
||||
// 解码失败只丢当前帧,不主动断开连接。真实设备偶发脏字节时,FrameDecoder 会继续寻找下一帧头。
|
||||
log.warn("[gb32960] decode failed peer={} len={}", addr(ctx), frame.length, e);
|
||||
return;
|
||||
}
|
||||
@@ -138,6 +143,7 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
if (platformAccount != null && !platformAccount.isBlank()) {
|
||||
sourceMeta.put("platformAccount", platformAccount);
|
||||
}
|
||||
// RawFrame.rawBytes 是后续 raw archive 和按 rawArchiveUri 回查的源头;不要在这里裁剪或重编码。
|
||||
RawFrame rf = new RawFrame(
|
||||
ProtocolId.GB32960,
|
||||
cmd.code(),
|
||||
@@ -244,6 +250,9 @@ public class Gb32960ChannelHandler extends SimpleChannelInboundHandler<byte[]> {
|
||||
* 0x02/0x03/0x07 实时 / 补发 / 心跳:按 §6.3.2 回应答帧,保留原帧采集时间。
|
||||
* 部分车端实现严格按规范,没收到应答会停止后续上报或重连。ack-tag 按 cmd.code 区分便于日志筛选。
|
||||
*
|
||||
* <p>注意:应答成功不代表下游已完成持久化,只表示平台已接收并通过 Dispatcher 投递。
|
||||
* 生产排查存储问题时,应继续检查 raw archive sink、EventFileStoreSink 和 Kafka sink 的日志。
|
||||
*
|
||||
* <p>日志策略(按 (channel, vin, rawTypeSignature) 去重,避免高频帧刷屏):
|
||||
* <ul>
|
||||
* <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();
|
||||
CommandType cmd = msg.header().command();
|
||||
if (cmd == CommandType.VEHICLE_LOGIN) {
|
||||
// 规范要求登入失败要回 ACK,让终端能明确知道 VIN 不存在,而不是当作网络超时重试。
|
||||
ackService.writeAndClose(
|
||||
ctx,
|
||||
msg.header().protocolVersion(),
|
||||
|
||||
@@ -15,6 +15,9 @@ import java.util.Set;
|
||||
|
||||
/**
|
||||
* Channel-scoped diagnostics for high-frequency GB/T 32960 reports.
|
||||
*
|
||||
* <p>每个 TCP channel 只记录有限个首次出现的 (vin, rawTypeSignature),用于发现厂商 profile
|
||||
* 没命中、字段解析退化为 Raw 的问题。它不参与业务判断,也不会影响 raw archive 和历史查询。
|
||||
*/
|
||||
public final class Gb32960FrameDiagnostics {
|
||||
|
||||
@@ -76,6 +79,7 @@ public final class Gb32960FrameDiagnostics {
|
||||
|
||||
private void trimToLimit(Set<String> seen) {
|
||||
while (seen.size() > maxKeysPerChannel) {
|
||||
// LinkedHashSet 保持插入顺序,超限时淘汰最早的签名,避免异常设备导致 attribute 无界增长。
|
||||
String first = seen.iterator().next();
|
||||
seen.remove(first);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import java.util.concurrent.ThreadFactory;
|
||||
* GB/T 32960 Netty Server 启动器。
|
||||
*
|
||||
* <p>EventLoop 只做解码与投递 {@link Dispatcher},业务处理由 Disruptor 侧的虚拟线程承担。
|
||||
* 端口是否启动由 {@code lingniu.ingest.gb32960.enabled} 控制,生产 32960 线应确认该开关、
|
||||
* 端口、防火墙和上游平台账号同时匹配。
|
||||
*
|
||||
* <p>支持可选 TLS:{@link Gb32960Properties.Tls#isEnabled()} = true 时,pipeline 前置
|
||||
* {@code SslHandler},加载服务端证书 + 私钥 + 受信 CA,默认要求客户端证书(双向 TLS)。
|
||||
@@ -97,6 +99,7 @@ public class Gb32960NettyServer implements InitializingBean, DisposableBean {
|
||||
.childHandler(new ChannelInitializer<SocketChannel>() {
|
||||
@Override
|
||||
protected void initChannel(SocketChannel ch) {
|
||||
// Pipeline 顺序不能调换:TLS 先解密,FrameDecoder 再做拆包,ChannelHandler 最后做业务应答和派发。
|
||||
if (sslContext != null) {
|
||||
ch.pipeline().addLast("ssl", sslContext.newHandler(ch.alloc()));
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ import java.util.stream.Collectors;
|
||||
*
|
||||
* <p>实时/补发上报产出 {@code Realtime + Location}(可选)+ {@code Alarm}(可选);
|
||||
* 车辆登入/登出/心跳产出对应的会话事件。
|
||||
*
|
||||
* <p>该 Mapper 只生成统一领域事件,字段是面向实时总线的精简视图。32960 的完整包字段、
|
||||
* 厂商扩展字段和 snapshot 合并字段由历史查询服务从 raw archive 重新解码,不在这里展开。
|
||||
*/
|
||||
public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
|
||||
@@ -42,6 +45,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
|
||||
CommandType cmd = header.command();
|
||||
if (cmd == CommandType.REALTIME_REPORT || cmd == CommandType.RESEND_REPORT) {
|
||||
// 补发包与实时包进入同一套实时事件模型,差异保留在 metadata.command 里供下游判断。
|
||||
buildRealtime(message, header, baseMeta, eventTime, ingestTime, out);
|
||||
return out;
|
||||
}
|
||||
@@ -155,6 +159,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
null, meta, payload));
|
||||
}
|
||||
if (p != null) {
|
||||
// 位置事件只从 POSITION 子包生成。没有 POSITION 时不从整车数据推断经纬度,避免制造假定位。
|
||||
LocationPayload loc = new LocationPayload(
|
||||
p.longitude, p.latitude, 0.0,
|
||||
v != null && v.speedKmh != null ? v.speedKmh : 0.0,
|
||||
@@ -165,6 +170,7 @@ public final class Gb32960EventMapper implements EventMapper<Gb32960Message> {
|
||||
null, meta, loc));
|
||||
}
|
||||
if (alarm != null && shouldEmitAlarm(alarm)) {
|
||||
// 只在存在告警等级或氢安全关键位时发 Alarm,减少正常高频帧对告警消费者的噪声。
|
||||
List<String> faultCodes = new ArrayList<>();
|
||||
addFaultCodes(faultCodes, "BAT", alarm.batteryFaults);
|
||||
addFaultCodes(faultCodes, "MOT", alarm.motorFaults);
|
||||
|
||||
@@ -274,6 +274,7 @@ public sealed interface InfoBlock
|
||||
record MinParallelVoltage(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
|
||||
@Override public InfoBlockType type() { return InfoBlockType.MIN_PARALLEL_VOLTAGE_V2025; }
|
||||
|
||||
/** totalUnits 是车端声明数量;frameVoltages 是本帧实际解析出的完整电压值。 */
|
||||
public record Pack(
|
||||
int packNo,
|
||||
Double packVoltageV,
|
||||
@@ -287,6 +288,7 @@ public sealed interface InfoBlock
|
||||
record BatteryTemperature(int subSystemCount, List<Pack> packs) implements Gb32960V2025 {
|
||||
@Override public InfoBlockType type() { return InfoBlockType.BATTERY_TEMPERATURE_V2025; }
|
||||
|
||||
/** probeCount 是车端声明数量;probeTempsC 是本帧实际解析出的温度值。 */
|
||||
public record Pack(int packNo, int probeCount, List<Integer> probeTempsC) {}
|
||||
}
|
||||
|
||||
@@ -298,6 +300,7 @@ public sealed interface InfoBlock
|
||||
int stackNo,
|
||||
Double voltageV,
|
||||
Double currentA,
|
||||
// 标准字段名写 pressure,协议比例按 kPa 命名,避免与 2016 Mpa 字段混淆。
|
||||
Double hydrogenInletPressureKpa,
|
||||
Double airInletPressureKpa,
|
||||
Integer airInletTempC,
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.jt1078")
|
||||
public class Jt1078Properties {
|
||||
|
||||
/** 是否启用 JT/T 1078 音视频能力;它不参与 32960 快照/历史字段查询。 */
|
||||
private boolean enabled = false;
|
||||
private MediaStream mediaStream = new MediaStream();
|
||||
|
||||
@@ -14,13 +15,18 @@ public class Jt1078Properties {
|
||||
public void setMediaStream(MediaStream mediaStream) { this.mediaStream = mediaStream; }
|
||||
|
||||
public static class MediaStream {
|
||||
/** 媒体流子模块开关,便于只启用信令、不落媒体文件。 */
|
||||
private boolean enabled = true;
|
||||
/** TCP 媒体流监听,适合生产默认路径。 */
|
||||
private boolean tcpEnabled = true;
|
||||
/** UDP 媒体流监听默认关闭,开启前需要确认网络和包乱序处理能力。 */
|
||||
private boolean udpEnabled = false;
|
||||
private int port = 11078;
|
||||
private int udpPort = 11078;
|
||||
private int workerThreads = 0;
|
||||
/** 单包最大长度保护,防止异常媒体流耗尽内存。 */
|
||||
private int maxPacketLength = 1024 * 1024;
|
||||
/** 媒体文件分段秒数,影响归档文件粒度。 */
|
||||
private int segmentSeconds = 300;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
|
||||
@@ -54,6 +54,7 @@ public final class Jt1078MediaArchiveService {
|
||||
long segment = segment(packet.timestamp());
|
||||
String key = key(vin, packet, segment);
|
||||
try {
|
||||
// JT1078 是 RTP 分片流,同一分段追加写入 .media;只发布一次 MediaMeta 作为索引事件。
|
||||
String uri = archive.append(key, packet.payload());
|
||||
long segmentSizeBytes = archiveSize(key, packet.payload().length);
|
||||
publishOnce(packet, peer, identity, vin, segment, key, uri, segmentSizeBytes);
|
||||
@@ -103,6 +104,7 @@ public final class Jt1078MediaArchiveService {
|
||||
: archiveKey;
|
||||
String old = publishedSegmentRefs.getIfPresent(segmentKey);
|
||||
if (old != null) {
|
||||
// 同一个分段只发一次索引事件,避免每个 RTP 包都在历史库产生一条 media meta。
|
||||
return;
|
||||
}
|
||||
publishedSegmentRefs.put(segmentKey, uri);
|
||||
@@ -166,6 +168,7 @@ public final class Jt1078MediaArchiveService {
|
||||
|
||||
private long segment(long timestamp) {
|
||||
long seconds = timestamp > 10_000_000_000L ? timestamp / 1000 : timestamp;
|
||||
// 按配置秒数对齐分段,保证同一路通道同一时间窗落到同一个 archive key。
|
||||
return seconds - Math.floorMod(seconds, segmentSeconds);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ final class Jt1078RtpPacketParser {
|
||||
String sim = readBcd(in, 6);
|
||||
int channelId = in.readUnsignedByte();
|
||||
int dataAndPacketType = in.readUnsignedByte();
|
||||
// 高 4 位是数据类型,低 4 位是分包类型;归档 key 需要二者共同区分。
|
||||
int dataType = (dataAndPacketType >>> 4) & 0x0F;
|
||||
int packetType = dataAndPacketType & 0x0F;
|
||||
long timestamp = in.readLong();
|
||||
@@ -37,6 +38,7 @@ final class Jt1078RtpPacketParser {
|
||||
if (in == null || in.readableBytes() < HEADER_LEN || in.getInt(in.readerIndex()) != MAGIC) {
|
||||
return "";
|
||||
}
|
||||
// UDP/TCP 解码器在完整 parse 前先取 SIM,用于日志和初步身份定位;不移动 readerIndex。
|
||||
StringBuilder sb = new StringBuilder(12);
|
||||
int simOffset = in.readerIndex() + 6;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
|
||||
@@ -47,6 +47,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
|
||||
int bodyLength = attrs & 0x03FF;
|
||||
int encryptType = (attrs >> 10) & 0x07;
|
||||
boolean subpacket = (attrs & 0x2000) != 0;
|
||||
// JT/T 808-2019 通过属性 bit14 标识,终端手机号从 6B BCD 扩展到 10B BCD。
|
||||
boolean v2019 = (attrs & 0x4000) != 0;
|
||||
|
||||
Jt808Header.ProtocolVersion version = v2019
|
||||
@@ -64,6 +65,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
|
||||
int totalPkgs = 0, pkgSeq = 0;
|
||||
int bodyStart = headerLen;
|
||||
if (subpacket) {
|
||||
// 分包头紧跟消息流水号之后;当前只暴露包总数/序号,重组由上游或后续模块处理。
|
||||
totalPkgs = ((bytes[bodyStart] & 0xFF) << 8) | (bytes[bodyStart + 1] & 0xFF);
|
||||
pkgSeq = ((bytes[bodyStart + 2] & 0xFF) << 8) | (bytes[bodyStart + 3] & 0xFF);
|
||||
bodyStart += 4;
|
||||
@@ -82,6 +84,7 @@ public final class Jt808MessageDecoder implements FrameDecoder<Jt808Message> {
|
||||
BodyParser parser = registry.find(messageId);
|
||||
Jt808Body body;
|
||||
if (parser == null) {
|
||||
// 未实现的消息体保持 Raw,仍可进入 archive/event-history,避免协议扩展导致数据丢失。
|
||||
byte[] raw = new byte[bodyLength];
|
||||
bodyBuf.get(raw);
|
||||
body = new Jt808Body.Raw(messageId, raw);
|
||||
|
||||
@@ -4,8 +4,11 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.jt808")
|
||||
public class Jt808Properties {
|
||||
/** 是否启动 JT/T 808 TCP 监听。与 GB32960 独立,通常用于部标终端。 */
|
||||
private boolean enabled = false;
|
||||
/** JT808 默认监听端口;不要与 32960 端口共用。 */
|
||||
private int port = 10808;
|
||||
/** Netty worker 线程数;0 表示使用框架默认值。 */
|
||||
private int workerThreads = 0;
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
|
||||
@@ -67,6 +67,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
|
||||
CompletableFuture<Jt808Message> pending = pendingRequests.await(ctx.phone, ctx.serial);
|
||||
writeFrames(ctx, true).whenComplete((unused, ex) -> {
|
||||
if (ex != null) {
|
||||
// 写 channel 失败时必须移除 pending,否则后续同 phone/serial 重用会匹配到旧 future。
|
||||
pendingRequests.cancel(ctx.phone, ctx.serial);
|
||||
pending.completeExceptionally(ex);
|
||||
}
|
||||
@@ -74,6 +75,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
|
||||
|
||||
return pending.orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS)
|
||||
.whenComplete((r, ex) -> {
|
||||
// 超时/取消同样清理 pending,避免长期运行后内存泄漏。
|
||||
if (ex != null) pendingRequests.cancel(ctx.phone, ctx.serial);
|
||||
})
|
||||
.thenApply(msg -> {
|
||||
@@ -122,6 +124,7 @@ public final class Jt808CommandDispatcher implements CommandDispatcher {
|
||||
Optional<DeviceSession> s = sessions.findBySessionId(sessionId)
|
||||
.or(() -> sessions.findByPhone(sessionId))
|
||||
.or(() -> sessions.findByVin(sessionId));
|
||||
// HTTP 调用方可以传 sessionId/VIN/phone;最终写 Netty Channel 必须落到 JT808 phone。
|
||||
return s.map(DeviceSession::phone)
|
||||
.filter(p -> p != null && !p.isBlank())
|
||||
.orElse(sessionId); // 回落:直接把 sessionId 当 phone
|
||||
|
||||
@@ -105,6 +105,8 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
|
||||
try {
|
||||
processDecodedFrame(ctx, frame, msg);
|
||||
} catch (RuntimeException e) {
|
||||
// 解码成功但业务处理失败时仍派发一条 malformed RawFrame,
|
||||
// 保证原始帧能被 archive/event-file-store 留痕,方便事后排查。
|
||||
log.warn("[jt808] processing failed peer={} phone={} msgId=0x{} error={}",
|
||||
addr(ctx), msg.header().phone(), Integer.toHexString(msg.header().messageId()), e.getMessage());
|
||||
log.debug("[jt808] processing failed stack peer={}", addr(ctx), e);
|
||||
@@ -175,11 +177,13 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
|
||||
private IdentityResolution resolveIdentity(Jt808Message msg) {
|
||||
String phone = msg.header().phone();
|
||||
if (identityResolver == null) {
|
||||
// 没有绑定表时用手机号兜底为 VIN,保证会话/下行仍可按 phone 找到连接。
|
||||
return new IdentityResolution(
|
||||
new VehicleIdentity(phone, false, VehicleIdentitySource.FALLBACK_PHONE),
|
||||
null);
|
||||
}
|
||||
try {
|
||||
// 优先把 JT808 phone/device/plate 映射到内部 VIN,后续所有事件都用内部 VIN 做主键。
|
||||
return new IdentityResolution(
|
||||
identityResolver.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808,
|
||||
@@ -206,6 +210,7 @@ public class Jt808ChannelHandler extends SimpleChannelInboundHandler<Object> {
|
||||
|
||||
private void dispatchMalformed(String peer, byte[] frame, String errorMessage, boolean frameError) {
|
||||
String message = errorMessage == null ? "" : errorMessage;
|
||||
// 无法解出 header 的坏帧也进入 Dispatcher,后续 archive sink 可以保留 raw bytes。
|
||||
Jt808Message malformed = new Jt808Message(
|
||||
new Jt808Header(0, frame == null ? 0 : frame.length, 0, false,
|
||||
Jt808Header.ProtocolVersion.V2013, "unknown", 0, 0, 0),
|
||||
|
||||
@@ -168,6 +168,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
|
||||
|
||||
private IdentityResolution resolveIdentity(Jt808Message message) {
|
||||
if (message.body() instanceof Jt808Body.Malformed) {
|
||||
// 坏帧没有可信 header/body,统一 unknown,原始字节仍由 passthrough 事件保留。
|
||||
return IdentityResolution.ok(new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN));
|
||||
}
|
||||
var header = message.header();
|
||||
@@ -212,6 +213,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
|
||||
Instant ingestTime) {
|
||||
List<VehicleEvent> out = new ArrayList<>(batch.locations().size());
|
||||
Map<String, String> batchMeta = withMeta(meta, "batchType", Integer.toString(batch.batchType()));
|
||||
// 批量位置拆成多条 Location 事件,方便历史库按单点时间查询和导出。
|
||||
for (Jt808Body.Location loc : batch.locations()) {
|
||||
out.add(locationEvent(vin, batchMeta, loc, ingestTime));
|
||||
}
|
||||
@@ -224,6 +226,7 @@ public final class Jt808EventMapper implements EventMapper<Jt808Message> {
|
||||
Instant ingestTime) {
|
||||
List<VehicleEvent> out = new ArrayList<>(2);
|
||||
if (media.location() != null) {
|
||||
// 多媒体上传消息内嵌位置时同时产出 Location,避免只看到媒体元数据看不到拍摄位置。
|
||||
out.add(locationEvent(vin, withMeta(meta, "mediaId", Long.toString(media.mediaId())),
|
||||
media.location(), ingestTime));
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public final class Jt808ChannelRegistry {
|
||||
reverse.put(channel, phone);
|
||||
if (old != null && old != channel) {
|
||||
reverse.remove(old);
|
||||
// 同一 phone 新连接上线时关闭旧 channel,避免下行命令写到已被终端替换的连接。
|
||||
old.close();
|
||||
log.info("channel rebound phone={} oldChannel={} newChannel={}", phone, old.id(), channel.id());
|
||||
}
|
||||
@@ -55,6 +56,7 @@ public final class Jt808ChannelRegistry {
|
||||
}
|
||||
|
||||
public int nextSerial(String phone) {
|
||||
// JT808 流水号 16 bit,无符号回绕到 0;pending 匹配也使用这个序号。
|
||||
return serials.computeIfAbsent(phone, k -> new AtomicInteger())
|
||||
.updateAndGet(i -> (i + 1) & 0xFFFF);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public final class Jt808PendingRequests {
|
||||
|
||||
public CompletableFuture<Jt808Message> await(String phone, int serial) {
|
||||
CompletableFuture<Jt808Message> cf = new CompletableFuture<>();
|
||||
// JT808 平台下行流水号按终端 phone 独立递增,因此 phone+serial 才能唯一定位一次请求。
|
||||
pending.put(key(phone, serial), cf);
|
||||
return cf;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 统一 API 错误响应。
|
||||
*
|
||||
* <p>业务接口抛出的参数错误、时间格式错误和 Spring 参数绑定错误都会转成包含具体原因的
|
||||
* JSON,而不是让前端只能看到默认 400/500。这里的 timestamp 仅表示错误发生时间,
|
||||
* 按东八区展示;业务数据里的 eventTime/ingestTime 不在这里改时区。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public final class ApiExceptionHandler {
|
||||
|
||||
@@ -65,6 +72,7 @@ public final class ApiExceptionHandler {
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiError> generic(Exception ex,
|
||||
HttpServletRequest request) {
|
||||
// 保留异常 message 方便联调定位;底层如果没有 message,则退回通用文案。
|
||||
return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 通用历史事件查询接口。
|
||||
*
|
||||
* <p>这是跨协议的低层记录查询,返回的是 EventFileStore 中的 envelope/snapshot 记录。
|
||||
* GB32960 业务查询应优先使用 {@link Gb32960FrameController},因为它会回读 RAW 并按协议展开全部字段。
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(EventFileStore.class)
|
||||
@@ -158,6 +164,7 @@ public class EventHistoryController {
|
||||
if (!fields.isArray()) {
|
||||
return Map.of();
|
||||
}
|
||||
// 通用导出只识别 telemetry snapshot 的扁平 fields;复杂 GB32960 block 字段用专用 CSV 接口。
|
||||
Map<String, String> out = new LinkedHashMap<>();
|
||||
for (JsonNode field : fields) {
|
||||
String key = field.path("key").asText("");
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.io.IOException;
|
||||
/**
|
||||
* Consumer-side entry point that stores one Kafka envelope value into the
|
||||
* historical detail file store.
|
||||
*
|
||||
* <p>当前 32960 本机运行配置没有启用 Kafka consumer;这个类保留给“其他服务把 envelope
|
||||
* 写回历史库”的解耦部署方式。失败时返回结构化结果,由 Kafka worker 决定是否进 DLQ。
|
||||
*/
|
||||
public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
|
||||
|
||||
@@ -44,10 +47,12 @@ public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
|
||||
store.append(record);
|
||||
return EnvelopeIngestResult.stored(record.eventId(), record.vin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// 协议不可解析或缺少必要字段属于不可重试问题,避免 Kafka consumer 无限重放。
|
||||
return envelope == null
|
||||
? EnvelopeIngestResult.invalid(ex.getMessage())
|
||||
: EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage());
|
||||
} catch (IOException ex) {
|
||||
// 文件库写入失败可能是临时 I/O 问题,交给上层 worker 按失败处理。
|
||||
return EnvelopeIngestResult.failed(
|
||||
envelope == null ? "" : envelope.getEventId(),
|
||||
envelope == null ? "" : envelope.getVin(),
|
||||
|
||||
@@ -27,6 +27,16 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
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 {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Gb32960DecodedFrameService.class);
|
||||
@@ -76,6 +86,7 @@ public final class Gb32960DecodedFrameService {
|
||||
String vin,
|
||||
String platformAccount) throws IOException {
|
||||
int frameLimit = Math.max(1, Math.min(limit, 1000));
|
||||
// 只查 RAW_ARCHIVE:REALTIME/LOCATION 等派生事件不再作为 32960 历史查询的数据源。
|
||||
List<EventFileRecord> records = store.query(new EventFileQuery(
|
||||
ProtocolId.GB32960,
|
||||
dateFrom,
|
||||
@@ -91,6 +102,7 @@ public final class Gb32960DecodedFrameService {
|
||||
LinkedHashSet<String> seenUris = new LinkedHashSet<>();
|
||||
for (EventFileRecord record : records) {
|
||||
String rawArchiveUri = rawArchiveUri(record);
|
||||
// 同一个 RAW 可能因为索引重建或事件补偿被看到多次,按 URI 去重保证返回帧唯一。
|
||||
if (rawArchiveUri == null || rawArchiveUri.isBlank() || !seenUris.add(rawArchiveUri)) {
|
||||
continue;
|
||||
}
|
||||
@@ -201,6 +213,7 @@ public final class Gb32960DecodedFrameService {
|
||||
throw new IllegalArgumentException("vin is required for gb32960 snapshots");
|
||||
}
|
||||
int snapshotLimit = Math.max(1, Math.min(limit, 1000));
|
||||
// 经验上一个完整 snapshot 可能由多个 RAW 子包组成,查询帧数需要放大后再按 snapshot 截断。
|
||||
List<DecodedFrame> frames = query(dateFrom, dateTo, eventTimeFrom, eventTimeTo,
|
||||
order, snapshotLimit * 30, vin, platformAccount);
|
||||
LinkedHashMap<String, SnapshotBuilder> builders = new LinkedHashMap<>();
|
||||
@@ -227,6 +240,7 @@ public final class Gb32960DecodedFrameService {
|
||||
try {
|
||||
return decode(record, rawArchiveUri, platformAccount);
|
||||
} catch (NoSuchFileException e) {
|
||||
// 索引存在但 RAW 文件被人工清理时不中断整次查询,返回仍可用的其他帧。
|
||||
log.warn("skip gb32960 frame because raw archive is missing eventId={} rawArchiveUri={}",
|
||||
record.eventId(), rawArchiveUri);
|
||||
return null;
|
||||
@@ -402,6 +416,7 @@ public final class Gb32960DecodedFrameService {
|
||||
if (!Double.isFinite(value)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
// 协议缩放后的 double 容易出现 12.300000000000002;对外统一保留 6 位并去掉尾零。
|
||||
BigDecimal normalized = BigDecimal.valueOf(value)
|
||||
.setScale(OUTPUT_DOUBLE_SCALE, RoundingMode.HALF_UP)
|
||||
.stripTrailingZeros();
|
||||
@@ -418,6 +433,7 @@ public final class Gb32960DecodedFrameService {
|
||||
? rawArchiveUri.substring("archive://".length())
|
||||
: rawArchiveUri;
|
||||
Path path = archiveRoot.resolve(key.replace("..", "_").replaceAll("^/+", "")).normalize();
|
||||
// archive:// 是外部输入,必须限制在 archiveRoot 内,避免通过 ../ 读取任意文件。
|
||||
if (!path.startsWith(archiveRoot)) {
|
||||
throw new IOException("raw archive path escapes root: " + rawArchiveUri);
|
||||
}
|
||||
@@ -562,6 +578,7 @@ public final class Gb32960DecodedFrameService {
|
||||
int index = Integer.parseInt(part);
|
||||
return index >= 0 && index < list.size() ? list.get(index) : null;
|
||||
}
|
||||
// 对数组字段支持 "stacks.cellVoltages" 这类投影,返回每个元素上对应属性的列表。
|
||||
List<Object> out = new ArrayList<>(list.size());
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> itemMap) {
|
||||
@@ -593,8 +610,10 @@ public final class Gb32960DecodedFrameService {
|
||||
for (Map<String, Object> block : frame.blocks()) {
|
||||
String type = String.valueOf(block.get("type"));
|
||||
if ("GD_FC_STACK".equals(type)) {
|
||||
// 广东燃料电池堆电压可能按帧号拆成多个 RAW,这里按 cell 起始序号合并。
|
||||
mergeStack(block);
|
||||
} else {
|
||||
// 非分帧块同一 snapshot 内只保留第一次出现的块,避免后到空值覆盖前面完整值。
|
||||
blocks.putIfAbsent(type, deepCopy(block));
|
||||
}
|
||||
}
|
||||
@@ -625,6 +644,7 @@ public final class Gb32960DecodedFrameService {
|
||||
int incomingStart = intValue(incoming.get("frameCellStart"), 1);
|
||||
List<Double> targetVoltages = doubleList(target.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 needed = Math.max(cellCount, Math.max(
|
||||
targetStart - 1 + targetVoltages.size(),
|
||||
@@ -693,6 +713,7 @@ public final class Gb32960DecodedFrameService {
|
||||
normalized.removeLast();
|
||||
}
|
||||
long missing = normalized.stream().filter(item -> item == null).count();
|
||||
// 给前端明确的完整性标记,避免只能靠数组长度猜测是否缺帧。
|
||||
target.put("frameCellStart", 1);
|
||||
target.put("frameCellCount", normalized.size());
|
||||
target.put("frameCellVoltagesV", normalized);
|
||||
|
||||
@@ -2,6 +2,13 @@ package com.lingniu.ingest.eventhistory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* GB32960 字段字典。
|
||||
*
|
||||
* <p>字典是前端字段选择器、中文表头、Tooltip、枚举/位图展示的单一来源。
|
||||
* 字段 key 必须和 {@link Gb32960DecodedFrameService} 输出的 block data 路径一致;
|
||||
* 支持点号路径和数组投影路径,例如 {@code GD_FC_STACK.stacks.frameCellVoltagesV}。
|
||||
*/
|
||||
public final class Gb32960FieldDictionary {
|
||||
|
||||
private Gb32960FieldDictionary() {
|
||||
@@ -12,6 +19,7 @@ public final class Gb32960FieldDictionary {
|
||||
}
|
||||
|
||||
private static final List<Packet> PACKETS = List.of(
|
||||
// 标准 32960 字段优先放前面;广东燃料电池扩展字段放后面,方便前端按常用程度展示。
|
||||
packet("VEHICLE", "整车数据", "GB/T 32960 整车运行状态、车速、里程、电压、电流、SOC 等核心字段。",
|
||||
field("vehicleState", "车辆状态", "", "车辆运行、停止等状态编码。",
|
||||
mappings(
|
||||
@@ -178,13 +186,22 @@ public final class Gb32960FieldDictionary {
|
||||
return new ValueMapping(code, value, nameZh, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个数据包/信息体类型的字段集合,例如 VEHICLE 或 GD_FC_DCDC。
|
||||
*/
|
||||
public record Packet(String code, String nameZh, String description, List<Field> fields) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 可查询字段定义。key 是接口查询参数使用的英文路径,nameZh/unit/description 用于前端展示。
|
||||
*/
|
||||
public record Field(String key, String nameZh, String unit, String description,
|
||||
List<ValueMapping> valueMappings) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议枚举值或位图值的展示映射。code 保留原始协议表示,value 是解析后的业务值。
|
||||
*/
|
||||
public record ValueMapping(String code, String value, String nameZh, String description) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* GB32960 历史查询 HTTP 边界。
|
||||
*
|
||||
* <p>这个 Controller 只做参数校验、时间范围解析和 Swagger 说明,真正的 RAW 读取、
|
||||
* 协议解码、snapshot 合并、字段投影都放在 {@link Gb32960DecodedFrameService}。
|
||||
* 这样可以保证接口层足够薄,后续如果要把查询服务拆出去,只需要迁移 service 和 store。
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(Gb32960DecodedFrameService.class)
|
||||
@@ -90,6 +97,7 @@ public final class Gb32960FrameController {
|
||||
@RequestParam String vin,
|
||||
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
|
||||
@RequestParam(required = false) String platformAccount) throws IOException {
|
||||
// snapshot 是按同一 VIN + eventTime 合并多个 RAW 子包,跨车查询会造成错误合并和低效扫描。
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshots");
|
||||
}
|
||||
@@ -115,6 +123,7 @@ public final class Gb32960FrameController {
|
||||
@RequestParam String fields,
|
||||
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
|
||||
@RequestParam(required = false) String platformAccount) throws IOException {
|
||||
// 字段投影接口是前端高频接口,强制单车查询可以命中 VIN 分区,避免全库扫描后再 join。
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field query");
|
||||
}
|
||||
@@ -140,6 +149,7 @@ public final class Gb32960FrameController {
|
||||
@RequestParam String fields,
|
||||
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
|
||||
@RequestParam(required = false) String platformAccount) throws IOException {
|
||||
// CSV 复用字段投影逻辑,表头中文化由 dictionary 提供,避免导出和页面展示出现两套字段定义。
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field csv export");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* 查询时间范围解析器。
|
||||
*
|
||||
* <p>HTTP 查询允许传日期、带时区时间或不带时区的本地时间:
|
||||
* 日期用于分区裁剪,不带时区的时间统一按 Asia/Shanghai 转成 UTC Instant。
|
||||
* 返回值同时包含 LocalDate 分区边界和精确到秒/毫秒的 eventTime 边界。
|
||||
*/
|
||||
final class QueryTimeRange {
|
||||
|
||||
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
@@ -56,6 +63,7 @@ final class QueryTimeRange {
|
||||
}
|
||||
String value = raw.trim();
|
||||
if (value.length() == 10) {
|
||||
// 纯日期查询覆盖东八区自然日;结束日期取当天最后一毫秒,兼容 dateTo=2026-06-23。
|
||||
LocalDate date = LocalDate.parse(value);
|
||||
Instant instant = endOfRange
|
||||
? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1)
|
||||
@@ -63,9 +71,11 @@ final class QueryTimeRange {
|
||||
return new Boundary(date, instant);
|
||||
}
|
||||
try {
|
||||
// 带 Z 或 +08:00 的时间按调用方显式时区解析。
|
||||
Instant instant = OffsetDateTime.parse(value).toInstant();
|
||||
return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant);
|
||||
} catch (RuntimeException ignored) {
|
||||
// 不带时区的前端输入按东八区解释,接口返回的数据时间本身仍保持原始 UTC 字符串。
|
||||
LocalDateTime local = LocalDateTime.parse(value);
|
||||
Instant instant = local.atZone(DEFAULT_ZONE).toInstant();
|
||||
return new Boundary(local.toLocalDate(), instant);
|
||||
|
||||
@@ -13,6 +13,10 @@ import java.util.Map;
|
||||
/**
|
||||
* Maps Kafka protobuf envelopes into records stored by the historical detail
|
||||
* file store.
|
||||
*
|
||||
* <p>这是跨协议 Kafka envelope 回灌历史库的通用适配器。它保存的是 protobuf
|
||||
* telemetry_snapshot 的 JSON 视图,不负责 GB32960 原始包解码;32960 全字段 snapshot
|
||||
* 查询仍以 rawArchiveUri 指向的 .bin 为准。
|
||||
*/
|
||||
public final class TelemetryEnvelopeRecordMapper {
|
||||
|
||||
@@ -28,6 +32,7 @@ public final class TelemetryEnvelopeRecordMapper {
|
||||
ProtocolId protocol = protocol(envelope.getSource());
|
||||
Map<String, String> metadata = new LinkedHashMap<>(envelope.getMetadataMap());
|
||||
if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) {
|
||||
// 保留未知 source 原文,避免历史库标准化成 UNKNOWN 后丢失排障线索。
|
||||
metadata.putIfAbsent("originalSource", envelope.getSource());
|
||||
}
|
||||
return new EventFileRecord(
|
||||
|
||||
@@ -22,6 +22,18 @@ import org.springframework.context.annotation.Bean;
|
||||
import java.net.URI;
|
||||
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
|
||||
@AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class})
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
|
||||
@@ -46,6 +58,7 @@ public class EventHistoryAutoConfiguration {
|
||||
@ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
// 只注册 processor;真正拉 Kafka 的 runner 在 sink-mq 模块按 consumer.enabled 决定是否创建。
|
||||
return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink);
|
||||
}
|
||||
|
||||
@@ -62,6 +75,7 @@ public class EventHistoryAutoConfiguration {
|
||||
public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store,
|
||||
Gb32960MessageDecoder decoder,
|
||||
SinkArchiveProperties archiveProperties) {
|
||||
// snapshot/frame 查询通过 EventFileStore 找到 rawArchiveUri,再从 archiveRoot 读取 .bin 即时解码。
|
||||
return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null);
|
||||
}
|
||||
|
||||
@@ -76,6 +90,7 @@ public class EventHistoryAutoConfiguration {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive");
|
||||
}
|
||||
// 支持 file:// URI,便于部署配置里统一用 URI 风格表达 archive 根路径。
|
||||
if (value.startsWith("file://")) {
|
||||
return Path.of(URI.create(value));
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ public final class DailyMileageCalculator {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
|
||||
// 两种策略都只基于同一 VIN 的里程点计算,不能跨车辆 join;
|
||||
// 上层 repository 负责按 VIN 过滤,避免大车队查询时扩大扫描面。
|
||||
double value = switch (strategy) {
|
||||
case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points);
|
||||
case DAY_MAX_MINUS_DAY_MIN -> dayMaxMinusDayMin(statDate, points);
|
||||
@@ -39,6 +41,7 @@ public final class DailyMileageCalculator {
|
||||
}
|
||||
|
||||
private double currentLastMinusPreviousLast(LocalDate statDate, List<MileagePoint> points) {
|
||||
// 适用于累计里程单调递增的设备:当天最后一帧减前一日最后一帧。
|
||||
Optional<MileagePoint> previousLast = points.stream()
|
||||
.filter(point -> localDate(point).isBefore(statDate))
|
||||
.max(Comparator.comparing(MileagePoint::eventTime));
|
||||
@@ -53,6 +56,7 @@ public final class DailyMileageCalculator {
|
||||
}
|
||||
|
||||
private double dayMaxMinusDayMin(LocalDate statDate, List<MileagePoint> points) {
|
||||
// 兜底策略:仅使用当天数据,适合缺少前一日最后点但当天点足够的场景。
|
||||
List<MileagePoint> currentDay = points.stream()
|
||||
.filter(point -> localDate(point).isEqual(statDate))
|
||||
.toList();
|
||||
|
||||
@@ -29,6 +29,7 @@ public final class DailyVehicleStatService {
|
||||
|
||||
public Optional<VehicleDailyStatResult> calculateAndSave(String vin, LocalDate statDate) {
|
||||
VehicleStatRule rule = ruleRepository.ruleFor(vin);
|
||||
// 统计是从里程点二次计算出来的派生数据,不作为 32960 全字段历史查询的数据源。
|
||||
OptionalDouble dailyMileage = mileageCalculator.calculate(
|
||||
statDate,
|
||||
rule.dailyMileageStrategy(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user