feat: make gb32960 archive history query production ready
This commit is contained in:
69
modules/services/event-history-service/pom.xml
Normal file
69
modules/services/event-history-service/pom.xml
Normal 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>
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.eventhistory.config.EventHistoryAutoConfiguration
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "燃料电池");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
57
modules/services/vehicle-stat-service/pom.xml
Normal file
57
modules/services/vehicle-stat-service/pom.xml
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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>vehicle-stat-service</artifactId>
|
||||
<name>vehicle-stat-service</name>
|
||||
<description>Kafka full-field event consumer + configurable vehicle daily statistics.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>sink-mq</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.kafka</groupId>
|
||||
<artifactId>kafka-clients</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</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.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public final class DailyMileageCalculator {
|
||||
|
||||
private final ZoneId zoneId;
|
||||
|
||||
public DailyMileageCalculator(ZoneId zoneId) {
|
||||
if (zoneId == null) {
|
||||
throw new IllegalArgumentException("zoneId must not be null");
|
||||
}
|
||||
this.zoneId = zoneId;
|
||||
}
|
||||
|
||||
public OptionalDouble calculate(LocalDate statDate,
|
||||
DailyMileageStrategy strategy,
|
||||
List<MileagePoint> points) {
|
||||
if (statDate == null) {
|
||||
throw new IllegalArgumentException("statDate must not be null");
|
||||
}
|
||||
if (strategy == null) {
|
||||
throw new IllegalArgumentException("strategy must not be null");
|
||||
}
|
||||
if (points == null || points.isEmpty()) {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
|
||||
double value = switch (strategy) {
|
||||
case CURRENT_LAST_MINUS_PREVIOUS_LAST -> currentLastMinusPreviousLast(statDate, points);
|
||||
case DAY_MAX_MINUS_DAY_MIN -> dayMaxMinusDayMin(statDate, points);
|
||||
};
|
||||
return Double.isFinite(value) && value >= 0 ? OptionalDouble.of(value) : OptionalDouble.empty();
|
||||
}
|
||||
|
||||
private double currentLastMinusPreviousLast(LocalDate statDate, List<MileagePoint> points) {
|
||||
Optional<MileagePoint> previousLast = points.stream()
|
||||
.filter(point -> localDate(point).isBefore(statDate))
|
||||
.max(Comparator.comparing(MileagePoint::eventTime));
|
||||
Optional<MileagePoint> currentLast = points.stream()
|
||||
.filter(point -> localDate(point).isEqual(statDate))
|
||||
.max(Comparator.comparing(MileagePoint::eventTime));
|
||||
|
||||
if (previousLast.isEmpty() || currentLast.isEmpty()) {
|
||||
return Double.NaN;
|
||||
}
|
||||
return currentLast.get().totalMileageKm() - previousLast.get().totalMileageKm();
|
||||
}
|
||||
|
||||
private double dayMaxMinusDayMin(LocalDate statDate, List<MileagePoint> points) {
|
||||
List<MileagePoint> currentDay = points.stream()
|
||||
.filter(point -> localDate(point).isEqual(statDate))
|
||||
.toList();
|
||||
if (currentDay.size() < 2) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double min = currentDay.stream().mapToDouble(MileagePoint::totalMileageKm).min().orElse(Double.NaN);
|
||||
double max = currentDay.stream().mapToDouble(MileagePoint::totalMileageKm).max().orElse(Double.NaN);
|
||||
return max - min;
|
||||
}
|
||||
|
||||
private LocalDate localDate(MileagePoint point) {
|
||||
return point.eventTime().atZone(zoneId).toLocalDate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
public enum DailyMileageStrategy {
|
||||
CURRENT_LAST_MINUS_PREVIOUS_LAST,
|
||||
DAY_MAX_MINUS_DAY_MIN
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public final class DailyVehicleStatService {
|
||||
|
||||
private final VehicleStatRepository repository;
|
||||
private final VehicleStatRuleRepository ruleRepository;
|
||||
private final DailyMileageCalculator mileageCalculator;
|
||||
|
||||
public DailyVehicleStatService(VehicleStatRepository repository,
|
||||
VehicleStatRuleRepository ruleRepository,
|
||||
DailyMileageCalculator mileageCalculator) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
if (ruleRepository == null) {
|
||||
throw new IllegalArgumentException("ruleRepository must not be null");
|
||||
}
|
||||
if (mileageCalculator == null) {
|
||||
throw new IllegalArgumentException("mileageCalculator must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
this.ruleRepository = ruleRepository;
|
||||
this.mileageCalculator = mileageCalculator;
|
||||
}
|
||||
|
||||
public Optional<VehicleDailyStatResult> calculateAndSave(String vin, LocalDate statDate) {
|
||||
VehicleStatRule rule = ruleRepository.ruleFor(vin);
|
||||
OptionalDouble dailyMileage = mileageCalculator.calculate(
|
||||
statDate,
|
||||
rule.dailyMileageStrategy(),
|
||||
repository.mileagePoints(vin, statDate));
|
||||
if (dailyMileage.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
VehicleDailyStatResult result = new VehicleDailyStatResult(
|
||||
vin,
|
||||
statDate,
|
||||
dailyMileage,
|
||||
rule.dailyMileageStrategy());
|
||||
repository.saveDailyStat(result);
|
||||
return Optional.of(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public final class FileVehicleStatRepository implements VehicleStatRepository {
|
||||
|
||||
private final Path pointsFile;
|
||||
private final Path dailyStatsFile;
|
||||
|
||||
public FileVehicleStatRepository(Path root) {
|
||||
if (root == null) {
|
||||
throw new IllegalArgumentException("root must not be null");
|
||||
}
|
||||
Path absoluteRoot = root.toAbsolutePath();
|
||||
this.pointsFile = absoluteRoot.resolve("mileage-points.tsv");
|
||||
this.dailyStatsFile = absoluteRoot.resolve("daily-stats.tsv");
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void appendMileagePoint(String vin, MileagePoint point) {
|
||||
if (point == null) {
|
||||
throw new IllegalArgumentException("point must not be null");
|
||||
}
|
||||
appendLine(pointsFile, clean(vin) + '\t' + point.eventTime() + '\t' + point.totalMileageKm());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized List<MileagePoint> mileagePoints(String vin, LocalDate statDate) {
|
||||
String normalizedVin = clean(vin);
|
||||
List<MileagePoint> out = new ArrayList<>();
|
||||
for (String line : readLines(pointsFile)) {
|
||||
String[] parts = line.split("\\t", -1);
|
||||
if (parts.length != 3 || !normalizedVin.equals(parts[0])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out.add(new MileagePoint(Instant.parse(parts[1]), Double.parseDouble(parts[2])));
|
||||
} catch (RuntimeException ignored) {
|
||||
// Ignore corrupt rows instead of making the whole statistics API unavailable.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void saveDailyStat(VehicleDailyStatResult result) {
|
||||
if (result == null) {
|
||||
throw new IllegalArgumentException("result must not be null");
|
||||
}
|
||||
String mileage = result.dailyMileageKm().isPresent()
|
||||
? Double.toString(result.dailyMileageKm().getAsDouble())
|
||||
: "";
|
||||
appendLine(dailyStatsFile,
|
||||
clean(result.vin()) + '\t'
|
||||
+ result.statDate() + '\t'
|
||||
+ result.dailyMileageStrategy().name() + '\t'
|
||||
+ mileage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
String normalizedVin = clean(vin);
|
||||
VehicleDailyStatResult latest = null;
|
||||
for (String line : readLines(dailyStatsFile)) {
|
||||
String[] parts = line.split("\\t", -1);
|
||||
if (parts.length != 4 || !normalizedVin.equals(parts[0])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
LocalDate rowDate = LocalDate.parse(parts[1]);
|
||||
if (!rowDate.equals(statDate)) {
|
||||
continue;
|
||||
}
|
||||
DailyMileageStrategy strategy = DailyMileageStrategy.valueOf(parts[2]);
|
||||
OptionalDouble mileage = parts[3].isBlank()
|
||||
? OptionalDouble.empty()
|
||||
: OptionalDouble.of(Double.parseDouble(parts[3]));
|
||||
latest = new VehicleDailyStatResult(normalizedVin, rowDate, mileage, strategy);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Ignore corrupt rows; later valid rows can still provide the answer.
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(latest);
|
||||
}
|
||||
|
||||
private static void appendLine(Path file, String line) {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, line + System.lineSeparator(), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("write vehicle stat file failed: " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> readLines(Path file) {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return Files.readAllLines(file, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("read vehicle stat file failed: " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
return value.trim().replace('\t', '_').replace('\n', '_').replace('\r', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record MileagePoint(Instant eventTime, double totalMileageKm) {
|
||||
|
||||
public MileagePoint {
|
||||
if (eventTime == null) {
|
||||
throw new IllegalArgumentException("eventTime must not be null");
|
||||
}
|
||||
if (!Double.isFinite(totalMileageKm)) {
|
||||
throw new IllegalArgumentException("totalMileageKm must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public record VehicleDailyStatResult(String vin,
|
||||
LocalDate statDate,
|
||||
OptionalDouble dailyMileageKm,
|
||||
DailyMileageStrategy dailyMileageStrategy) {
|
||||
|
||||
public VehicleDailyStatResult {
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
if (statDate == null) {
|
||||
throw new IllegalArgumentException("statDate must not be null");
|
||||
}
|
||||
if (dailyMileageKm == null) {
|
||||
dailyMileageKm = OptionalDouble.empty();
|
||||
}
|
||||
if (dailyMileageStrategy == null) {
|
||||
dailyMileageStrategy = DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(DailyVehicleStatService.class)
|
||||
@RequestMapping(path = "/api/vehicle-stat", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public final class VehicleStatController {
|
||||
|
||||
private final VehicleStatRepository repository;
|
||||
private final DailyVehicleStatService dailyStatService;
|
||||
|
||||
public VehicleStatController(VehicleStatRepository repository,
|
||||
DailyVehicleStatService dailyStatService) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
if (dailyStatService == null) {
|
||||
throw new IllegalArgumentException("dailyStatService must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
this.dailyStatService = dailyStatService;
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/daily")
|
||||
public ResponseEntity<Map<String, Object>> daily(
|
||||
@PathVariable String vin,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
|
||||
@RequestParam(defaultValue = "true") boolean calculateIfMissing) {
|
||||
var result = repository.findDailyStat(vin, date)
|
||||
.or(() -> calculateIfMissing ? dailyStatService.calculateAndSave(vin, date) : java.util.Optional.empty());
|
||||
return result.map(stat -> ResponseEntity.ok(toJson(stat)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
private static Map<String, Object> toJson(VehicleDailyStatResult stat) {
|
||||
Map<String, Object> json = new LinkedHashMap<>();
|
||||
json.put("vin", stat.vin());
|
||||
json.put("statDate", stat.statDate().toString());
|
||||
json.put("dailyMileageKm", stat.dailyMileageKm().isPresent()
|
||||
? stat.dailyMileageKm().getAsDouble()
|
||||
: null);
|
||||
json.put("dailyMileageStrategy", stat.dailyMileageStrategy().name());
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
public final class VehicleStatEnvelopeIngestor implements EnvelopeIngestor {
|
||||
|
||||
private final VehicleStatEventProcessor processor;
|
||||
|
||||
public VehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor) {
|
||||
if (processor == null) {
|
||||
throw new IllegalArgumentException("processor must not be null");
|
||||
}
|
||||
this.processor = processor;
|
||||
}
|
||||
|
||||
public void ingest(byte[] kafkaValue) {
|
||||
processor.process(parse(kafkaValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public EnvelopeIngestResult tryIngest(byte[] kafkaValue) {
|
||||
VehicleEnvelope envelope = null;
|
||||
try {
|
||||
envelope = parse(kafkaValue);
|
||||
if (!envelope.hasTelemetrySnapshot()) {
|
||||
return EnvelopeIngestResult.skipped(
|
||||
envelope.getEventId(), envelope.getVin(), "envelope telemetry_snapshot is required");
|
||||
}
|
||||
processor.process(envelope);
|
||||
return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return envelope == null
|
||||
? EnvelopeIngestResult.invalid(ex.getMessage())
|
||||
: EnvelopeIngestResult.skipped(envelope.getEventId(), 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 ex) {
|
||||
throw new IllegalArgumentException("VehicleEnvelope bytes are invalid", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
public final class VehicleStatEventProcessor {
|
||||
|
||||
private static final String TOTAL_MILEAGE_KEY = "total_mileage_km";
|
||||
|
||||
private final VehicleStatRepository repository;
|
||||
|
||||
public VehicleStatEventProcessor(VehicleStatRepository repository) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public void process(VehicleEnvelope envelope) {
|
||||
if (envelope == null) {
|
||||
throw new IllegalArgumentException("envelope must not be null");
|
||||
}
|
||||
if (!envelope.hasTelemetrySnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
OptionalDouble totalMileage = totalMileage(envelope);
|
||||
if (totalMileage.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
repository.appendMileagePoint(
|
||||
envelope.getVin(),
|
||||
new MileagePoint(Instant.ofEpochMilli(envelope.getEventTimeMs()), totalMileage.getAsDouble()));
|
||||
}
|
||||
|
||||
private static OptionalDouble totalMileage(VehicleEnvelope envelope) {
|
||||
for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) {
|
||||
if (!TOTAL_MILEAGE_KEY.equals(field.getKey())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
double value = Double.parseDouble(field.getValue());
|
||||
return Double.isFinite(value) ? OptionalDouble.of(value) : OptionalDouble.empty();
|
||||
} catch (NumberFormatException ex) {
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
}
|
||||
return OptionalDouble.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.sink.EventSink;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class VehicleStatEventSink implements EventSink {
|
||||
|
||||
private final VehicleStatRepository repository;
|
||||
private final DailyVehicleStatService dailyStatService;
|
||||
private final ZoneId zoneId;
|
||||
|
||||
public VehicleStatEventSink(VehicleStatRepository repository,
|
||||
DailyVehicleStatService dailyStatService,
|
||||
ZoneId zoneId) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
if (dailyStatService == null) {
|
||||
throw new IllegalArgumentException("dailyStatService must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
this.dailyStatService = dailyStatService;
|
||||
this.zoneId = zoneId == null ? ZoneId.of("Asia/Shanghai") : zoneId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "vehicle-stat";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean accepts(VehicleEvent event) {
|
||||
return event instanceof VehicleEvent.Realtime realtime
|
||||
&& realtime.payload() != null
|
||||
&& realtime.payload().totalMileageKm() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> publish(VehicleEvent event) {
|
||||
if (!accepts(event)) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
try {
|
||||
VehicleEvent.Realtime realtime = (VehicleEvent.Realtime) event;
|
||||
repository.appendMileagePoint(
|
||||
realtime.vin(),
|
||||
new MileagePoint(realtime.eventTime(), realtime.payload().totalMileageKm()));
|
||||
LocalDate statDate = LocalDate.ofInstant(realtime.eventTime(), zoneId);
|
||||
dailyStatService.calculateAndSave(realtime.vin(), statDate);
|
||||
future.complete(null);
|
||||
} catch (RuntimeException e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
return future;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface VehicleStatRepository {
|
||||
|
||||
void appendMileagePoint(String vin, MileagePoint point);
|
||||
|
||||
List<MileagePoint> mileagePoints(String vin, LocalDate statDate);
|
||||
|
||||
void saveDailyStat(VehicleDailyStatResult result);
|
||||
|
||||
Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
public record VehicleStatRule(String vin, DailyMileageStrategy dailyMileageStrategy) {
|
||||
|
||||
public VehicleStatRule {
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
if (dailyMileageStrategy == null) {
|
||||
dailyMileageStrategy = DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
public interface VehicleStatRuleRepository {
|
||||
|
||||
VehicleStatRule ruleFor(String vin);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.lingniu.ingest.vehiclestat.config;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
|
||||
import com.lingniu.ingest.vehiclestat.DailyMileageCalculator;
|
||||
import com.lingniu.ingest.vehiclestat.DailyMileageStrategy;
|
||||
import com.lingniu.ingest.vehiclestat.DailyVehicleStatService;
|
||||
import com.lingniu.ingest.vehiclestat.FileVehicleStatRepository;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatController;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEventSink;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEventProcessor;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRule;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRuleRepository;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(VehicleStatProperties.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true")
|
||||
public class VehicleStatAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatRepository vehicleStatRepository(VehicleStatProperties props) {
|
||||
return new FileVehicleStatRepository(Path.of(props.getFilePath()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatRuleRepository vehicleStatRuleRepository() {
|
||||
return vin -> new VehicleStatRule(vin, DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public DailyMileageCalculator dailyMileageCalculator(VehicleStatProperties props) {
|
||||
return new DailyMileageCalculator(ZoneId.of(props.getZoneId()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStatRepository.class)
|
||||
@ConditionalOnMissingBean
|
||||
public DailyVehicleStatService dailyVehicleStatService(VehicleStatRepository repository,
|
||||
VehicleStatRuleRepository ruleRepository,
|
||||
DailyMileageCalculator mileageCalculator) {
|
||||
return new DailyVehicleStatService(repository, ruleRepository, mileageCalculator);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStatRepository.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatEventProcessor vehicleStatEventProcessor(VehicleStatRepository repository) {
|
||||
return new VehicleStatEventProcessor(repository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStatEventProcessor.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatEnvelopeIngestor vehicleStatEnvelopeIngestor(VehicleStatEventProcessor processor) {
|
||||
return new VehicleStatEnvelopeIngestor(processor);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({VehicleStatRepository.class, DailyVehicleStatService.class})
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatEventSink vehicleStatEventSink(VehicleStatRepository repository,
|
||||
DailyVehicleStatService dailyStatService,
|
||||
VehicleStatProperties props) {
|
||||
return new VehicleStatEventSink(repository, dailyStatService, ZoneId.of(props.getZoneId()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(DailyVehicleStatService.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStatController vehicleStatController(VehicleStatRepository repository,
|
||||
DailyVehicleStatService dailyStatService) {
|
||||
return new VehicleStatController(repository, dailyStatService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({VehicleStatEnvelopeIngestor.class, EnvelopeDeadLetterSink.class})
|
||||
@ConditionalOnMissingBean(name = "vehicleStatEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor vehicleStatEnvelopeConsumerProcessor(VehicleStatEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
return new EnvelopeConsumerProcessor("vehicle-stat", ingestor, deadLetterSink);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.lingniu.ingest.vehiclestat.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.vehicle-stat")
|
||||
public class VehicleStatProperties {
|
||||
|
||||
private String filePath = "./target/vehicle-stat/";
|
||||
|
||||
private String zoneId = "Asia/Shanghai";
|
||||
|
||||
public String getFilePath() {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public void setFilePath(String filePath) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
public String getZoneId() {
|
||||
return zoneId;
|
||||
}
|
||||
|
||||
public void setZoneId(String zoneId) {
|
||||
this.zoneId = zoneId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.vehiclestat.config.VehicleStatAutoConfiguration
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DailyMileageCalculatorTest {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private final DailyMileageCalculator calculator = new DailyMileageCalculator(ZONE);
|
||||
|
||||
@Test
|
||||
void calculatesCurrentLastMinusPreviousLast() {
|
||||
OptionalDouble mileage = calculator.calculate(
|
||||
LocalDate.of(2026, 6, 22),
|
||||
DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST,
|
||||
List.of(
|
||||
point("2026-06-21T15:59:00Z", 1_000.0),
|
||||
point("2026-06-22T00:10:00Z", 1_002.0),
|
||||
point("2026-06-22T10:00:00Z", 1_035.5)));
|
||||
|
||||
assertThat(mileage).hasValue(35.5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatesDayMaxMinusDayMin() {
|
||||
OptionalDouble mileage = calculator.calculate(
|
||||
LocalDate.of(2026, 6, 22),
|
||||
DailyMileageStrategy.DAY_MAX_MINUS_DAY_MIN,
|
||||
List.of(
|
||||
point("2026-06-22T00:10:00Z", 1_002.0),
|
||||
point("2026-06-22T10:00:00Z", 1_035.5),
|
||||
point("2026-06-22T11:00:00Z", 1_030.0)));
|
||||
|
||||
assertThat(mileage).hasValue(33.5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNegativeMileageFromCounterReset() {
|
||||
OptionalDouble mileage = calculator.calculate(
|
||||
LocalDate.of(2026, 6, 22),
|
||||
DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST,
|
||||
List.of(
|
||||
point("2026-06-21T15:59:00Z", 1_000.0),
|
||||
point("2026-06-22T10:00:00Z", 10.0)));
|
||||
|
||||
assertThat(mileage).isEmpty();
|
||||
}
|
||||
|
||||
private static MileagePoint point(String instant, double totalMileageKm) {
|
||||
return new MileagePoint(Instant.parse(instant), totalMileageKm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DailyVehicleStatServiceTest {
|
||||
|
||||
private final InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository();
|
||||
private final VehicleStatRuleRepository rules = vin -> new VehicleStatRule(
|
||||
vin,
|
||||
"VIN-DAYMAX".equals(vin)
|
||||
? DailyMileageStrategy.DAY_MAX_MINUS_DAY_MIN
|
||||
: DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST);
|
||||
private final DailyVehicleStatService service = new DailyVehicleStatService(
|
||||
repository,
|
||||
rules,
|
||||
new DailyMileageCalculator(ZoneId.of("Asia/Shanghai")));
|
||||
|
||||
@Test
|
||||
void calculatesAndSavesDailyMileageWithVehicleRule() {
|
||||
repository.appendMileagePoint("VIN001", point("2026-06-21T15:59:00Z", 100.0));
|
||||
repository.appendMileagePoint("VIN001", point("2026-06-22T10:00:00Z", 135.5));
|
||||
|
||||
Optional<VehicleDailyStatResult> result = service.calculateAndSave("VIN001", LocalDate.of(2026, 6, 22));
|
||||
|
||||
assertThat(result).hasValueSatisfying(stat -> {
|
||||
assertThat(stat.vin()).isEqualTo("VIN001");
|
||||
assertThat(stat.statDate()).isEqualTo(LocalDate.of(2026, 6, 22));
|
||||
assertThat(stat.dailyMileageKm()).hasValue(35.5);
|
||||
});
|
||||
assertThat(repository.findDailyStat("VIN001", LocalDate.of(2026, 6, 22))).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesPerVehicleDayMaxStrategy() {
|
||||
repository.appendMileagePoint("VIN-DAYMAX", point("2026-06-22T00:10:00Z", 100.0));
|
||||
repository.appendMileagePoint("VIN-DAYMAX", point("2026-06-22T10:00:00Z", 118.0));
|
||||
repository.appendMileagePoint("VIN-DAYMAX", point("2026-06-22T11:00:00Z", 115.0));
|
||||
|
||||
Optional<VehicleDailyStatResult> result =
|
||||
service.calculateAndSave("VIN-DAYMAX", LocalDate.of(2026, 6, 22));
|
||||
|
||||
assertThat(result).hasValueSatisfying(stat -> assertThat(stat.dailyMileageKm()).hasValue(18.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEmptyWhenMileageCannotBeCalculated() {
|
||||
repository.appendMileagePoint("VIN001", point("2026-06-22T10:00:00Z", 135.5));
|
||||
|
||||
Optional<VehicleDailyStatResult> result = service.calculateAndSave("VIN001", LocalDate.of(2026, 6, 22));
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
private static MileagePoint point(String instant, double totalMileageKm) {
|
||||
return new MileagePoint(Instant.parse(instant), totalMileageKm);
|
||||
}
|
||||
|
||||
private static final class InMemoryVehicleStatRepository implements VehicleStatRepository {
|
||||
private final java.util.Map<String, java.util.List<MileagePoint>> points = new java.util.HashMap<>();
|
||||
private final java.util.Map<String, VehicleDailyStatResult> results = new java.util.HashMap<>();
|
||||
|
||||
@Override
|
||||
public void appendMileagePoint(String vin, MileagePoint point) {
|
||||
points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MileagePoint> mileagePoints(String vin, LocalDate statDate) {
|
||||
return points.getOrDefault(vin, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveDailyStat(VehicleDailyStatResult result) {
|
||||
results.put(result.vin() + ":" + result.statDate(), result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
return Optional.ofNullable(results.get(vin + ":" + statDate));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.OptionalDouble;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FileVehicleStatRepositoryTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void persistsMileagePointsAndLatestDailyStat() {
|
||||
FileVehicleStatRepository repository = new FileVehicleStatRepository(tempDir);
|
||||
|
||||
repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-21T15:59:00Z"), 100.0));
|
||||
repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-22T10:00:00Z"), 135.5));
|
||||
repository.saveDailyStat(new VehicleDailyStatResult(
|
||||
"VIN001",
|
||||
LocalDate.parse("2026-06-22"),
|
||||
OptionalDouble.of(35.5),
|
||||
DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST));
|
||||
|
||||
FileVehicleStatRepository reopened = new FileVehicleStatRepository(tempDir);
|
||||
|
||||
assertThat(reopened.mileagePoints("VIN001", LocalDate.parse("2026-06-22")))
|
||||
.extracting(MileagePoint::totalMileageKm)
|
||||
.containsExactly(100.0, 135.5);
|
||||
assertThat(reopened.findDailyStat("VIN001", LocalDate.parse("2026-06-22")))
|
||||
.hasValueSatisfying(stat -> assertThat(stat.dailyMileageKm()).hasValue(35.5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
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 VehicleStatControllerTest {
|
||||
|
||||
@Test
|
||||
void returnsDailyStatJson() throws Exception {
|
||||
InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository();
|
||||
DailyVehicleStatService service = new DailyVehicleStatService(
|
||||
repository,
|
||||
vin -> new VehicleStatRule(vin, DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST),
|
||||
new DailyMileageCalculator(ZoneId.of("Asia/Shanghai")));
|
||||
repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-21T15:59:00Z"), 100.0));
|
||||
repository.appendMileagePoint("VIN001", new MileagePoint(Instant.parse("2026-06-22T10:00:00Z"), 135.5));
|
||||
service.calculateAndSave("VIN001", LocalDate.parse("2026-06-22"));
|
||||
MockMvc mvc = standaloneSetup(new VehicleStatController(repository, service)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-stat/VIN001/daily").param("date", "2026-06-22"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.vin").value("VIN001"))
|
||||
.andExpect(jsonPath("$.statDate").value("2026-06-22"))
|
||||
.andExpect(jsonPath("$.dailyMileageKm").value(35.5));
|
||||
}
|
||||
|
||||
private static final class InMemoryVehicleStatRepository implements VehicleStatRepository {
|
||||
private final java.util.Map<String, java.util.List<MileagePoint>> points = new java.util.HashMap<>();
|
||||
private final java.util.Map<String, VehicleDailyStatResult> results = new java.util.HashMap<>();
|
||||
|
||||
@Override
|
||||
public void appendMileagePoint(String vin, MileagePoint point) {
|
||||
points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<MileagePoint> mileagePoints(String vin, LocalDate statDate) {
|
||||
return points.getOrDefault(vin, java.util.List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveDailyStat(VehicleDailyStatResult result) {
|
||||
results.put(result.vin() + ":" + result.statDate(), result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
return java.util.Optional.ofNullable(results.get(vin + ":" + statDate));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
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.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class VehicleStatEnvelopeIngestorTest {
|
||||
|
||||
@Test
|
||||
void parsesEnvelopeBytesAndProcessesMileagePoint() {
|
||||
InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository();
|
||||
VehicleStatEnvelopeIngestor ingestor =
|
||||
new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(repository));
|
||||
|
||||
assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class);
|
||||
ingestor.ingest(envelope().toByteArray());
|
||||
|
||||
assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22)))
|
||||
.extracting(MileagePoint::totalMileageKm)
|
||||
.containsExactly(123.45);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidEnvelopeBytes() {
|
||||
VehicleStatEnvelopeIngestor ingestor =
|
||||
new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(new InMemoryVehicleStatRepository()));
|
||||
|
||||
assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02}))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("VehicleEnvelope");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutAppendingMileagePoint() {
|
||||
InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository();
|
||||
VehicleStatEnvelopeIngestor ingestor =
|
||||
new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(repository));
|
||||
|
||||
var result = ingestor.tryIngest(new byte[]{0x01, 0x02});
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
|
||||
assertThat(result.message()).contains("VehicleEnvelope");
|
||||
assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryIngestEnvelopeWithoutTelemetrySnapshotReturnsSkipped() {
|
||||
InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository();
|
||||
VehicleStatEnvelopeIngestor ingestor =
|
||||
new VehicleStatEnvelopeIngestor(new VehicleStatEventProcessor(repository));
|
||||
VehicleEnvelope envelope = VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-login-1")
|
||||
.setVin("VIN001")
|
||||
.setSource("GB32960")
|
||||
.setEventTimeMs(1_782_112_400_000L)
|
||||
.setIngestTimeMs(1_782_112_401_000L)
|
||||
.build();
|
||||
|
||||
var result = ingestor.tryIngest(envelope.toByteArray());
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.SKIPPED);
|
||||
assertThat(result.eventId()).isEqualTo("event-login-1");
|
||||
assertThat(result.message()).contains("telemetry_snapshot");
|
||||
assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))).isEmpty();
|
||||
}
|
||||
|
||||
private static VehicleEnvelope envelope() {
|
||||
return VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-1")
|
||||
.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("123.45")
|
||||
.setQuality("GOOD")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final class InMemoryVehicleStatRepository implements VehicleStatRepository {
|
||||
private final java.util.Map<String, java.util.List<MileagePoint>> points = new java.util.HashMap<>();
|
||||
|
||||
@Override
|
||||
public void appendMileagePoint(String vin, MileagePoint point) {
|
||||
points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MileagePoint> mileagePoints(String vin, LocalDate statDate) {
|
||||
return points.getOrDefault(vin, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveDailyStat(VehicleDailyStatResult result) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
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.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VehicleStatEventProcessorTest {
|
||||
|
||||
private final InMemoryVehicleStatRepository repository = new InMemoryVehicleStatRepository();
|
||||
private final VehicleStatEventProcessor processor = new VehicleStatEventProcessor(repository);
|
||||
|
||||
@Test
|
||||
void appendsMileagePointFromInternalField() {
|
||||
processor.process(envelope("total_mileage_km", "123.45"));
|
||||
|
||||
assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22)))
|
||||
.extracting(MileagePoint::totalMileageKm)
|
||||
.containsExactly(123.45);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresEventsWithoutMileageField() {
|
||||
processor.process(envelope("speed_kmh", "80.5"));
|
||||
|
||||
assertThat(repository.mileagePoints("VIN001", LocalDate.of(2026, 6, 22))).isEmpty();
|
||||
}
|
||||
|
||||
private static VehicleEnvelope envelope(String key, String value) {
|
||||
return VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-1")
|
||||
.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(key)
|
||||
.setValueType("DOUBLE")
|
||||
.setValue(value)
|
||||
.setQuality("GOOD")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final class InMemoryVehicleStatRepository implements VehicleStatRepository {
|
||||
private final java.util.Map<String, java.util.List<MileagePoint>> points = new java.util.HashMap<>();
|
||||
|
||||
@Override
|
||||
public void appendMileagePoint(String vin, MileagePoint point) {
|
||||
points.computeIfAbsent(vin, ignored -> new java.util.ArrayList<>()).add(point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MileagePoint> mileagePoints(String vin, LocalDate statDate) {
|
||||
return points.getOrDefault(vin, List.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveDailyStat(VehicleDailyStatResult result) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.lingniu.ingest.vehiclestat;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.RealtimePayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VehicleStatEventSinkTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void consumesRealtimeEventsAndCalculatesDailyMileage() {
|
||||
FileVehicleStatRepository repository = new FileVehicleStatRepository(tempDir);
|
||||
DailyVehicleStatService service = new DailyVehicleStatService(
|
||||
repository,
|
||||
vin -> new VehicleStatRule(vin, DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST),
|
||||
new DailyMileageCalculator(ZoneId.of("Asia/Shanghai")));
|
||||
VehicleStatEventSink sink = new VehicleStatEventSink(repository, service, ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
sink.publish(realtime("previous", Instant.parse("2026-06-21T15:59:00Z"), 100.0)).join();
|
||||
sink.publish(realtime("current", Instant.parse("2026-06-22T10:00:00Z"), 135.5)).join();
|
||||
|
||||
assertThat(repository.findDailyStat("VIN001", LocalDate.parse("2026-06-22")))
|
||||
.hasValueSatisfying(stat -> assertThat(stat.dailyMileageKm()).hasValue(35.5));
|
||||
}
|
||||
|
||||
private static VehicleEvent.Realtime realtime(String id, Instant eventTime, double totalMileageKm) {
|
||||
return new VehicleEvent.Realtime(
|
||||
id,
|
||||
"VIN001",
|
||||
ProtocolId.GB32960,
|
||||
eventTime,
|
||||
eventTime.plusMillis(100),
|
||||
"trace-" + id,
|
||||
Map.of(),
|
||||
new RealtimePayload(
|
||||
42.1, totalMileageKm, 87.0, null, null,
|
||||
null, null, null, 8.3, null, null,
|
||||
RealtimePayload.VehicleState.STARTED,
|
||||
null, null, null, null, null,
|
||||
113.12, 23.45, null, null, null, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.lingniu.ingest.vehiclestat.config;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
|
||||
import com.lingniu.ingest.vehiclestat.DailyMileageCalculator;
|
||||
import com.lingniu.ingest.vehiclestat.DailyMileageStrategy;
|
||||
import com.lingniu.ingest.vehiclestat.DailyVehicleStatService;
|
||||
import com.lingniu.ingest.vehiclestat.FileVehicleStatRepository;
|
||||
import com.lingniu.ingest.vehiclestat.MileagePoint;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatController;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleDailyStatResult;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEventSink;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatEventProcessor;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||
import com.lingniu.ingest.vehiclestat.VehicleStatRuleRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VehicleStatAutoConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleStatAutoConfiguration.class))
|
||||
.withBean(EnvelopeDeadLetterSink.class, () -> record -> {})
|
||||
.withBean(VehicleStatRepository.class, InMemoryVehicleStatRepository::new);
|
||||
|
||||
@Test
|
||||
void createsStatBeansWhenEnabledAndRepositoryExists() {
|
||||
contextRunner
|
||||
.withPropertyValues("lingniu.ingest.vehicle-stat.enabled=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleStatRuleRepository.class);
|
||||
assertThat(context).hasSingleBean(DailyMileageCalculator.class);
|
||||
assertThat(context).hasSingleBean(DailyVehicleStatService.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatEventProcessor.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatEventSink.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatController.class);
|
||||
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
||||
assertThat(context.getBean(VehicleStatRuleRepository.class)
|
||||
.ruleFor("VIN001").dailyMileageStrategy())
|
||||
.isEqualTo(DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void backsOffWhenDisabled() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).doesNotHaveBean(DailyVehicleStatService.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStatEventProcessor.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStatEnvelopeIngestor.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsFileRepositoryWhenNoRepositoryBeanExists() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleStatAutoConfiguration.class))
|
||||
.withPropertyValues("lingniu.ingest.vehicle-stat.enabled=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleStatRepository.class);
|
||||
assertThat(context).hasSingleBean(FileVehicleStatRepository.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatRuleRepository.class);
|
||||
assertThat(context).hasSingleBean(DailyMileageCalculator.class);
|
||||
assertThat(context).hasSingleBean(DailyVehicleStatService.class);
|
||||
assertThat(context).hasSingleBean(VehicleStatEventProcessor.class);
|
||||
});
|
||||
}
|
||||
|
||||
private static final class InMemoryVehicleStatRepository implements VehicleStatRepository {
|
||||
@Override public void appendMileagePoint(String vin, MileagePoint point) {}
|
||||
@Override public List<MileagePoint> mileagePoints(String vin, LocalDate statDate) { return List.of(); }
|
||||
@Override public void saveDailyStat(VehicleDailyStatResult result) {}
|
||||
@Override public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
61
modules/services/vehicle-state-service/pom.xml
Normal file
61
modules/services/vehicle-state-service/pom.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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>vehicle-state-service</artifactId>
|
||||
<name>vehicle-state-service</name>
|
||||
<description>Kafka full-field event consumer + Redis hot vehicle state query API.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>sink-mq</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.kafka</groupId>
|
||||
<artifactId>kafka-clients</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</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>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.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public final class RedisVehicleStateRepository implements VehicleStateRepository {
|
||||
|
||||
private final StringRedisTemplate redis;
|
||||
|
||||
public RedisVehicleStateRepository(StringRedisTemplate redis) {
|
||||
if (redis == null) {
|
||||
throw new IllegalArgumentException("redis must not be null");
|
||||
}
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putState(String vin, String json) {
|
||||
put(key("vehicle:state:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLocation(String vin, String json) {
|
||||
put(key("vehicle:location:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putSafety(String vin, String json) {
|
||||
put(key("vehicle:safety:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLastEvent(String vin, String json) {
|
||||
put(key("vehicle:event:last:", vin), json);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getState(String vin) {
|
||||
return get(key("vehicle:state:", vin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLocation(String vin) {
|
||||
return get(key("vehicle:location:", vin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getSafety(String vin) {
|
||||
return get(key("vehicle:safety:", vin));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLastEvent(String vin) {
|
||||
return get(key("vehicle:event:last:", vin));
|
||||
}
|
||||
|
||||
private void put(String key, String json) {
|
||||
redis.opsForValue().set(key, json == null ? "{}" : json);
|
||||
}
|
||||
|
||||
private Optional<String> get(String key) {
|
||||
return Optional.ofNullable(redis.opsForValue().get(key));
|
||||
}
|
||||
|
||||
private static String key(String prefix, String vin) {
|
||||
if (vin == null || vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
return prefix + vin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-state", name = "enabled", havingValue = "true")
|
||||
@ConditionalOnBean(VehicleStateRepository.class)
|
||||
@RequestMapping(path = "/api/vehicle-state", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public final class VehicleStateController {
|
||||
|
||||
private final VehicleStateRepository repository;
|
||||
|
||||
public VehicleStateController(VehicleStateRepository repository) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}")
|
||||
public ResponseEntity<String> state(@PathVariable String vin) {
|
||||
return json(repository.getState(vin));
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/location")
|
||||
public ResponseEntity<String> location(@PathVariable String vin) {
|
||||
return json(repository.getLocation(vin));
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/safety")
|
||||
public ResponseEntity<String> safety(@PathVariable String vin) {
|
||||
return json(repository.getSafety(vin));
|
||||
}
|
||||
|
||||
@GetMapping("/{vin}/last-event")
|
||||
public ResponseEntity<String> lastEvent(@PathVariable String vin) {
|
||||
return json(repository.getLastEvent(vin));
|
||||
}
|
||||
|
||||
private static ResponseEntity<String> json(Optional<String> value) {
|
||||
return value.map(json -> ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(json))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
public final class VehicleStateEnvelopeIngestor implements EnvelopeIngestor {
|
||||
|
||||
private final VehicleStateUpdater updater;
|
||||
|
||||
public VehicleStateEnvelopeIngestor(VehicleStateUpdater updater) {
|
||||
if (updater == null) {
|
||||
throw new IllegalArgumentException("updater must not be null");
|
||||
}
|
||||
this.updater = updater;
|
||||
}
|
||||
|
||||
public void ingest(byte[] kafkaValue) {
|
||||
updater.update(parse(kafkaValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public EnvelopeIngestResult tryIngest(byte[] kafkaValue) {
|
||||
VehicleEnvelope envelope = null;
|
||||
try {
|
||||
envelope = parse(kafkaValue);
|
||||
updater.update(envelope);
|
||||
return EnvelopeIngestResult.processed(envelope.getEventId(), envelope.getVin());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return envelope == null
|
||||
? EnvelopeIngestResult.invalid(ex.getMessage())
|
||||
: EnvelopeIngestResult.skipped(envelope.getEventId(), 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 ex) {
|
||||
throw new IllegalArgumentException("VehicleEnvelope bytes are invalid", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface VehicleStateRepository {
|
||||
|
||||
void putState(String vin, String json);
|
||||
|
||||
void putLocation(String vin, String json);
|
||||
|
||||
void putSafety(String vin, String json);
|
||||
|
||||
void putLastEvent(String vin, String json);
|
||||
|
||||
Optional<String> getState(String vin);
|
||||
|
||||
Optional<String> getLocation(String vin);
|
||||
|
||||
Optional<String> getSafety(String vin);
|
||||
|
||||
Optional<String> getLastEvent(String vin);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.lingniu.ingest.sink.mq.proto.TelemetryField;
|
||||
import com.lingniu.ingest.sink.mq.proto.VehicleEnvelope;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class VehicleStateUpdater {
|
||||
|
||||
private final VehicleStateRepository repository;
|
||||
|
||||
public VehicleStateUpdater(VehicleStateRepository repository) {
|
||||
if (repository == null) {
|
||||
throw new IllegalArgumentException("repository must not be null");
|
||||
}
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public void update(VehicleEnvelope envelope) {
|
||||
if (envelope == null) {
|
||||
throw new IllegalArgumentException("envelope must not be null");
|
||||
}
|
||||
if (!envelope.hasTelemetrySnapshot()) {
|
||||
throw new IllegalArgumentException("envelope telemetry_snapshot is required");
|
||||
}
|
||||
Map<String, String> fields = fields(envelope);
|
||||
String vin = envelope.getVin();
|
||||
|
||||
repository.putState(vin, json(base(envelope, fields)));
|
||||
repository.putLastEvent(vin, json(lastEvent(envelope)));
|
||||
|
||||
if (fields.containsKey("longitude") && fields.containsKey("latitude")) {
|
||||
repository.putLocation(vin, json(location(envelope, fields)));
|
||||
}
|
||||
if (hasSafetyFields(fields)) {
|
||||
repository.putSafety(vin, json(safety(envelope, fields)));
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> fields(VehicleEnvelope envelope) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for (TelemetryField field : envelope.getTelemetrySnapshot().getFieldsList()) {
|
||||
fields.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static Map<String, Object> base(VehicleEnvelope envelope, Map<String, String> fields) {
|
||||
Map<String, Object> map = eventIdentity(envelope);
|
||||
map.put("eventType", envelope.getTelemetrySnapshot().getEventType());
|
||||
map.put("fields", fields);
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Object> location(VehicleEnvelope envelope, Map<String, String> fields) {
|
||||
Map<String, Object> map = eventIdentity(envelope);
|
||||
putIfPresent(map, fields, "longitude");
|
||||
putIfPresent(map, fields, "latitude");
|
||||
putIfPresent(map, fields, "altitude_m");
|
||||
putIfPresent(map, fields, "direction_deg");
|
||||
putIfPresent(map, fields, "speed_kmh");
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Object> safety(VehicleEnvelope envelope, Map<String, String> fields) {
|
||||
Map<String, Object> map = eventIdentity(envelope);
|
||||
putIfPresent(map, fields, "safety_category");
|
||||
putIfPresent(map, fields, "hydrogen_leak_detected");
|
||||
putIfPresent(map, fields, "hydrogen_leak_level");
|
||||
putIfPresent(map, fields, "hydrogen_leak_action_required");
|
||||
putIfPresent(map, fields, "alarm_level");
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Object> lastEvent(VehicleEnvelope envelope) {
|
||||
return eventIdentity(envelope);
|
||||
}
|
||||
|
||||
private static Map<String, Object> eventIdentity(VehicleEnvelope envelope) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("eventId", envelope.getEventId());
|
||||
map.put("traceId", envelope.getTraceId());
|
||||
map.put("vin", envelope.getVin());
|
||||
map.put("source", envelope.getSource());
|
||||
map.put("eventTimeMs", Long.toString(envelope.getEventTimeMs()));
|
||||
map.put("ingestTimeMs", Long.toString(envelope.getIngestTimeMs()));
|
||||
return map;
|
||||
}
|
||||
|
||||
private static boolean hasSafetyFields(Map<String, String> fields) {
|
||||
return fields.containsKey("safety_category")
|
||||
|| fields.containsKey("hydrogen_leak_detected")
|
||||
|| fields.containsKey("hydrogen_leak_level")
|
||||
|| fields.containsKey("hydrogen_leak_action_required")
|
||||
|| fields.containsKey("alarm_level");
|
||||
}
|
||||
|
||||
private static void putIfPresent(Map<String, Object> map, Map<String, String> fields, String key) {
|
||||
if (fields.containsKey(key)) {
|
||||
map.put(key, fields.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
private static String json(Map<String, ?> map) {
|
||||
StringBuilder out = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, ?> entry : map.entrySet()) {
|
||||
if (!first) {
|
||||
out.append(',');
|
||||
}
|
||||
first = false;
|
||||
out.append('"').append(escape(entry.getKey())).append('"').append(':');
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof Map<?, ?> nested) {
|
||||
out.append(jsonObject(nested));
|
||||
} else {
|
||||
out.append('"').append(escape(String.valueOf(value))).append('"');
|
||||
}
|
||||
}
|
||||
return out.append('}').toString();
|
||||
}
|
||||
|
||||
private static String jsonObject(Map<?, ?> map) {
|
||||
StringBuilder out = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (!first) {
|
||||
out.append(',');
|
||||
}
|
||||
first = false;
|
||||
out.append('"').append(escape(String.valueOf(entry.getKey()))).append('"')
|
||||
.append(':')
|
||||
.append('"').append(escape(String.valueOf(entry.getValue()))).append('"');
|
||||
}
|
||||
return out.append('}').toString();
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.vehiclestate.config;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
|
||||
import com.lingniu.ingest.vehiclestate.RedisVehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateController;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateEnvelopeIngestor;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateUpdater;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
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 org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-state", name = "enabled", havingValue = "true")
|
||||
public class VehicleStateAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(StringRedisTemplate.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateRepository vehicleStateRepository(StringRedisTemplate redis) {
|
||||
return new RedisVehicleStateRepository(redis);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStateRepository.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateUpdater vehicleStateUpdater(VehicleStateRepository repository) {
|
||||
return new VehicleStateUpdater(repository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStateUpdater.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateEnvelopeIngestor vehicleStateEnvelopeIngestor(VehicleStateUpdater updater) {
|
||||
return new VehicleStateEnvelopeIngestor(updater);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean({VehicleStateEnvelopeIngestor.class, EnvelopeDeadLetterSink.class})
|
||||
@ConditionalOnMissingBean(name = "vehicleStateEnvelopeConsumerProcessor")
|
||||
public EnvelopeConsumerProcessor vehicleStateEnvelopeConsumerProcessor(VehicleStateEnvelopeIngestor ingestor,
|
||||
EnvelopeDeadLetterSink deadLetterSink) {
|
||||
return new EnvelopeConsumerProcessor("vehicle-state", ingestor, deadLetterSink);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnBean(VehicleStateRepository.class)
|
||||
@ConditionalOnMissingBean
|
||||
public VehicleStateController vehicleStateController(VehicleStateRepository repository) {
|
||||
return new VehicleStateController(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.vehiclestate.config.VehicleStateAutoConfiguration
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
final class InMemoryVehicleStateRepository implements VehicleStateRepository {
|
||||
String state;
|
||||
String location;
|
||||
String safety;
|
||||
String lastEvent;
|
||||
|
||||
@Override
|
||||
public void putState(String vin, String json) {
|
||||
this.state = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLocation(String vin, String json) {
|
||||
this.location = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putSafety(String vin, String json) {
|
||||
this.safety = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLastEvent(String vin, String json) {
|
||||
this.lastEvent = json;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getState(String vin) {
|
||||
return Optional.ofNullable(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLocation(String vin) {
|
||||
return Optional.ofNullable(location);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getSafety(String vin) {
|
||||
return Optional.ofNullable(safety);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getLastEvent(String vin) {
|
||||
return Optional.ofNullable(lastEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RedisVehicleStateRepositoryTest {
|
||||
|
||||
@Test
|
||||
void writesAndReadsHotStateJsonByStableRedisKey() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> ops = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(ops);
|
||||
when(ops.get("vehicle:state:VIN001")).thenReturn("{\"vin\":\"VIN001\"}");
|
||||
|
||||
RedisVehicleStateRepository repository = new RedisVehicleStateRepository(redis);
|
||||
|
||||
repository.putState("VIN001", "{\"vin\":\"VIN001\"}");
|
||||
Optional<String> value = repository.getState("VIN001");
|
||||
|
||||
verify(ops).set("vehicle:state:VIN001", "{\"vin\":\"VIN001\"}");
|
||||
assertThat(value).contains("{\"vin\":\"VIN001\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesLocationSafetyAndLastEventToSeparateKeys() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> ops = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(ops);
|
||||
|
||||
RedisVehicleStateRepository repository = new RedisVehicleStateRepository(redis);
|
||||
|
||||
repository.putLocation("VIN001", "{\"longitude\":113.12}");
|
||||
repository.putSafety("VIN001", "{\"hydrogen_leak_detected\":true}");
|
||||
repository.putLastEvent("VIN001", "{\"eventId\":\"event-1\"}");
|
||||
|
||||
verify(ops).set("vehicle:location:VIN001", "{\"longitude\":113.12}");
|
||||
verify(ops).set("vehicle:safety:VIN001", "{\"hydrogen_leak_detected\":true}");
|
||||
verify(ops).set("vehicle:event:last:VIN001", "{\"eventId\":\"event-1\"}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
|
||||
|
||||
class VehicleStateControllerTest {
|
||||
|
||||
@Test
|
||||
void returnsLatestStateJson() throws Exception {
|
||||
VehicleStateRepository repository = mock(VehicleStateRepository.class);
|
||||
when(repository.getState("VIN001")).thenReturn(java.util.Optional.of("{\"speed_kmh\":\"80.5\"}"));
|
||||
MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-state/VIN001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"speed_kmh\":\"80.5\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsLocationSafetyAndLastEventJson() throws Exception {
|
||||
VehicleStateRepository repository = mock(VehicleStateRepository.class);
|
||||
when(repository.getLocation("VIN001")).thenReturn(java.util.Optional.of("{\"longitude\":\"113.12\"}"));
|
||||
when(repository.getSafety("VIN001")).thenReturn(java.util.Optional.of("{\"hydrogen_leak_detected\":\"true\"}"));
|
||||
when(repository.getLastEvent("VIN001")).thenReturn(java.util.Optional.of("{\"eventId\":\"event-1\"}"));
|
||||
MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-state/VIN001/location"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"longitude\":\"113.12\"}"));
|
||||
mvc.perform(get("/api/vehicle-state/VIN001/safety"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"hydrogen_leak_detected\":\"true\"}"));
|
||||
mvc.perform(get("/api/vehicle-state/VIN001/last-event"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{\"eventId\":\"event-1\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsNotFoundWhenStateIsMissing() throws Exception {
|
||||
VehicleStateRepository repository = mock(VehicleStateRepository.class);
|
||||
when(repository.getState("VIN001")).thenReturn(java.util.Optional.empty());
|
||||
MockMvc mvc = standaloneSetup(new VehicleStateController(repository)).build();
|
||||
|
||||
mvc.perform(get("/api/vehicle-state/VIN001"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestResult;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeIngestor;
|
||||
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.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class VehicleStateEnvelopeIngestorTest {
|
||||
|
||||
@Test
|
||||
void parsesEnvelopeBytesAndUpdatesRepository() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
VehicleStateEnvelopeIngestor ingestor = new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(repository));
|
||||
|
||||
assertThat(ingestor).isInstanceOf(EnvelopeIngestor.class);
|
||||
ingestor.ingest(envelope().toByteArray());
|
||||
|
||||
assertThat(repository.getState("VIN001")).hasValueSatisfying(json -> assertThat(json).contains("speed_kmh"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidEnvelopeBytes() {
|
||||
VehicleStateEnvelopeIngestor ingestor =
|
||||
new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(new EmptyRepository()));
|
||||
|
||||
assertThatThrownBy(() -> ingestor.ingest(new byte[]{0x01, 0x02}))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("VehicleEnvelope");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryIngestInvalidEnvelopeBytesReturnsInvalidWithoutUpdatingRepository() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
VehicleStateEnvelopeIngestor ingestor = new VehicleStateEnvelopeIngestor(new VehicleStateUpdater(repository));
|
||||
|
||||
var result = ingestor.tryIngest(new byte[]{0x01, 0x02});
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.INVALID_ENVELOPE);
|
||||
assertThat(result.message()).contains("VehicleEnvelope");
|
||||
assertThat(repository.getState("VIN001")).isEmpty();
|
||||
}
|
||||
|
||||
private static VehicleEnvelope envelope() {
|
||||
return VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-1")
|
||||
.setVin("VIN001")
|
||||
.setSource("GB32960")
|
||||
.setEventTimeMs(1)
|
||||
.setIngestTimeMs(2)
|
||||
.setTelemetrySnapshot(TelemetrySnapshot.newBuilder()
|
||||
.setEventType("REALTIME")
|
||||
.addFields(TelemetryField.newBuilder()
|
||||
.setKey("speed_kmh")
|
||||
.setValueType("DOUBLE")
|
||||
.setValue("80.5")
|
||||
.setUnit("km/h")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final class EmptyRepository implements VehicleStateRepository {
|
||||
@Override public void putState(String vin, String json) {}
|
||||
@Override public void putLocation(String vin, String json) {}
|
||||
@Override public void putSafety(String vin, String json) {}
|
||||
@Override public void putLastEvent(String vin, String json) {}
|
||||
@Override public Optional<String> getState(String vin) { return Optional.empty(); }
|
||||
@Override public Optional<String> getLocation(String vin) { return Optional.empty(); }
|
||||
@Override public Optional<String> getSafety(String vin) { return Optional.empty(); }
|
||||
@Override public Optional<String> getLastEvent(String vin) { return Optional.empty(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.lingniu.ingest.vehiclestate;
|
||||
|
||||
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;
|
||||
|
||||
class VehicleStateUpdaterTest {
|
||||
|
||||
@Test
|
||||
void writesStateLocationSafetyAndLastEventFromTelemetrySnapshot() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
VehicleStateUpdater updater = new VehicleStateUpdater(repository);
|
||||
|
||||
updater.update(envelope()
|
||||
.addField("speed_kmh", "DOUBLE", "80.5", "km/h")
|
||||
.addField("longitude", "DOUBLE", "113.12", "deg")
|
||||
.addField("latitude", "DOUBLE", "23.45", "deg")
|
||||
.addField("hydrogen_leak_detected", "BOOLEAN", "true", "")
|
||||
.addField("hydrogen_leak_level", "STRING", "CRITICAL", "")
|
||||
.build());
|
||||
|
||||
assertThat(repository.state).contains("\"vin\":\"VIN001\"");
|
||||
assertThat(repository.state).contains("\"speed_kmh\"");
|
||||
assertThat(repository.location).contains("\"longitude\":\"113.12\"");
|
||||
assertThat(repository.location).contains("\"latitude\":\"23.45\"");
|
||||
assertThat(repository.safety).contains("\"hydrogen_leak_detected\":\"true\"");
|
||||
assertThat(repository.lastEvent).contains("\"eventId\":\"event-1\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotOverwriteLocationOrSafetyWhenFieldsAreMissing() {
|
||||
InMemoryVehicleStateRepository repository = new InMemoryVehicleStateRepository();
|
||||
repository.location = "{\"longitude\":\"old\"}";
|
||||
repository.safety = "{\"hydrogen_leak_detected\":\"old\"}";
|
||||
|
||||
new VehicleStateUpdater(repository).update(envelope()
|
||||
.addField("speed_kmh", "DOUBLE", "80.5", "km/h")
|
||||
.build());
|
||||
|
||||
assertThat(repository.location).isEqualTo("{\"longitude\":\"old\"}");
|
||||
assertThat(repository.safety).isEqualTo("{\"hydrogen_leak_detected\":\"old\"}");
|
||||
assertThat(repository.state).contains("\"speed_kmh\"");
|
||||
}
|
||||
|
||||
private static EnvelopeBuilder envelope() {
|
||||
return new EnvelopeBuilder();
|
||||
}
|
||||
|
||||
private static final class EnvelopeBuilder {
|
||||
private final TelemetrySnapshot.Builder snapshot = TelemetrySnapshot.newBuilder()
|
||||
.setEventType("REALTIME")
|
||||
.setRawArchiveUri("archive://raw/event-1.bin");
|
||||
|
||||
EnvelopeBuilder addField(String key, String valueType, String value, String unit) {
|
||||
snapshot.addFields(TelemetryField.newBuilder()
|
||||
.setKey(key)
|
||||
.setValueType(valueType)
|
||||
.setValue(value)
|
||||
.setUnit(unit)
|
||||
.setQuality("GOOD")
|
||||
.setSourcePath("test"));
|
||||
return this;
|
||||
}
|
||||
|
||||
VehicleEnvelope build() {
|
||||
return VehicleEnvelope.newBuilder()
|
||||
.setEventId("event-1")
|
||||
.setTraceId("trace-1")
|
||||
.setVin("VIN001")
|
||||
.setSource("GB32960")
|
||||
.setEventTimeMs(1_782_112_400_000L)
|
||||
.setIngestTimeMs(1_782_112_401_000L)
|
||||
.setTelemetrySnapshot(snapshot)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.lingniu.ingest.vehiclestate.config;
|
||||
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeConsumerProcessor;
|
||||
import com.lingniu.ingest.api.consumer.EnvelopeDeadLetterSink;
|
||||
import com.lingniu.ingest.vehiclestate.RedisVehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateController;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateEnvelopeIngestor;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateRepository;
|
||||
import com.lingniu.ingest.vehiclestate.VehicleStateUpdater;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class VehicleStateAutoConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleStateAutoConfiguration.class))
|
||||
.withBean(EnvelopeDeadLetterSink.class, () -> record -> {})
|
||||
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class));
|
||||
|
||||
@Test
|
||||
void createsVehicleStateBeansWhenEnabled() {
|
||||
contextRunner
|
||||
.withPropertyValues("lingniu.ingest.vehicle-state.enabled=true")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleStateRepository.class);
|
||||
assertThat(context).hasSingleBean(RedisVehicleStateRepository.class);
|
||||
assertThat(context).hasSingleBean(VehicleStateUpdater.class);
|
||||
assertThat(context).hasSingleBean(VehicleStateEnvelopeIngestor.class);
|
||||
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
||||
assertThat(context).hasSingleBean(VehicleStateController.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void backsOffWhenDisabled() {
|
||||
contextRunner.run(context -> {
|
||||
assertThat(context).doesNotHaveBean(VehicleStateRepository.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStateUpdater.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStateEnvelopeIngestor.class);
|
||||
assertThat(context).doesNotHaveBean(VehicleStateController.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotCreateRepositoryWithoutRedisTemplate() {
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleStateAutoConfiguration.class))
|
||||
.withPropertyValues("lingniu.ingest.vehicle-state.enabled=true")
|
||||
.run(context -> assertThat(context).doesNotHaveBean(VehicleStateRepository.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user