feat: make gb32960 archive history query production ready

This commit is contained in:
kkfluous
2026-06-23 11:55:44 +08:00
parent b14871ff1c
commit ba68ffe061
462 changed files with 23639 additions and 2341 deletions

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lingniu.ingest</groupId>
<artifactId>lingniu-vehicle-ingest</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../../pom.xml</relativePath>
</parent>
<artifactId>event-history-service</artifactId>
<name>event-history-service</name>
<description>Kafka full-field event consumer + Parquet/DuckDB historical query API.</description>
<dependencies>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>event-file-store</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-mq</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>sink-archive</artifactId>
</dependency>
<dependency>
<groupId>com.lingniu.ingest</groupId>
<artifactId>protocol-gb32960</artifactId>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations-jakarta</artifactId>
<version>2.2.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,92 @@
package com.lingniu.ingest.eventhistory;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
@RestControllerAdvice
public final class ApiExceptionHandler {
private static final ZoneId DEFAULT_TIME_ZONE = ZoneId.of("Asia/Shanghai");
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiError> missingRequestParameter(MissingServletRequestParameterException ex,
HttpServletRequest request) {
return error(HttpStatus.BAD_REQUEST,
"missing required query parameter: " + ex.getParameterName(),
request);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiError> typeMismatch(MethodArgumentTypeMismatchException ex,
HttpServletRequest request) {
String message = "invalid query parameter: " + ex.getName();
if (ex.getValue() != null) {
message += " value=" + ex.getValue();
}
return error(HttpStatus.BAD_REQUEST, message, request);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiError> responseStatus(ResponseStatusException ex,
HttpServletRequest request) {
HttpStatus status = HttpStatus.resolve(ex.getStatusCode().value());
if (status == null) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
String message = ex.getReason();
if (message == null || message.isBlank()) {
message = status.getReasonPhrase();
}
return error(status, message, request);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiError> illegalArgument(IllegalArgumentException ex,
HttpServletRequest request) {
return error(HttpStatus.BAD_REQUEST, safeMessage(ex, "bad request"), request);
}
@ExceptionHandler(DateTimeParseException.class)
public ResponseEntity<ApiError> dateTimeParse(DateTimeParseException ex,
HttpServletRequest request) {
return error(HttpStatus.BAD_REQUEST, "invalid date/time parameter: " + ex.getParsedString(), request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> generic(Exception ex,
HttpServletRequest request) {
return error(HttpStatus.INTERNAL_SERVER_ERROR, safeMessage(ex, "internal server error"), request);
}
private static ResponseEntity<ApiError> error(HttpStatus status, String message, HttpServletRequest request) {
return ResponseEntity.status(status).body(new ApiError(
OffsetDateTime.now(DEFAULT_TIME_ZONE).toString(),
status.value(),
status.getReasonPhrase(),
message,
request.getRequestURI()));
}
private static String safeMessage(Exception ex, String fallback) {
String message = ex.getMessage();
return message == null || message.isBlank() ? fallback : message;
}
public record ApiError(
String timestamp,
int status,
String error,
String message,
String path) {
}
}

View File

@@ -0,0 +1,196 @@
package com.lingniu.ingest.eventhistory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.eventfilestore.EventFileQuery;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@RestController
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
@ConditionalOnBean(EventFileStore.class)
@RequestMapping("/api/event-history")
public class EventHistoryController {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final EventFileStore store;
public EventHistoryController(EventFileStore store) {
this.store = store;
}
@GetMapping("/records")
public List<RecordResponse> query(
@RequestParam ProtocolId protocol,
@RequestParam String dateFrom,
@RequestParam String dateTo,
@RequestParam(defaultValue = "ASC") EventFileQuery.Order order,
@RequestParam(defaultValue = "100") int limit,
@RequestParam(required = false) String vin) throws IOException {
return queryRecords(protocol, dateFrom, dateTo, order, limit, vin).stream()
.map(RecordResponse::from)
.toList();
}
@GetMapping(value = "/export.csv", produces = "text/csv;charset=UTF-8")
public String exportCsv(
@RequestParam ProtocolId protocol,
@RequestParam String dateFrom,
@RequestParam String dateTo,
@RequestParam(defaultValue = "ASC") EventFileQuery.Order order,
@RequestParam(defaultValue = "100000") int limit,
@RequestParam(required = false) String vin,
@RequestParam(required = false) String fields) throws IOException {
List<EventFileRecord> records = queryRecords(protocol, dateFrom, dateTo, order, limit, vin);
List<String> selectedFields = parseFields(fields);
StringBuilder csv = new StringBuilder(256 + records.size() * 128);
csv.append("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri");
for (String field : selectedFields) {
csv.append(',').append(csv(field));
}
csv.append(",payload_json\n");
for (EventFileRecord record : records) {
Map<String, String> telemetryFields = selectedFields.isEmpty()
? Map.of()
: telemetryFields(record.payloadJson());
csv.append(csv(record.eventId())).append(',')
.append(csv(record.protocol().name())).append(',')
.append(csv(record.eventType())).append(',')
.append(csv(record.vin())).append(',')
.append(csv(record.eventTime().toString())).append(',')
.append(csv(record.ingestTime().toString())).append(',')
.append(csv(record.rawArchiveUri()));
for (String field : selectedFields) {
csv.append(',').append(csv(fieldValue(record, telemetryFields, field)));
}
csv.append(',')
.append(csv(record.payloadJson())).append('\n');
}
return csv.toString();
}
public String exportCsv(ProtocolId protocol,
String dateFrom,
String dateTo,
EventFileQuery.Order order,
int limit) throws IOException {
return exportCsv(protocol, dateFrom, dateTo, order, limit, null, null);
}
private List<EventFileRecord> queryRecords(ProtocolId protocol,
String dateFrom,
String dateTo,
EventFileQuery.Order order,
int limit,
String vin) throws IOException {
QueryTimeRange range = QueryTimeRange.parse(dateFrom, dateTo);
return store.query(new EventFileQuery(
protocol,
range.dateFrom(),
range.dateTo(),
range.eventTimeFrom(),
range.eventTimeTo(),
order,
limit,
vin,
null));
}
public record RecordResponse(
String eventId,
ProtocolId protocol,
String eventType,
String vin,
String eventTime,
String ingestTime,
String rawArchiveUri,
Map<String, String> metadata,
String payloadJson) {
private static RecordResponse from(EventFileRecord record) {
return new RecordResponse(
record.eventId(),
record.protocol(),
record.eventType(),
record.vin(),
record.eventTime().toString(),
record.ingestTime().toString(),
record.rawArchiveUri(),
record.metadata(),
record.payloadJson());
}
}
private static List<String> parseFields(String fields) {
if (fields == null || fields.isBlank()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (String field : fields.split(",")) {
String normalized = field.trim();
if (!normalized.isBlank()) {
out.add(normalized);
}
}
return List.copyOf(out);
}
private static Map<String, String> telemetryFields(String payloadJson) throws IOException {
if (payloadJson == null || payloadJson.isBlank()) {
return Map.of();
}
JsonNode root = OBJECT_MAPPER.readTree(payloadJson);
JsonNode fields = root.path("fields");
if (!fields.isArray()) {
return Map.of();
}
Map<String, String> out = new LinkedHashMap<>();
for (JsonNode field : fields) {
String key = field.path("key").asText("");
if (!key.isBlank()) {
out.put(key, field.path("value").asText(""));
}
}
return out;
}
private static String fieldValue(EventFileRecord record,
Map<String, String> telemetryFields,
String field) {
if (field == null || field.isBlank()) {
return "";
}
if (field.startsWith("metadata.")) {
return record.metadata().get(field.substring("metadata.".length()));
}
return telemetryFields.get(field);
}
private static String csv(String value) {
if (value == null) {
return "";
}
boolean quote = value.indexOf(',') >= 0
|| value.indexOf('"') >= 0
|| value.indexOf('\n') >= 0
|| value.indexOf('\r') >= 0;
if (!quote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
}

View File

@@ -0,0 +1,68 @@
package com.lingniu.ingest.eventhistory;
import com.google.protobuf.InvalidProtocolBufferException;
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
import java.io.IOException;
/**
* Consumer-side entry point that stores one Kafka envelope value into the
* historical detail file store.
*/
public final class EventHistoryEnvelopeIngestor implements EnvelopeIngestor {
private final EventFileStore store;
private final TelemetryEnvelopeRecordMapper mapper;
public EventHistoryEnvelopeIngestor(EventFileStore store, TelemetryEnvelopeRecordMapper mapper) {
if (store == null) {
throw new IllegalArgumentException("store must not be null");
}
if (mapper == null) {
throw new IllegalArgumentException("mapper must not be null");
}
this.store = store;
this.mapper = mapper;
}
public void ingest(byte[] kafkaValue) throws IOException {
VehicleEnvelope envelope = parse(kafkaValue);
EventFileRecord record = mapper.toRecord(envelope);
store.append(record);
}
@Override
public EnvelopeIngestResult tryIngest(byte[] kafkaValue) {
VehicleEnvelope envelope = null;
try {
envelope = parse(kafkaValue);
EventFileRecord record = mapper.toRecord(envelope);
store.append(record);
return EnvelopeIngestResult.stored(record.eventId(), record.vin());
} catch (IllegalArgumentException ex) {
return envelope == null
? EnvelopeIngestResult.invalid(ex.getMessage())
: EnvelopeIngestResult.skipped(envelope.getEventId(), envelope.getVin(), ex.getMessage());
} catch (IOException ex) {
return EnvelopeIngestResult.failed(
envelope == null ? "" : envelope.getEventId(),
envelope == null ? "" : envelope.getVin(),
ex.getMessage());
}
}
private static VehicleEnvelope parse(byte[] kafkaValue) {
if (kafkaValue == null || kafkaValue.length == 0) {
throw new IllegalArgumentException("VehicleEnvelope bytes must not be empty");
}
try {
return VehicleEnvelope.parseFrom(kafkaValue);
} catch (InvalidProtocolBufferException e) {
throw new IllegalArgumentException("failed to parse VehicleEnvelope", e);
}
}
}

View File

@@ -0,0 +1,764 @@
package com.lingniu.ingest.eventhistory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.eventfilestore.EventFileQuery;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
public final class Gb32960DecodedFrameService {
private static final Logger log = LoggerFactory.getLogger(Gb32960DecodedFrameService.class);
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final int OUTPUT_DOUBLE_SCALE = 6;
private static final String RAW_ARCHIVE_EVENT_TYPE = "RAW_ARCHIVE";
private final EventFileStore store;
private final Gb32960MessageDecoder decoder;
private final Path archiveRoot;
private final ObjectMapper objectMapper;
public Gb32960DecodedFrameService(EventFileStore store,
Gb32960MessageDecoder decoder,
Path archiveRoot,
ObjectMapper objectMapper) {
if (store == null) {
throw new IllegalArgumentException("store must not be null");
}
if (decoder == null) {
throw new IllegalArgumentException("decoder must not be null");
}
if (archiveRoot == null) {
throw new IllegalArgumentException("archiveRoot must not be null");
}
this.store = store;
this.decoder = decoder;
this.archiveRoot = archiveRoot.toAbsolutePath().normalize();
this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper;
}
public List<DecodedFrame> query(LocalDate dateFrom,
LocalDate dateTo,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount) throws IOException {
return query(dateFrom, dateTo, null, null, order, limit, vin, platformAccount);
}
public List<DecodedFrame> query(LocalDate dateFrom,
LocalDate dateTo,
java.time.Instant eventTimeFrom,
java.time.Instant eventTimeTo,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount) throws IOException {
int frameLimit = Math.max(1, Math.min(limit, 1000));
List<EventFileRecord> records = store.query(new EventFileQuery(
ProtocolId.GB32960,
dateFrom,
dateTo,
eventTimeFrom,
eventTimeTo,
order == null ? EventFileQuery.Order.DESC : order,
frameLimit,
vin,
RAW_ARCHIVE_EVENT_TYPE));
List<DecodedFrame> out = new ArrayList<>();
LinkedHashSet<String> seenUris = new LinkedHashSet<>();
for (EventFileRecord record : records) {
String rawArchiveUri = rawArchiveUri(record);
if (rawArchiveUri == null || rawArchiveUri.isBlank() || !seenUris.add(rawArchiveUri)) {
continue;
}
DecodedFrame frame = decodeIfAvailable(record, rawArchiveUri, platformAccount);
if (frame == null) {
continue;
}
out.add(frame);
if (out.size() >= frameLimit) {
break;
}
}
return out;
}
public List<LogicalSnapshot> snapshots(QueryTimeRange range,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount) throws IOException {
return snapshots(range.dateFrom(), range.dateTo(), range.eventTimeFrom(), range.eventTimeTo(),
order, limit, vin, platformAccount);
}
public List<FieldSnapshot> snapshotFields(QueryTimeRange range,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount,
String fields) throws IOException {
List<String> selectedFields = parseSelectedFields(fields);
if (selectedFields.isEmpty()) {
throw new IllegalArgumentException("fields is required for gb32960 snapshot field query");
}
validateSelectedFields(selectedFields);
return snapshots(range, order, limit, vin, platformAccount).stream()
.map(snapshot -> FieldSnapshot.from(snapshot, selectedFields))
.toList();
}
public String snapshotFieldsCsv(QueryTimeRange range,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount,
String fields) throws IOException {
List<String> selectedFields = parseSelectedFields(fields);
if (selectedFields.isEmpty()) {
throw new IllegalArgumentException("fields is required for gb32960 snapshot field query");
}
validateSelectedFields(selectedFields);
List<FieldSnapshot> snapshots = snapshots(range, order, limit, vin, platformAccount).stream()
.map(snapshot -> FieldSnapshot.from(snapshot, selectedFields))
.toList();
Map<String, String> headers = chineseHeadersByField();
StringBuilder csv = new StringBuilder(256 + snapshots.size() * Math.max(128, selectedFields.size() * 32));
csv.append("VIN,事件时间,首次接收时间,最后接收时间,原始包");
for (String field : selectedFields) {
csv.append(',').append(csv(headers.getOrDefault(field, field)));
}
csv.append('\n');
for (FieldSnapshot snapshot : snapshots) {
csv.append(csv(snapshot.vin())).append(',')
.append(csv(snapshot.eventTime())).append(',')
.append(csv(snapshot.ingestTimeFirst())).append(',')
.append(csv(snapshot.ingestTimeLast())).append(',')
.append(csv(sourceFrameUris(snapshot.sourceFrames())));
for (String field : selectedFields) {
csv.append(',').append(csv(csvValue(snapshot.fields().get(field))));
}
csv.append('\n');
}
return csv.toString();
}
public DecodedFrame findByRawArchiveUri(String rawArchiveUri, String platformAccount) throws IOException {
if (rawArchiveUri == null || rawArchiveUri.isBlank()) {
return null;
}
EventFileRecord record = store.findByRawArchiveUri(rawArchiveUri);
if (record != null) {
return decode(record, rawArchiveUri(record), platformAccount);
}
return decodeWithoutIndex(rawArchiveUri, platformAccount);
}
public List<LogicalSnapshot> snapshots(LocalDate dateFrom,
LocalDate dateTo,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount) throws IOException {
if (vin == null || vin.isBlank()) {
throw new IllegalArgumentException("vin is required for gb32960 snapshots");
}
return snapshots(dateFrom, dateTo, null, null, order, limit, vin, platformAccount);
}
private List<LogicalSnapshot> snapshots(LocalDate dateFrom,
LocalDate dateTo,
java.time.Instant eventTimeFrom,
java.time.Instant eventTimeTo,
EventFileQuery.Order order,
int limit,
String vin,
String platformAccount) throws IOException {
if (vin == null || vin.isBlank()) {
throw new IllegalArgumentException("vin is required for gb32960 snapshots");
}
int snapshotLimit = Math.max(1, Math.min(limit, 1000));
List<DecodedFrame> frames = query(dateFrom, dateTo, eventTimeFrom, eventTimeTo,
order, snapshotLimit * 30, vin, platformAccount);
LinkedHashMap<String, SnapshotBuilder> builders = new LinkedHashMap<>();
for (DecodedFrame frame : frames) {
if (frame.eventTime() == null || frame.eventTime().isBlank()) {
continue;
}
String key = frame.vin() + "|" + frame.eventTime();
builders.computeIfAbsent(key, ignored -> new SnapshotBuilder(frame)).add(frame);
}
List<LogicalSnapshot> out = new ArrayList<>(snapshotLimit);
for (SnapshotBuilder builder : builders.values()) {
out.add(builder.build());
if (out.size() >= snapshotLimit) {
break;
}
}
return out;
}
private DecodedFrame decodeIfAvailable(EventFileRecord record,
String rawArchiveUri,
String platformAccount) throws IOException {
try {
return decode(record, rawArchiveUri, platformAccount);
} catch (NoSuchFileException e) {
log.warn("skip gb32960 frame because raw archive is missing eventId={} rawArchiveUri={}",
record.eventId(), rawArchiveUri);
return null;
}
}
private DecodedFrame decode(EventFileRecord record, String rawArchiveUri, String platformAccount) throws IOException {
Path rawPath = archivePath(rawArchiveUri);
byte[] rawBytes = Files.readAllBytes(rawPath);
Gb32960Message message = decoder.decode(ByteBuffer.wrap(rawBytes), platformAccount(record, platformAccount));
List<Map<String, Object>> blocks = new ArrayList<>();
for (InfoBlock block : message.infoBlocks()) {
blocks.add(blockJson(block));
}
return new DecodedFrame(
record.eventId(),
message.header().vin(),
message.header().command().name(),
message.header().responseFlag().name(),
message.header().protocolVersion().name(),
message.header().encryptType().name(),
message.header().dataLength(),
message.header().eventTime() == null ? null : message.header().eventTime().toString(),
record.ingestTime().toString(),
rawArchiveUri,
rawBytes.length,
blocks);
}
private DecodedFrame decodeWithoutIndex(String rawArchiveUri, String platformAccount) throws IOException {
Path rawPath = archivePath(rawArchiveUri);
byte[] rawBytes = Files.readAllBytes(rawPath);
Gb32960Message message = decoder.decode(ByteBuffer.wrap(rawBytes), platformAccount);
List<Map<String, Object>> blocks = new ArrayList<>();
for (InfoBlock block : message.infoBlocks()) {
blocks.add(blockJson(block));
}
String ingestTime = Files.getLastModifiedTime(rawPath).toInstant().toString();
return new DecodedFrame(
rawArchiveEventId(rawArchiveUri),
message.header().vin(),
message.header().command().name(),
message.header().responseFlag().name(),
message.header().protocolVersion().name(),
message.header().encryptType().name(),
message.header().dataLength(),
message.header().eventTime() == null ? null : message.header().eventTime().toString(),
ingestTime,
rawArchiveUri,
rawBytes.length,
blocks);
}
private Map<String, Object> blockJson(InfoBlock block) {
Map<String, Object> json = new LinkedHashMap<>();
json.put("type", block.type().name());
json.put("className", block.getClass().getSimpleName());
json.put("data", normalizeJsonValue(objectMapper.convertValue(block, MAP_TYPE)));
if (block instanceof InfoBlock.Raw raw) {
json.put("typeCode", raw.typeCode());
json.put("bytesHex", hex(raw.bytes()));
}
return json;
}
private static List<String> parseSelectedFields(String fields) {
if (fields == null || fields.isBlank()) {
return List.of();
}
List<String> out = new ArrayList<>();
for (String field : fields.split(",")) {
String normalized = field.trim();
if (!normalized.isBlank()) {
out.add(normalized);
}
}
return List.copyOf(out);
}
private static void validateSelectedFields(List<String> selectedFields) {
LinkedHashSet<String> knownFields = new LinkedHashSet<>();
for (Gb32960FieldDictionary.Packet packet : Gb32960FieldDictionary.packets()) {
for (Gb32960FieldDictionary.Field field : packet.fields()) {
knownFields.add(packet.code() + "." + field.key());
}
}
List<String> unknown = selectedFields.stream()
.filter(field -> !knownFields.contains(field))
.toList();
if (!unknown.isEmpty()) {
throw new IllegalArgumentException("unknown gb32960 snapshot fields: " + String.join(",", unknown));
}
}
private static Map<String, String> chineseHeadersByField() {
Map<String, String> headers = new LinkedHashMap<>();
for (Gb32960FieldDictionary.Packet packet : Gb32960FieldDictionary.packets()) {
for (Gb32960FieldDictionary.Field field : packet.fields()) {
String header = packet.nameZh() + "-" + field.nameZh();
if (field.unit() != null && !field.unit().isBlank()) {
header += "(" + field.unit() + ")";
}
headers.put(packet.code() + "." + field.key(), header);
}
}
return headers;
}
private static String sourceFrameUris(List<SourceFrame> sourceFrames) {
if (sourceFrames == null || sourceFrames.isEmpty()) {
return "";
}
return sourceFrames.stream()
.map(SourceFrame::rawArchiveUri)
.filter(uri -> uri != null && !uri.isBlank())
.reduce((left, right) -> left + ";" + right)
.orElse("");
}
private static String csvValue(Object value) {
if (value == null) {
return "";
}
if (value instanceof Iterable<?> iterable) {
List<String> values = new ArrayList<>();
for (Object item : iterable) {
values.add(String.valueOf(item));
}
return String.join(";", values);
}
return String.valueOf(value);
}
private static String csv(String value) {
if (value == null) {
return "";
}
boolean quote = value.indexOf(',') >= 0
|| value.indexOf('"') >= 0
|| value.indexOf('\n') >= 0
|| value.indexOf('\r') >= 0;
if (!quote) {
return value;
}
return "\"" + value.replace("\"", "\"\"") + "\"";
}
static Object normalizeJsonValue(Object value) {
if (value instanceof Double d) {
return normalizeFloatingPoint(d);
}
if (value instanceof Float f) {
return normalizeFloatingPoint(f.doubleValue());
}
if (value instanceof Map<?, ?> map) {
Map<String, Object> normalized = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
normalized.put(String.valueOf(entry.getKey()), normalizeJsonValue(entry.getValue()));
}
return normalized;
}
if (value instanceof List<?> list) {
List<Object> normalized = new ArrayList<>(list.size());
for (Object item : list) {
normalized.add(normalizeJsonValue(item));
}
return normalized;
}
return value;
}
private static BigDecimal normalizeFloatingPoint(double value) {
if (!Double.isFinite(value)) {
return BigDecimal.ZERO;
}
BigDecimal normalized = BigDecimal.valueOf(value)
.setScale(OUTPUT_DOUBLE_SCALE, RoundingMode.HALF_UP)
.stripTrailingZeros();
return normalized.scale() < 0 ? normalized.setScale(0) : normalized;
}
@SuppressWarnings("unchecked")
private Map<String, Object> deepCopy(Map<String, Object> value) {
return objectMapper.convertValue(value, MAP_TYPE);
}
private Path archivePath(String rawArchiveUri) throws IOException {
String key = rawArchiveUri.startsWith("archive://")
? rawArchiveUri.substring("archive://".length())
: rawArchiveUri;
Path path = archiveRoot.resolve(key.replace("..", "_").replaceAll("^/+", "")).normalize();
if (!path.startsWith(archiveRoot)) {
throw new IOException("raw archive path escapes root: " + rawArchiveUri);
}
return path;
}
private static String rawArchiveUri(EventFileRecord record) {
if (record.rawArchiveUri() != null && !record.rawArchiveUri().isBlank()) {
return record.rawArchiveUri();
}
if (record.metadata() == null) {
return "";
}
return record.metadata().getOrDefault("rawArchiveUri", "");
}
private static String platformAccount(EventFileRecord record, String override) {
if (override != null && !override.isBlank()) {
return override;
}
if (record.metadata() == null) {
return null;
}
return record.metadata().get("platformAccount");
}
private static String rawArchiveEventId(String rawArchiveUri) {
if (rawArchiveUri == null || rawArchiveUri.isBlank()) {
return "";
}
String key = rawArchiveUri.startsWith("archive://")
? rawArchiveUri.substring("archive://".length())
: rawArchiveUri;
int slash = key.lastIndexOf('/');
String fileName = slash >= 0 ? key.substring(slash + 1) : key;
return fileName.endsWith(".bin") ? fileName.substring(0, fileName.length() - 4) : fileName;
}
private static String hex(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return "";
}
StringBuilder out = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
out.append(Character.forDigit((b >> 4) & 0xF, 16));
out.append(Character.forDigit(b & 0xF, 16));
}
return out.toString();
}
public record DecodedFrame(
String eventId,
String vin,
String command,
String responseFlag,
String protocolVersion,
String encryptType,
int dataLength,
String eventTime,
String ingestTime,
String rawArchiveUri,
int rawSizeBytes,
List<Map<String, Object>> blocks) {
}
public record SourceFrame(
String eventId,
String ingestTime,
String rawArchiveUri,
int rawSizeBytes,
List<String> blockTypes) {
}
public record LogicalSnapshot(
String vin,
String command,
String responseFlag,
String protocolVersion,
String encryptType,
String eventTime,
String ingestTimeFirst,
String ingestTimeLast,
List<SourceFrame> sourceFrames,
List<Map<String, Object>> blocks) {
}
public record FieldSnapshot(
String vin,
String eventTime,
String ingestTimeFirst,
String ingestTimeLast,
List<SourceFrame> sourceFrames,
Map<String, Object> fields) {
private static FieldSnapshot from(LogicalSnapshot snapshot, List<String> selectedFields) {
Map<String, Object> values = new LinkedHashMap<>();
for (String selectedField : selectedFields) {
values.put(selectedField, findField(snapshot.blocks(), selectedField));
}
return new FieldSnapshot(
snapshot.vin(),
snapshot.eventTime(),
snapshot.ingestTimeFirst(),
snapshot.ingestTimeLast(),
snapshot.sourceFrames(),
values);
}
private static Object findField(List<Map<String, Object>> blocks, String selectedField) {
int dot = selectedField.indexOf('.');
if (dot <= 0 || dot == selectedField.length() - 1) {
return null;
}
String type = selectedField.substring(0, dot);
String path = selectedField.substring(dot + 1);
for (Map<String, Object> block : blocks) {
if (type.equals(block.get("type"))) {
return valueAt(block.get("data"), path.split("\\."));
}
}
return null;
}
private static Object valueAt(Object current, String[] path) {
Object value = current;
for (String part : path) {
value = step(value, part);
if (value == null) {
return null;
}
}
return value;
}
@SuppressWarnings("unchecked")
private static Object step(Object value, String part) {
if (value instanceof Map<?, ?> map) {
return map.get(part);
}
if (value instanceof List<?> list) {
if (part.matches("\\d+")) {
int index = Integer.parseInt(part);
return index >= 0 && index < list.size() ? list.get(index) : null;
}
List<Object> out = new ArrayList<>(list.size());
for (Object item : list) {
if (item instanceof Map<?, ?> itemMap) {
out.add(((Map<String, Object>) itemMap).get(part));
}
}
return out;
}
return null;
}
}
private final class SnapshotBuilder {
private final DecodedFrame first;
private final List<SourceFrame> sourceFrames = new ArrayList<>();
private final LinkedHashMap<String, Map<String, Object>> blocks = new LinkedHashMap<>();
private SnapshotBuilder(DecodedFrame first) {
this.first = first;
}
private void add(DecodedFrame frame) {
sourceFrames.add(new SourceFrame(
frame.eventId(),
frame.ingestTime(),
frame.rawArchiveUri(),
frame.rawSizeBytes(),
frame.blocks().stream().map(block -> String.valueOf(block.get("type"))).toList()));
for (Map<String, Object> block : frame.blocks()) {
String type = String.valueOf(block.get("type"));
if ("GD_FC_STACK".equals(type)) {
mergeStack(block);
} else {
blocks.putIfAbsent(type, deepCopy(block));
}
}
}
private void mergeStack(Map<String, Object> block) {
Map<String, Object> current = blocks.get("GD_FC_STACK");
if (current == null) {
blocks.put("GD_FC_STACK", deepCopy(block));
return;
}
Map<String, Object> currentData = map(current.get("data"));
Map<String, Object> incomingData = map(block.get("data"));
List<Map<String, Object>> currentStacks = listOfMaps(currentData.get("stacks"));
List<Map<String, Object>> incomingStacks = listOfMaps(incomingData.get("stacks"));
for (int i = 0; i < incomingStacks.size(); i++) {
if (i >= currentStacks.size()) {
currentStacks.add(deepCopy(incomingStacks.get(i)));
} else {
mergeStackItem(currentStacks.get(i), incomingStacks.get(i));
}
}
currentData.put("stackCount", currentStacks.size());
}
private void mergeStackItem(Map<String, Object> target, Map<String, Object> incoming) {
int targetStart = intValue(target.get("frameCellStart"), 1);
int incomingStart = intValue(incoming.get("frameCellStart"), 1);
List<Double> targetVoltages = doubleList(target.get("frameCellVoltagesV"));
List<Double> incomingVoltages = doubleList(incoming.get("frameCellVoltagesV"));
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(),
incomingStart - 1 + incomingVoltages.size()));
List<Double> merged = new ArrayList<>(needed);
for (int i = 0; i < needed; i++) {
merged.add(null);
}
putVoltages(merged, targetStart, targetVoltages);
putVoltages(merged, incomingStart, incomingVoltages);
while (!merged.isEmpty() && merged.getLast() == null) {
merged.removeLast();
}
target.put("frameCellStart", 1);
target.put("frameCellCount", merged.size());
target.put("frameCellVoltagesV", merged);
target.put("cellCount", Math.max(cellCount, merged.size()));
normalizeStackItem(target);
}
private LogicalSnapshot build() {
List<SourceFrame> orderedFrames = sourceFrames.stream()
.sorted(Comparator.comparing(SourceFrame::ingestTime))
.toList();
Map<String, Object> stackBlock = blocks.get("GD_FC_STACK");
if (stackBlock != null) {
normalizeStackBlock(stackBlock);
}
List<Map<String, Object>> orderedBlocks = new ArrayList<>(blocks.values());
orderedBlocks.sort(Comparator.comparingInt(block -> blockOrder(String.valueOf(block.get("type")))));
List<Map<String, Object>> normalizedBlocks = orderedBlocks.stream()
.map(block -> map(normalizeJsonValue(block)))
.toList();
return new LogicalSnapshot(
first.vin(),
first.command(),
first.responseFlag(),
first.protocolVersion(),
first.encryptType(),
first.eventTime(),
orderedFrames.isEmpty() ? null : orderedFrames.getFirst().ingestTime(),
orderedFrames.isEmpty() ? null : orderedFrames.getLast().ingestTime(),
sourceFrames,
normalizedBlocks);
}
}
private static void normalizeStackBlock(Map<String, Object> stackBlock) {
Map<String, Object> data = map(stackBlock.get("data"));
for (Map<String, Object> stack : listOfMaps(data.get("stacks"))) {
normalizeStackItem(stack);
}
}
private static void normalizeStackItem(Map<String, Object> target) {
int start = intValue(target.get("frameCellStart"), 1);
List<Double> voltages = doubleList(target.get("frameCellVoltagesV"));
int cellCount = intValue(target.get("cellCount"), 0);
int needed = Math.max(cellCount, start - 1 + voltages.size());
List<Double> normalized = new ArrayList<>(needed);
for (int i = 0; i < needed; i++) {
normalized.add(null);
}
putVoltages(normalized, start, voltages);
while (cellCount == 0 && !normalized.isEmpty() && normalized.getLast() == null) {
normalized.removeLast();
}
long missing = normalized.stream().filter(item -> item == null).count();
target.put("frameCellStart", 1);
target.put("frameCellCount", normalized.size());
target.put("frameCellVoltagesV", normalized);
target.put("cellCount", Math.max(cellCount, normalized.size()));
target.put("frameCellComplete", missing == 0 && (cellCount == 0 || normalized.size() >= cellCount));
target.put("frameCellMissingCount", missing);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return value instanceof Map<?, ?> m ? (Map<String, Object>) m : new LinkedHashMap<>();
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> listOfMaps(Object value) {
return value instanceof List<?> list ? (List<Map<String, Object>>) list : new ArrayList<>();
}
private static int intValue(Object value, int fallback) {
return value instanceof Number n ? n.intValue() : fallback;
}
private static List<Double> doubleList(Object value) {
if (!(value instanceof List<?> list)) {
return List.of();
}
List<Double> out = new ArrayList<>(list.size());
for (Object item : list) {
out.add(item instanceof Number n ? n.doubleValue() : null);
}
return out;
}
private static void putVoltages(List<Double> target, int start, List<Double> values) {
int offset = Math.max(0, start - 1);
for (int i = 0; i < values.size(); i++) {
int index = offset + i;
if (index >= target.size()) {
target.add(values.get(i));
} else if (values.get(i) != null) {
target.set(index, values.get(i));
}
}
}
private static int blockOrder(String type) {
return switch (type) {
case "VEHICLE" -> 10;
case "DRIVE_MOTOR" -> 20;
case "FUEL_CELL_V2016", "FUEL_CELL_V2025" -> 30;
case "ENGINE" -> 40;
case "POSITION_V2016", "POSITION_V2025" -> 50;
case "EXTREME_V2016" -> 60;
case "ALARM_V2016", "ALARM_V2025" -> 70;
case "VOLTAGE_V2016", "MIN_PARALLEL_VOLTAGE_V2025" -> 80;
case "TEMPERATURE_V2016", "BATTERY_TEMPERATURE_V2025" -> 90;
case "FUEL_CELL_STACK", "GD_FC_STACK" -> 100;
case "SUPER_CAPACITOR" -> 110;
case "SUPER_CAPACITOR_EXTREME" -> 120;
case "GD_FC_AUXILIARY" -> 130;
case "GD_FC_DCDC" -> 140;
case "GD_FC_AIR_CONDITIONER" -> 150;
case "GD_FC_VEHICLE_INFO" -> 160;
case "GD_FC_DEMO_EXTENSION" -> 170;
case "GD_FC_VENDOR_TLV" -> 180;
default -> 1000;
};
}
}

View File

@@ -0,0 +1,190 @@
package com.lingniu.ingest.eventhistory;
import java.util.List;
public final class Gb32960FieldDictionary {
private Gb32960FieldDictionary() {
}
public static List<Packet> packets() {
return PACKETS;
}
private static final List<Packet> PACKETS = List.of(
packet("VEHICLE", "整车数据", "GB/T 32960 整车运行状态、车速、里程、电压、电流、SOC 等核心字段。",
field("vehicleState", "车辆状态", "", "车辆运行、停止等状态编码。",
mappings(
mapping("01", "1", "启动", "车辆处于可行驶启动状态。"),
mapping("02", "2", "熄火", "车辆处于熄火状态。"),
mapping("03", "3", "其他", "车辆处于其他状态。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("chargingState", "充电状态", "", "车辆充电状态编码。",
mappings(
mapping("01", "1", "停车充电", "车辆停车充电。"),
mapping("02", "2", "行驶充电", "车辆行驶充电。"),
mapping("03", "3", "未充电", "车辆未充电。"),
mapping("04", "4", "充电完成", "车辆充电完成。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("runningMode", "运行模式", "", "纯电、混动、燃料电池等运行模式编码。",
mappings(
mapping("01", "1", "纯电", "纯电驱动模式。"),
mapping("02", "2", "混合动力", "混合动力驱动模式。"),
mapping("03", "3", "燃料电池", "燃料电池驱动模式。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("speedKmh", "车速", "km/h", "车辆当前速度。"),
field("totalMileageKm", "累计里程", "km", "车辆累计行驶里程。"),
field("totalVoltageV", "总电压", "V", "动力系统总电压。"),
field("totalCurrentA", "总电流", "A", "动力系统总电流。"),
field("socPercent", "SOC", "%", "动力电池荷电状态。"),
field("dcDcStatus", "DC/DC 状态", "", "DC/DC 工作状态编码。",
mappings(
mapping("01", "1", "工作", "DC/DC 处于工作状态。"),
mapping("02", "2", "断开", "DC/DC 处于断开状态。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("gearRaw", "挡位原始值", "", "挡位、驱动力和制动力组合原始编码。"),
field("insulationResistanceKohm", "绝缘电阻", "kOhm", "高压系统绝缘电阻。"),
field("acceleratorPedal", "加速踏板行程", "%", "2016 版字段,加速踏板开度。"),
field("brakePedal", "制动踏板状态", "%", "2016 版字段,制动踏板状态或开度。")),
packet("DRIVE_MOTOR", "驱动电机数据", "GB/T 32960 驱动电机控制器和电机运行状态。",
field("motors.serialNo", "驱动电机序号", "", "电机编号。"),
field("motors.state", "驱动电机状态", "", "电机工作状态编码。",
mappings(
mapping("01", "1", "耗电", "驱动电机耗电。"),
mapping("02", "2", "发电", "驱动电机发电。"),
mapping("03", "3", "关闭", "驱动电机关闭。"),
mapping("04", "4", "准备", "驱动电机准备。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("motors.controllerTempC", "控制器温度", "C", "驱动电机控制器温度。"),
field("motors.rpm", "转速", "rpm", "驱动电机转速。"),
field("motors.torqueNm", "转矩", "N.m", "驱动电机输出转矩。"),
field("motors.motorTempC", "电机温度", "C", "驱动电机温度。"),
field("motors.controllerInputVoltageV", "控制器输入电压", "V", "电机控制器直流母线输入电压。"),
field("motors.controllerDcCurrentA", "控制器直流母线电流", "A", "电机控制器直流母线电流。")),
packet("FUEL_CELL_V2016", "燃料电池数据2016", "GB/T 32960.3-2016 燃料电池电压、电流、氢耗、温度、浓度、压力等字段。",
field("fcVoltageV", "燃料电池电压", "V", "燃料电池系统输出电压。"),
field("fcCurrentA", "燃料电池电流", "A", "燃料电池系统输出电流。"),
field("hydrogenConsumptionKgPer100km", "氢耗", "kg/100km", "燃料电池氢气消耗率。"),
field("probeTempC", "探针温度", "C", "燃料电池温度探针列表。"),
field("maxHydrogenSystemTempC", "氢系统最高温度", "C", "氢系统最高温度。"),
field("maxHydrogenConcentrationPercent", "最高氢浓度", "%", "氢气浓度最高值。"),
field("maxHydrogenPressureMpa", "最高氢压力", "MPa", "氢系统最高压力。"),
field("hvDcDcStatus", "高压 DC/DC 状态", "", "燃料电池高压 DC/DC 状态。")),
packet("POSITION_V2016", "车辆位置数据2016", "GB/T 32960.3-2016 定位状态和经纬度。",
field("statusFlag", "定位状态标志", "", "定位有效性及经纬度方向标志。",
mappings(
mapping("bit0=0", "bit0=0", "有效定位", "定位状态有效。"),
mapping("bit0=1", "bit0=1", "无效定位", "定位状态无效。"),
mapping("bit1=0", "bit1=0", "北纬", "纬度方向为北纬。"),
mapping("bit1=1", "bit1=1", "南纬", "纬度方向为南纬。"),
mapping("bit2=0", "bit2=0", "东经", "经度方向为东经。"),
mapping("bit2=1", "bit2=1", "西经", "经度方向为西经。"))),
field("longitude", "经度", "deg", "车辆经度。"),
field("latitude", "纬度", "deg", "车辆纬度。")),
packet("POSITION_V2025", "车辆位置数据2025", "GB/T 32960.3-2025 定位状态、坐标系和经纬度。",
field("statusFlag", "定位状态标志", "", "定位有效性及经纬度方向标志。",
mappings(
mapping("bit0=0", "bit0=0", "有效定位", "定位状态有效。"),
mapping("bit0=1", "bit0=1", "无效定位", "定位状态无效。"),
mapping("bit1=0", "bit1=0", "北纬", "纬度方向为北纬。"),
mapping("bit1=1", "bit1=1", "南纬", "纬度方向为南纬。"),
mapping("bit2=0", "bit2=0", "东经", "经度方向为东经。"),
mapping("bit2=1", "bit2=1", "西经", "经度方向为西经。"))),
field("coordSystem", "坐标系", "", "2025 版新增坐标系编码。",
mappings(
mapping("01", "1", "WGS84", "WGS84 坐标系。"),
mapping("02", "2", "GCJ-02", "GCJ-02 坐标系。"),
mapping("03", "3", "其他", "其他坐标系。"))),
field("longitude", "经度", "deg", "车辆经度。"),
field("latitude", "纬度", "deg", "车辆纬度。")),
packet("ALARM_V2016", "报警数据2016", "GB/T 32960.3-2016 最高报警等级、通用报警位和故障码列表。",
field("maxLevel", "最高报警等级", "", "当前最高报警等级。",
mappings(
mapping("00", "0", "无故障", "当前无报警故障。"),
mapping("01", "1", "一级故障", "一级报警故障。"),
mapping("02", "2", "二级故障", "二级报警故障。"),
mapping("03", "3", "三级故障", "三级报警故障。"))),
field("generalAlarmFlag", "通用报警标志", "", "通用报警位图。"),
field("batteryFaults", "可充电储能装置故障码", "", "动力电池相关故障码列表。"),
field("motorFaults", "驱动电机故障码", "", "驱动电机相关故障码列表。"),
field("engineFaults", "发动机故障码", "", "发动机相关故障码列表。"),
field("otherFaults", "其他故障码", "", "其他故障码列表。")),
packet("GD_FC_STACK", "广东燃料电池电堆数据", "广东燃料电池汽车数据接入规范扩展,描述电堆状态、压力、温度和单体电压分段。",
field("stackCount", "电堆数量", "", "本包包含的电堆数量。"),
field("stacks.engineWorkState", "发动机工作状态", "", "燃料电池发动机工作状态。"),
field("stacks.stackWaterOutletTempC", "电堆出水温度", "C", "电堆冷却水出口温度。"),
field("stacks.hydrogenInletPressureKpa", "氢气入口压力", "kPa", "电堆氢气入口压力。"),
field("stacks.airInletPressureKpa", "空气入口压力", "kPa", "电堆空气入口压力。"),
field("stacks.airInletTempC", "空气入口温度", "C", "电堆空气入口温度。"),
field("stacks.maxCellVoltageV", "单体最高电压", "V", "本电堆单体最高电压。"),
field("stacks.minCellVoltageV", "单体最低电压", "V", "本电堆单体最低电压。"),
field("stacks.avgCellVoltageV", "单体平均电压", "V", "本电堆单体平均电压。"),
field("stacks.cellCount", "单体总数", "", "电堆单体总数量。"),
field("stacks.frameCellStart", "本帧单体起始序号", "", "当前分段电压起始单体序号。"),
field("stacks.frameCellCount", "本帧单体数量", "", "当前分段包含的单体数量。"),
field("stacks.frameCellVoltagesV", "本帧单体电压列表", "V", "当前分段的单体电压数组。"),
field("stacks.frameCellComplete", "单体电压是否完整", "", "snapshot 合并后是否已覆盖全部单体。"),
field("stacks.frameCellMissingCount", "缺失单体数量", "", "snapshot 合并后仍缺失的单体数量。")),
packet("GD_FC_DCDC", "广东燃料电池 DC/DC 数据", "广东燃料电池汽车数据接入规范扩展,描述 DC/DC 输入输出和控制器温度。",
field("inputVoltageV", "输入电压", "V", "DC/DC 输入电压。"),
field("inputCurrentA", "输入电流", "A", "DC/DC 输入电流。"),
field("outputVoltageV", "输出电压", "V", "DC/DC 输出电压。"),
field("outputCurrentA", "输出电流", "A", "DC/DC 输出电流。"),
field("controllerTempC", "控制器温度", "C", "DC/DC 控制器温度。")),
packet("GD_FC_AIR_CONDITIONER", "广东燃料电池空调数据", "广东燃料电池汽车数据接入规范扩展,描述空调状态、功率和压缩机输入电压。",
field("status", "空调状态", "", "空调工作状态。",
mappings(
mapping("00", "0", "关闭", "空调关闭。"),
mapping("01", "1", "启动", "空调启动。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("powerKw", "空调功率", "kW", "空调消耗功率。"),
field("compressorInputVoltageV", "压缩机输入电压", "V", "空调压缩机输入电压。")),
packet("GD_FC_VEHICLE_INFO", "广东燃料电池车辆信息数据", "广东燃料电池汽车数据接入规范扩展,描述碰撞报警、环境温度压力和车载氢量。",
field("collisionAlarm", "碰撞报警", "", "碰撞报警状态。",
mappings(
mapping("00", "0", "无碰撞报警", "当前无碰撞报警。"),
mapping("01", "1", "有碰撞报警", "当前有碰撞报警。"),
mapping("FE", "254", "异常", "协议异常值。"),
mapping("FF", "255", "无效", "协议无效值。"))),
field("ambientTempC", "环境温度", "C", "车辆环境温度。"),
field("ambientPressureKpa", "环境压力", "kPa", "车辆环境压力。"),
field("hydrogenMassKg", "车载氢量", "kg", "当前车载氢气质量。"))
);
private static Packet packet(String code, String nameZh, String description, Field... fields) {
return new Packet(code, nameZh, description, List.of(fields));
}
private static Field field(String key, String nameZh, String unit, String description) {
return field(key, nameZh, unit, description, List.of());
}
private static Field field(String key, String nameZh, String unit, String description,
List<ValueMapping> valueMappings) {
return new Field(key, nameZh, unit, description, valueMappings);
}
private static List<ValueMapping> mappings(ValueMapping... mappings) {
return List.of(mappings);
}
private static ValueMapping mapping(String code, String value, String nameZh, String description) {
return new ValueMapping(code, value, nameZh, description);
}
public record Packet(String code, String nameZh, String description, List<Field> fields) {
}
public record Field(String key, String nameZh, String unit, String description,
List<ValueMapping> valueMappings) {
}
public record ValueMapping(String code, String value, String nameZh, String description) {
}
}

View File

@@ -0,0 +1,156 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.eventfilestore.EventFileQuery;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.List;
@RestController
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
@ConditionalOnBean(Gb32960DecodedFrameService.class)
@RequestMapping("/api/event-history/gb32960")
@Tag(name = "gb-32960-frame-controller", description = "GB32960 专用历史帧、业务快照、字段查询和字段字典接口。")
public final class Gb32960FrameController {
private final Gb32960DecodedFrameService service;
public Gb32960FrameController(Gb32960DecodedFrameService service) {
if (service == null) {
throw new IllegalArgumentException("service must not be null");
}
this.service = service;
}
@GetMapping("/frames")
@Operation(
summary = "查询 GB32960 原始帧解析结果",
description = "按时间范围查询某辆车或账号下的 GB32960 原始帧,并返回每帧解析出的整包 blocks。主要用于排查原始报文、解析结果和子包内容。")
public List<Gb32960DecodedFrameService.DecodedFrame> frames(
@Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00")
@RequestParam String dateFrom,
@Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00")
@RequestParam String dateTo,
@Parameter(description = "排序方向。", example = "DESC")
@RequestParam(defaultValue = "DESC") EventFileQuery.Order order,
@Parameter(description = "返回帧数量上限。", example = "10")
@RequestParam(defaultValue = "10") int limit,
@Parameter(description = "车辆 VIN用于缩小到单车查询。", example = "LB9A32A20P0LS1257")
@RequestParam(required = false) String vin,
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
@RequestParam(required = false) String platformAccount) throws IOException {
QueryTimeRange range = QueryTimeRange.parse(dateFrom, dateTo);
return service.query(
range.dateFrom(),
range.dateTo(),
range.eventTimeFrom(),
range.eventTimeTo(),
order,
limit,
vin,
platformAccount);
}
@GetMapping("/frame")
@Operation(
summary = "按 rawArchiveUri 查询单个 GB32960 原始帧",
description = "直接读取指定 archive://*.bin 原始包并返回解析结果。主要用于从 snapshots 的 sourceFrames 回查某个原始报文。")
public Gb32960DecodedFrameService.DecodedFrame frame(
@Parameter(description = "原始包归档地址。", example = "archive://2026/06/23/GB32960/LB9A32A20P0LS1257/1782182749928000.bin")
@RequestParam String rawArchiveUri,
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
@RequestParam(required = false) String platformAccount) throws IOException {
return service.findByRawArchiveUri(rawArchiveUri, platformAccount);
}
@GetMapping("/snapshots")
@Operation(
summary = "查询 GB32960 完整业务快照",
description = "按 VIN 和时间范围查询完整业务快照。会把同一车辆、同一 eventTime 的多个子包合并为一个 snapshot适合车辆详情页和某一时刻全字段查看。VIN 为必填。")
public List<Gb32960DecodedFrameService.LogicalSnapshot> snapshots(
@Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00")
@RequestParam String dateFrom,
@Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00")
@RequestParam String dateTo,
@Parameter(description = "排序方向。", example = "DESC")
@RequestParam(defaultValue = "DESC") EventFileQuery.Order order,
@Parameter(description = "返回 snapshot 数量上限。", example = "10")
@RequestParam(defaultValue = "10") int limit,
@Parameter(description = "车辆 VIN必填snapshot 查询只支持单车,避免跨车合并和低效扫描。", required = true, example = "LB9A32A20P0LS1257")
@RequestParam String vin,
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
@RequestParam(required = false) String platformAccount) throws IOException {
if (vin == null || vin.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshots");
}
return service.snapshots(QueryTimeRange.parse(dateFrom, dateTo), order, limit, vin, platformAccount);
}
@GetMapping("/snapshots/fields")
@Operation(
summary = "按所需字段查询 GB32960 快照",
description = "高频业务查询接口。按 VIN、时间范围和字段列表返回轻量快照只包含 fields 指定字段;字段格式为 数据包代码.字段名,例如 VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV。字段名应来自 dictionary 接口。VIN 和 fields 为必填。")
public List<Gb32960DecodedFrameService.FieldSnapshot> snapshotFields(
@Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00")
@RequestParam String dateFrom,
@Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00")
@RequestParam String dateTo,
@Parameter(description = "排序方向。", example = "DESC")
@RequestParam(defaultValue = "DESC") EventFileQuery.Order order,
@Parameter(description = "返回 snapshot 数量上限。", example = "10")
@RequestParam(defaultValue = "10") int limit,
@Parameter(description = "车辆 VIN必填字段查询只支持单车。", required = true, example = "LB9A32A20P0LS1257")
@RequestParam String vin,
@Parameter(description = "逗号分隔的字段列表,字段来自 dictionary格式为 数据包代码.字段名。", required = true, example = "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA")
@RequestParam String fields,
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
@RequestParam(required = false) String platformAccount) throws IOException {
if (vin == null || vin.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field query");
}
return service.snapshotFields(QueryTimeRange.parse(dateFrom, dateTo), order, limit, vin, platformAccount, fields);
}
@GetMapping(value = "/snapshots/fields.csv", produces = "text/csv;charset=UTF-8")
@Operation(
summary = "导出 GB32960 指定字段快照 CSV",
description = "参数与 snapshots/fields 一致,返回 CSV 文本。基础列和字段列表表头均为中文,字段表头来自 dictionary例如 整车数据-累计里程(km)。适合前端下载和临时报表。")
public String snapshotFieldsCsv(
@Parameter(description = "开始时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:48:00")
@RequestParam String dateFrom,
@Parameter(description = "结束时间,支持日期或精确到秒的时间;无时区时按东八区解析。", example = "2026-06-23T10:49:00")
@RequestParam String dateTo,
@Parameter(description = "排序方向。", example = "DESC")
@RequestParam(defaultValue = "DESC") EventFileQuery.Order order,
@Parameter(description = "返回 snapshot 数量上限。", example = "1000")
@RequestParam(defaultValue = "1000") int limit,
@Parameter(description = "车辆 VIN必填字段导出只支持单车。", required = true, example = "LB9A32A20P0LS1257")
@RequestParam String vin,
@Parameter(description = "逗号分隔的字段列表,字段来自 dictionary格式为 数据包代码.字段名。", required = true, example = "VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA")
@RequestParam String fields,
@Parameter(description = "平台账号;用于选择对应厂商扩展解析规则。", example = "Hyundai")
@RequestParam(required = false) String platformAccount) throws IOException {
if (vin == null || vin.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "vin is required for gb32960 snapshot field csv export");
}
return service.snapshotFieldsCsv(QueryTimeRange.parse(dateFrom, dateTo), order, limit, vin, platformAccount, fields);
}
@GetMapping("/dictionary")
@Operation(
summary = "查询 GB32960 字段字典",
description = "返回数据包代码、数据包中文名、字段 key、中文名、单位、解释和枚举/位图值域对照。前端应用可用它渲染字段选择器、表头、Tooltip 和状态码中文展示。")
public List<Gb32960FieldDictionary.Packet> dictionary() {
return Gb32960FieldDictionary.packets();
}
}

View File

@@ -0,0 +1,77 @@
package com.lingniu.ingest.eventhistory;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
final class QueryTimeRange {
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Shanghai");
private final LocalDate dateFrom;
private final LocalDate dateTo;
private final Instant eventTimeFrom;
private final Instant eventTimeTo;
private QueryTimeRange(LocalDate dateFrom, LocalDate dateTo, Instant eventTimeFrom, Instant eventTimeTo) {
this.dateFrom = dateFrom;
this.dateTo = dateTo;
this.eventTimeFrom = eventTimeFrom;
this.eventTimeTo = eventTimeTo;
}
static QueryTimeRange parse(String from, String to) {
Boundary start = parseBoundary(from, false);
Boundary end = parseBoundary(to, true);
if (end.instant() != null && start.instant() != null && end.instant().isBefore(start.instant())) {
throw new IllegalArgumentException("dateTo must not be before dateFrom");
}
if (end.date().isBefore(start.date())) {
throw new IllegalArgumentException("dateTo must not be before dateFrom");
}
return new QueryTimeRange(start.date(), end.date(), start.instant(), end.instant());
}
LocalDate dateFrom() {
return dateFrom;
}
LocalDate dateTo() {
return dateTo;
}
Instant eventTimeFrom() {
return eventTimeFrom;
}
Instant eventTimeTo() {
return eventTimeTo;
}
private static Boundary parseBoundary(String raw, boolean endOfRange) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("date range must not be blank");
}
String value = raw.trim();
if (value.length() == 10) {
LocalDate date = LocalDate.parse(value);
Instant instant = endOfRange
? date.plusDays(1).atStartOfDay(DEFAULT_ZONE).toInstant().minusMillis(1)
: date.atStartOfDay(DEFAULT_ZONE).toInstant();
return new Boundary(date, instant);
}
try {
Instant instant = OffsetDateTime.parse(value).toInstant();
return new Boundary(LocalDate.ofInstant(instant, DEFAULT_ZONE), instant);
} catch (RuntimeException ignored) {
LocalDateTime local = LocalDateTime.parse(value);
Instant instant = local.atZone(DEFAULT_ZONE).toInstant();
return new Boundary(local.toLocalDate(), instant);
}
}
private record Boundary(LocalDate date, Instant instant) {
}
}

View File

@@ -0,0 +1,64 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.util.JsonFormat;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Maps Kafka protobuf envelopes into records stored by the historical detail
* file store.
*/
public final class TelemetryEnvelopeRecordMapper {
private static final JsonFormat.Printer JSON_PRINTER = JsonFormat.printer();
public EventFileRecord toRecord(VehicleEnvelope envelope) {
if (envelope == null) {
throw new IllegalArgumentException("envelope must not be null");
}
if (!envelope.hasTelemetrySnapshot()) {
throw new IllegalArgumentException("envelope telemetry_snapshot is required");
}
ProtocolId protocol = protocol(envelope.getSource());
Map<String, String> metadata = new LinkedHashMap<>(envelope.getMetadataMap());
if (protocol == ProtocolId.UNKNOWN && !envelope.getSource().isBlank()) {
metadata.putIfAbsent("originalSource", envelope.getSource());
}
return new EventFileRecord(
envelope.getEventId(),
protocol,
envelope.getTelemetrySnapshot().getEventType(),
envelope.getVin(),
Instant.ofEpochMilli(envelope.getEventTimeMs()),
Instant.ofEpochMilli(envelope.getIngestTimeMs()),
envelope.getTelemetrySnapshot().getRawArchiveUri(),
metadata,
snapshotJson(envelope)
);
}
private static ProtocolId protocol(String source) {
if (source == null || source.isBlank()) {
return ProtocolId.UNKNOWN;
}
try {
return ProtocolId.valueOf(source);
} catch (IllegalArgumentException ex) {
return ProtocolId.UNKNOWN;
}
}
private static String snapshotJson(VehicleEnvelope envelope) {
try {
return JSON_PRINTER.print(envelope.getTelemetrySnapshot());
} catch (InvalidProtocolBufferException e) {
throw new IllegalArgumentException("failed to serialize telemetry_snapshot", e);
}
}
}

View File

@@ -0,0 +1,84 @@
package com.lingniu.ingest.eventhistory.config;
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.eventhistory.EventHistoryController;
import com.lingniu.ingest.eventhistory.EventHistoryEnvelopeIngestor;
import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.config.Gb32960AutoConfiguration;
import com.lingniu.ingest.sink.archive.config.SinkArchiveAutoConfiguration;
import com.lingniu.ingest.sink.archive.config.SinkArchiveProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import java.net.URI;
import java.nio.file.Path;
@AutoConfiguration
@AutoConfigureAfter({Gb32960AutoConfiguration.class, SinkArchiveAutoConfiguration.class})
@ConditionalOnProperty(prefix = "lingniu.ingest.event-history", name = "enabled", havingValue = "true")
public class EventHistoryAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public TelemetryEnvelopeRecordMapper telemetryEnvelopeRecordMapper() {
return new TelemetryEnvelopeRecordMapper();
}
@Bean
@ConditionalOnBean(EventFileStore.class)
@ConditionalOnMissingBean
public EventHistoryEnvelopeIngestor eventHistoryEnvelopeIngestor(EventFileStore store,
TelemetryEnvelopeRecordMapper mapper) {
return new EventHistoryEnvelopeIngestor(store, mapper);
}
@Bean
@ConditionalOnBean({EventHistoryEnvelopeIngestor.class, EnvelopeDeadLetterSink.class})
@ConditionalOnMissingBean(name = "eventHistoryEnvelopeConsumerProcessor")
public EnvelopeConsumerProcessor eventHistoryEnvelopeConsumerProcessor(EventHistoryEnvelopeIngestor ingestor,
EnvelopeDeadLetterSink deadLetterSink) {
return new EnvelopeConsumerProcessor("event-history", ingestor, deadLetterSink);
}
@Bean
@ConditionalOnBean(EventFileStore.class)
@ConditionalOnMissingBean
public EventHistoryController eventHistoryController(EventFileStore store) {
return new EventHistoryController(store);
}
@Bean
@ConditionalOnBean({EventFileStore.class, Gb32960MessageDecoder.class})
@ConditionalOnMissingBean
public Gb32960DecodedFrameService gb32960DecodedFrameService(EventFileStore store,
Gb32960MessageDecoder decoder,
SinkArchiveProperties archiveProperties) {
return new Gb32960DecodedFrameService(store, decoder, archiveRoot(archiveProperties.getPath()), null);
}
@Bean
@ConditionalOnBean(Gb32960DecodedFrameService.class)
@ConditionalOnMissingBean
public Gb32960FrameController gb32960FrameController(Gb32960DecodedFrameService service) {
return new Gb32960FrameController(service);
}
static Path archiveRoot(String value) {
if (value == null || value.isBlank()) {
return Path.of(System.getProperty("java.io.tmpdir"), "lingniu-archive");
}
if (value.startsWith("file://")) {
return Path.of(URI.create(value));
}
return Path.of(value);
}
}

View File

@@ -0,0 +1 @@
com.lingniu.ingest.eventhistory.config.EventHistoryAutoConfiguration

View File

@@ -0,0 +1,72 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.file.Path;
import java.util.List;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
class ApiExceptionHandlerTest {
@TempDir
Path archiveRoot;
@Test
void missingRequiredQueryParameterReturnsConcreteReason() throws Exception {
MockMvc mvc = standaloneSetup(new Gb32960FrameController(service()))
.setControllerAdvice(new ApiExceptionHandler())
.build();
mvc.perform(get("/api/event-history/gb32960/snapshots")
.param("dateFrom", "2026-06-23")
.param("dateTo", "2026-06-23")
.param("limit", "1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("missing required query parameter: vin"))
.andExpect(jsonPath("$.timestamp").value(org.hamcrest.Matchers.endsWith("+08:00")))
.andExpect(jsonPath("$.path").value("/api/event-history/gb32960/snapshots"));
}
@Test
void responseStatusExceptionReturnsReason() throws Exception {
MockMvc mvc = standaloneSetup(new Gb32960FrameController(service()))
.setControllerAdvice(new ApiExceptionHandler())
.build();
mvc.perform(get("/api/event-history/gb32960/snapshots")
.param("dateFrom", "2026-06-23")
.param("dateTo", "2026-06-23")
.param("limit", "1")
.param("vin", ""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("vin is required for gb32960 snapshots"));
}
private Gb32960DecodedFrameService service() {
EventFileStore store = new EventFileStore() {
@Override
public void appendAll(List<EventFileRecord> records) {
}
@Override
public List<EventFileRecord> query(com.lingniu.ingest.eventfilestore.EventFileQuery query) {
return List.of();
}
};
return new Gb32960DecodedFrameService(
store,
mock(Gb32960MessageDecoder.class),
archiveRoot,
null);
}
}

View File

@@ -0,0 +1,152 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.eventfilestore.EventFileQuery;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class EventHistoryControllerTest {
@Test
void queriesRecordsByDateRangeAndOrder() throws Exception {
CapturingStore store = new CapturingStore(List.of(record("event-1")));
EventHistoryController controller = new EventHistoryController(store);
List<EventHistoryController.RecordResponse> records = controller.query(
ProtocolId.GB32960,
"2026-06-21",
"2026-06-22",
EventFileQuery.Order.DESC,
50,
"VIN001");
assertThat(records).extracting(EventHistoryController.RecordResponse::eventId).containsExactly("event-1");
assertThat(records.getFirst().eventTime()).isEqualTo("2026-06-22T08:00:00Z");
assertThat(records.getFirst().ingestTime()).isEqualTo("2026-06-22T08:00:01Z");
assertThat(store.lastQuery.protocol()).isEqualTo(ProtocolId.GB32960);
assertThat(store.lastQuery.dateFrom().toString()).isEqualTo("2026-06-21");
assertThat(store.lastQuery.dateTo().toString()).isEqualTo("2026-06-22");
assertThat(store.lastQuery.order()).isEqualTo(EventFileQuery.Order.DESC);
assertThat(store.lastQuery.limit()).isEqualTo(50);
assertThat(store.lastQuery.vin()).isEqualTo("VIN001");
}
@Test
void exportsRecordsAsCsv() throws Exception {
EventHistoryController controller = new EventHistoryController(new CapturingStore(List.of(record("event-1"))));
String csv = controller.exportCsv(
ProtocolId.GB32960,
"2026-06-22",
"2026-06-22",
EventFileQuery.Order.ASC,
100);
assertThat(csv).startsWith("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri,payload_json\n");
assertThat(csv).contains("event-1,GB32960,REALTIME,VIN001");
assertThat(csv).contains("\"{\"\"fields\"\":[]}\"");
}
@Test
void exportsConfiguredTelemetryFieldsAsCsvColumns() throws Exception {
EventHistoryController controller = new EventHistoryController(new CapturingStore(List.of(
new EventFileRecord(
"media-1",
ProtocolId.JT1078,
"MEDIA_META",
"VIN001",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"file:///archive/jt1078/media-1.rtp",
Map.of(),
"""
{"fields":[
{"key":"media_archive_ref","value":"file:///archive/jt1078/media-1.rtp"},
{"key":"passthrough_size_bytes","value":"128"}
]}
"""))));
String csv = controller.exportCsv(
ProtocolId.JT1078,
"2026-06-22",
"2026-06-22",
EventFileQuery.Order.ASC,
100,
null,
"media_archive_ref,passthrough_size_bytes");
assertThat(csv).startsWith("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri,media_archive_ref,passthrough_size_bytes,payload_json\n");
assertThat(csv).contains("media-1,JT1078,MEDIA_META,VIN001,2026-06-22T08:00:00Z,2026-06-22T08:00:01Z,file:///archive/jt1078/media-1.rtp,file:///archive/jt1078/media-1.rtp,128,");
}
@Test
void exportsConfiguredMetadataFieldsAsCsvColumns() throws Exception {
EventHistoryController controller = new EventHistoryController(new CapturingStore(List.of(
new EventFileRecord(
"media-2",
ProtocolId.JT1078,
"MEDIA_META",
"VIN001",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"file:///archive/jt1078/media-2.rtp",
Map.of(
"channelId", "3",
"segmentSizeBytes", "512",
"archiveKey", "jt1078/2026/06/22/VIN001/ch-3/dt-0/1782115200.media"),
"{\"fields\":[]}"))));
String csv = controller.exportCsv(
ProtocolId.JT1078,
"2026-06-22",
"2026-06-22",
EventFileQuery.Order.ASC,
100,
null,
"metadata.channelId,metadata.segmentSizeBytes,metadata.archiveKey");
assertThat(csv).startsWith("event_id,protocol,event_type,vin,event_time,ingest_time,raw_archive_uri,metadata.channelId,metadata.segmentSizeBytes,metadata.archiveKey,payload_json\n");
assertThat(csv).contains("media-2,JT1078,MEDIA_META,VIN001,2026-06-22T08:00:00Z,2026-06-22T08:00:01Z,file:///archive/jt1078/media-2.rtp,3,512,jt1078/2026/06/22/VIN001/ch-3/dt-0/1782115200.media,");
}
private static EventFileRecord record(String eventId) {
return new EventFileRecord(
eventId,
ProtocolId.GB32960,
"REALTIME",
"VIN001",
Instant.parse("2026-06-22T08:00:00Z"),
Instant.parse("2026-06-22T08:00:01Z"),
"archive://raw/" + eventId + ".bin",
Map.of(),
"{\"fields\":[]}");
}
private static final class CapturingStore implements EventFileStore {
private final List<EventFileRecord> records;
private EventFileQuery lastQuery;
private CapturingStore(List<EventFileRecord> records) {
this.records = records;
}
@Override
public void appendAll(List<EventFileRecord> records) {
throw new UnsupportedOperationException();
}
@Override
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
this.lastQuery = query;
return records;
}
}
}

View File

@@ -0,0 +1,89 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
import com.lingniu.ingest.eventfilestore.EventFileQuery;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot;
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EventHistoryEnvelopeIngestorTest {
@Test
void parsesKafkaEnvelopeBytesAndAppendsRecord() throws Exception {
CapturingStore store = new CapturingStore();
EventHistoryEnvelopeIngestor ingestor =
new EventHistoryEnvelopeIngestor(store, new TelemetryEnvelopeRecordMapper());
assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class);
ingestor.ingest(envelope("event-1").toByteArray());
assertThat(store.records).extracting(EventFileRecord::eventId).containsExactly("event-1");
assertThat(store.records.getFirst().payloadJson()).contains("total_mileage_km");
}
@Test
void rejectsInvalidEnvelopeBytes() {
EventHistoryEnvelopeIngestor ingestor =
new EventHistoryEnvelopeIngestor(new CapturingStore(), new TelemetryEnvelopeRecordMapper());
assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02}))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("VehicleEnvelope");
}
@Test
void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutAppendingRecord() {
CapturingStore store = new CapturingStore();
EventHistoryEnvelopeIngestor ingestor =
new EventHistoryEnvelopeIngestor(store, new TelemetryEnvelopeRecordMapper());
var result = ingestor.tryIngest(new byte[]{0x01, 0x02});
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
assertThat(result.message()).contains("VehicleEnvelope");
assertThat(store.records).isEmpty();
}
private static VehicleEnvelope envelope(String eventId) {
return VehicleEnvelope.newBuilder()
.setEventId(eventId)
.setVin("VIN001")
.setSource("GB32960")
.setEventTimeMs(1_782_112_400_000L)
.setIngestTimeMs(1_782_112_401_000L)
.setTelemetrySnapshot(TelemetrySnapshot.newBuilder()
.setEventType("REALTIME")
.addFields(TelemetryField.newBuilder()
.setKey("total_mileage_km")
.setValueType("DOUBLE")
.setValue("12345.6")
.setUnit("km")
.setQuality("GOOD")))
.build();
}
private static final class CapturingStore implements EventFileStore {
private final List<EventFileRecord> records = new ArrayList<>();
@Override
public void appendAll(List<EventFileRecord> records) {
this.records.addAll(records);
}
@Override
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
return List.copyOf(records);
}
}
}

View File

@@ -0,0 +1,564 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.codec.BccChecksum;
import com.lingniu.ingest.eventfilestore.EventFileQuery;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser;
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog;
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionSelector;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.within;
class Gb32960DecodedFrameServiceTest {
@TempDir
Path archiveRoot;
@Test
void decodesFullGb32960BlocksFromRawArchive() throws Exception {
String key = "2026/06/22/GB32960/LNVFC000000000001/raw-1.bin";
Path rawFile = archiveRoot.resolve(key);
Files.createDirectories(rawFile.getParent());
Files.write(rawFile, realtimeFrame());
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(new EventFileRecord(
"raw-1",
ProtocolId.GB32960,
"RAW_ARCHIVE",
"LNVFC000000000001",
Instant.parse("2026-06-22T10:00:00Z"),
Instant.parse("2026-06-22T10:00:01Z"),
"archive://" + key,
Map.of("platformAccount", "Hyundai"),
"{}"))),
decoder(),
archiveRoot,
null);
List<Gb32960DecodedFrameService.DecodedFrame> frames = service.query(
LocalDate.parse("2026-06-22"),
LocalDate.parse("2026-06-22"),
EventFileQuery.Order.DESC,
10,
null,
null);
assertThat(frames).hasSize(1);
Gb32960DecodedFrameService.DecodedFrame frame = frames.getFirst();
assertThat(frame.vin()).isEqualTo("LNVFC000000000001");
assertThat(frame.command()).isEqualTo("REALTIME_REPORT");
assertThat(frame.blocks()).extracting(block -> block.get("type"))
.containsExactly("VEHICLE", "GD_FC_DCDC");
@SuppressWarnings("unchecked")
Map<String, Object> vehicle = (Map<String, Object>) frames.getFirst().blocks().getFirst().get("data");
assertThat(vehicle.get("totalMileageKm").toString()).isEqualTo("10000");
assertThat(vehicle).containsEntry("socPercent", 85);
@SuppressWarnings("unchecked")
Map<String, Object> dcdc = (Map<String, Object>) frames.getFirst().blocks().get(1).get("data");
assertThat(dcdc.get("inputVoltageV").toString()).isEqualTo("300");
assertThat(dcdc.get("outputCurrentA").toString()).isEqualTo("110");
}
@Test
void queryNormalizesFloatingPointNoiseForJsonOutput() throws Exception {
Object normalized = Gb32960DecodedFrameService.normalizeJsonValue(636.3000000000001);
assertThat(normalized).isInstanceOf(BigDecimal.class);
assertThat(normalized.toString()).isEqualTo("636.3");
}
@Test
void findsSingleFrameDirectlyByRawArchiveUri() throws Exception {
String key = "2026/06/22/GB32960/LNVFC000000000001/1792527837000000.bin";
writeArchive(key, realtimeFrame());
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of()),
decoder(),
archiveRoot,
null);
Gb32960DecodedFrameService.DecodedFrame frame =
service.findByRawArchiveUri("archive://" + key, "Hyundai");
assertThat(frame.eventId()).isEqualTo("1792527837000000");
assertThat(frame.vin()).isEqualTo("LNVFC000000000001");
assertThat(frame.blocks()).extracting(block -> block.get("type"))
.containsExactly("VEHICLE", "GD_FC_DCDC");
}
@Test
void findsSingleFrameByRawArchiveUriIndexWhenAvailable() throws Exception {
String key = "2026/06/22/GB32960/LNVFC000000000001/1792527837000001.bin";
String uri = "archive://" + key;
writeArchive(key, realtimeFrame());
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(record("indexed-raw", uri))),
decoder(),
archiveRoot,
null);
Gb32960DecodedFrameService.DecodedFrame frame = service.findByRawArchiveUri(uri, null);
assertThat(frame.eventId()).isEqualTo("indexed-raw");
assertThat(frame.rawArchiveUri()).isEqualTo(uri);
}
@Test
void skipsMissingRawArchiveAndContinues() throws Exception {
String missingKey = "2026/06/22/GB32960/LNVFC000000000001/missing.bin";
String availableKey = "2026/06/22/GB32960/LNVFC000000000001/raw-2.bin";
Path rawFile = archiveRoot.resolve(availableKey);
Files.createDirectories(rawFile.getParent());
Files.write(rawFile, realtimeFrame());
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(
record("missing", "archive://" + missingKey),
record("available", "archive://" + availableKey))),
decoder(),
archiveRoot,
null);
List<Gb32960DecodedFrameService.DecodedFrame> frames = service.query(
LocalDate.parse("2026-06-22"),
LocalDate.parse("2026-06-22"),
EventFileQuery.Order.DESC,
10,
null,
null);
assertThat(frames).hasSize(1);
assertThat(frames.getFirst().eventId()).isEqualTo("available");
assertThat(frames.getFirst().blocks()).extracting(block -> block.get("type"))
.containsExactly("VEHICLE", "GD_FC_DCDC");
}
@Test
void queryRequestsOnlyRawArchiveRecordsForOneVin() throws Exception {
String key = "2026/06/22/GB32960/LNVFC000000000001/raw-vin.bin";
writeArchive(key, realtimeFrame());
CapturingStore store = new CapturingStore(List.of(
businessRecord("business-newer", "LNVFC000000000001"),
record("raw-target", "archive://" + key),
record("raw-other-vin", "archive://2026/06/22/GB32960/OTHER/raw.bin", "OTHER000000000000")));
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
store,
decoder(),
archiveRoot,
null);
List<Gb32960DecodedFrameService.DecodedFrame> frames = service.query(
LocalDate.parse("2026-06-22"),
LocalDate.parse("2026-06-22"),
EventFileQuery.Order.DESC,
1,
"LNVFC000000000001",
null);
assertThat(store.lastQuery.eventType()).isEqualTo("RAW_ARCHIVE");
assertThat(store.lastQuery.vin()).isEqualTo("LNVFC000000000001");
assertThat(frames).hasSize(1);
assertThat(frames.getFirst().eventId()).isEqualTo("raw-target");
}
@Test
void snapshotsRequireVinSoSegmentsAreMergedOnlyWithinOneVehicle() {
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of()),
decoder(),
archiveRoot,
null);
assertThatThrownBy(() -> service.snapshots(
LocalDate.parse("2026-06-22"),
LocalDate.parse("2026-06-22"),
EventFileQuery.Order.DESC,
10,
null,
null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("vin is required");
}
@Test
void snapshotsMergeGb32960StackCellVoltageSegmentsByEventTime() throws Exception {
String vin = "LNVFC000000000001";
String key1 = "2026/06/22/GB32960/" + vin + "/stack-1.bin";
String key2 = "2026/06/22/GB32960/" + vin + "/stack-2.bin";
String key3 = "2026/06/22/GB32960/" + vin + "/stack-3.bin";
writeArchive(key1, stackFrame(1, 4, true));
writeArchive(key2, stackFrame(5, 3, false));
writeArchive(key3, stackFrame(8, 2, false));
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(
record("segment-3", "archive://" + key3),
record("segment-2", "archive://" + key2),
record("segment-1", "archive://" + key1))),
decoder(),
archiveRoot,
null);
List<Gb32960DecodedFrameService.LogicalSnapshot> snapshots = service.snapshots(
LocalDate.parse("2026-06-22"),
LocalDate.parse("2026-06-22"),
EventFileQuery.Order.DESC,
10,
vin,
null);
assertThat(snapshots).hasSize(1);
Gb32960DecodedFrameService.LogicalSnapshot snapshot = snapshots.getFirst();
assertThat(snapshot.vin()).isEqualTo(vin);
assertThat(snapshot.eventTime()).isEqualTo("2026-06-22T10:00:00Z");
assertThat(snapshot.sourceFrames()).hasSize(3);
assertThat(snapshot.blocks()).extracting(block -> block.get("type"))
.contains("VEHICLE", "GD_FC_STACK");
@SuppressWarnings("unchecked")
Map<String, Object> stackBlock = (Map<String, Object>) snapshot.blocks().stream()
.filter(block -> "GD_FC_STACK".equals(block.get("type")))
.findFirst()
.orElseThrow()
.get("data");
@SuppressWarnings("unchecked")
List<Map<String, Object>> stacks = (List<Map<String, Object>>) stackBlock.get("stacks");
assertThat(stacks).hasSize(1);
@SuppressWarnings("unchecked")
List<?> mergedVoltages = (List<?>) stacks.getFirst().get("frameCellVoltagesV");
assertThat(stacks.getFirst()).containsEntry("frameCellStart", 1);
assertThat(stacks.getFirst()).containsEntry("frameCellCount", 9);
assertThat(stacks.getFirst()).containsEntry("frameCellComplete", true);
assertThat(stacks.getFirst()).containsEntry("frameCellMissingCount", 0L);
assertThat(mergedVoltages).hasSize(9);
List<Double> expected = List.of(1.001, 1.002, 1.003, 1.004, 1.005, 1.006, 1.007, 1.008, 1.009);
for (int i = 0; i < expected.size(); i++) {
assertThat(((Number) mergedVoltages.get(i)).doubleValue()).isCloseTo(expected.get(i), within(0.000001));
}
}
@Test
void snapshotsKeepMissingStackCellsAsNullsUpToCellCount() throws Exception {
String vin = "LNVFC000000000001";
String key = "2026/06/22/GB32960/" + vin + "/stack-tail-missing.bin";
writeArchive(key, stackFrame(1, 4, true));
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(record("partial", "archive://" + key))),
decoder(),
archiveRoot,
null);
List<Gb32960DecodedFrameService.LogicalSnapshot> snapshots = service.snapshots(
LocalDate.parse("2026-06-22"),
LocalDate.parse("2026-06-22"),
EventFileQuery.Order.DESC,
10,
vin,
null);
@SuppressWarnings("unchecked")
Map<String, Object> stackBlock = (Map<String, Object>) snapshots.getFirst().blocks().stream()
.filter(block -> "GD_FC_STACK".equals(block.get("type")))
.findFirst()
.orElseThrow()
.get("data");
@SuppressWarnings("unchecked")
List<Map<String, Object>> stacks = (List<Map<String, Object>>) stackBlock.get("stacks");
@SuppressWarnings("unchecked")
List<?> voltages = (List<?>) stacks.getFirst().get("frameCellVoltagesV");
assertThat(stacks.getFirst()).containsEntry("frameCellStart", 1);
assertThat(stacks.getFirst()).containsEntry("frameCellCount", 9);
assertThat(stacks.getFirst()).containsEntry("frameCellComplete", false);
assertThat(stacks.getFirst()).containsEntry("frameCellMissingCount", 5L);
assertThat(voltages).hasSize(9);
assertThat(voltages.subList(4, 9)).containsOnlyNulls();
}
@Test
void snapshotFieldsReturnsOnlySelectedFields() throws Exception {
String vin = "LNVFC000000000001";
String key = "2026/06/22/GB32960/" + vin + "/selected-fields.bin";
writeArchive(key, realtimeFrame());
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(record("selected", "archive://" + key))),
decoder(),
archiveRoot,
null);
List<Gb32960DecodedFrameService.FieldSnapshot> snapshots = service.snapshotFields(
QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
EventFileQuery.Order.DESC,
10,
vin,
null,
"VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV,GD_FC_DCDC.outputCurrentA");
assertThat(snapshots).hasSize(1);
assertThat(snapshots.getFirst().fields())
.containsEntry("VEHICLE.totalMileageKm", new BigDecimal("10000"))
.containsEntry("GD_FC_DCDC.inputVoltageV", new BigDecimal("300"))
.containsEntry("GD_FC_DCDC.outputCurrentA", new BigDecimal("110"));
}
@Test
void snapshotFieldsCsvUsesChineseDictionaryHeaders() throws Exception {
String vin = "LNVFC000000000001";
String key = "2026/06/22/GB32960/" + vin + "/selected-fields-csv.bin";
writeArchive(key, realtimeFrame());
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of(record("selected-csv", "archive://" + key))),
decoder(),
archiveRoot,
null);
String csv = service.snapshotFieldsCsv(
QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
EventFileQuery.Order.DESC,
10,
vin,
null,
"VEHICLE.totalMileageKm,GD_FC_DCDC.inputVoltageV");
assertThat(csv).startsWith("VIN,事件时间,首次接收时间,最后接收时间,原始包,整车数据-累计里程(km),广东燃料电池 DC/DC 数据-输入电压(V)\n");
assertThat(csv).contains("LNVFC000000000001,2026-06-22T10:00:00Z");
assertThat(csv).contains(",10000,300\n");
}
@Test
void snapshotFieldsRejectsUnknownDictionaryFields() throws Exception {
Gb32960DecodedFrameService service = new Gb32960DecodedFrameService(
new CapturingStore(List.of()),
decoder(),
archiveRoot,
null);
assertThatThrownBy(() -> service.snapshotFields(
QueryTimeRange.parse("2026-06-22T18:00:00", "2026-06-22T18:00:00"),
EventFileQuery.Order.DESC,
10,
"LNVFC000000000001",
null,
"VEHICLE.totalMileageKm,VEHICLE.notExists"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("unknown gb32960 snapshot fields: VEHICLE.notExists");
}
private static Gb32960MessageDecoder decoder() {
List<InfoBlockParser> standardParsers = List.of(new VehicleV2016BlockParser());
VendorExtensionCatalog catalog = new VendorExtensionCatalog(Map.of(
"guangdong-fc", List.of(new GdFcDcDcBlockParser(), new GdFcStackBlockParser())));
Gb32960ProfileRegistry registry = new Gb32960ProfileRegistry(
standardParsers, catalog, Set.of("guangdong-fc"));
VendorExtensionSelector selector = ctx -> "guangdong-fc";
return new Gb32960MessageDecoder(new Gb32960BodyParser(registry, selector));
}
private static EventFileRecord record(String eventId, String rawArchiveUri) {
return record(eventId, rawArchiveUri, "LNVFC000000000001");
}
private static EventFileRecord record(String eventId, String rawArchiveUri, String vin) {
return new EventFileRecord(
eventId,
ProtocolId.GB32960,
"RAW_ARCHIVE",
vin,
Instant.parse("2026-06-22T10:00:00Z"),
Instant.parse("2026-06-22T10:00:01Z"),
rawArchiveUri,
Map.of("platformAccount", "Hyundai"),
"{}");
}
private static EventFileRecord businessRecord(String eventId, String vin) {
return new EventFileRecord(
eventId,
ProtocolId.GB32960,
"REALTIME",
vin,
Instant.parse("2026-06-22T10:00:03Z"),
Instant.parse("2026-06-22T10:00:04Z"),
"archive://business/" + eventId + ".bin",
Map.of("platformAccount", "Hyundai"),
"{}");
}
private static byte[] realtimeFrame() {
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0);
body.write(0x01);
body.write(0x01);
body.write(0x03);
body.write(0x02);
writeU16(body, 0);
writeU32(body, 100_000L);
writeU16(body, 5605);
writeU16(body, 10000);
body.write(85);
body.write(0x01);
body.write(0x00);
writeU16(body, 10000);
body.write(0x00);
body.write(0x00);
body.write(0x32);
writeU16(body, 3000);
writeU16(body, 1200);
writeU16(body, 5600);
writeU16(body, 1100);
body.write(105);
byte[] bodyBytes = body.toByteArray();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02);
out.write(0xFE);
byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
out.write(vin, 0, 17);
out.write(0x01);
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
return out.toByteArray();
}
private void writeArchive(String key, byte[] frame) throws Exception {
Path rawFile = archiveRoot.resolve(key);
Files.createDirectories(rawFile.getParent());
Files.write(rawFile, frame);
}
private static byte[] stackFrame(int frameCellStart, int frameCellCount, boolean includeVehicle) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(26); body.write(6); body.write(22); body.write(18); body.write(0); body.write(0);
if (includeVehicle) {
body.write(0x01);
body.write(0x01);
body.write(0x03);
body.write(0x02);
writeU16(body, 0);
writeU32(body, 100_000L);
writeU16(body, 5605);
writeU16(body, 10000);
body.write(85);
body.write(0x01);
body.write(0x00);
writeU16(body, 10000);
body.write(0x00);
body.write(0x00);
}
body.write(0x30);
body.write(0x01);
body.write(0x01);
body.write(90);
writeU16(body, 1850);
writeU16(body, 1500);
body.write(70);
writeU16(body, 12);
writeU16(body, 88);
writeU16(body, 1009);
writeU16(body, 1001);
writeU16(body, 1005);
writeU16(body, 9);
writeU16(body, frameCellStart);
body.write(frameCellCount);
for (int i = 0; i < frameCellCount; i++) {
writeU16(body, 1000 + frameCellStart + i);
}
byte[] bodyBytes = body.toByteArray();
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(0x23); out.write(0x23);
out.write(0x02);
out.write(0xFE);
byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
out.write(vin, 0, 17);
out.write(0x01);
writeU16(out, bodyBytes.length);
out.write(bodyBytes, 0, bodyBytes.length);
byte[] almost = out.toByteArray();
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
return out.toByteArray();
}
private static void writeU16(ByteArrayOutputStream os, int v) {
os.write((v >> 8) & 0xFF);
os.write(v & 0xFF);
}
private static void writeU32(ByteArrayOutputStream os, long v) {
os.write((int) ((v >> 24) & 0xFF));
os.write((int) ((v >> 16) & 0xFF));
os.write((int) ((v >> 8) & 0xFF));
os.write((int) (v & 0xFF));
}
private static final class CapturingStore implements EventFileStore {
private final List<EventFileRecord> records;
private EventFileQuery lastQuery;
private CapturingStore(List<EventFileRecord> records) {
this.records = records;
}
@Override
public void appendAll(List<EventFileRecord> records) {
throw new UnsupportedOperationException();
}
@Override
public List<EventFileRecord> query(EventFileQuery query) {
lastQuery = query;
return records.stream()
.filter(record -> query.vin() == null || query.vin().equals(record.vin()))
.filter(record -> query.eventType() == null || query.eventType().equals(record.eventType()))
.limit(query.limit())
.toList();
}
@Override
public EventFileRecord findByRawArchiveUri(String rawArchiveUri) {
return records.stream()
.filter(record -> record.rawArchiveUri().equals(rawArchiveUri))
.findFirst()
.orElse(null);
}
}
}

View File

@@ -0,0 +1,30 @@
package com.lingniu.ingest.eventhistory;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static java.util.stream.Collectors.toMap;
import static org.assertj.core.api.Assertions.assertThat;
class Gb32960FieldDictionaryTest {
@Test
void vehicleRunningModeIncludesProtocolValueMappings() {
Gb32960FieldDictionary.Field runningMode = Gb32960FieldDictionary.packets().stream()
.filter(packet -> packet.code().equals("VEHICLE"))
.flatMap(packet -> packet.fields().stream())
.filter(field -> field.key().equals("runningMode"))
.findFirst()
.orElseThrow();
Map<String, String> namesByCode = runningMode.valueMappings().stream()
.collect(toMap(Gb32960FieldDictionary.ValueMapping::code,
Gb32960FieldDictionary.ValueMapping::nameZh));
assertThat(namesByCode)
.containsEntry("01", "纯电")
.containsEntry("02", "混合动力")
.containsEntry("03", "燃料电池");
}
}

View File

@@ -0,0 +1,94 @@
package com.lingniu.ingest.eventhistory;
import com.lingniu.ingest.api.ProtocolId;
import com.lingniu.ingest.eventfilestore.EventFileRecord;
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
import com.lingniu.ingest.sink.mq.proto.TelemetrySnapshot;
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TelemetryEnvelopeRecordMapperTest {
@Test
void mapsFullFieldEnvelopeToEventFileRecord() {
VehicleEnvelope envelope = VehicleEnvelope.newBuilder()
.setSchemaVersion("1.0")
.setEventId("event-1")
.setTraceId("trace-1")
.setVin("VIN001")
.setSource("GB32960")
.setEventTimeMs(1_782_112_400_000L)
.setIngestTimeMs(1_782_112_401_000L)
.putMetadata("protocolVersion", "V2016")
.setTelemetrySnapshot(TelemetrySnapshot.newBuilder()
.setEventType("REALTIME")
.setRawArchiveUri("archive://raw/event-1.bin")
.addFields(TelemetryField.newBuilder()
.setKey("total_mileage_km")
.setValueType("DOUBLE")
.setValue("12345.6")
.setUnit("km")
.setQuality("GOOD")
.setSourcePath("event.realtime.totalMileageKm"))
.build())
.build();
EventFileRecord record = new TelemetryEnvelopeRecordMapper().toRecord(envelope);
assertThat(record.eventId()).isEqualTo("event-1");
assertThat(record.protocol()).isEqualTo(ProtocolId.GB32960);
assertThat(record.eventType()).isEqualTo("REALTIME");
assertThat(record.vin()).isEqualTo("VIN001");
assertThat(record.rawArchiveUri()).isEqualTo("archive://raw/event-1.bin");
assertThat(record.metadata()).containsEntry("protocolVersion", "V2016");
assertThat(record.payloadJson()).contains("\"fields\"");
assertThat(record.payloadJson()).contains("\"total_mileage_km\"");
}
@Test
void rejectsEnvelopeWithoutTelemetrySnapshot() {
VehicleEnvelope envelope = VehicleEnvelope.newBuilder()
.setEventId("event-1")
.setVin("VIN001")
.setSource("GB32960")
.setEventTimeMs(1)
.setIngestTimeMs(2)
.build();
assertThatThrownBy(() -> new TelemetryEnvelopeRecordMapper().toRecord(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("telemetry_snapshot");
}
@Test
void unknownEnvelopeSourceIsStoredAsUnknownProtocolWithOriginalSourceMetadata() {
VehicleEnvelope envelope = VehicleEnvelope.newBuilder()
.setSchemaVersion("1.0")
.setEventId("event-future-1")
.setTraceId("trace-future-1")
.setVin("VIN-FUTURE-001")
.setSource("FUTURE_OEM")
.setEventTimeMs(1_782_112_400_000L)
.setIngestTimeMs(1_782_112_401_000L)
.putMetadata("tenant", "ln")
.setTelemetrySnapshot(TelemetrySnapshot.newBuilder()
.setEventType("REALTIME")
.addFields(TelemetryField.newBuilder()
.setKey("speed_kmh")
.setValueType("DOUBLE")
.setValue("30.5")
.setQuality("GOOD"))
.build())
.build();
EventFileRecord record = new TelemetryEnvelopeRecordMapper().toRecord(envelope);
assertThat(record.protocol()).isEqualTo(ProtocolId.UNKNOWN);
assertThat(record.metadata())
.containsEntry("tenant", "ln")
.containsEntry("originalSource", "FUTURE_OEM");
}
}

View File

@@ -0,0 +1,71 @@
package com.lingniu.ingest.eventhistory.config;
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
import com.lingniu.ingest.eventfilestore.EventFileStore;
import com.lingniu.ingest.eventhistory.EventHistoryController;
import com.lingniu.ingest.eventhistory.EventHistoryEnvelopeIngestor;
import com.lingniu.ingest.eventhistory.Gb32960DecodedFrameService;
import com.lingniu.ingest.eventhistory.Gb32960FrameController;
import com.lingniu.ingest.eventhistory.TelemetryEnvelopeRecordMapper;
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
import com.lingniu.ingest.sink.archive.config.SinkArchiveProperties;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class EventHistoryAutoConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(EventHistoryAutoConfiguration.class))
.withBean(EnvelopeDeadLetterSink.class, () -> record -> {})
.withBean(EventFileStore.class, () -> mock(EventFileStore.class));
@Test
void createsHistoryBeansWhenEnabled() {
contextRunner
.withPropertyValues("lingniu.ingest.event-history.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(TelemetryEnvelopeRecordMapper.class);
assertThat(context).hasSingleBean(EventHistoryEnvelopeIngestor.class);
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
assertThat(context).hasSingleBean(EventHistoryController.class);
assertThat(context).doesNotHaveBean(Gb32960DecodedFrameService.class);
assertThat(context).doesNotHaveBean(Gb32960FrameController.class);
});
}
@Test
void backsOffWhenDisabled() {
contextRunner.run(context -> {
assertThat(context).doesNotHaveBean(TelemetryEnvelopeRecordMapper.class);
assertThat(context).doesNotHaveBean(EventHistoryEnvelopeIngestor.class);
assertThat(context).doesNotHaveBean(EventHistoryController.class);
});
}
@Test
void createsGb32960FrameBeansWhenDecoderAndArchivePropertiesExist() {
contextRunner
.withPropertyValues(
"lingniu.ingest.event-history.enabled=true",
"lingniu.ingest.sink.archive.path=/tmp/lingniu-test-archive")
.withBean(Gb32960MessageDecoder.class, () -> mock(Gb32960MessageDecoder.class))
.withBean(SinkArchiveProperties.class, SinkArchiveProperties::new)
.run(context -> {
assertThat(context).hasSingleBean(Gb32960DecodedFrameService.class);
assertThat(context).hasSingleBean(Gb32960FrameController.class);
});
}
@Test
void parsesFileUriArchiveRoot() {
assertThat(EventHistoryAutoConfiguration.archiveRoot("file:///tmp/lingniu-test-archive"))
.isEqualTo(Path.of("/tmp/lingniu-test-archive"));
}
}