diff --git a/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/FactIds.java b/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/FactIds.java new file mode 100644 index 00000000..aa1cdfdd --- /dev/null +++ b/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/FactIds.java @@ -0,0 +1,50 @@ +package com.lingniu.ingest.facts; + +import com.lingniu.ingest.api.ProtocolId; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; + +public final class FactIds { + + private FactIds() { + } + + public static String rawFrameId(ProtocolId protocol, + String vehicleKey, + int messageId, + int subType, + Instant receivedAt, + String checksum) { + if (protocol == null) { + throw new IllegalArgumentException("protocol must not be null"); + } + String input = protocol.name() + "|" + + clean(vehicleKey) + "|" + + messageId + "|" + + subType + "|" + + (receivedAt == null ? "" : receivedAt.toString()) + "|" + + clean(checksum); + return "rf_" + sha256(input).substring(0, 32); + } + + public static String decodedFactId(String frameId, String kind, int ordinal) { + return "df_" + clean(frameId) + "_" + clean(kind) + "_" + ordinal; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} diff --git a/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/ParseStatus.java b/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/ParseStatus.java new file mode 100644 index 00000000..1855f7f1 --- /dev/null +++ b/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/ParseStatus.java @@ -0,0 +1,7 @@ +package com.lingniu.ingest.facts; + +public enum ParseStatus { + NOT_PARSED, + SUCCEEDED, + FAILED +} diff --git a/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/RawFrameFact.java b/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/RawFrameFact.java new file mode 100644 index 00000000..17298499 --- /dev/null +++ b/modules/core/ingest-facts/src/main/java/com/lingniu/ingest/facts/RawFrameFact.java @@ -0,0 +1,65 @@ +package com.lingniu.ingest.facts; + +import com.lingniu.ingest.api.ProtocolId; + +import java.time.Instant; +import java.util.Map; + +public record RawFrameFact( + String frameId, + ProtocolId protocol, + String vehicleKey, + String vin, + String phone, + int messageId, + int subType, + Instant eventTime, + Instant receivedAt, + String peer, + String rawUri, + String checksum, + long rawSizeBytes, + ParseStatus parseStatus, + String parseError, + Map metadata +) { + public RawFrameFact { + frameId = required(frameId, "frameId"); + if (protocol == null) { + throw new IllegalArgumentException("protocol must not be null"); + } + vehicleKey = required(vehicleKey, "vehicleKey"); + receivedAt = required(receivedAt, "receivedAt"); + eventTime = eventTime == null ? receivedAt : eventTime; + rawUri = required(rawUri, "rawUri"); + checksum = required(checksum, "checksum"); + if (rawSizeBytes < 0) { + throw new IllegalArgumentException("rawSizeBytes must not be negative"); + } + parseStatus = parseStatus == null ? ParseStatus.NOT_PARSED : parseStatus; + vin = clean(vin); + phone = clean(phone); + peer = clean(peer); + parseError = clean(parseError); + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + + private static String required(String value, String field) { + String cleaned = clean(value); + if (cleaned.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return cleaned; + } + + private static Instant required(Instant value, String field) { + if (value == null) { + throw new IllegalArgumentException(field + " must not be null"); + } + return value; + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/modules/core/ingest-facts/src/test/java/com/lingniu/ingest/facts/FactIdsTest.java b/modules/core/ingest-facts/src/test/java/com/lingniu/ingest/facts/FactIdsTest.java new file mode 100644 index 00000000..cf5ffa29 --- /dev/null +++ b/modules/core/ingest-facts/src/test/java/com/lingniu/ingest/facts/FactIdsTest.java @@ -0,0 +1,38 @@ +package com.lingniu.ingest.facts; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class FactIdsTest { + + @Test + void rawFrameIdIsStableForSameInputs() { + String first = FactIds.rawFrameId( + ProtocolId.GB32960, + "VIN001", + 2, + 7, + Instant.parse("2026-06-29T00:00:00Z"), + "sha256:abc"); + String second = FactIds.rawFrameId( + ProtocolId.GB32960, + "VIN001", + 2, + 7, + Instant.parse("2026-06-29T00:00:00Z"), + "sha256:abc"); + + assertThat(first).isEqualTo(second).startsWith("rf_"); + } + + @Test + void decodedFactIdIncludesKind() { + String id = FactIds.decodedFactId("rf_123", "location", 0); + + assertThat(id).isEqualTo("df_rf_123_location_0"); + } +} diff --git a/modules/core/ingest-facts/src/test/java/com/lingniu/ingest/facts/RawFrameFactTest.java b/modules/core/ingest-facts/src/test/java/com/lingniu/ingest/facts/RawFrameFactTest.java new file mode 100644 index 00000000..74299833 --- /dev/null +++ b/modules/core/ingest-facts/src/test/java/com/lingniu/ingest/facts/RawFrameFactTest.java @@ -0,0 +1,63 @@ +package com.lingniu.ingest.facts; + +import com.lingniu.ingest.api.ProtocolId; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RawFrameFactTest { + + @Test + void normalizesOptionalStringsAndMetadata() { + RawFrameFact fact = new RawFrameFact( + "frame-1", + ProtocolId.JT808, + "jt808:013912345678", + null, + "013912345678", + 0x0200, + 0, + null, + Instant.parse("2026-06-29T00:00:00Z"), + null, + "archive://2026/06/29/JT808/jt808-013912345678/frame-1.bin", + "sha256:abc", + 128, + ParseStatus.SUCCEEDED, + null, + Map.of("messageName", "LOCATION")); + + assertThat(fact.vin()).isEmpty(); + assertThat(fact.eventTime()).isEqualTo(fact.receivedAt()); + assertThat(fact.peer()).isEmpty(); + assertThat(fact.parseError()).isEmpty(); + assertThat(fact.metadata()).containsEntry("messageName", "LOCATION"); + } + + @Test + void rejectsMissingRequiredFields() { + assertThatThrownBy(() -> new RawFrameFact( + "", + ProtocolId.GB32960, + "VIN001", + "VIN001", + "", + 2, + 0, + Instant.parse("2026-06-29T00:00:00Z"), + Instant.parse("2026-06-29T00:00:00Z"), + "", + "archive://x", + "sha256:abc", + 1, + ParseStatus.SUCCEEDED, + "", + Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("frameId"); + } +}