feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88015 additions and 1974 deletions
@@ -0,0 +1,215 @@
package authentication
import (
"crypto/subtle"
"fmt"
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type Mode string
const (
ModeDisabled Mode = "disabled"
ModeObserve Mode = "observe"
ModeEnforce Mode = "enforce"
)
const (
StatusAccepted = "accepted"
StatusRejected = "rejected"
StatusUnknownAccount = "unknown_account"
StatusMissingCredential = "missing_credential"
StatusUnconfigured = "unconfigured"
)
type Result struct {
Applicable bool
Allowed bool
Mode Mode
Status string
Source string
}
type Authenticator interface {
Authenticate(envelope.FrameEnvelope) Result
}
func ParseMode(value string, fallback Mode) (Mode, error) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
value = string(fallback)
}
switch Mode(value) {
case ModeDisabled, ModeObserve, ModeEnforce:
return Mode(value), nil
default:
return "", fmt.Errorf("unsupported authentication mode %q", value)
}
}
type GB32960PlatformAuthenticator struct {
mode Mode
credentials map[string][]string
}
func NewGB32960PlatformAuthenticator(mode Mode, credentials map[string][]string) *GB32960PlatformAuthenticator {
normalized := make(map[string][]string, len(credentials))
for username, passwords := range credentials {
username = strings.TrimSpace(username)
if username == "" {
continue
}
for _, password := range passwords {
if password == "" {
continue
}
normalized[username] = append(normalized[username], password)
}
}
return &GB32960PlatformAuthenticator{mode: mode, credentials: normalized}
}
func (a *GB32960PlatformAuthenticator) Authenticate(env envelope.FrameEnvelope) Result {
if env.Protocol != envelope.ProtocolGB32960 || env.MessageID != "0x05" || a == nil || a.mode == ModeDisabled {
return Result{}
}
login := nestedMap(env.Parsed, "platform_login")
username := strings.TrimSpace(textValue(login, "username"))
password := textValue(login, "password")
status := StatusRejected
switch {
case len(a.credentials) == 0:
status = StatusUnconfigured
case username == "" || password == "":
status = StatusMissingCredential
default:
expected, ok := a.credentials[username]
if !ok || len(expected) == 0 {
status = StatusUnknownAccount
} else if anyConstantTimeEqual(expected, password) {
status = StatusAccepted
}
}
source := "none"
if status == StatusAccepted {
source = "configured"
}
return resultForMode(a.mode, status, source)
}
type JT808Authenticator struct {
mode Mode
authCode string
deviceTokens JT808DeviceTokenProvider
}
type JT808DeviceTokenProvider interface {
JT808AuthToken(phone string) (string, bool)
}
func NewJT808Authenticator(mode Mode, authCode string, deviceTokens JT808DeviceTokenProvider) *JT808Authenticator {
return &JT808Authenticator{mode: mode, authCode: authCode, deviceTokens: deviceTokens}
}
func (a *JT808Authenticator) Authenticate(env envelope.FrameEnvelope) Result {
if env.Protocol != envelope.ProtocolJT808 || env.MessageID != "0x0102" || a == nil || a.mode == ModeDisabled {
return Result{}
}
token := textValue(nestedMap(env.Parsed, "authentication"), "token")
status := StatusRejected
source := "none"
switch {
case token == "":
status = StatusMissingCredential
default:
if a.authCode != "" && constantTimeEqual(a.authCode, token) {
status = StatusAccepted
source = "configured"
break
}
deviceToken, knownDevice := "", false
if a.deviceTokens != nil {
deviceToken, knownDevice = a.deviceTokens.JT808AuthToken(env.Phone)
}
if knownDevice && deviceToken != "" && constantTimeEqual(deviceToken, token) {
status = StatusAccepted
source = "device"
} else if a.authCode == "" && !knownDevice {
status = StatusUnconfigured
}
}
return resultForMode(a.mode, status, source)
}
func Apply(env *envelope.FrameEnvelope, result Result) {
if env == nil || !result.Applicable {
return
}
env.AuthenticationMode = string(result.Mode)
env.AuthenticationStatus = result.Status
env.AuthenticationEnforced = result.Mode == ModeEnforce
}
// RedactParsedCredentials removes convenience copies of secrets before parsed
// fields are flattened and published. The original protocol frame remains in
// raw_hex for restricted forensic access.
func RedactParsedCredentials(env *envelope.FrameEnvelope) {
if env == nil || env.Protocol != envelope.ProtocolGB32960 {
return
}
login := nestedMap(env.Parsed, "platform_login")
if login == nil {
return
}
if password := textValue(login, "password"); password != "" {
login["password_present"] = true
}
delete(login, "password")
}
func resultForMode(mode Mode, status string, source ...string) Result {
allowed := status == StatusAccepted || mode != ModeEnforce
credentialSource := "none"
if len(source) > 0 && strings.TrimSpace(source[0]) != "" {
credentialSource = strings.TrimSpace(source[0])
}
return Result{Applicable: true, Allowed: allowed, Mode: mode, Status: status, Source: credentialSource}
}
func constantTimeEqual(expected string, actual string) bool {
if len(expected) != len(actual) {
return false
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func anyConstantTimeEqual(expected []string, actual string) bool {
matched := 0
for _, candidate := range expected {
if len(candidate) == len(actual) {
matched |= subtle.ConstantTimeCompare([]byte(candidate), []byte(actual))
}
}
return matched == 1
}
func nestedMap(parent map[string]any, key string) map[string]any {
if parent == nil {
return nil
}
value, _ := parent[key].(map[string]any)
return value
}
func textValue(values map[string]any, key string) string {
if values == nil {
return ""
}
value, ok := values[key]
if !ok || value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
@@ -0,0 +1,111 @@
package authentication
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestGB32960PlatformAuthenticatorModes(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x05",
Parsed: map[string]any{
"platform_login": map[string]any{"username": "platform-a", "password": "secret-a"},
},
}
accepted := NewGB32960PlatformAuthenticator(ModeEnforce, map[string][]string{"platform-a": {"secret-a"}}).Authenticate(env)
if !accepted.Applicable || !accepted.Allowed || accepted.Status != StatusAccepted {
t.Fatalf("accepted result = %#v", accepted)
}
rejected := NewGB32960PlatformAuthenticator(ModeEnforce, map[string][]string{"platform-a": {"other"}}).Authenticate(env)
if !rejected.Applicable || rejected.Allowed || rejected.Status != StatusRejected {
t.Fatalf("enforced rejected result = %#v", rejected)
}
observed := NewGB32960PlatformAuthenticator(ModeObserve, map[string][]string{"platform-a": {"other"}}).Authenticate(env)
if !observed.Allowed || observed.Status != StatusRejected {
t.Fatalf("observed rejected result = %#v", observed)
}
}
func TestGB32960PlatformAuthenticatorReportsUnknownAccount(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x05",
Parsed: map[string]any{
"platform_login": map[string]any{"username": "unknown", "password": "secret"},
},
}
result := NewGB32960PlatformAuthenticator(ModeEnforce, map[string][]string{"known": {"secret"}}).Authenticate(env)
if result.Allowed || result.Status != StatusUnknownAccount {
t.Fatalf("result = %#v", result)
}
}
func TestJT808AuthenticatorValidatesAuthenticationFrame(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0102",
Parsed: map[string]any{
"authentication": map[string]any{"token": "issued-code"},
},
}
accepted := NewJT808Authenticator(ModeEnforce, "issued-code", nil).Authenticate(env)
if !accepted.Allowed || accepted.Status != StatusAccepted {
t.Fatalf("accepted result = %#v", accepted)
}
rejected := NewJT808Authenticator(ModeEnforce, "different-code", nil).Authenticate(env)
if rejected.Allowed || rejected.Status != StatusRejected {
t.Fatalf("rejected result = %#v", rejected)
}
}
func TestJT808AuthenticatorAcceptsDeviceSnapshotToken(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0102",
Phone: "013307795425",
Parsed: map[string]any{
"authentication": map[string]any{"token": "device-code"},
},
}
provider := staticJT808DeviceTokens{"13307795425": "device-code"}
result := NewJT808Authenticator(ModeEnforce, "configured-code", provider).Authenticate(env)
if !result.Allowed || result.Status != StatusAccepted || result.Source != "device" {
t.Fatalf("device token result = %#v", result)
}
}
type staticJT808DeviceTokens map[string]string
func (p staticJT808DeviceTokens) JT808AuthToken(phone string) (string, bool) {
phone = strings.TrimLeft(phone, "0")
token, ok := p[phone]
return token, ok
}
func TestRedactParsedCredentialsRemovesGB32960Password(t *testing.T) {
login := map[string]any{"username": "platform-a", "password": "secret-a"}
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
Parsed: map[string]any{"platform_login": login},
}
RedactParsedCredentials(&env)
if _, exists := login["password"]; exists {
t.Fatalf("password remained in parsed login: %#v", login)
}
if login["password_present"] != true {
t.Fatalf("password presence marker missing: %#v", login)
}
}
func TestParseModeRejectsUnknownValue(t *testing.T) {
if _, err := ParseMode("strict-ish", ModeObserve); err == nil {
t.Fatal("expected unsupported mode error")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,14 @@ const (
ParseBadFrame ParseStatus = "BAD_FRAME"
)
type EventKind string
const (
EventKindRaw EventKind = "RAW"
EventKindFields EventKind = "FIELDS"
EventKindUnified EventKind = "UNIFIED"
)
const (
FieldSpeedKMH = "speed_kmh"
FieldTotalMileageKM = "total_mileage_km"
@@ -33,27 +41,36 @@ const (
)
type FrameEnvelope struct {
EventID string `json:"event_id"`
TraceID string `json:"trace_id"`
Protocol Protocol `json:"protocol"`
MessageID string `json:"message_id"`
Sequence uint16 `json:"sequence"`
VIN string `json:"vin,omitempty"`
VehicleKeyHint string `json:"vehicle_key,omitempty"`
Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
EventTimeMS int64 `json:"event_time_ms"`
ReceivedAtMS int64 `json:"received_at_ms"`
RawHex string `json:"raw_hex,omitempty"`
RawText string `json:"raw_text,omitempty"`
Parsed map[string]any `json:"parsed,omitempty"`
ParsedFields map[string]any `json:"parsed_fields,omitempty"`
ParsedFieldTypes map[string]string `json:"parsed_field_types,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
ParseStatus ParseStatus `json:"parse_status"`
ParseError string `json:"parse_error,omitempty"`
EventID string `json:"event_id"`
TraceID string `json:"trace_id"`
EventKind EventKind `json:"event_kind,omitempty"`
SourceEventID string `json:"source_event_id,omitempty"`
FieldMapping string `json:"field_mapping,omitempty"`
Protocol Protocol `json:"protocol"`
MessageID string `json:"message_id"`
Sequence uint16 `json:"sequence"`
VIN string `json:"vin,omitempty"`
VehicleKeyHint string `json:"vehicle_key,omitempty"`
Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Plate string `json:"plate,omitempty"`
SourceEndpoint string `json:"source_endpoint,omitempty"`
SourceCode string `json:"source_code,omitempty"`
PlatformName string `json:"platform_name,omitempty"`
SourceKind string `json:"source_kind,omitempty"`
AuthenticationMode string `json:"authentication_mode,omitempty"`
AuthenticationStatus string `json:"authentication_status,omitempty"`
AuthenticationEnforced bool `json:"authentication_enforced,omitempty"`
EventTimeMS int64 `json:"event_time_ms"`
ReceivedAtMS int64 `json:"received_at_ms"`
RawHex string `json:"raw_hex,omitempty"`
RawText string `json:"raw_text,omitempty"`
Parsed map[string]any `json:"parsed,omitempty"`
ParsedFields map[string]any `json:"parsed_fields,omitempty"`
ParsedFieldTypes map[string]string `json:"parsed_field_types,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
ParseStatus ParseStatus `json:"parse_status"`
ParseError string `json:"parse_error,omitempty"`
}
func (e FrameEnvelope) VehicleKey() string {
@@ -93,5 +110,8 @@ func (e FrameEnvelope) MarshalJSONBytes() ([]byte, error) {
if e.ParseStatus == "" {
e.ParseStatus = ParseOK
}
if e.EventKind == "" {
e.EventKind = EventKindRaw
}
return json.Marshal(e)
}
@@ -3,6 +3,7 @@ package envelope
import (
"encoding/json"
"testing"
"time"
)
func TestFrameEnvelopeVehicleKeyPrefersVIN(t *testing.T) {
@@ -52,4 +53,160 @@ func TestFrameEnvelopeMarshalDefaults(t *testing.T) {
if decoded.ParseStatus != ParseOK {
t.Fatalf("parse status = %q", decoded.ParseStatus)
}
if decoded.EventKind != EventKindRaw {
t.Fatalf("event kind = %q, want %q", decoded.EventKind, EventKindRaw)
}
}
func TestNormalizedEventTimeMSFallsBackToReceivedWhenEventIsFarFuture(t *testing.T) {
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC).UnixMilli()
event := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC).UnixMilli()
got, ok := NormalizedEventTimeMS(FrameEnvelope{EventTimeMS: event, ReceivedAtMS: received})
if !ok {
t.Fatal("NormalizedEventTimeMS() ok = false")
}
if got != received {
t.Fatalf("normalized event time = %d, want received %d", got, received)
}
_, reason, ok := NormalizedEventTimeMSWithReason(FrameEnvelope{EventTimeMS: event, ReceivedAtMS: received})
if !ok || reason != EventTimeReasonReceivedFutureEvent {
t.Fatalf("reason = %q ok=%v, want future fallback", reason, ok)
}
}
func TestNormalizedEventTimeMSKeepsSmallFutureSkew(t *testing.T) {
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC).UnixMilli()
event := time.Date(2026, 7, 12, 9, 35, 0, 0, time.UTC).UnixMilli()
got, ok := NormalizedEventTimeMS(FrameEnvelope{EventTimeMS: event, ReceivedAtMS: received})
if !ok {
t.Fatal("NormalizedEventTimeMS() ok = false")
}
if got != event {
t.Fatalf("normalized event time = %d, want event %d", got, event)
}
}
func TestIsRealtimeTelemetryFrameRejectsKnownNonRealtimeMessageIDs(t *testing.T) {
fields := map[string]any{
FieldLatitude: 30.590151,
FieldLongitude: 121.069881,
FieldTotalMileageKM: 10241.2,
}
tests := []FrameEnvelope{
{Protocol: ProtocolJT808, MessageID: "0x0100", Fields: fields, ParseStatus: ParseOK},
{Protocol: ProtocolGB32960, MessageID: "0x01", Fields: fields, Parsed: map[string]any{"data_units": []any{}}, ParseStatus: ParseOK},
}
for _, env := range tests {
if IsRealtimeTelemetryFrame(env) {
t.Fatalf("IsRealtimeTelemetryFrame(%s/%s) = true, want false", env.Protocol, env.MessageID)
}
}
}
func TestIsRealtimeTelemetryFrameAcceptsKnownRealtimeMessageIDs(t *testing.T) {
tests := []FrameEnvelope{
{Protocol: ProtocolJT808, MessageID: "0x0200", ParsedFields: map[string]any{"jt808.location.speed_kmh": 1}, ParseStatus: ParseOK},
{Protocol: ProtocolGB32960, MessageID: "0x02", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 1}, ParseStatus: ParseOK},
{Protocol: ProtocolGB32960, MessageID: "0x03", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 1}, ParseStatus: ParseOK},
}
for _, env := range tests {
if !IsRealtimeTelemetryFrame(env) {
t.Fatalf("IsRealtimeTelemetryFrame(%s/%s) = false, want true", env.Protocol, env.MessageID)
}
}
}
func TestIsRealtimeTelemetryFrameUsesCanonicalMQTTParsedFields(t *testing.T) {
realtime := FrameEnvelope{
Protocol: ProtocolYutongMQTT,
MessageID: "MQTT",
ParseStatus: ParseOK,
ParsedFields: map[string]any{
"yutong_mqtt.data.latitude": 30.590151,
},
}
if !IsRealtimeTelemetryFrame(realtime) {
t.Fatal("canonical MQTT data field should be classified as realtime")
}
metadataOnly := realtime
metadataOnly.ParsedFields = map[string]any{"yutong_mqtt.metadata.topic": "/ytforward/shln/3"}
if IsRealtimeTelemetryFrame(metadataOnly) {
t.Fatal("MQTT metadata-only field must not be classified as realtime")
}
}
func TestRequiresVehicleIdentity(t *testing.T) {
tests := []struct {
name string
env FrameEnvelope
want bool
}{
{
name: "gb32960 realtime upload",
env: FrameEnvelope{Protocol: ProtocolGB32960, MessageID: "0x02", Parsed: map[string]any{"data_units": []any{map[string]any{"name": "vehicle"}}}, ParseStatus: ParseOK},
want: true,
},
{
name: "gb32960 platform login",
env: FrameEnvelope{Protocol: ProtocolGB32960, MessageID: "0x05", ParseStatus: ParseOK},
want: false,
},
{
name: "gb32960 command response",
env: FrameEnvelope{Protocol: ProtocolGB32960, MessageID: "0x07", ParseStatus: ParseOK},
want: false,
},
{
name: "jt808 registration",
env: FrameEnvelope{Protocol: ProtocolJT808, MessageID: "0x0100", ParseStatus: ParseOK},
want: true,
},
{
name: "jt808 location",
env: FrameEnvelope{Protocol: ProtocolJT808, MessageID: "0x0200", Parsed: map[string]any{"location": map[string]any{"speed_kmh": 1}}, ParseStatus: ParseOK},
want: true,
},
{
name: "yutong empty data",
env: FrameEnvelope{Protocol: ProtocolYutongMQTT, MessageID: "MQTT", Parsed: map[string]any{"data": map[string]any{}}, ParseStatus: ParseOK},
want: false,
},
{
name: "yutong telemetry data",
env: FrameEnvelope{Protocol: ProtocolYutongMQTT, MessageID: "MQTT", Parsed: map[string]any{"data": map[string]any{"TOTAL_MILEAGE": 123}}, ParseStatus: ParseOK},
want: true,
},
{
name: "bad frame",
env: FrameEnvelope{Protocol: ProtocolJT808, MessageID: "0x0200", ParseStatus: ParseBadFrame},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := RequiresVehicleIdentity(tt.env); got != tt.want {
t.Fatalf("RequiresVehicleIdentity() = %v, want %v", got, tt.want)
}
})
}
}
func TestNormalizeSourceEndpointKey(t *testing.T) {
tests := map[string]string{
"115.231.168.135:20215": "115.231.168.135",
"115.231.168.135": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
"": "",
" ": "",
}
for input, want := range tests {
if got := NormalizeSourceEndpointKey(input); got != want {
t.Fatalf("NormalizeSourceEndpointKey(%q) = %q, want %q", input, got, want)
}
}
}
@@ -14,33 +14,93 @@ func IsRealtimeTelemetryFrame(env FrameEnvelope) bool {
command = strings.TrimSpace(stringAny(header["command"]))
}
}
if strings.EqualFold(command, "0x02") || strings.EqualFold(command, "0x03") {
return true
if command != "" && !strings.EqualFold(command, "0x02") && !strings.EqualFold(command, "0x03") {
return false
}
_, hasDataUnits := env.Parsed["data_units"]
return hasDataUnits || hasRealtimeField(env)
return hasDataUnits || hasGB32960ParsedTelemetryField(env) || hasRealtimeField(env)
case ProtocolJT808:
if strings.EqualFold(strings.TrimSpace(env.MessageID), "0x0200") {
return true
messageID := strings.TrimSpace(env.MessageID)
if messageID != "" && !strings.EqualFold(messageID, "0x0200") {
return false
}
if _, ok := env.Parsed["location"]; ok {
return true
}
return hasRealtimeField(env)
return hasParsedFieldPrefix(env, "jt808.location.") || hasRealtimeField(env)
case ProtocolYutongMQTT:
data, ok := env.Parsed["data"].(map[string]any)
return ok && len(data) > 0
if ok && len(data) > 0 {
return true
}
return hasParsedFieldPrefix(env, "yutong_mqtt.data.") ||
hasParsedFieldPrefix(env, "yutong_mqtt.root.data.")
default:
return false
}
}
func hasGB32960ParsedTelemetryField(env FrameEnvelope) bool {
for field := range env.ParsedFields {
if !strings.HasPrefix(field, "gb32960.") {
continue
}
if strings.HasPrefix(field, "gb32960.header.") ||
strings.HasPrefix(field, "gb32960.platform.") ||
strings.HasPrefix(field, "gb32960.identity.") ||
strings.HasPrefix(field, "gb32960.device_time.") {
continue
}
return true
}
return false
}
// RequiresVehicleIdentity keeps platform/control frames out of vehicle identity
// quality metrics while preserving JT808's phone-centric registration/auth flow.
func RequiresVehicleIdentity(env FrameEnvelope) bool {
if env.ParseStatus == ParseBadFrame {
return false
}
switch env.Protocol {
case ProtocolGB32960:
return IsRealtimeTelemetryFrame(env)
case ProtocolJT808:
return true
case ProtocolYutongMQTT:
return IsRealtimeTelemetryFrame(env)
default:
return false
}
}
func hasRealtimeField(env FrameEnvelope) bool {
return env.Fields[FieldLatitude] != nil ||
if env.Fields[FieldLatitude] != nil ||
env.Fields[FieldLongitude] != nil ||
env.Fields[FieldTotalMileageKM] != nil ||
env.Fields[FieldSpeedKMH] != nil ||
env.Fields[FieldSOCPercent] != nil
env.Fields[FieldSOCPercent] != nil {
return true
}
for field := range env.ParsedFields {
if strings.HasSuffix(field, ".latitude") ||
strings.HasSuffix(field, ".longitude") ||
strings.HasSuffix(field, ".total_mileage_km") ||
strings.HasSuffix(field, ".speed_kmh") ||
strings.HasSuffix(field, ".soc_percent") {
return true
}
}
return false
}
func hasParsedFieldPrefix(env FrameEnvelope, prefix string) bool {
for field := range env.ParsedFields {
if strings.HasPrefix(field, prefix) {
return true
}
}
return false
}
func stringAny(value any) string {
@@ -0,0 +1,19 @@
package envelope
import "strings"
// NormalizeSourceEndpointKey returns the stable, low-cardinality source key used
// by identity lookup and statistics source tracking.
func NormalizeSourceEndpointKey(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(endpoint), "mqtt://") {
return "mqtt"
}
if host, _, ok := strings.Cut(endpoint, ":"); ok {
return strings.TrimSpace(host)
}
return endpoint
}
@@ -0,0 +1,31 @@
package envelope
import "time"
const (
MaxFutureEventSkew = 10 * time.Minute
MinPlausibleReceivedTimeMS = 1577836800000 // 2020-01-01T00:00:00Z
)
const (
EventTimeReasonEventTime = "event_time"
EventTimeReasonReceivedMissingEvent = "received_time_missing_event"
EventTimeReasonReceivedFutureEvent = "received_time_future_event"
)
func NormalizedEventTimeMS(env FrameEnvelope) (int64, bool) {
eventMS, _, ok := NormalizedEventTimeMSWithReason(env)
return eventMS, ok
}
func NormalizedEventTimeMSWithReason(env FrameEnvelope) (int64, string, bool) {
eventMS := env.EventTimeMS
receivedMS := env.ReceivedAtMS
if eventMS <= 0 {
return receivedMS, EventTimeReasonReceivedMissingEvent, receivedMS > 0
}
if receivedMS >= MinPlausibleReceivedTimeMS && eventMS > receivedMS+MaxFutureEventSkew.Milliseconds() {
return receivedMS, EventTimeReasonReceivedFutureEvent, true
}
return eventMS, EventTimeReasonEventTime, true
}
@@ -13,6 +13,7 @@ import (
type AsyncConfig struct {
QueueSize int
Workers int
EnqueueTimeout time.Duration
OperationTimeout time.Duration
OnError func(error)
Metrics *metrics.Registry
@@ -20,12 +21,14 @@ type AsyncConfig struct {
}
type AsyncSink struct {
delegate Sink
jobs chan asyncJob
timeout time.Duration
onError func(error)
metrics *metrics.Registry
name string
delegate Sink
jobs chan asyncJob
enqueueTimeout time.Duration
timeout time.Duration
onError func(error)
metrics *metrics.Registry
name string
queueWait *metrics.RecentLatencyByKey
closeOnce sync.Once
closed chan struct{}
@@ -34,11 +37,13 @@ type AsyncSink struct {
}
type asyncJob struct {
kind string
env envelope.FrameEnvelope
kind string
env envelope.FrameEnvelope
enqueuedAt time.Time
}
var ErrAsyncSinkClosed = errors.New("async sink is closed")
var ErrAsyncSinkEnqueueTimeout = errors.New("async sink enqueue timeout")
var asyncSinkPublishDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
@@ -52,6 +57,12 @@ func NewAsyncSink(delegate Sink, cfg AsyncConfig) *AsyncSink {
if cfg.Workers <= 0 {
cfg.Workers = 1
}
if cfg.EnqueueTimeout == 0 {
cfg.EnqueueTimeout = time.Second
}
if cfg.EnqueueTimeout < 0 {
cfg.EnqueueTimeout = 0
}
if cfg.OperationTimeout <= 0 {
cfg.OperationTimeout = 30 * time.Second
}
@@ -59,19 +70,23 @@ func NewAsyncSink(delegate Sink, cfg AsyncConfig) *AsyncSink {
cfg.Name = "async"
}
s := &AsyncSink{
delegate: delegate,
jobs: make(chan asyncJob, cfg.QueueSize),
timeout: cfg.OperationTimeout,
onError: cfg.OnError,
metrics: cfg.Metrics,
name: cfg.Name,
closed: make(chan struct{}),
done: make(chan struct{}),
delegate: delegate,
jobs: make(chan asyncJob, cfg.QueueSize),
enqueueTimeout: cfg.EnqueueTimeout,
timeout: cfg.OperationTimeout,
onError: cfg.OnError,
metrics: cfg.Metrics,
name: cfg.Name,
queueWait: metrics.NewRecentLatencyByKey(512),
closed: make(chan struct{}),
done: make(chan struct{}),
}
s.wg.Add(cfg.Workers)
for i := 0; i < cfg.Workers; i++ {
go s.worker()
}
s.recordQueueCapacity()
s.recordWorkers("default", cfg.Workers)
go func() {
s.wg.Wait()
close(s.done)
@@ -94,7 +109,6 @@ func (s *AsyncSink) PublishFields(ctx context.Context, env envelope.FrameEnvelop
func (s *AsyncSink) Close() error {
s.closeOnce.Do(func() {
close(s.closed)
close(s.jobs)
})
<-s.done
return s.delegate.Close()
@@ -107,6 +121,14 @@ func (s *AsyncSink) enqueue(ctx context.Context, job asyncJob) error {
return ErrAsyncSinkClosed
default:
}
var timeoutC <-chan time.Time
var timer *time.Timer
if s.enqueueTimeout > 0 {
timer = time.NewTimer(s.enqueueTimeout)
timeoutC = timer.C
defer timer.Stop()
}
job.enqueuedAt = time.Now()
select {
case s.jobs <- job:
s.recordEnqueue(job.kind, "queued")
@@ -119,37 +141,58 @@ func (s *AsyncSink) enqueue(ctx context.Context, job asyncJob) error {
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth()
return ctx.Err()
case <-timeoutC:
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth()
return ErrAsyncSinkEnqueueTimeout
}
}
func (s *AsyncSink) worker() {
defer s.wg.Done()
for job := range s.jobs {
s.recordQueueDepth()
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
started := time.Now()
var err error
switch job.kind {
case "raw":
err = s.delegate.PublishRaw(ctx, job.env)
case "unified":
err = s.delegate.PublishUnified(ctx, job.env)
case "fields":
err = s.delegate.PublishFields(ctx, job.env)
for {
select {
case job := <-s.jobs:
s.publishJob(job)
case <-s.closed:
for {
select {
case job := <-s.jobs:
s.publishJob(job)
default:
return
}
}
}
cancel()
status := "ok"
if err != nil {
status = "error"
}
s.recordPublish(job.kind, status, time.Since(started))
if err != nil && s.onError != nil {
s.onError(err)
}
s.recordQueueDepth()
}
}
func (s *AsyncSink) publishJob(job asyncJob) {
s.recordQueueDepth()
s.recordQueueWait("default", job)
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
started := time.Now()
var err error
switch job.kind {
case "raw":
err = s.delegate.PublishRaw(ctx, job.env)
case "unified":
err = s.delegate.PublishUnified(ctx, job.env)
case "fields":
err = s.delegate.PublishFields(ctx, job.env)
}
cancel()
status := "ok"
if err != nil {
status = "error"
}
s.recordPublish(job.kind, status, time.Since(started))
if err != nil && s.onError != nil {
s.onError(err)
}
s.recordQueueDepth()
}
func (s *AsyncSink) recordEnqueue(kind string, status string) {
if s.metrics == nil {
return
@@ -184,3 +227,38 @@ func (s *AsyncSink) recordQueueDepth() {
"sink": s.name,
}, float64(len(s.jobs)))
}
func (s *AsyncSink) recordQueueCapacity() {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_queue_capacity", metrics.Labels{
"sink": s.name,
}, float64(cap(s.jobs)))
}
func (s *AsyncSink) recordQueueWait(queueName string, job asyncJob) {
if s.metrics == nil || job.enqueuedAt.IsZero() {
return
}
elapsedMS := float64(time.Since(job.enqueuedAt)) / float64(time.Millisecond)
labels := metrics.Labels{
"sink": s.name,
"queue": queueName,
"kind": job.kind,
}
s.metrics.ObserveHistogram("vehicle_async_sink_queue_wait_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
p99, samples := s.queueWait.Observe(queueName+"\x00"+job.kind, elapsedMS)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_p99_ms", labels, p99)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_samples", labels, float64(samples))
}
func (s *AsyncSink) recordWorkers(queueName string, workers int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_workers", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(workers))
}
@@ -2,6 +2,7 @@ package eventbus
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -80,12 +81,17 @@ func TestAsyncSinkRecordsQueueMetrics(t *testing.T) {
for _, want := range []string{
`vehicle_async_sink_enqueue_total{kind="raw",sink="nats",status="queued"} 1`,
`vehicle_async_sink_enqueue_total{kind="fields",sink="nats",status="queued"} 1`,
`vehicle_async_sink_queue_capacity{sink="nats"} 2`,
`vehicle_async_sink_queue_depth{sink="nats"}`,
`vehicle_async_sink_workers{queue="default",sink="nats"} 1`,
`vehicle_async_sink_publish_total{kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_total{kind="fields",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_duration_ms_histogram_bucket{le="+Inf",kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_duration_ms_histogram_count{kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_duration_ms_histogram_sum{kind="raw",sink="nats",status="ok"}`,
`vehicle_async_sink_queue_wait_duration_ms_histogram_count{kind="raw",queue="default",sink="nats"} 1`,
`vehicle_async_sink_queue_wait_recent_p99_ms{kind="raw",queue="default",sink="nats"}`,
`vehicle_async_sink_queue_wait_recent_samples{kind="raw",queue="default",sink="nats"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("async sink metric missing %s:\n%s", want, text)
@@ -135,6 +141,145 @@ func TestAsyncSinkRecordsEnqueueTimeoutWhenQueueIsFull(t *testing.T) {
delegate.release()
}
func TestAsyncSinkEnqueueTimeoutDoesNotRequireCallerDeadline(t *testing.T) {
registry := metrics.NewRegistry()
delegate := newBlockingSink()
sink := NewAsyncSink(delegate, AsyncConfig{
QueueSize: 1,
Workers: 1,
EnqueueTimeout: 10 * time.Millisecond,
OperationTimeout: time.Second,
Metrics: registry,
Name: "nats",
})
defer sink.Close()
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("first PublishRaw() error = %v", err)
}
select {
case <-delegate.rawStarted:
case <-time.After(time.Second):
t.Fatal("delegate raw publish was not started")
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("second PublishFields() error = %v", err)
}
if err := sink.PublishUnified(context.Background(), env); !errors.Is(err, ErrAsyncSinkEnqueueTimeout) {
t.Fatalf("third PublishUnified() error = %v, want ErrAsyncSinkEnqueueTimeout", err)
}
text := registry.Render()
for _, want := range []string{
`vehicle_async_sink_enqueue_total{kind="unified",sink="nats",status="timeout"} 1`,
`vehicle_async_sink_queue_depth{sink="nats"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("async sink enqueue timeout metric missing %s:\n%s", want, text)
}
}
delegate.release()
}
func TestAsyncSinkCloseDoesNotPanicWithConcurrentBlockedEnqueue(t *testing.T) {
delegate := newBlockingSink()
sink := NewAsyncSink(delegate, AsyncConfig{
QueueSize: 1,
Workers: 1,
EnqueueTimeout: 20 * time.Millisecond,
OperationTimeout: time.Second,
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("first PublishRaw() error = %v", err)
}
select {
case <-delegate.rawStarted:
case <-time.After(time.Second):
t.Fatal("delegate raw publish was not started")
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("second PublishFields() error = %v", err)
}
publishErr := make(chan error, 1)
go func() {
defer func() {
if recovered := recover(); recovered != nil {
publishErr <- errors.New("publish panicked")
}
}()
publishErr <- sink.PublishUnified(context.Background(), env)
}()
closeErr := make(chan error, 1)
go func() {
closeErr <- sink.Close()
}()
if err := <-publishErr; !errors.Is(err, ErrAsyncSinkEnqueueTimeout) && !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("concurrent PublishUnified() error = %v, want timeout or closed", err)
}
delegate.release()
if err := <-closeErr; err != nil {
t.Fatalf("Close() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), env); !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("PublishRaw() after Close error = %v, want ErrAsyncSinkClosed", err)
}
}
func TestAsyncSinkCloseUnblocksPublishWhenEnqueueTimeoutIsDisabled(t *testing.T) {
delegate := newBlockingSink()
sink := NewAsyncSink(delegate, AsyncConfig{
QueueSize: 1,
Workers: 1,
EnqueueTimeout: -1,
OperationTimeout: time.Second,
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("first PublishRaw() error = %v", err)
}
select {
case <-delegate.rawStarted:
case <-time.After(time.Second):
t.Fatal("delegate raw publish was not started")
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("second PublishFields() error = %v", err)
}
publishErr := make(chan error, 1)
go func() {
publishErr <- sink.PublishUnified(context.Background(), env)
}()
closeErr := make(chan error, 1)
go func() {
closeErr <- sink.Close()
}()
select {
case err := <-publishErr:
if !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("blocked PublishUnified() error = %v, want ErrAsyncSinkClosed", err)
}
case <-time.After(time.Second):
t.Fatal("blocked PublishUnified() was not released by Close")
}
delegate.release()
select {
case err := <-closeErr:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close() did not finish after delegate release")
}
}
type blockingSink struct {
rawStarted chan struct{}
releaseRaw chan struct{}
@@ -0,0 +1,348 @@
package eventbus
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type DurableOutboxConfig struct {
Directory string
ReplayBatchSize int
SyncWrites bool
CloseTimeout time.Duration
WALSegmentBytes int64
WALSegmentAge time.Duration
WALAppendQueue int
WALCommitBatch int
WALCommitWait time.Duration
Metrics *metrics.Registry
Name string
OnError func(error)
}
type asyncRecordPublishingSink interface {
Sink
ValidateRecord(durableRecord) error
PublishRecordAsync(durableRecord, func(error)) error
}
// DurableOutboxSink accepts a record only after its WAL commit is durable.
// Publishing is asynchronous; the WAL record remains replayable until the
// broker returns PubAck. Stable event IDs make crash-window replays idempotent.
type DurableOutboxSink struct {
delegate asyncRecordPublishingSink
store *durableOutboxWAL
replayBatchSize int
closeTimeout time.Duration
metrics *metrics.Registry
name string
onError func(error)
acceptMu sync.RWMutex
mu sync.Mutex
closed bool
inflight map[outboxWALRecordRef]struct{}
pending sync.WaitGroup
closeOne sync.Once
closeErr error
}
var ErrDurableOutboxClosed = errors.New("durable outbox is closed")
func NewDurableOutboxSink(delegate Sink, cfg DurableOutboxConfig) (*DurableOutboxSink, error) {
publisher, ok := delegate.(asyncRecordPublishingSink)
if !ok || publisher == nil {
return nil, errors.New("durable outbox delegate must support async record publishing")
}
dir := strings.TrimSpace(cfg.Directory)
if dir == "" {
return nil, errors.New("durable outbox directory is required")
}
if cfg.ReplayBatchSize <= 0 {
cfg.ReplayBatchSize = 1000
}
if cfg.CloseTimeout <= 0 {
cfg.CloseTimeout = 5 * time.Second
}
store, err := newDurableOutboxWAL(durableOutboxWALConfig{
Directory: dir,
SyncWrites: cfg.SyncWrites,
SegmentBytes: cfg.WALSegmentBytes,
SegmentAge: cfg.WALSegmentAge,
AppendQueue: cfg.WALAppendQueue,
CommitBatch: cfg.WALCommitBatch,
CommitInterval: cfg.WALCommitWait,
})
if err != nil {
return nil, err
}
s := &DurableOutboxSink{
delegate: publisher,
store: store,
replayBatchSize: cfg.ReplayBatchSize,
closeTimeout: cfg.CloseTimeout,
metrics: cfg.Metrics,
name: durableMetricName(cfg.Name),
onError: cfg.OnError,
inflight: map[outboxWALRecordRef]struct{}{},
}
s.recordBacklog()
return s, nil
}
func (s *DurableOutboxSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
return s.persistAndSubmit(ctx, "raw", env)
}
func (s *DurableOutboxSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
return s.persistAndSubmit(ctx, "unified", env)
}
func (s *DurableOutboxSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
return s.persistAndSubmit(ctx, "fields", env)
}
func (s *DurableOutboxSink) Close() error {
s.closeOne.Do(func() {
s.acceptMu.Lock()
s.mu.Lock()
s.closed = true
s.mu.Unlock()
s.acceptMu.Unlock()
walErr := s.store.Close()
done := make(chan struct{})
go func() {
s.pending.Wait()
close(done)
}()
var waitErr error
select {
case <-done:
case <-time.After(s.closeTimeout):
waitErr = fmt.Errorf("durable outbox close timed out after %s", s.closeTimeout)
s.recordPublish("all", "close_timeout")
}
s.closeErr = errors.Join(walErr, waitErr, s.delegate.Close())
})
return s.closeErr
}
// ReplayOnce claims at most one configured batch. A bounded claim prevents a
// large recovered backlog from creating an unbounded number of PubAck futures.
func (s *DurableOutboxSink) ReplayOnce(ctx context.Context) error {
s.acceptMu.RLock()
defer s.acceptMu.RUnlock()
if s.isClosed() {
return ErrDurableOutboxClosed
}
return s.replay(ctx, s.replayBatchSize)
}
func (s *DurableOutboxSink) ReplayLoop(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Second
}
if err := s.ReplayOnce(ctx); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
s.reportError(err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.ReplayOnce(ctx); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
s.reportError(err)
}
}
}
}
func (s *DurableOutboxSink) persistAndSubmit(ctx context.Context, kind string, env envelope.FrameEnvelope) error {
s.acceptMu.RLock()
defer s.acceptMu.RUnlock()
if s.isClosed() {
return ErrDurableOutboxClosed
}
record := durableRecord{Kind: kind, Envelope: normalizeDurableEnvelope(env)}
if err := s.delegate.ValidateRecord(record); err != nil {
s.recordPublish(kind, "validation_error")
return err
}
stored, err := s.store.Append(ctx, record)
if err != nil {
s.recordSpool(kind, "error")
return err
}
s.recordSpool(kind, "ok")
s.recordBacklog()
if err := s.submit(stored); err != nil {
// The durable commit is the device-facing acceptance boundary. Broker
// submission errors are surfaced operationally and recovered by replay.
s.reportError(err)
}
return nil
}
func normalizeDurableEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope {
if env.EventID == "" {
env.EventID = env.StableEventID()
}
if env.ParseStatus == "" {
env.ParseStatus = envelope.ParseOK
}
return env
}
func (s *DurableOutboxSink) submit(stored storedOutboxRecord) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
s.store.Release(stored.Ref)
return ErrDurableOutboxClosed
}
if _, exists := s.inflight[stored.Ref]; exists {
s.mu.Unlock()
return nil
}
s.inflight[stored.Ref] = struct{}{}
s.pending.Add(1)
inflight := len(s.inflight)
s.mu.Unlock()
s.recordInflight(inflight)
err := s.delegate.PublishRecordAsync(stored.Record, func(publishErr error) {
s.complete(stored, publishErr)
})
if err == nil {
s.recordPublish(stored.Record.Kind, "submitted")
return nil
}
s.mu.Lock()
delete(s.inflight, stored.Ref)
inflight = len(s.inflight)
s.mu.Unlock()
s.pending.Done()
s.store.Release(stored.Ref)
s.recordInflight(inflight)
s.recordPublish(stored.Record.Kind, "submit_error")
return fmt.Errorf("submit durable outbox record: %w", err)
}
func (s *DurableOutboxSink) complete(stored storedOutboxRecord, publishErr error) {
defer s.pending.Done()
status := "acked"
if publishErr != nil {
status = "ack_error"
s.store.Release(stored.Ref)
} else if err := s.store.Ack(stored.Ref); err != nil {
publishErr = err
status = "remove_error"
}
s.mu.Lock()
delete(s.inflight, stored.Ref)
inflight := len(s.inflight)
s.mu.Unlock()
s.recordInflight(inflight)
s.recordBacklog()
s.recordPublish(stored.Record.Kind, status)
if publishErr != nil {
s.reportError(publishErr)
}
}
func (s *DurableOutboxSink) replay(ctx context.Context, limit int) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
records, err := s.store.ClaimPending(limit)
if err != nil {
return fmt.Errorf("claim durable outbox records: %w", err)
}
for index, stored := range records {
if ctx != nil && ctx.Err() != nil {
for _, remaining := range records[index:] {
s.store.Release(remaining.Ref)
}
return ctx.Err()
}
if err := s.delegate.ValidateRecord(stored.Record); err != nil {
s.store.Release(stored.Ref)
for _, remaining := range records[index+1:] {
s.store.Release(remaining.Ref)
}
s.recordPublish(stored.Record.Kind, "validation_error")
return fmt.Errorf("validate durable outbox replay record: %w", err)
}
if err := s.submit(stored); err != nil && !errors.Is(err, ErrDurableOutboxClosed) {
s.reportError(err)
}
}
return nil
}
func (s *DurableOutboxSink) isClosed() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
func (s *DurableOutboxSink) reportError(err error) {
if err != nil && s.onError != nil {
s.onError(err)
}
}
func (s *DurableOutboxSink) recordSpool(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_spool_records_total", metrics.Labels{
"name": s.name, "kind": kind, "status": status,
})
}
func (s *DurableOutboxSink) recordPublish(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_outbox_publish_total", metrics.Labels{
"name": s.name, "kind": kind, "status": status,
})
}
func (s *DurableOutboxSink) recordInflight(value int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_durable_outbox_inflight", metrics.Labels{"name": s.name}, float64(value))
}
func (s *DurableOutboxSink) recordBacklog() {
if s.metrics == nil {
return
}
count, oldest := s.store.Stats()
labels := metrics.Labels{"name": s.name}
// Keep the legacy gauge during migration because capacity gates already
// consume it; its value now represents durable WAL records, not files.
s.metrics.SetGauge("vehicle_durable_spool_backlog_files", labels, float64(count))
s.metrics.SetGauge("vehicle_durable_outbox_backlog_records", labels, float64(count))
ageSeconds := 0.0
if count > 0 && !oldest.IsZero() {
ageSeconds = time.Since(oldest).Seconds()
if ageSeconds < 0 {
ageSeconds = 0
}
}
s.metrics.SetGauge("vehicle_durable_spool_oldest_age_seconds", labels, ageSeconds)
}
@@ -0,0 +1,380 @@
package eventbus
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestDurableOutboxPersistsBeforeAsyncSubmitAndDeletesAfterAck(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
var sink *DurableOutboxSink
delegate.onSubmit = func(record durableRecord) {
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("durable backlog visible at submit = %d, want 1", got)
}
if record.Envelope.EventID == "" {
t.Fatal("submitted record must have a stable event id")
}
}
var err error
sink, err = NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog before ack = %d, want 1", got)
}
delegate.completeNext(t, nil)
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("backlog after ack = %d, want 0", got)
}
}
func TestDurableOutboxImmediateSubmitErrorKeepsAcceptedRecord(t *testing.T) {
dir := t.TempDir()
submitErr := errors.New("nats pending limit")
delegate := &outboxAsyncSink{submitErrors: []error{submitErr}}
var reported error
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
Directory: dir,
OnError: func(err error) {
reported = err
},
})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("accepted durable record should hide submit error, got %v", err)
}
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog after submit error = %d, want 1", got)
}
if reported == nil || !strings.Contains(reported.Error(), submitErr.Error()) {
t.Fatalf("reported error = %v", reported)
}
}
func TestDurableOutboxAckErrorIsRetriedAndStableRecordIsRemoved(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
delegate.completeNext(t, errors.New("ack timeout"))
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog after ack error = %d, want 1", got)
}
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if got := delegate.recordCount(); got != 2 {
t.Fatalf("async submissions = %d, want 2", got)
}
first, second := delegate.recordAt(0), delegate.recordAt(1)
if first.Envelope.EventID == "" || first.Envelope.EventID != second.Envelope.EventID {
t.Fatalf("replay event ids = %q and %q", first.Envelope.EventID, second.Envelope.EventID)
}
delegate.completeNext(t, nil)
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("backlog after replay ack = %d, want 0", got)
}
}
func TestDurableOutboxReplaysPreexistingRecordAfterRestart(t *testing.T) {
dir := t.TempDir()
first := &outboxAsyncSink{submitErrors: []error{errors.New("nats unavailable")}}
writer, err := NewDurableOutboxSink(first, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("create first outbox: %v", err)
}
if err := writer.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("persist restart record: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close first outbox: %v", err)
}
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir, ReplayBatchSize: 10})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if got := delegate.recordCount(); got != 1 {
t.Fatalf("replayed records = %d, want 1", got)
}
delegate.completeNext(t, nil)
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("backlog after replay ack = %d, want 0", got)
}
}
func TestDurableOutboxDoesNotResubmitInflightRecord(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if got := delegate.recordCount(); got != 1 {
t.Fatalf("submissions while inflight = %d, want 1", got)
}
delegate.completeNext(t, nil)
}
func TestDurableOutboxRejectsInvalidRecordBeforePersistence(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{validateErr: errors.New("subject not configured")}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{Directory: dir})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
err = sink.PublishRaw(context.Background(), durableTestEnvelope())
if err == nil || !strings.Contains(err.Error(), "subject not configured") {
t.Fatalf("PublishRaw() error = %v", err)
}
if got := outboxBacklog(sink); got != 0 {
t.Fatalf("invalid record backlog = %d, want 0", got)
}
if got := delegate.recordCount(); got != 0 {
t.Fatalf("invalid record submissions = %d, want 0", got)
}
}
func TestDurableOutboxCloseWaitsForPendingAck(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
Directory: dir,
CloseTimeout: time.Second,
})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
done := make(chan error, 1)
go func() { done <- sink.Close() }()
select {
case err := <-done:
t.Fatalf("Close() returned before ack: %v", err)
case <-time.After(20 * time.Millisecond):
}
delegate.completeNext(t, nil)
select {
case err := <-done:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close() did not return after ack")
}
}
func TestDurableOutboxCloseTimeoutRetainsRecord(t *testing.T) {
dir := t.TempDir()
delegate := &outboxAsyncSink{}
sink, err := NewDurableOutboxSink(delegate, DurableOutboxConfig{
Directory: dir,
CloseTimeout: 10 * time.Millisecond,
})
if err != nil {
t.Fatalf("NewDurableOutboxSink() error = %v", err)
}
if err := sink.PublishRaw(context.Background(), durableTestEnvelope()); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
err = sink.Close()
if err == nil || !strings.Contains(err.Error(), "close timed out") {
t.Fatalf("Close() error = %v", err)
}
if got := outboxBacklog(sink); got != 1 {
t.Fatalf("backlog after close timeout = %d, want 1", got)
}
delegate.completeNext(t, errors.New("connection closed"))
}
func BenchmarkDurableOutboxPublishRaw(b *testing.B) {
for _, syncWrites := range []bool{false, true} {
name := "fsync_off"
if syncWrites {
name = "fsync_on"
}
b.Run(name, func(b *testing.B) {
sink, err := NewDurableOutboxSink(autoAckOutboxSink{}, DurableOutboxConfig{
Directory: b.TempDir(),
SyncWrites: syncWrites,
})
if err != nil {
b.Fatalf("NewDurableOutboxSink() error = %v", err)
}
var sequence atomic.Uint32
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
env := durableTestEnvelope()
env.EventID = ""
env.Sequence = uint16(sequence.Add(1))
if err := sink.PublishRaw(context.Background(), env); err != nil {
b.Fatalf("PublishRaw() error = %v", err)
}
}
})
b.StopTimer()
if err := sink.Close(); err != nil {
b.Fatalf("Close() error = %v", err)
}
})
}
}
func outboxBacklog(sink *DurableOutboxSink) int {
count, _ := sink.store.Stats()
return count
}
type outboxAsyncSink struct {
mu sync.Mutex
validateErr error
submitErrors []error
records []durableRecord
callbacks []func(error)
onSubmit func(durableRecord)
closeCalls int
}
func (s *outboxAsyncSink) ValidateRecord(record durableRecord) error {
if s.validateErr != nil {
return s.validateErr
}
switch record.Kind {
case "raw", "unified", "fields":
return nil
default:
return errUnknownRecordKind(record.Kind)
}
}
func (s *outboxAsyncSink) PublishRecordAsync(record durableRecord, complete func(error)) error {
if s.onSubmit != nil {
s.onSubmit(record)
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.submitErrors) > 0 {
err := s.submitErrors[0]
s.submitErrors = s.submitErrors[1:]
if err != nil {
return err
}
}
s.records = append(s.records, record)
s.callbacks = append(s.callbacks, complete)
return nil
}
func (s *outboxAsyncSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *outboxAsyncSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *outboxAsyncSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *outboxAsyncSink) Close() error {
s.mu.Lock()
s.closeCalls++
s.mu.Unlock()
return nil
}
func (s *outboxAsyncSink) completeNext(t *testing.T, err error) {
t.Helper()
s.mu.Lock()
if len(s.callbacks) == 0 {
s.mu.Unlock()
t.Fatal("no pending async callback")
}
callback := s.callbacks[0]
s.callbacks = s.callbacks[1:]
s.mu.Unlock()
callback(err)
}
func (s *outboxAsyncSink) recordCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.records)
}
func (s *outboxAsyncSink) recordAt(index int) durableRecord {
s.mu.Lock()
defer s.mu.Unlock()
return s.records[index]
}
type autoAckOutboxSink struct{}
func (autoAckOutboxSink) ValidateRecord(record durableRecord) error {
switch record.Kind {
case "raw", "unified", "fields":
return nil
default:
return errUnknownRecordKind(record.Kind)
}
}
func (autoAckOutboxSink) PublishRecordAsync(_ durableRecord, complete func(error)) error {
complete(nil)
return nil
}
func (autoAckOutboxSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (autoAckOutboxSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (autoAckOutboxSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (autoAckOutboxSink) Close() error { return nil }
@@ -0,0 +1,804 @@
package eventbus
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
outboxWALMagic = uint32(0x4c4e5731) // LNW1
outboxWALHeaderSize = 12
outboxWALMaxRecordBytes = 16 << 20
defaultWALSegmentBytes = 16 << 20
defaultWALAppendQueue = 100_000
defaultWALCommitBatch = 256
defaultWALCommitInterval = time.Millisecond
defaultWALSegmentAge = 5 * time.Second
)
var ErrDurableOutboxWALClosed = errors.New("durable outbox wal is closed")
type durableOutboxWALConfig struct {
Directory string
SyncWrites bool
SegmentBytes int64
SegmentAge time.Duration
AppendQueue int
CommitBatch int
CommitInterval time.Duration
}
type outboxWALRecordRef struct {
segmentID uint64
index int
}
type storedOutboxRecord struct {
Ref outboxWALRecordRef
Record durableRecord
}
type outboxWALRecordStatus uint8
const (
outboxWALPending outboxWALRecordStatus = iota
outboxWALClaimed
outboxWALAcknowledged
)
type outboxWALRecordState struct {
payloadOffset int64
payloadLength uint32
status outboxWALRecordStatus
}
type outboxWALSegment struct {
id uint64
path string
createdAt time.Time
size int64
closed bool
deleting bool
acked int
records []*outboxWALRecordState
}
type outboxWALAppendRequest struct {
record durableRecord
payload []byte
frame []byte
result chan outboxWALAppendResult
}
type outboxWALAppendResult struct {
stored storedOutboxRecord
err error
}
type durableOutboxWAL struct {
dir string
syncWrites bool
segmentBytes int64
segmentAge time.Duration
commitBatch int
commitInterval time.Duration
mu sync.Mutex
segments []*outboxWALSegment
segmentByID map[uint64]*outboxWALSegment
current *outboxWALSegment
currentFile *os.File
backlog int
fatalErr error
appendMu sync.RWMutex
closed bool
queue chan outboxWALAppendRequest
writerWG sync.WaitGroup
closeOne sync.Once
}
func newDurableOutboxWAL(cfg durableOutboxWALConfig) (*durableOutboxWAL, error) {
dir := strings.TrimSpace(cfg.Directory)
if dir == "" {
return nil, errors.New("durable outbox wal directory is required")
}
if cfg.SegmentBytes <= 0 {
cfg.SegmentBytes = defaultWALSegmentBytes
}
if cfg.SegmentAge <= 0 {
cfg.SegmentAge = defaultWALSegmentAge
}
if cfg.AppendQueue <= 0 {
cfg.AppendQueue = defaultWALAppendQueue
}
if cfg.CommitBatch <= 0 {
cfg.CommitBatch = defaultWALCommitBatch
}
if cfg.CommitInterval <= 0 {
cfg.CommitInterval = defaultWALCommitInterval
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create durable outbox wal directory: %w", err)
}
w := &durableOutboxWAL{
dir: dir,
syncWrites: cfg.SyncWrites,
segmentBytes: cfg.SegmentBytes,
segmentAge: cfg.SegmentAge,
commitBatch: cfg.CommitBatch,
commitInterval: cfg.CommitInterval,
segmentByID: map[uint64]*outboxWALSegment{},
queue: make(chan outboxWALAppendRequest, cfg.AppendQueue),
}
if err := w.loadSegments(); err != nil {
return nil, err
}
if err := w.createCurrentSegment(); err != nil {
return nil, err
}
w.writerWG.Add(1)
go w.appendLoop()
return w, nil
}
func (w *durableOutboxWAL) Append(ctx context.Context, record durableRecord) (storedOutboxRecord, error) {
payload, err := json.Marshal(record)
if err != nil {
return storedOutboxRecord{}, fmt.Errorf("marshal durable outbox wal record: %w", err)
}
if len(payload) > outboxWALMaxRecordBytes {
return storedOutboxRecord{}, fmt.Errorf("durable outbox wal record is %d bytes, max %d", len(payload), outboxWALMaxRecordBytes)
}
request := outboxWALAppendRequest{
record: record,
payload: payload,
frame: encodeOutboxWALFrame(payload),
result: make(chan outboxWALAppendResult, 1),
}
if ctx == nil {
ctx = context.Background()
}
w.appendMu.RLock()
if w.closed {
w.appendMu.RUnlock()
return storedOutboxRecord{}, ErrDurableOutboxWALClosed
}
select {
case w.queue <- request:
w.appendMu.RUnlock()
case <-ctx.Done():
w.appendMu.RUnlock()
return storedOutboxRecord{}, ctx.Err()
}
// Once admitted to the WAL queue, wait for the durability result even if
// the caller context is cancelled. Otherwise a committed record could be
// left claimed with no publisher responsible for it.
result := <-request.result
return result.stored, result.err
}
func (w *durableOutboxWAL) ClaimPending(limit int) ([]storedOutboxRecord, error) {
if limit <= 0 {
limit = defaultWALCommitBatch
}
w.mu.Lock()
refs := make([]outboxWALRecordRef, 0, limit)
for _, segment := range w.segments {
for index, state := range segment.records {
if state.status != outboxWALPending {
continue
}
state.status = outboxWALClaimed
refs = append(refs, outboxWALRecordRef{segmentID: segment.id, index: index})
if len(refs) >= limit {
break
}
}
if len(refs) >= limit {
break
}
}
w.mu.Unlock()
if len(refs) == 0 {
return nil, nil
}
records, err := w.readClaimedRecords(refs)
if err != nil {
for _, ref := range refs {
w.Release(ref)
}
return nil, err
}
return records, nil
}
func (w *durableOutboxWAL) Release(ref outboxWALRecordRef) {
w.mu.Lock()
defer w.mu.Unlock()
state := w.recordStateLocked(ref)
if state != nil && state.status == outboxWALClaimed {
state.status = outboxWALPending
}
}
func (w *durableOutboxWAL) Ack(ref outboxWALRecordRef) error {
w.mu.Lock()
segment := w.segmentByID[ref.segmentID]
if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
w.mu.Unlock()
return nil
}
state := segment.records[ref.index]
if state.status == outboxWALAcknowledged {
w.mu.Unlock()
return nil
}
state.status = outboxWALAcknowledged
segment.acked++
if w.backlog > 0 {
w.backlog--
}
shouldDelete := segment.closed && segment.acked == len(segment.records) && !segment.deleting
if shouldDelete {
segment.deleting = true
}
w.mu.Unlock()
if !shouldDelete {
return nil
}
return w.deleteAcknowledgedSegment(segment)
}
func (w *durableOutboxWAL) Stats() (backlog int, oldest time.Time) {
w.mu.Lock()
defer w.mu.Unlock()
backlog = w.backlog
if backlog == 0 {
return backlog, time.Time{}
}
for _, segment := range w.segments {
if segment.acked < len(segment.records) {
return backlog, segment.createdAt
}
}
return backlog, time.Time{}
}
func (w *durableOutboxWAL) Close() error {
w.closeOne.Do(func() {
w.appendMu.Lock()
w.closed = true
close(w.queue)
w.appendMu.Unlock()
w.writerWG.Wait()
})
w.mu.Lock()
err := w.fatalErr
w.mu.Unlock()
return err
}
func (w *durableOutboxWAL) appendLoop() {
defer w.writerWG.Done()
maintenanceInterval := minDuration(w.segmentAge/2, time.Second)
if maintenanceInterval < 10*time.Millisecond {
maintenanceInterval = 10 * time.Millisecond
}
maintenance := time.NewTicker(maintenanceInterval)
defer maintenance.Stop()
for {
select {
case request, ok := <-w.queue:
if !ok {
w.finishWriter()
return
}
batch := w.collectAppendBatch(request)
w.commitAppendBatch(batch)
case <-maintenance.C:
if err := w.rotateIfAged(); err != nil {
w.setFatal(err)
}
}
}
}
func (w *durableOutboxWAL) collectAppendBatch(first outboxWALAppendRequest) []outboxWALAppendRequest {
batch := make([]outboxWALAppendRequest, 0, w.commitBatch)
batch = append(batch, first)
timer := time.NewTimer(w.commitInterval)
defer timer.Stop()
for len(batch) < w.commitBatch {
select {
case request, ok := <-w.queue:
if !ok {
return batch
}
batch = append(batch, request)
case <-timer.C:
return batch
}
}
return batch
}
func (w *durableOutboxWAL) commitAppendBatch(batch []outboxWALAppendRequest) {
if len(batch) == 0 {
return
}
w.mu.Lock()
fatalErr := w.fatalErr
w.mu.Unlock()
if fatalErr != nil {
w.completeAppendErrors(batch, fatalErr)
return
}
for len(batch) > 0 {
if err := w.rotateBeforeAppend(len(batch[0].frame)); err != nil {
w.setFatal(err)
w.completeAppendErrors(batch, err)
return
}
count := w.batchCountForCurrentSegment(batch)
if count <= 0 {
count = 1
}
group := batch[:count]
if err := w.commitAppendGroup(group); err != nil {
w.setFatal(err)
w.completeAppendErrors(batch, err)
return
}
batch = batch[count:]
}
}
func (w *durableOutboxWAL) commitAppendGroup(group []outboxWALAppendRequest) error {
start := w.current.size
totalBytes := 0
for _, request := range group {
totalBytes += len(request.frame)
}
buffer := make([]byte, 0, totalBytes)
for _, request := range group {
buffer = append(buffer, request.frame...)
}
written, err := w.currentFile.Write(buffer)
if err != nil || written != len(buffer) {
if err == nil {
err = io.ErrShortWrite
}
w.rollbackAppend(start)
return fmt.Errorf("append durable outbox wal: %w", err)
}
if w.syncWrites {
if err := w.currentFile.Sync(); err != nil {
w.rollbackAppend(start)
return fmt.Errorf("sync durable outbox wal: %w", err)
}
}
w.mu.Lock()
segment := w.current
offset := start
results := make([]outboxWALAppendResult, 0, len(group))
for _, request := range group {
state := &outboxWALRecordState{
payloadOffset: offset + outboxWALHeaderSize,
payloadLength: uint32(len(request.payload)),
status: outboxWALClaimed,
}
index := len(segment.records)
segment.records = append(segment.records, state)
w.backlog++
results = append(results, outboxWALAppendResult{stored: storedOutboxRecord{
Ref: outboxWALRecordRef{segmentID: segment.id, index: index},
Record: request.record,
}})
offset += int64(len(request.frame))
}
segment.size += int64(len(buffer))
w.mu.Unlock()
for index, request := range group {
request.result <- results[index]
}
return nil
}
func (w *durableOutboxWAL) rollbackAppend(size int64) {
if w.currentFile == nil {
return
}
_ = w.currentFile.Truncate(size)
if w.syncWrites {
_ = w.currentFile.Sync()
}
}
func (w *durableOutboxWAL) rotateBeforeAppend(frameBytes int) error {
if w.current == nil {
return w.createCurrentSegment()
}
if len(w.current.records) == 0 {
return nil
}
tooLarge := w.current.size+int64(frameBytes) > w.segmentBytes
tooOld := time.Since(w.current.createdAt) >= w.segmentAge
if !tooLarge && !tooOld {
return nil
}
return w.rotateCurrentSegment()
}
func (w *durableOutboxWAL) rotateIfAged() error {
w.mu.Lock()
current := w.current
shouldRotate := current != nil && len(current.records) > 0 && time.Since(current.createdAt) >= w.segmentAge
w.mu.Unlock()
if !shouldRotate {
return nil
}
return w.rotateCurrentSegment()
}
func (w *durableOutboxWAL) rotateCurrentSegment() error {
if w.currentFile == nil || w.current == nil {
return w.createCurrentSegment()
}
if w.syncWrites {
if err := w.currentFile.Sync(); err != nil {
return fmt.Errorf("sync closing durable outbox wal segment: %w", err)
}
}
if err := w.currentFile.Close(); err != nil {
return fmt.Errorf("close durable outbox wal segment: %w", err)
}
w.mu.Lock()
old := w.current
old.closed = true
w.current = nil
w.currentFile = nil
deleteOld := old.acked == len(old.records) && !old.deleting
if deleteOld {
old.deleting = true
}
w.mu.Unlock()
if deleteOld {
if err := w.deleteAcknowledgedSegment(old); err != nil {
return err
}
}
return w.createCurrentSegment()
}
func (w *durableOutboxWAL) createCurrentSegment() error {
w.mu.Lock()
lastID := uint64(0)
if len(w.segments) > 0 {
lastID = w.segments[len(w.segments)-1].id
}
w.mu.Unlock()
id := uint64(time.Now().UnixNano())
if id <= lastID {
id = lastID + 1
}
path := filepath.Join(w.dir, outboxWALFileName(id))
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|os.O_APPEND, 0o640)
if err != nil {
return fmt.Errorf("create durable outbox wal segment: %w", err)
}
if w.syncWrites {
if err := syncDirectory(w.dir); err != nil {
_ = file.Close()
return fmt.Errorf("sync durable outbox wal directory after create: %w", err)
}
}
segment := &outboxWALSegment{id: id, path: path, createdAt: time.Now()}
w.mu.Lock()
w.segments = append(w.segments, segment)
w.segmentByID[id] = segment
w.current = segment
w.currentFile = file
w.mu.Unlock()
return nil
}
func (w *durableOutboxWAL) finishWriter() {
if w.currentFile == nil || w.current == nil {
return
}
var finishErr error
if w.syncWrites {
finishErr = w.currentFile.Sync()
}
if closeErr := w.currentFile.Close(); finishErr == nil {
finishErr = closeErr
}
w.mu.Lock()
current := w.current
current.closed = true
w.current = nil
w.currentFile = nil
deleteCurrent := current.acked == len(current.records) && !current.deleting
if deleteCurrent {
current.deleting = true
}
w.mu.Unlock()
if deleteCurrent {
if err := w.deleteAcknowledgedSegment(current); finishErr == nil {
finishErr = err
}
}
if finishErr != nil {
w.setFatal(finishErr)
}
}
func (w *durableOutboxWAL) batchCountForCurrentSegment(batch []outboxWALAppendRequest) int {
remaining := w.segmentBytes - w.current.size
count := 0
for _, request := range batch {
if count > 0 && int64(len(request.frame)) > remaining {
break
}
remaining -= int64(len(request.frame))
count++
if remaining <= 0 {
break
}
}
return count
}
func (w *durableOutboxWAL) completeAppendErrors(batch []outboxWALAppendRequest, err error) {
for _, request := range batch {
request.result <- outboxWALAppendResult{err: err}
}
}
func (w *durableOutboxWAL) readClaimedRecords(refs []outboxWALRecordRef) ([]storedOutboxRecord, error) {
files := map[uint64]*os.File{}
defer func() {
for _, file := range files {
_ = file.Close()
}
}()
records := make([]storedOutboxRecord, 0, len(refs))
for _, ref := range refs {
w.mu.Lock()
segment := w.segmentByID[ref.segmentID]
state := w.recordStateLocked(ref)
w.mu.Unlock()
if segment == nil || state == nil {
return nil, fmt.Errorf("durable outbox wal record reference not found: segment=%d index=%d", ref.segmentID, ref.index)
}
file := files[segment.id]
if file == nil {
opened, err := os.Open(segment.path)
if err != nil {
return nil, fmt.Errorf("open durable outbox wal segment for replay: %w", err)
}
files[segment.id] = opened
file = opened
}
payload := make([]byte, state.payloadLength)
if _, err := file.ReadAt(payload, state.payloadOffset); err != nil {
return nil, fmt.Errorf("read durable outbox wal record: %w", err)
}
var record durableRecord
if err := json.Unmarshal(payload, &record); err != nil {
return nil, fmt.Errorf("decode durable outbox wal record: %w", err)
}
records = append(records, storedOutboxRecord{Ref: ref, Record: record})
}
return records, nil
}
func (w *durableOutboxWAL) loadSegments() error {
paths, err := filepath.Glob(filepath.Join(w.dir, "outbox-*.wal"))
if err != nil {
return fmt.Errorf("list durable outbox wal segments: %w", err)
}
sort.Strings(paths)
for _, path := range paths {
segment, err := loadOutboxWALSegment(path, w.syncWrites)
if err != nil {
return err
}
if len(segment.records) == 0 {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove empty durable outbox wal segment: %w", err)
}
if w.syncWrites {
if err := syncDirectory(w.dir); err != nil {
return fmt.Errorf("sync durable outbox wal directory after removing empty segment: %w", err)
}
}
continue
}
w.segments = append(w.segments, segment)
w.segmentByID[segment.id] = segment
w.backlog += len(segment.records)
}
return nil
}
func loadOutboxWALSegment(path string, syncWrites bool) (*outboxWALSegment, error) {
id, err := parseOutboxWALFileName(filepath.Base(path))
if err != nil {
return nil, err
}
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("open durable outbox wal segment: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat durable outbox wal segment: %w", err)
}
segment := &outboxWALSegment{
id: id,
path: path,
createdAt: info.ModTime(),
closed: true,
}
offset := int64(0)
header := make([]byte, outboxWALHeaderSize)
for offset < info.Size() {
n, readErr := file.ReadAt(header, offset)
if readErr != nil {
if readErr == io.EOF && n < outboxWALHeaderSize {
if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
return nil, err
}
break
}
return nil, fmt.Errorf("read durable outbox wal header at %d: %w", offset, readErr)
}
if binary.BigEndian.Uint32(header[0:4]) != outboxWALMagic {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid magic", path, offset)
}
length := binary.BigEndian.Uint32(header[4:8])
checksum := binary.BigEndian.Uint32(header[8:12])
if length == 0 || length > outboxWALMaxRecordBytes {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid length %d", path, offset, length)
}
frameEnd := offset + outboxWALHeaderSize + int64(length)
if frameEnd > info.Size() {
if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
return nil, err
}
break
}
payload := make([]byte, length)
if _, err := file.ReadAt(payload, offset+outboxWALHeaderSize); err != nil {
return nil, fmt.Errorf("read durable outbox wal payload at %d: %w", offset, err)
}
if crc32.ChecksumIEEE(payload) != checksum {
return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: checksum mismatch", path, offset)
}
segment.records = append(segment.records, &outboxWALRecordState{
payloadOffset: offset + outboxWALHeaderSize,
payloadLength: length,
status: outboxWALPending,
})
offset = frameEnd
}
segment.size = offset
return segment, nil
}
func truncateOutboxWALTail(file *os.File, size int64, syncWrites bool) error {
if err := file.Truncate(size); err != nil {
return fmt.Errorf("truncate incomplete durable outbox wal tail: %w", err)
}
if syncWrites {
if err := file.Sync(); err != nil {
return fmt.Errorf("sync truncated durable outbox wal tail: %w", err)
}
}
return nil
}
func (w *durableOutboxWAL) deleteAcknowledgedSegment(segment *outboxWALSegment) error {
err := os.Remove(segment.path)
if os.IsNotExist(err) {
err = nil
}
if err == nil && w.syncWrites {
err = syncDirectory(w.dir)
}
w.mu.Lock()
defer w.mu.Unlock()
if err != nil {
segment.deleting = false
return fmt.Errorf("delete acknowledged durable outbox wal segment: %w", err)
}
delete(w.segmentByID, segment.id)
for index, candidate := range w.segments {
if candidate != segment {
continue
}
w.segments = append(w.segments[:index], w.segments[index+1:]...)
break
}
return nil
}
func (w *durableOutboxWAL) recordStateLocked(ref outboxWALRecordRef) *outboxWALRecordState {
segment := w.segmentByID[ref.segmentID]
if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
return nil
}
return segment.records[ref.index]
}
func (w *durableOutboxWAL) setFatal(err error) {
if err == nil {
return
}
w.mu.Lock()
if w.fatalErr == nil {
w.fatalErr = err
}
w.mu.Unlock()
}
func encodeOutboxWALFrame(payload []byte) []byte {
frame := make([]byte, outboxWALHeaderSize+len(payload))
binary.BigEndian.PutUint32(frame[0:4], outboxWALMagic)
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
binary.BigEndian.PutUint32(frame[8:12], crc32.ChecksumIEEE(payload))
copy(frame[outboxWALHeaderSize:], payload)
return frame
}
func outboxWALFileName(id uint64) string {
return fmt.Sprintf("outbox-%020d.wal", id)
}
func parseOutboxWALFileName(name string) (uint64, error) {
if !strings.HasPrefix(name, "outbox-") || !strings.HasSuffix(name, ".wal") {
return 0, fmt.Errorf("invalid durable outbox wal file name %q", name)
}
value := strings.TrimSuffix(strings.TrimPrefix(name, "outbox-"), ".wal")
id, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse durable outbox wal file name %q: %w", name, err)
}
return id, nil
}
func syncDirectory(dir string) error {
handle, err := os.Open(dir)
if err != nil {
return err
}
syncErr := handle.Sync()
closeErr := handle.Close()
return errors.Join(syncErr, closeErr)
}
func minDuration(left, right time.Duration) time.Duration {
if left <= 0 {
return right
}
if right <= 0 || left < right {
return left
}
return right
}
@@ -0,0 +1,229 @@
package eventbus
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestDurableOutboxWALConcurrentGroupCommitRecoversEveryRecord(t *testing.T) {
dir := t.TempDir()
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{
Directory: dir,
SyncWrites: true,
CommitBatch: 64,
CommitInterval: 5 * time.Millisecond,
})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
const total = 256
refs := make(chan outboxWALRecordRef, total)
errs := make(chan error, total)
var workers sync.WaitGroup
for i := 0; i < total; i++ {
workers.Add(1)
go func(sequence int) {
defer workers.Done()
record := walTestRecord(sequence)
stored, appendErr := wal.Append(context.Background(), record)
if appendErr != nil {
errs <- appendErr
return
}
refs <- stored.Ref
}(i)
}
workers.Wait()
close(errs)
for appendErr := range errs {
t.Fatalf("concurrent append: %v", appendErr)
}
close(refs)
for ref := range refs {
wal.Release(ref)
}
if got, _ := wal.Stats(); got != total {
t.Fatalf("backlog before restart = %d, want %d", got, total)
}
if err := wal.Close(); err != nil {
t.Fatalf("close first WAL: %v", err)
}
recovered, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: dir, SyncWrites: true})
if err != nil {
t.Fatalf("reopen WAL: %v", err)
}
records, err := recovered.ClaimPending(total + 1)
if err != nil {
t.Fatalf("claim recovered records: %v", err)
}
if len(records) != total {
t.Fatalf("recovered records = %d, want %d", len(records), total)
}
seen := make(map[string]struct{}, total)
for _, record := range records {
seen[record.Record.Envelope.EventID] = struct{}{}
if err := recovered.Ack(record.Ref); err != nil {
t.Fatalf("ack recovered record: %v", err)
}
}
if len(seen) != total {
t.Fatalf("unique recovered event ids = %d, want %d", len(seen), total)
}
if got, _ := recovered.Stats(); got != 0 {
t.Fatalf("backlog after ack = %d, want 0", got)
}
if err := recovered.Close(); err != nil {
t.Fatalf("close recovered WAL: %v", err)
}
}
func TestDurableOutboxWALTruncatesIncompleteTrailingFrame(t *testing.T) {
dir := t.TempDir()
first := encodedWALTestFrame(t, 1)
second := encodedWALTestFrame(t, 2)
path := filepath.Join(dir, outboxWALFileName(1))
payload := append(append([]byte{}, first...), second[:len(second)/2]...)
if err := os.WriteFile(path, payload, 0o640); err != nil {
t.Fatalf("write incomplete WAL: %v", err)
}
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: dir, SyncWrites: true})
if err != nil {
t.Fatalf("recover incomplete WAL: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat recovered segment: %v", err)
}
if got, want := info.Size(), int64(len(first)); got != want {
t.Fatalf("truncated size = %d, want %d", got, want)
}
if got, _ := wal.Stats(); got != 1 {
t.Fatalf("recovered backlog = %d, want 1", got)
}
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
}
func TestDurableOutboxWALRejectsChecksumCorruption(t *testing.T) {
dir := t.TempDir()
frame := encodedWALTestFrame(t, 1)
frame[len(frame)-1] ^= 0xff
path := filepath.Join(dir, outboxWALFileName(1))
if err := os.WriteFile(path, frame, 0o640); err != nil {
t.Fatalf("write corrupt WAL: %v", err)
}
_, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: dir})
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("corrupt WAL error = %v", err)
}
}
func TestDurableOutboxWALAckDeletesClosedSegment(t *testing.T) {
dir := t.TempDir()
frameSize := int64(len(encodedWALTestFrame(t, 1)))
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{
Directory: dir,
SegmentBytes: frameSize,
CommitBatch: 1,
})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
first, err := wal.Append(context.Background(), walTestRecord(1))
if err != nil {
t.Fatalf("append first: %v", err)
}
firstPath := filepath.Join(dir, outboxWALFileName(first.Ref.segmentID))
second, err := wal.Append(context.Background(), walTestRecord(2))
if err != nil {
t.Fatalf("append second: %v", err)
}
if first.Ref.segmentID == second.Ref.segmentID {
t.Fatal("second append should rotate to a new segment")
}
if err := wal.Ack(first.Ref); err != nil {
t.Fatalf("ack first: %v", err)
}
if _, err := os.Stat(firstPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("closed acknowledged segment still exists: %v", err)
}
wal.Release(second.Ref)
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
}
func TestDurableOutboxWALClaimIsBoundedAndNotDuplicated(t *testing.T) {
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: t.TempDir(), CommitBatch: 1})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
for i := 0; i < 10; i++ {
stored, err := wal.Append(context.Background(), walTestRecord(i))
if err != nil {
t.Fatalf("append %d: %v", i, err)
}
wal.Release(stored.Ref)
}
first, err := wal.ClaimPending(3)
if err != nil || len(first) != 3 {
t.Fatalf("first claim = %d, error = %v", len(first), err)
}
second, err := wal.ClaimPending(3)
if err != nil || len(second) != 3 {
t.Fatalf("second claim = %d, error = %v", len(second), err)
}
claimed := map[outboxWALRecordRef]struct{}{}
for _, record := range append(first, second...) {
if _, duplicate := claimed[record.Ref]; duplicate {
t.Fatalf("record claimed twice: %#v", record.Ref)
}
claimed[record.Ref] = struct{}{}
wal.Release(record.Ref)
}
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
}
func TestDurableOutboxWALRejectsAppendAfterClose(t *testing.T) {
wal, err := newDurableOutboxWAL(durableOutboxWALConfig{Directory: t.TempDir()})
if err != nil {
t.Fatalf("new WAL: %v", err)
}
if err := wal.Close(); err != nil {
t.Fatalf("close WAL: %v", err)
}
_, err = wal.Append(context.Background(), walTestRecord(1))
if !errors.Is(err, ErrDurableOutboxWALClosed) {
t.Fatalf("append after close error = %v", err)
}
}
func walTestRecord(sequence int) durableRecord {
env := normalizeDurableEnvelope(durableTestEnvelope())
env.EventID = "wal-event-" + time.Unix(0, int64(sequence)+1).UTC().Format("150405.000000000")
env.Sequence = uint16(sequence)
return durableRecord{Kind: "raw", Envelope: env}
}
func encodedWALTestFrame(t *testing.T, sequence int) []byte {
t.Helper()
payload, err := json.Marshal(walTestRecord(sequence))
if err != nil {
t.Fatalf("marshal WAL test record: %v", err)
}
return encodeOutboxWALFrame(payload)
}
@@ -13,16 +13,21 @@ import (
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type DurableConfig struct {
Directory string
ReplayBatchSize int
Metrics *metrics.Registry
Name string
}
type DurableSink struct {
delegate Sink
dir string
metrics *metrics.Registry
name string
mu sync.Mutex
seq uint64
@@ -50,12 +55,16 @@ func NewDurableSink(delegate Sink, cfg DurableConfig) *DurableSink {
if delegate == nil {
panic("durable delegate sink must not be nil")
}
return &DurableSink{
s := &DurableSink{
delegate: delegate,
dir: strings.TrimSpace(cfg.Directory),
metrics: cfg.Metrics,
name: durableMetricName(cfg.Name),
rawPending: map[string]struct{}{},
replayBatchSize: cfg.ReplayBatchSize,
}
s.recordBacklogAfterReplay()
return s
}
func (s *DurableSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -96,16 +105,29 @@ func (s *DurableSink) ReplayOnce(ctx context.Context) error {
func (s *DurableSink) replay(ctx context.Context, limit int) error {
files, err := durableFiles(s.dir, limit)
if err != nil {
s.recordReplay("list_error", 0)
return err
}
s.recordBacklogAfterReplay()
records := make([]durableRecordFile, 0, len(files))
for _, file := range files {
record, err := readDurableRecord(file)
if err != nil {
return err
s.recordReplay("read_error", 1)
if quarantineErr := quarantineDurableFile(file); quarantineErr != nil {
s.recordReplay("quarantine_error", 1)
return fmt.Errorf("quarantine unreadable durable record %s: read error: %w; quarantine error: %v", file, err, quarantineErr)
}
s.recordReplay("quarantined", 1)
continue
}
records = append(records, durableRecordFile{path: file, record: record})
}
if len(records) == 0 {
s.recordReplay("empty", 0)
s.recordBacklogAfterReplay()
return nil
}
sortDurableRecords(records)
if publisher, ok := s.delegate.(recordPublishingSink); ok {
durableRecords := make([]durableRecord, 0, len(records))
@@ -113,29 +135,41 @@ func (s *DurableSink) replay(ctx context.Context, limit int) error {
durableRecords = append(durableRecords, item.record)
}
if err := publisher.PublishRecords(ctx, durableRecords); err != nil {
s.recordReplay("publish_error", len(records))
s.recordReplayRecords(records, "publish_error")
return err
}
for _, item := range records {
if err := os.Remove(item.path); err != nil {
s.recordReplay("delete_error", len(records))
return err
}
if item.record.Kind == "raw" {
s.clearRawPending(item.record.Envelope)
}
}
s.recordReplay("ok", len(records))
s.recordReplayRecords(records, "ok")
s.recordBacklogAfterReplay()
return nil
}
for _, item := range records {
if err := s.publishRecord(ctx, item.record); err != nil {
s.recordReplay("publish_error", len(records))
s.recordReplayRecord(item.record, "publish_error")
return err
}
if err := os.Remove(item.path); err != nil {
s.recordReplay("delete_error", len(records))
return err
}
if item.record.Kind == "raw" {
s.clearRawPending(item.record.Envelope)
}
s.recordReplayRecord(item.record, "ok")
}
s.recordReplay("ok", len(records))
s.recordBacklogAfterReplay()
return nil
}
@@ -210,7 +244,7 @@ func errUnknownRecordKind(kind string) error {
func (s *DurableSink) spool(kind string, env envelope.FrameEnvelope) error {
if s.dir == "" {
return fmt.Errorf("durable spool directory is empty")
return s.spoolError(kind, fmt.Errorf("durable spool directory is empty"))
}
if env.EventID == "" {
env.EventID = env.StableEventID()
@@ -219,19 +253,24 @@ func (s *DurableSink) spool(kind string, env envelope.FrameEnvelope) error {
env.ParseStatus = envelope.ParseOK
}
if err := os.MkdirAll(s.dir, 0o750); err != nil {
return err
return s.spoolError(kind, err)
}
payload, err := json.Marshal(durableRecord{Kind: kind, Envelope: env})
if err != nil {
return err
return s.spoolError(kind, err)
}
name := s.nextFileName(env, kind)
path := filepath.Join(s.dir, name)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o640); err != nil {
return err
return s.spoolError(kind, err)
}
return os.Rename(tmp, path)
if err := os.Rename(tmp, path); err != nil {
return s.spoolError(kind, err)
}
s.recordSpool(kind, "ok")
s.recordBacklogAfterReplay()
return nil
}
func (s *DurableSink) nextFileName(env envelope.FrameEnvelope, kind string) string {
@@ -273,6 +312,16 @@ func readDurableRecord(path string) (durableRecord, error) {
return record, json.Unmarshal(payload, &record)
}
func quarantineDurableFile(path string) error {
target := path + ".bad"
if _, err := os.Stat(target); err == nil {
target = fmt.Sprintf("%s.%d.bad", path, time.Now().UnixNano())
} else if !os.IsNotExist(err) {
return err
}
return os.Rename(path, target)
}
func durableFiles(dir string, limit int) ([]string, error) {
if limit > 0 {
handle, err := os.Open(dir)
@@ -330,3 +379,108 @@ func durableFilesFromReader(dir string, limit int, reader durableNameReader) ([]
func errorsIsEOF(err error) bool {
return err == io.EOF
}
func durableMetricName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "default"
}
return name
}
func (s *DurableSink) spoolError(kind string, err error) error {
s.recordSpool(kind, "error")
return err
}
func (s *DurableSink) recordSpool(kind string, status string) {
if s == nil || s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_spool_records_total", metrics.Labels{
"name": s.name,
"kind": kind,
"status": status,
})
}
func (s *DurableSink) recordReplay(status string, records int) {
if s == nil || s.metrics == nil {
return
}
labels := metrics.Labels{"name": s.name, "status": status}
s.metrics.IncCounter("vehicle_durable_spool_replay_total", labels)
if records > 0 {
s.metrics.AddCounter("vehicle_durable_spool_replay_records_total", metrics.Labels{
"name": s.name,
"kind": "all",
"status": status,
}, float64(records))
}
}
func (s *DurableSink) recordReplayRecords(records []durableRecordFile, status string) {
for _, item := range records {
s.recordReplayRecord(item.record, status)
}
}
func (s *DurableSink) recordReplayRecord(record durableRecord, status string) {
if s == nil || s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_durable_spool_replay_records_total", metrics.Labels{
"name": s.name,
"kind": record.Kind,
"status": status,
})
}
func (s *DurableSink) recordBacklogAfterReplay() {
if s == nil || s.metrics == nil {
return
}
if strings.TrimSpace(s.dir) == "" {
s.recordBacklog(0, 0)
return
}
files, oldestAge, err := durableBacklogStats(s.dir, time.Now())
if err != nil {
return
}
s.recordBacklog(files, oldestAge)
}
func (s *DurableSink) recordBacklog(files int, oldestAge time.Duration) {
if s == nil || s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_durable_spool_backlog_files", metrics.Labels{"name": s.name}, float64(files))
ageSeconds := 0.0
if files > 0 && oldestAge > 0 {
ageSeconds = oldestAge.Seconds()
}
s.metrics.SetGauge("vehicle_durable_spool_oldest_age_seconds", metrics.Labels{"name": s.name}, ageSeconds)
}
func durableBacklogStats(dir string, now time.Time) (count int, oldestAge time.Duration, err error) {
files, err := durableFiles(dir, 0)
if err != nil {
return 0, 0, err
}
for _, file := range files {
info, statErr := os.Stat(file)
if statErr != nil {
return 0, 0, statErr
}
age := now.Sub(info.ModTime())
if age < 0 {
age = 0
}
if count == 0 || age > oldestAge {
oldestAge = age
}
count++
}
return count, oldestAge, nil
}
@@ -8,8 +8,10 @@ import (
"os"
"path/filepath"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestDurableSinkSpoolsUnifiedWhenRawWasSpooled(t *testing.T) {
@@ -167,6 +169,156 @@ func TestDurableSinkReplayUsesBatchPublisherWhenAvailable(t *testing.T) {
}
}
func TestDurableSinkRecordsSpoolAndReplayMetrics(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
delegate := &scriptedSink{rawErrors: []error{errSpoolTest}}
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, Metrics: registry, Name: "nats"})
env := durableTestEnvelope()
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_records_total{kind="raw",name="nats",status="ok"} 1`,
`vehicle_durable_spool_backlog_files{name="nats"} 1`,
`vehicle_durable_spool_oldest_age_seconds{name="nats"}`,
} {
if !containsString(text, want) {
t.Fatalf("spool metric missing %s:\n%s", want, text)
}
}
delegate.rawErrors = nil
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
text = registry.Render()
for _, want := range []string{
`vehicle_durable_spool_replay_total{name="nats",status="ok"} 1`,
`vehicle_durable_spool_replay_records_total{kind="all",name="nats",status="ok"} 1`,
`vehicle_durable_spool_replay_records_total{kind="raw",name="nats",status="ok"} 1`,
`vehicle_durable_spool_backlog_files{name="nats"} 0`,
`vehicle_durable_spool_oldest_age_seconds{name="nats"} 0`,
} {
if !containsString(text, want) {
t.Fatalf("replay metric missing %s:\n%s", want, text)
}
}
}
func TestDurableSinkInitializesBacklogMetrics(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
_ = NewDurableSink(&scriptedSink{}, DurableConfig{Directory: dir, Metrics: registry, Name: "nats"})
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_backlog_files{name="nats"} 0`,
`vehicle_durable_spool_oldest_age_seconds{name="nats"} 0`,
} {
if !containsString(text, want) {
t.Fatalf("initial spool metric missing %s:\n%s", want, text)
}
}
}
func TestDurableSinkRecordsReplayPublishErrorMetrics(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
env := durableTestEnvelope()
writeDurableRecord(t, filepath.Join(dir, "0001-raw.json"), durableRecord{Kind: "raw", Envelope: env})
delegate := &scriptedSink{rawErrors: []error{errSpoolTest}}
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, Metrics: registry, Name: "kafka"})
if err := sink.ReplayOnce(context.Background()); err == nil {
t.Fatal("ReplayOnce() error = nil, want delegate publish error")
}
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_replay_total{name="kafka",status="publish_error"} 1`,
`vehicle_durable_spool_replay_records_total{kind="all",name="kafka",status="publish_error"} 1`,
`vehicle_durable_spool_replay_records_total{kind="raw",name="kafka",status="publish_error"} 1`,
`vehicle_durable_spool_backlog_files{name="kafka"} 1`,
} {
if !containsString(text, want) {
t.Fatalf("replay error metric missing %s:\n%s", want, text)
}
}
if files := spoolFiles(t, dir); len(files) != 1 {
t.Fatalf("failed replay should keep spool file, files=%#v", files)
}
}
func TestDurableBacklogStatsCountsAllFilesAndOldestAge(t *testing.T) {
dir := t.TempDir()
env := durableTestEnvelope()
now := time.Date(2026, 7, 12, 15, 0, 0, 0, time.UTC)
oldFile := filepath.Join(dir, "0001-raw.json")
newFile := filepath.Join(dir, "0002-fields.json")
writeDurableRecord(t, oldFile, durableRecord{Kind: "raw", Envelope: env})
writeDurableRecord(t, newFile, durableRecord{Kind: "fields", Envelope: env})
if err := os.Chtimes(oldFile, now.Add(-10*time.Minute), now.Add(-10*time.Minute)); err != nil {
t.Fatalf("chtimes old file: %v", err)
}
if err := os.Chtimes(newFile, now.Add(-30*time.Second), now.Add(-30*time.Second)); err != nil {
t.Fatalf("chtimes new file: %v", err)
}
count, oldestAge, err := durableBacklogStats(dir, now)
if err != nil {
t.Fatalf("durableBacklogStats() error = %v", err)
}
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
if oldestAge != 10*time.Minute {
t.Fatalf("oldestAge = %s, want 10m", oldestAge)
}
}
func TestDurableSinkQuarantinesBadRecordAndReplaysRemainingFiles(t *testing.T) {
dir := t.TempDir()
registry := metrics.NewRegistry()
env := durableTestEnvelope()
if err := os.WriteFile(filepath.Join(dir, "0001-bad.json"), []byte("{bad json"), 0o640); err != nil {
t.Fatalf("write bad durable record: %v", err)
}
writeDurableRecord(t, filepath.Join(dir, "0002-raw.json"), durableRecord{Kind: "raw", Envelope: env})
delegate := &scriptedSink{}
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, Metrics: registry, Name: "nats"})
if err := sink.ReplayOnce(context.Background()); err != nil {
t.Fatalf("ReplayOnce() error = %v", err)
}
if delegate.rawCalls != 1 {
t.Fatalf("raw calls = %d, want 1", delegate.rawCalls)
}
if files := spoolFiles(t, dir); len(files) != 0 {
t.Fatalf("normal spool files after replay = %#v, want none", files)
}
badFiles, err := filepath.Glob(filepath.Join(dir, "*.bad"))
if err != nil {
t.Fatalf("glob bad files: %v", err)
}
if len(badFiles) != 1 {
t.Fatalf("bad files = %#v, want one quarantined file", badFiles)
}
text := registry.Render()
for _, want := range []string{
`vehicle_durable_spool_replay_total{name="nats",status="read_error"} 1`,
`vehicle_durable_spool_replay_total{name="nats",status="quarantined"} 1`,
`vehicle_durable_spool_replay_total{name="nats",status="ok"} 1`,
`vehicle_durable_spool_replay_records_total{kind="raw",name="nats",status="ok"} 1`,
`vehicle_durable_spool_backlog_files{name="nats"} 0`,
} {
if !containsString(text, want) {
t.Fatalf("quarantine metric missing %s:\n%s", want, text)
}
}
}
func TestDurableFilesFromReaderStopsAfterLimitedJSONBatch(t *testing.T) {
reader := &fakeNameReader{
batches: [][]string{
@@ -0,0 +1,30 @@
package eventbus
import "github.com/segmentio/kafka-go"
// MessagesAfterCommittedPrefixes keeps every fetched message that is not
// covered by the highest committed offset for its topic partition. This is
// used when a batch partially succeeds: later poison or valid messages must
// remain in memory until the failed offset ahead of them is durably handled.
func MessagesAfterCommittedPrefixes(messages []kafka.Message, committed []kafka.Message) []kafka.Message {
type partitionKey struct {
topic string
partition int
}
highest := make(map[partitionKey]int64, len(committed))
for _, message := range committed {
key := partitionKey{topic: message.Topic, partition: message.Partition}
if offset, ok := highest[key]; !ok || message.Offset > offset {
highest[key] = message.Offset
}
}
remaining := make([]kafka.Message, 0, len(messages))
for _, message := range messages {
key := partitionKey{topic: message.Topic, partition: message.Partition}
if offset, ok := highest[key]; ok && message.Offset <= offset {
continue
}
remaining = append(remaining, message)
}
return remaining
}
@@ -0,0 +1,34 @@
package eventbus
import (
"testing"
"github.com/segmentio/kafka-go"
)
func TestMessagesAfterCommittedPrefixesKeepsPartitionGaps(t *testing.T) {
messages := []kafka.Message{
{Topic: "raw", Partition: 0, Offset: 10},
{Topic: "raw", Partition: 1, Offset: 20},
{Topic: "raw", Partition: 0, Offset: 11},
{Topic: "raw", Partition: 1, Offset: 21},
{Topic: "raw", Partition: 0, Offset: 12},
}
committed := []kafka.Message{
{Topic: "raw", Partition: 0, Offset: 10},
{Topic: "raw", Partition: 1, Offset: 21},
}
remaining := MessagesAfterCommittedPrefixes(messages, committed)
if len(remaining) != 2 || remaining[0].Partition != 0 || remaining[0].Offset != 11 || remaining[1].Offset != 12 {
t.Fatalf("remaining = %#v", remaining)
}
}
func TestMessagesAfterCommittedPrefixesWithoutCommitKeepsWholeBatch(t *testing.T) {
messages := []kafka.Message{{Topic: "raw", Partition: 0, Offset: 10}}
remaining := MessagesAfterCommittedPrefixes(messages, nil)
if len(remaining) != 1 || remaining[0].Offset != 10 {
t.Fatalf("remaining = %#v", remaining)
}
}
@@ -41,16 +41,38 @@ func NewKafkaSink(cfg KafkaConfig) (*KafkaSink, error) {
if len(cfg.Brokers) == 0 {
return nil, errors.New("kafka brokers are required")
}
if err := ValidateKafkaConfig(cfg); err != nil {
return nil, err
}
rawTopics, fieldsTopics := kafkaTopicMaps(cfg)
return newKafkaSinkWithWriter(&kafka.Writer{
Addr: kafka.TCP(cfg.Brokers...),
Balancer: &kafka.Hash{},
AllowAutoTopicCreation: false,
RequiredAcks: kafka.RequireAll,
Async: false,
}, cfg), nil
}, KafkaConfig{
RawTopics: rawTopics,
FieldsTopics: fieldsTopics,
UnifiedTopic: cfg.UnifiedTopic,
}), nil
}
func ValidateKafkaConfig(cfg KafkaConfig) error {
rawTopics, fieldsTopics := kafkaTopicMaps(cfg)
return topics.ValidateKafkaRawFields(protocolTopicLabels(rawTopics), protocolTopicLabels(fieldsTopics))
}
func newKafkaSinkWithWriter(writer kafkaWriter, cfg KafkaConfig) *KafkaSink {
rawTopics, fieldsTopics := kafkaTopicMaps(cfg)
unifiedTopic := cfg.UnifiedTopic
if unifiedTopic == "" {
unifiedTopic = topics.Unified
}
return &KafkaSink{writer: writer, rawTopics: rawTopics, fieldsTopics: fieldsTopics, unifiedTopic: unifiedTopic}
}
func kafkaTopicMaps(cfg KafkaConfig) (map[envelope.Protocol]string, map[envelope.Protocol]string) {
rawTopics := map[envelope.Protocol]string{
envelope.ProtocolGB32960: topics.RawGB32960,
envelope.ProtocolJT808: topics.RawJT808,
@@ -71,11 +93,15 @@ func newKafkaSinkWithWriter(writer kafkaWriter, cfg KafkaConfig) *KafkaSink {
fieldsTopics[protocol] = topic
}
}
unifiedTopic := cfg.UnifiedTopic
if unifiedTopic == "" {
unifiedTopic = topics.Unified
return rawTopics, fieldsTopics
}
func protocolTopicLabels(values map[envelope.Protocol]string) map[string]string {
out := make(map[string]string, len(values))
for protocol, topic := range values {
out[string(protocol)] = topic
}
return &KafkaSink{writer: writer, rawTopics: rawTopics, fieldsTopics: fieldsTopics, unifiedTopic: unifiedTopic}
return out
}
func (s *KafkaSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/segmentio/kafka-go"
@@ -112,6 +113,49 @@ func TestNewKafkaSinkUsesProductionDeliveryGuarantees(t *testing.T) {
}
}
func TestValidateKafkaConfigRejectsRawFieldsTopicOverlap(t *testing.T) {
err := ValidateKafkaConfig(KafkaConfig{
RawTopics: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.jt808.v1",
},
FieldsTopics: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.jt808.v1",
},
})
if err == nil {
t.Fatal("ValidateKafkaConfig() error = nil, want overlap rejection")
}
if !strings.Contains(err.Error(), "fields kafka topic") {
t.Fatalf("error = %q, want fields topic family hint", err)
}
}
func TestValidateKafkaConfigRejectsKnownProtocolTopicMismatch(t *testing.T) {
err := ValidateKafkaConfig(KafkaConfig{
RawTopics: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.gb32960.v1",
},
})
if err == nil {
t.Fatal("ValidateKafkaConfig() error = nil, want raw protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
err = ValidateKafkaConfig(KafkaConfig{
FieldsTopics: map[envelope.Protocol]string{
envelope.ProtocolYutongMQTT: "vehicle.fields.go.jt808.v1",
},
})
if err == nil {
t.Fatal("ValidateKafkaConfig() error = nil, want fields protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
}
type recordingWriter struct {
messages []kafka.Message
writeCalls int
+161 -22
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
@@ -13,16 +14,19 @@ import (
)
type NATSConfig struct {
URL string
Name string
RawSubjects map[envelope.Protocol]string
FieldsSubjects map[envelope.Protocol]string
UnifiedSubject string
URL string
Name string
RawSubjects map[envelope.Protocol]string
FieldsSubjects map[envelope.Protocol]string
UnifiedSubject string
AsyncMaxPending int
AsyncAckTimeout time.Duration
}
type NATSSink struct {
conn *nats.Conn
publisher natsPublisher
asyncPublisher natsAsyncPublisher
rawSubjects map[envelope.Protocol]string
fieldsSubjects map[envelope.Protocol]string
unifiedSubject string
@@ -34,10 +38,18 @@ type natsPublisher interface {
Publish(context.Context, string, []byte, ...NATSPublishOption) error
}
type natsAsyncPublisher interface {
PublishAsync(string, []byte, ...NATSPublishOption) (nats.PubAckFuture, error)
}
func NewNATSSink(cfg NATSConfig) (*NATSSink, error) {
if cfg.URL == "" {
return nil, errors.New("nats url is required")
}
if err := ValidateNATSConfig(cfg); err != nil {
return nil, err
}
rawSubjects, fieldsSubjects := natsSubjectMaps(cfg)
name := cfg.Name
if name == "" {
name = "lingniu-vehicle-gateway"
@@ -46,17 +58,58 @@ func NewNATSSink(cfg NATSConfig) (*NATSSink, error) {
if err != nil {
return nil, err
}
js, err := conn.JetStream()
var jsOptions []nats.JSOpt
if cfg.AsyncMaxPending > 0 {
jsOptions = append(jsOptions, nats.PublishAsyncMaxPending(cfg.AsyncMaxPending))
}
if cfg.AsyncAckTimeout > 0 {
jsOptions = append(jsOptions, nats.PublishAsyncTimeout(cfg.AsyncAckTimeout))
}
js, err := conn.JetStream(jsOptions...)
if err != nil {
conn.Close()
return nil, err
}
sink := newNATSSinkWithPublisher(natsJetStreamPublisher{js: js}, cfg)
publisher := natsJetStreamPublisher{js: js}
sink := newNATSSinkWithPublishers(publisher, publisher, NATSConfig{
RawSubjects: rawSubjects,
FieldsSubjects: fieldsSubjects,
UnifiedSubject: cfg.UnifiedSubject,
})
sink.conn = conn
return sink, nil
}
func ValidateNATSConfig(cfg NATSConfig) error {
rawSubjects, fieldsSubjects := natsSubjectMaps(cfg)
raw := protocolTopicLabels(rawSubjects)
fields := protocolTopicLabels(fieldsSubjects)
if err := topics.ValidateKnownRawFieldsProtocols(raw, fields, "nats subject"); err != nil {
return err
}
return topics.ValidateRawFieldsDisjoint(raw, fields, "nats subject")
}
func newNATSSinkWithPublisher(publisher natsPublisher, cfg NATSConfig) *NATSSink {
return newNATSSinkWithPublishers(publisher, nil, cfg)
}
func newNATSSinkWithPublishers(publisher natsPublisher, asyncPublisher natsAsyncPublisher, cfg NATSConfig) *NATSSink {
rawSubjects, fieldsSubjects := natsSubjectMaps(cfg)
unifiedSubject := cfg.UnifiedSubject
if unifiedSubject == "" {
unifiedSubject = topics.Unified
}
return &NATSSink{
publisher: publisher,
asyncPublisher: asyncPublisher,
rawSubjects: rawSubjects,
fieldsSubjects: fieldsSubjects,
unifiedSubject: unifiedSubject,
}
}
func natsSubjectMaps(cfg NATSConfig) (map[envelope.Protocol]string, map[envelope.Protocol]string) {
rawSubjects := map[envelope.Protocol]string{
envelope.ProtocolGB32960: topics.RawGB32960,
envelope.ProtocolJT808: topics.RawJT808,
@@ -77,16 +130,7 @@ func newNATSSinkWithPublisher(publisher natsPublisher, cfg NATSConfig) *NATSSink
fieldsSubjects[protocol] = subject
}
}
unifiedSubject := cfg.UnifiedSubject
if unifiedSubject == "" {
unifiedSubject = topics.Unified
}
return &NATSSink{
publisher: publisher,
rawSubjects: rawSubjects,
fieldsSubjects: fieldsSubjects,
unifiedSubject: unifiedSubject,
}
return rawSubjects, fieldsSubjects
}
func (s *NATSSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -94,14 +138,14 @@ func (s *NATSSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) e
if !ok || subject == "" {
return fmt.Errorf("raw subject not configured for protocol %s", env.Protocol)
}
return s.publish(ctx, subject, env)
return s.publish(ctx, subject, "raw", env)
}
func (s *NATSSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
if s.unifiedSubject == "" {
return errors.New("unified subject is empty")
}
return s.publish(ctx, s.unifiedSubject, env)
return s.publish(ctx, s.unifiedSubject, "unified", env)
}
func (s *NATSSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -109,7 +153,62 @@ func (s *NATSSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope
if !ok || subject == "" {
return fmt.Errorf("fields subject not configured for protocol %s", env.Protocol)
}
return s.publish(ctx, subject, env)
return s.publish(ctx, subject, "fields", env)
}
func (s *NATSSink) PublishRecords(ctx context.Context, records []durableRecord) error {
for _, record := range records {
subject, err := s.subjectForRecord(record)
if err != nil {
return err
}
if err := s.publish(ctx, subject, record.Kind, record.Envelope); err != nil {
return err
}
}
return nil
}
func (s *NATSSink) PublishRecordAsync(record durableRecord, complete func(error)) error {
if s == nil || s.asyncPublisher == nil {
return errors.New("nats async publisher is not configured")
}
if complete == nil {
return errors.New("nats async publish completion callback is required")
}
subject, err := s.subjectForRecord(record)
if err != nil {
return err
}
payload, err := record.Envelope.MarshalJSONBytes()
if err != nil {
return err
}
future, err := s.asyncPublisher.PublishAsync(
subject,
payload,
nats.MsgId(natsMessageID(record.Kind, subject, record.Envelope)),
)
if err != nil {
return err
}
go func() {
select {
case <-future.Ok():
complete(nil)
case asyncErr := <-future.Err():
if asyncErr == nil {
asyncErr = errors.New("nats async publish failed without error detail")
}
complete(asyncErr)
}
}()
return nil
}
func (s *NATSSink) ValidateRecord(record durableRecord) error {
_, err := s.subjectForRecord(record)
return err
}
func (s *NATSSink) Close() error {
@@ -121,12 +220,48 @@ func (s *NATSSink) Close() error {
return nil
}
func (s *NATSSink) publish(ctx context.Context, subject string, env envelope.FrameEnvelope) error {
func (s *NATSSink) publish(ctx context.Context, subject string, kind string, env envelope.FrameEnvelope) error {
payload, err := env.MarshalJSONBytes()
if err != nil {
return err
}
return s.publisher.Publish(ctx, subject, payload, nats.MsgId(env.StableEventID()))
return s.publisher.Publish(ctx, subject, payload, nats.MsgId(natsMessageID(kind, subject, env)))
}
func natsMessageID(kind string, subject string, env envelope.FrameEnvelope) string {
kind = strings.TrimSpace(kind)
if kind == "" {
kind = "unknown"
}
subject = strings.TrimSpace(subject)
if subject == "" {
subject = "unknown"
}
return kind + ":" + subject + ":" + env.StableEventID()
}
func (s *NATSSink) subjectForRecord(record durableRecord) (string, error) {
switch record.Kind {
case "raw":
subject, ok := s.rawSubjects[record.Envelope.Protocol]
if !ok || subject == "" {
return "", fmt.Errorf("raw subject not configured for protocol %s", record.Envelope.Protocol)
}
return subject, nil
case "unified":
if s.unifiedSubject == "" {
return "", errors.New("unified subject is empty")
}
return s.unifiedSubject, nil
case "fields":
subject, ok := s.fieldsSubjects[record.Envelope.Protocol]
if !ok || subject == "" {
return "", fmt.Errorf("fields subject not configured for protocol %s", record.Envelope.Protocol)
}
return subject, nil
default:
return "", errUnknownRecordKind(record.Kind)
}
}
type natsJetStreamPublisher struct {
@@ -137,3 +272,7 @@ func (p natsJetStreamPublisher) Publish(ctx context.Context, subject string, dat
_, err := p.js.Publish(subject, data, append(opts, nats.Context(ctx))...)
return err
}
func (p natsJetStreamPublisher) PublishAsync(subject string, data []byte, opts ...NATSPublishOption) (nats.PubAckFuture, error) {
return p.js.PublishAsync(subject, data, opts...)
}
@@ -3,7 +3,12 @@ package eventbus
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
@@ -57,6 +62,190 @@ func TestNATSSinkDefaultsToGoRawSubjects(t *testing.T) {
}
}
func TestNATSSinkPublishesDurableRecords(t *testing.T) {
publisher := &recordingNATSPublisher{}
sink := newNATSSinkWithPublisher(publisher, NATSConfig{
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.jt808.v1",
},
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.fields.go.jt808.v1",
},
UnifiedSubject: "vehicle.event.go.unified.v1",
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
err := sink.PublishRecords(context.Background(), []durableRecord{
{Kind: "raw", Envelope: env},
{Kind: "fields", Envelope: env},
{Kind: "unified", Envelope: env},
})
if err != nil {
t.Fatalf("PublishRecords() error = %v", err)
}
if len(publisher.messages) != 3 {
t.Fatalf("published messages = %d, want 3", len(publisher.messages))
}
for i, want := range []string{
"vehicle.raw.go.jt808.v1",
"vehicle.fields.go.jt808.v1",
"vehicle.event.go.unified.v1",
} {
if got := publisher.messages[i].subject; got != want {
t.Fatalf("message %d subject = %q, want %q", i, got, want)
}
}
}
func TestNATSMessageIDSeparatesKindAndSubject(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200", Sequence: 7}
raw := natsMessageID("raw", "vehicle.raw.go.jt808.v1", env)
rawRetry := natsMessageID("raw", "vehicle.raw.go.jt808.v1", env)
fields := natsMessageID("fields", "vehicle.fields.go.jt808.v1", env)
unified := natsMessageID("unified", "vehicle.event.go.unified.v1", env)
rawOtherSubject := natsMessageID("raw", "vehicle.raw.go.gb32960.v1", env)
if raw != rawRetry {
t.Fatalf("same kind/subject/event id should be stable: %q vs %q", raw, rawRetry)
}
if raw == fields || raw == unified || raw == rawOtherSubject {
t.Fatalf("message ids should be unique per kind and subject: raw=%q fields=%q unified=%q rawOther=%q", raw, fields, unified, rawOtherSubject)
}
if !strings.Contains(raw, env.StableEventID()) {
t.Fatalf("message id %q should retain stable event id %q", raw, env.StableEventID())
}
}
func TestNATSSinkPublishRecordsRejectsUnknownKind(t *testing.T) {
sink := newNATSSinkWithPublisher(&recordingNATSPublisher{}, NATSConfig{})
err := sink.PublishRecords(context.Background(), []durableRecord{
{Kind: "unknown", Envelope: envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}},
})
if err == nil || !strings.Contains(err.Error(), "unknown durable record kind") {
t.Fatalf("PublishRecords() error = %v, want unknown kind", err)
}
}
func TestNATSSinkAsyncPublishCompletesOnlyAfterPubAck(t *testing.T) {
future := newTestPubAckFuture()
asyncPublisher := &recordingNATSAsyncPublisher{future: future}
sink := newNATSSinkWithPublishers(&recordingNATSPublisher{}, asyncPublisher, NATSConfig{})
record := durableRecord{Kind: "raw", Envelope: normalizeDurableEnvelope(durableTestEnvelope())}
completed := make(chan error, 1)
if err := sink.PublishRecordAsync(record, func(err error) { completed <- err }); err != nil {
t.Fatalf("PublishRecordAsync() error = %v", err)
}
select {
case err := <-completed:
t.Fatalf("completion fired before PubAck: %v", err)
case <-time.After(10 * time.Millisecond):
}
if got, want := asyncPublisher.subject, "vehicle.raw.go.jt808.v1"; got != want {
t.Fatalf("async subject = %q, want %q", got, want)
}
var decoded envelope.FrameEnvelope
if err := json.Unmarshal(asyncPublisher.data, &decoded); err != nil {
t.Fatalf("decode async payload: %v", err)
}
if decoded.EventID != record.Envelope.EventID {
t.Fatalf("async event id = %q, want %q", decoded.EventID, record.Envelope.EventID)
}
future.ok <- &nats.PubAck{Stream: "VEHICLE_RAW", Sequence: 1}
select {
case err := <-completed:
if err != nil {
t.Fatalf("completion error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("completion did not fire after PubAck")
}
}
func TestNATSSinkAsyncPublishPropagatesFutureError(t *testing.T) {
future := newTestPubAckFuture()
sink := newNATSSinkWithPublishers(
&recordingNATSPublisher{},
&recordingNATSAsyncPublisher{future: future},
NATSConfig{},
)
completed := make(chan error, 1)
errWant := errors.New("jetstream ack timeout")
if err := sink.PublishRecordAsync(durableRecord{
Kind: "raw",
Envelope: normalizeDurableEnvelope(durableTestEnvelope()),
}, func(err error) { completed <- err }); err != nil {
t.Fatalf("PublishRecordAsync() error = %v", err)
}
future.err <- errWant
select {
case err := <-completed:
if !errors.Is(err, errWant) {
t.Fatalf("completion error = %v, want %v", err, errWant)
}
case <-time.After(time.Second):
t.Fatal("completion did not fire after future error")
}
}
func TestNATSSinkValidatesDurableRecordBeforeOutboxPersistence(t *testing.T) {
sink := newNATSSinkWithPublisher(&recordingNATSPublisher{}, NATSConfig{})
err := sink.ValidateRecord(durableRecord{
Kind: "raw",
Envelope: envelope.FrameEnvelope{Protocol: envelope.Protocol("UNKNOWN")},
})
if err == nil || !strings.Contains(err.Error(), "raw subject not configured") {
t.Fatalf("ValidateRecord() error = %v", err)
}
}
func TestValidateNATSConfigRejectsRawFieldsSubjectOverlap(t *testing.T) {
err := ValidateNATSConfig(NATSConfig{
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.same.jt808",
},
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.same.jt808",
},
})
if err == nil {
t.Fatal("ValidateNATSConfig() error = nil, want overlap rejection")
}
if !strings.Contains(err.Error(), "nats subject") {
t.Fatalf("error = %q, want nats subject hint", err)
}
}
func TestValidateNATSConfigRejectsKnownProtocolSubjectMismatch(t *testing.T) {
err := ValidateNATSConfig(NATSConfig{
RawSubjects: map[envelope.Protocol]string{
envelope.ProtocolJT808: "vehicle.raw.go.gb32960.v1",
},
})
if err == nil {
t.Fatal("ValidateNATSConfig() error = nil, want raw protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
err = ValidateNATSConfig(NATSConfig{
FieldsSubjects: map[envelope.Protocol]string{
envelope.ProtocolGB32960: "vehicle.fields.go.yutong-mqtt.v1",
},
})
if err == nil {
t.Fatal("ValidateNATSConfig() error = nil, want fields protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
}
type recordingNATSPublisher struct {
messages []recordedNATSMessage
}
@@ -70,3 +259,36 @@ func (p *recordingNATSPublisher) Publish(_ context.Context, subject string, data
p.messages = append(p.messages, recordedNATSMessage{subject: subject, data: append([]byte(nil), data...)})
return nil
}
type recordingNATSAsyncPublisher struct {
subject string
data []byte
future nats.PubAckFuture
err error
}
func (p *recordingNATSAsyncPublisher) PublishAsync(subject string, data []byte, _ ...NATSPublishOption) (nats.PubAckFuture, error) {
p.subject = subject
p.data = append([]byte(nil), data...)
return p.future, p.err
}
type testPubAckFuture struct {
ok chan *nats.PubAck
err chan error
msg *nats.Msg
}
func newTestPubAckFuture() *testPubAckFuture {
return &testPubAckFuture{
ok: make(chan *nats.PubAck, 1),
err: make(chan error, 1),
msg: &nats.Msg{},
}
}
func (f *testPubAckFuture) Ok() <-chan *nats.PubAck { return f.ok }
func (f *testPubAckFuture) Err() <-chan error { return f.err }
func (f *testPubAckFuture) Msg() *nats.Msg { return f.msg }
@@ -0,0 +1,293 @@
package eventbus
import (
"context"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type PartitionedAsyncConfig struct {
RawQueueSize int
DerivedQueueSize int
RawWorkers int
DerivedWorkers int
EnqueueTimeout time.Duration
RawEnqueueTimeout time.Duration
DerivedEnqueueTimeout time.Duration
OperationTimeout time.Duration
OnError func(error)
Metrics *metrics.Registry
Name string
}
type PartitionedAsyncSink struct {
delegate Sink
rawJobs chan asyncJob
derivedJobs chan asyncJob
rawEnqueueTimeout time.Duration
derivedEnqueueTimeout time.Duration
timeout time.Duration
onError func(error)
metrics *metrics.Registry
name string
queueWait *metrics.RecentLatencyByKey
closeOnce sync.Once
closed chan struct{}
done chan struct{}
wg sync.WaitGroup
}
func NewPartitionedAsyncSink(delegate Sink, cfg PartitionedAsyncConfig) *PartitionedAsyncSink {
if delegate == nil {
panic("partitioned async delegate sink must not be nil")
}
if cfg.RawQueueSize <= 0 {
cfg.RawQueueSize = 100_000
}
if cfg.DerivedQueueSize <= 0 {
cfg.DerivedQueueSize = 50_000
}
if cfg.RawWorkers <= 0 {
cfg.RawWorkers = 4
}
if cfg.DerivedWorkers <= 0 {
cfg.DerivedWorkers = 2
}
enqueueTimeout := normalizePartitionedEnqueueTimeout(cfg.EnqueueTimeout, time.Second)
rawEnqueueTimeout := normalizePartitionedEnqueueTimeout(cfg.RawEnqueueTimeout, enqueueTimeout)
derivedEnqueueTimeout := normalizePartitionedEnqueueTimeout(cfg.DerivedEnqueueTimeout, enqueueTimeout)
if cfg.OperationTimeout <= 0 {
cfg.OperationTimeout = 30 * time.Second
}
if cfg.Name == "" {
cfg.Name = "partitioned-async"
}
s := &PartitionedAsyncSink{
delegate: delegate,
rawJobs: make(chan asyncJob, cfg.RawQueueSize),
derivedJobs: make(chan asyncJob, cfg.DerivedQueueSize),
rawEnqueueTimeout: rawEnqueueTimeout,
derivedEnqueueTimeout: derivedEnqueueTimeout,
timeout: cfg.OperationTimeout,
onError: cfg.OnError,
metrics: cfg.Metrics,
name: cfg.Name,
queueWait: metrics.NewRecentLatencyByKey(512),
closed: make(chan struct{}),
done: make(chan struct{}),
}
s.startWorkers("raw", s.rawJobs, cfg.RawWorkers)
s.startWorkers("derived", s.derivedJobs, cfg.DerivedWorkers)
s.recordWorkers("raw", cfg.RawWorkers)
s.recordWorkers("derived", cfg.DerivedWorkers)
s.recordQueueCapacity("raw", cap(s.rawJobs))
s.recordQueueCapacity("derived", cap(s.derivedJobs))
go func() {
s.wg.Wait()
close(s.done)
}()
return s
}
func (s *PartitionedAsyncSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
return s.enqueue(ctx, "raw", s.rawJobs, s.rawEnqueueTimeout, asyncJob{kind: "raw", env: env})
}
func (s *PartitionedAsyncSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
return s.enqueue(ctx, "derived", s.derivedJobs, s.derivedEnqueueTimeout, asyncJob{kind: "unified", env: env})
}
func (s *PartitionedAsyncSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
return s.enqueue(ctx, "derived", s.derivedJobs, s.derivedEnqueueTimeout, asyncJob{kind: "fields", env: env})
}
func (s *PartitionedAsyncSink) Close() error {
s.closeOnce.Do(func() {
close(s.closed)
})
<-s.done
return s.delegate.Close()
}
func (s *PartitionedAsyncSink) startWorkers(queueName string, jobs <-chan asyncJob, workers int) {
s.wg.Add(workers)
for i := 0; i < workers; i++ {
go s.worker(queueName, jobs)
}
}
func (s *PartitionedAsyncSink) enqueue(ctx context.Context, queueName string, jobs chan<- asyncJob, enqueueTimeout time.Duration, job asyncJob) error {
select {
case <-s.closed:
s.recordEnqueue(job.kind, "closed")
return ErrAsyncSinkClosed
default:
}
var timeoutC <-chan time.Time
var timer *time.Timer
if enqueueTimeout > 0 {
timer = time.NewTimer(enqueueTimeout)
timeoutC = timer.C
defer timer.Stop()
}
job.enqueuedAt = time.Now()
select {
case jobs <- job:
s.recordEnqueue(job.kind, "queued")
s.recordQueueDepth(queueName)
return nil
case <-s.closed:
s.recordEnqueue(job.kind, "closed")
return ErrAsyncSinkClosed
case <-ctx.Done():
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth(queueName)
return ctx.Err()
case <-timeoutC:
s.recordEnqueue(job.kind, "timeout")
s.recordQueueDepth(queueName)
return ErrAsyncSinkEnqueueTimeout
}
}
func normalizePartitionedEnqueueTimeout(value time.Duration, fallback time.Duration) time.Duration {
if value == 0 {
value = fallback
}
if value < 0 {
return 0
}
return value
}
func (s *PartitionedAsyncSink) worker(queueName string, jobs <-chan asyncJob) {
defer s.wg.Done()
for {
select {
case job := <-jobs:
s.publishJob(queueName, job)
case <-s.closed:
for {
select {
case job := <-jobs:
s.publishJob(queueName, job)
default:
return
}
}
}
}
}
func (s *PartitionedAsyncSink) publishJob(queueName string, job asyncJob) {
s.recordQueueDepth(queueName)
s.recordQueueWait(queueName, job)
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
started := time.Now()
var err error
switch job.kind {
case "raw":
err = s.delegate.PublishRaw(ctx, job.env)
case "unified":
err = s.delegate.PublishUnified(ctx, job.env)
case "fields":
err = s.delegate.PublishFields(ctx, job.env)
}
cancel()
status := "ok"
if err != nil {
status = "error"
}
s.recordPublish(job.kind, status, time.Since(started))
if err != nil && s.onError != nil {
s.onError(err)
}
s.recordQueueDepth(queueName)
}
func (s *PartitionedAsyncSink) recordEnqueue(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_async_sink_enqueue_total", metrics.Labels{
"sink": s.name,
"kind": kind,
"status": status,
})
}
func (s *PartitionedAsyncSink) recordPublish(kind string, status string, elapsed time.Duration) {
if s.metrics == nil {
return
}
labels := metrics.Labels{
"sink": s.name,
"kind": kind,
"status": status,
}
s.metrics.IncCounter("vehicle_async_sink_publish_total", labels)
elapsedMS := float64(elapsed.Milliseconds())
s.metrics.SetGauge("vehicle_async_sink_publish_duration_ms", labels, elapsedMS)
s.metrics.ObserveHistogram("vehicle_async_sink_publish_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
}
func (s *PartitionedAsyncSink) recordQueueDepth(queueName string) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_queue_depth", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(s.queueDepth(queueName)))
}
func (s *PartitionedAsyncSink) recordQueueCapacity(queueName string, value int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_queue_capacity", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(value))
}
func (s *PartitionedAsyncSink) recordQueueWait(queueName string, job asyncJob) {
if s.metrics == nil || job.enqueuedAt.IsZero() {
return
}
elapsedMS := float64(time.Since(job.enqueuedAt)) / float64(time.Millisecond)
labels := metrics.Labels{
"sink": s.name,
"queue": queueName,
"kind": job.kind,
}
s.metrics.ObserveHistogram("vehicle_async_sink_queue_wait_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
p99, samples := s.queueWait.Observe(queueName+"\x00"+job.kind, elapsedMS)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_p99_ms", labels, p99)
s.metrics.SetGauge("vehicle_async_sink_queue_wait_recent_samples", labels, float64(samples))
}
func (s *PartitionedAsyncSink) recordWorkers(queueName string, workers int) {
if s.metrics == nil {
return
}
s.metrics.SetGauge("vehicle_async_sink_workers", metrics.Labels{
"sink": s.name,
"queue": queueName,
}, float64(workers))
}
func (s *PartitionedAsyncSink) queueDepth(queueName string) int {
switch queueName {
case "raw":
return len(s.rawJobs)
case "derived":
return len(s.derivedJobs)
default:
return 0
}
}
@@ -0,0 +1,230 @@
package eventbus
import (
"context"
"errors"
"strings"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestPartitionedAsyncSinkRawQueueIsIsolatedFromDerivedBacklog(t *testing.T) {
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 1,
DerivedQueueSize: 1,
RawWorkers: 1,
DerivedWorkers: 1,
EnqueueTimeout: 20 * time.Millisecond,
OperationTimeout: time.Second,
})
defer sink.Close()
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
select {
case <-delegate.fieldsStarted:
case <-time.After(time.Second):
t.Fatal("delegate fields publish was not started")
}
if err := sink.PublishUnified(context.Background(), env); err != nil {
t.Fatalf("PublishUnified() error = %v", err)
}
start := time.Now()
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("PublishRaw() blocked behind derived backlog for %s", elapsed)
}
delegate.release()
}
func TestPartitionedAsyncSinkRecordsPerQueueMetrics(t *testing.T) {
registry := metrics.NewRegistry()
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 2,
DerivedQueueSize: 3,
RawWorkers: 1,
DerivedWorkers: 1,
OperationTimeout: time.Second,
Metrics: registry,
Name: "nats",
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "LNBSCB3D4R1234567"}
if err := sink.PublishRaw(context.Background(), env); err != nil {
t.Fatalf("PublishRaw() error = %v", err)
}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
delegate.release()
if err := sink.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
text := registry.Render()
for _, want := range []string{
`vehicle_async_sink_queue_capacity{queue="raw",sink="nats"} 2`,
`vehicle_async_sink_queue_capacity{queue="derived",sink="nats"} 3`,
`vehicle_async_sink_workers{queue="raw",sink="nats"} 1`,
`vehicle_async_sink_workers{queue="derived",sink="nats"} 1`,
`vehicle_async_sink_enqueue_total{kind="raw",sink="nats",status="queued"} 1`,
`vehicle_async_sink_enqueue_total{kind="fields",sink="nats",status="queued"} 1`,
`vehicle_async_sink_publish_total{kind="raw",sink="nats",status="ok"} 1`,
`vehicle_async_sink_publish_total{kind="fields",sink="nats",status="ok"} 1`,
`vehicle_async_sink_queue_wait_duration_ms_histogram_count{kind="raw",queue="raw",sink="nats"} 1`,
`vehicle_async_sink_queue_wait_recent_p99_ms{kind="raw",queue="raw",sink="nats"}`,
`vehicle_async_sink_queue_wait_recent_samples{kind="raw",queue="raw",sink="nats"} 1`,
`vehicle_async_sink_queue_wait_duration_ms_histogram_count{kind="fields",queue="derived",sink="nats"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("partitioned async metric missing %s:\n%s", want, text)
}
}
}
func TestPartitionedAsyncSinkUsesIndependentDerivedEnqueueTimeout(t *testing.T) {
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 1,
DerivedQueueSize: 1,
RawWorkers: 1,
DerivedWorkers: 1,
RawEnqueueTimeout: 200 * time.Millisecond,
DerivedEnqueueTimeout: 10 * time.Millisecond,
OperationTimeout: time.Second,
})
defer sink.Close()
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
select {
case <-delegate.fieldsStarted:
case <-time.After(time.Second):
t.Fatal("delegate fields publish was not started")
}
if err := sink.PublishUnified(context.Background(), env); err != nil {
t.Fatalf("first PublishUnified() error = %v", err)
}
start := time.Now()
err := sink.PublishUnified(context.Background(), env)
elapsed := time.Since(start)
if !errors.Is(err, ErrAsyncSinkEnqueueTimeout) {
t.Fatalf("second PublishUnified() error = %v, want ErrAsyncSinkEnqueueTimeout", err)
}
if elapsed > 100*time.Millisecond {
t.Fatalf("derived enqueue timeout took %s, want quick failure", elapsed)
}
delegate.release()
}
func TestPartitionedAsyncSinkCloseUnblocksBlockedDerivedEnqueue(t *testing.T) {
delegate := newBlockingDerivedSink()
sink := NewPartitionedAsyncSink(delegate, PartitionedAsyncConfig{
RawQueueSize: 1,
DerivedQueueSize: 1,
RawWorkers: 1,
DerivedWorkers: 1,
EnqueueTimeout: -1,
OperationTimeout: time.Second,
})
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
if err := sink.PublishFields(context.Background(), env); err != nil {
t.Fatalf("PublishFields() error = %v", err)
}
select {
case <-delegate.fieldsStarted:
case <-time.After(time.Second):
t.Fatal("delegate fields publish was not started")
}
if err := sink.PublishUnified(context.Background(), env); err != nil {
t.Fatalf("PublishUnified() error = %v", err)
}
publishErr := make(chan error, 1)
go func() {
publishErr <- sink.PublishUnified(context.Background(), env)
}()
closeErr := make(chan error, 1)
go func() {
closeErr <- sink.Close()
}()
select {
case err := <-publishErr:
if !errors.Is(err, ErrAsyncSinkClosed) {
t.Fatalf("blocked PublishUnified() error = %v, want ErrAsyncSinkClosed", err)
}
case <-time.After(time.Second):
t.Fatal("blocked PublishUnified() was not released by Close")
}
delegate.release()
select {
case err := <-closeErr:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close() did not finish after delegate release")
}
}
type blockingDerivedSink struct {
fieldsStarted chan struct{}
releaseFields chan struct{}
}
func newBlockingDerivedSink() *blockingDerivedSink {
return &blockingDerivedSink{
fieldsStarted: make(chan struct{}),
releaseFields: make(chan struct{}),
}
}
func (s *blockingDerivedSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *blockingDerivedSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
return nil
}
func (s *blockingDerivedSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
s.signalFieldsStarted()
<-s.releaseFields
return nil
}
func (s *blockingDerivedSink) Close() error {
s.release()
return nil
}
func (s *blockingDerivedSink) signalFieldsStarted() {
select {
case <-s.fieldsStarted:
default:
close(s.fieldsStarted)
}
}
func (s *blockingDerivedSink) release() {
select {
case <-s.releaseFields:
default:
close(s.releaseFields)
}
}
@@ -0,0 +1,42 @@
package gateway
import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
)
const (
gatewayFieldsPublished = "published"
gatewayFieldsDelegated = "delegated_to_bridge"
gatewayFieldsSkippedNonRealtime = "skipped_non_realtime"
gatewayFieldsSkippedMissing = "skipped_missing_fields"
gatewayFieldsPublishError = "publish_error"
)
func gatewayFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, string, bool) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return envelope.FrameEnvelope{}, gatewayFieldsSkippedNonRealtime, false
}
fieldsEnv, ok := realtime.BuildFieldsEnvelope(env)
if !ok {
return envelope.FrameEnvelope{}, gatewayFieldsSkippedMissing, false
}
return fieldsEnv, gatewayFieldsPublished, true
}
func gatewayDelegatedFields(env envelope.FrameEnvelope) (int, string, bool) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return 0, gatewayFieldsSkippedNonRealtime, false
}
if len(env.ParsedFields) == 0 {
return 0, gatewayFieldsSkippedMissing, false
}
return len(env.ParsedFields), gatewayFieldsDelegated, true
}
func canonicalRawEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope {
env.EventKind = envelope.EventKindRaw
env.Parsed = nil
env.Fields = nil
return env
}
@@ -0,0 +1,76 @@
package gateway
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestCanonicalRawEnvelopeDropsBareStandardizedFields(t *testing.T) {
original := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
Parsed: map[string]any{
"location": map[string]any{"latitude": 30.5, "speed_kmh": 20},
},
Fields: map[string]any{
envelope.FieldLatitude: 30.5,
envelope.FieldSpeedKMH: 20,
},
ParsedFields: map[string]any{
"jt808.location.latitude": 30.5,
"jt808.location.speed_kmh": 20,
},
}
canonical := canonicalRawEnvelope(original)
if canonical.EventKind != envelope.EventKindRaw {
t.Fatalf("event kind = %q", canonical.EventKind)
}
if len(canonical.Fields) != 0 {
t.Fatalf("canonical raw leaked bare fields: %#v", canonical.Fields)
}
if len(canonical.Parsed) != 0 {
t.Fatalf("canonical raw leaked duplicate parsed tree: %#v", canonical.Parsed)
}
if len(canonical.ParsedFields) != 2 {
t.Fatalf("canonical raw lost protocol fields: %#v", canonical.ParsedFields)
}
if len(original.Fields) != 2 {
t.Fatalf("canonical copy mutated in-process parser fields: %#v", original.Fields)
}
if len(original.Parsed) != 1 {
t.Fatalf("canonical copy mutated in-process parsed tree: %#v", original.Parsed)
}
}
func TestCanonicalRawEnvelopeReducesDuplicateParsedPayload(t *testing.T) {
original := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x02",
VIN: "LTESTVIN000000001",
ParseStatus: envelope.ParseOK,
Parsed: map[string]any{
"data_units": []any{map[string]any{
"name": "vendor",
"value": strings.Repeat("x", 4096),
}},
},
ParsedFields: map[string]any{
"gb32960.vendor.value": strings.Repeat("x", 4096),
},
}
before, err := original.MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
after, err := canonicalRawEnvelope(original).MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
if len(after)*100 >= len(before)*65 {
t.Fatalf("canonical raw should remove the duplicate parsed tree: before=%d after=%d", len(before), len(after))
}
t.Logf("canonical raw bytes before=%d after=%d reduction=%.1f%%", len(before), len(after), 100*(1-float64(len(after))/float64(len(before))))
}
@@ -0,0 +1,104 @@
package gateway
import (
"context"
"errors"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
var gatewayIdentityDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayFieldsCountBuckets = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000}
func identityErrorStatus(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "error"
}
func recordGatewayIdentityDuration(registry *metrics.Registry, protocol envelope.Protocol, status string, elapsed time.Duration) {
if registry == nil {
return
}
elapsedMS := float64(elapsed.Nanoseconds()) / float64(time.Millisecond)
labels := metrics.Labels{
"protocol": string(protocol),
"status": status,
}
registry.SetGauge("vehicle_gateway_identity_duration_ms", labels, elapsedMS)
registry.ObserveHistogram("vehicle_gateway_identity_duration_ms_histogram", labels, gatewayIdentityDurationBucketsMS, elapsedMS)
}
func recordGatewayIdentityCacheStatus(registry *metrics.Registry, protocol envelope.Protocol, env envelope.FrameEnvelope) {
if registry == nil {
return
}
status := identityCacheStatus(env)
if status == "" {
return
}
registry.IncCounter("vehicle_gateway_identity_cache_total", metrics.Labels{
"protocol": string(protocol),
"cache_status": status,
})
}
func identityCacheStatus(env envelope.FrameEnvelope) string {
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok {
return ""
}
status, ok := identity["cache_status"].(string)
if !ok {
return ""
}
return strings.TrimSpace(status)
}
func annotateIdentityError(env *envelope.FrameEnvelope, err error) {
if env == nil || err == nil {
return
}
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
identity, _ := env.Parsed["identity"].(map[string]any)
if identity == nil {
identity = map[string]any{}
}
if _, ok := identity["resolved"]; !ok {
identity["resolved"] = strings.TrimSpace(env.VIN) != ""
}
identity["error"] = err.Error()
env.Parsed["identity"] = identity
}
func recordGatewayFieldsMetric(registry *metrics.Registry, protocol envelope.Protocol, status string) {
if registry == nil {
return
}
registry.IncCounter("vehicle_gateway_fields_total", metrics.Labels{
"protocol": string(protocol),
"status": status,
})
}
func recordGatewayFieldsCount(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
if registry == nil {
return
}
if fieldCount < 0 {
fieldCount = 0
}
labels := metrics.Labels{
"protocol": string(protocol),
"status": status,
}
value := float64(fieldCount)
registry.SetGauge("vehicle_gateway_fields_count", labels, value)
registry.ObserveHistogram("vehicle_gateway_fields_count_histogram", labels, gatewayFieldsCountBuckets, value)
}
@@ -0,0 +1,27 @@
package gateway
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestRecordGatewayIdentityCacheStatus(t *testing.T) {
registry := metrics.NewRegistry()
recordGatewayIdentityCacheStatus(registry, envelope.ProtocolJT808, envelope.FrameEnvelope{
Parsed: map[string]any{
"identity": map[string]any{
"resolved": true,
"cache_status": "stale",
},
},
})
got := registry.Render()
want := `vehicle_gateway_identity_cache_total{cache_status="stale",protocol="JT808"} 1`
if !strings.Contains(got, want) {
t.Fatalf("metrics missing %q:\n%s", want, got)
}
}
@@ -41,6 +41,7 @@ type MQTTClientConfig struct {
Logger *slog.Logger
Metrics *metrics.Registry
PublishUnified bool
DelegateFields bool
}
type MQTTClient struct {
@@ -204,28 +205,42 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
}
env.EventID = env.StableEventID()
c.recordParseErrorMetric(err)
} else {
} else if envelope.RequiresVehicleIdentity(env) {
resolveStarted := time.Now()
resolved, resolveErr := c.cfg.Resolver.Resolve(messageCtx, env)
if resolveErr != nil {
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
if env.Parsed == nil {
env.Parsed = map[string]any{}
if resolved.Protocol != "" || strings.TrimSpace(resolved.VIN) != "" || strings.TrimSpace(resolved.Phone) != "" {
env = resolved
}
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
identityStatus := identityErrorStatus(resolveErr)
c.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
annotateIdentityError(&env, resolveErr)
env.ParseStatus = envelope.ParsePartial
c.recordIdentityMetric("error")
c.recordIdentityMetric(identityStatus)
c.recordIdentityIssueMetric(identityStatus, env, identityIssueReason(identityStatus, resolveErr))
} else {
env = resolved
annotateIdentityUnresolved(&env)
c.recordIdentityMetric(identityStatus(env))
identityStatus := identityStatus(env)
c.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
c.recordIdentityMetric(identityStatus)
if identityStatus != "resolved" {
c.recordIdentityIssueMetric(identityStatus, env, "no_binding")
}
recordGatewayIdentityCacheStatus(c.cfg.Metrics, envelope.ProtocolYutongMQTT, env)
}
} else {
c.recordIdentitySkipMetric("non_vehicle_frame")
}
frameStatus = env.ParseStatus
env.EventKind = envelope.EventKindRaw
c.recordFrameMetric(env.ParseStatus)
if env.ParseStatus != envelope.ParseBadFrame {
realtime.EnsureParsedFields(&env)
}
if err := c.cfg.Sink.PublishRaw(messageCtx, env); err != nil {
canonicalRaw := canonicalRawEnvelope(env)
if err := c.cfg.Sink.PublishRaw(messageCtx, canonicalRaw); err != nil {
c.recordPublishMetric("raw", "error")
c.cfg.Logger.Error("publish mqtt raw failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
@@ -234,21 +249,37 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
c.recordPublishMetric("fields", "error")
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
if c.cfg.DelegateFields {
fieldCount, fieldsStatus, ok := gatewayDelegatedFields(env)
c.recordFieldsMetric(fieldsStatus)
if ok {
c.recordFieldsCount(fieldsStatus, fieldCount)
c.recordPublishMetric("fields", "delegated")
}
} else {
fieldsEnv, fieldsStatus, ok := gatewayFieldsEnvelope(env)
if !ok {
c.recordFieldsMetric(fieldsStatus)
} else {
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
c.recordFieldsMetric(gatewayFieldsPublishError)
c.recordFieldsCount(gatewayFieldsPublishError, len(fieldsEnv.Fields))
c.recordPublishMetric("fields", "error")
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
} else {
c.recordFieldsMetric(fieldsStatus)
c.recordFieldsCount(fieldsStatus, len(fieldsEnv.Fields))
c.recordPublishMetric("fields", "ok")
}
}
c.recordPublishMetric("fields", "ok")
}
if c.cfg.PublishUnified {
if err := c.cfg.Sink.PublishUnified(messageCtx, env); err != nil {
if err := c.cfg.Sink.PublishUnified(messageCtx, canonicalRaw); err != nil {
c.recordPublishMetric("unified", "error")
c.cfg.Logger.Error("publish mqtt unified failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
} else {
c.recordPublishMetric("unified", "ok")
}
c.recordPublishMetric("unified", "ok")
}
}
@@ -256,10 +287,12 @@ func (c *MQTTClient) recordFrameMetric(status envelope.ParseStatus) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"status": string(status),
})
}
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", labels)
metrics.RecordLastActivity(c.cfg.Metrics, "vehicle_gateway_last_frame_unix_seconds", labels)
}
func (c *MQTTClient) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
@@ -282,11 +315,21 @@ func (c *MQTTClient) recordPublishMetric(kind string, status string) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"kind": kind,
"status": status,
})
}
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", labels)
metrics.RecordLastActivity(c.cfg.Metrics, "vehicle_gateway_last_publish_unix_seconds", labels)
}
func (c *MQTTClient) recordFieldsMetric(status string) {
recordGatewayFieldsMetric(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status)
}
func (c *MQTTClient) recordFieldsCount(status string, fieldCount int) {
recordGatewayFieldsCount(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status, fieldCount)
}
func (c *MQTTClient) recordParseErrorMetric(err error) {
@@ -308,3 +351,37 @@ func (c *MQTTClient) recordIdentityMetric(status string) {
"status": status,
})
}
func (c *MQTTClient) recordIdentitySkipMetric(reason string) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_skips_total", metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"reason": reason,
})
}
func (c *MQTTClient) recordIdentityIssueMetric(status string, env envelope.FrameEnvelope, reason string) {
if c.cfg.Metrics == nil {
return
}
messageID := strings.TrimSpace(env.MessageID)
if messageID == "" {
messageID = "unknown"
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "unknown"
}
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_issues_total", metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"status": status,
"message_id": messageID,
"reason": reason,
})
}
func (c *MQTTClient) recordIdentityDuration(status string, elapsed time.Duration) {
recordGatewayIdentityDuration(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status, elapsed)
}
@@ -7,6 +7,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"log/slog"
"math/big"
"os"
@@ -19,7 +20,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
func TestMQTTClientHandleMessagePublishesRawAndFieldsByDefault(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
@@ -39,18 +40,80 @@ func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified))
}
if sink.raw[0].Protocol != envelope.ProtocolYutongMQTT || sink.raw[0].VIN != "LTEST000000000001" {
t.Fatalf("unexpected raw envelope: %#v", sink.raw[0])
}
if sink.raw[0].EventKind != envelope.EventKindRaw {
t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw)
}
if sink.raw[0].RawText == "" {
t.Fatal("mqtt raw envelope should keep text payload")
}
if sink.raw[0].RawHex != "" {
t.Fatalf("mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex)
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
if got, want := sink.fields[0].Fields["yutong_mqtt.data.meter_speed"], sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != want {
t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want)
}
if sink.fields[0].EventKind != envelope.EventKindFields {
t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields)
}
if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want {
t.Fatalf("fields source event id = %#v, want %s", got, want)
}
if sink.fields[0].FieldMapping == "" {
t.Fatal("fields event should expose field mapping version")
}
if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 {
t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields)
}
}
func TestMQTTClientDelegatesFieldsProjectionWhenConfigured(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
DelegateFields: true,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.fields) != 0 {
t.Fatalf("raw=%d fields=%d, want canonical raw only", len(sink.raw), len(sink.fields))
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("delegated canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="delegated_to_bridge"} 1`,
`vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="delegated_to_bridge"} `,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="delegated"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("delegated fields metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
@@ -77,8 +140,18 @@ func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="OK"} 1`,
`vehicle_gateway_last_frame_unix_seconds{protocol="YUTONG_MQTT",status="OK"} `,
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="YUTONG_MQTT",status="resolved"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_sum{protocol="YUTONG_MQTT",status="resolved"}`,
`vehicle_gateway_publish_total{kind="raw",protocol="YUTONG_MQTT",status="ok"} 1`,
`vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="YUTONG_MQTT",status="ok"} `,
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`,
`vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="published"} `,
`vehicle_gateway_fields_count_histogram_count{protocol="YUTONG_MQTT",status="published"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="ok"} 1`,
`vehicle_gateway_frame_duration_ms{protocol="YUTONG_MQTT",status="OK"}`,
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="OK"} 1`,
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="OK"} 1`,
@@ -93,6 +166,36 @@ func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
}
}
func TestMQTTClientRecordsNonRealtimeFieldsSkipMetric(t *testing.T) {
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: &recordingSink{},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{}
}`))
text := registry.Render()
if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="YUTONG_MQTT",reason="non_vehicle_frame"} 1`) {
t.Fatalf("identity skip metric missing:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
@@ -167,6 +270,66 @@ func TestMQTTClientUsesUncancelledMessageContextForReceivedMessage(t *testing.T)
}
}
func TestMQTTClientPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
wantErr := errors.New("registration upsert failed")
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Resolver: resolvedErrorResolver{vin: "LRESOLVED00000001", err: wantErr},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LRESOLVED00000001" {
t.Fatalf("raw vin = %q, want resolver vin", sink.raw[0].VIN)
}
if sink.raw[0].ParseStatus != envelope.ParsePartial {
t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus)
}
if len(sink.raw[0].Parsed) != 0 {
t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed)
}
if got := sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != "52.3" {
t.Fatalf("protocol field lost after identity side-effect failure: %#v", got)
}
for field := range sink.raw[0].ParsedFields {
if strings.HasPrefix(field, "yutong_mqtt.identity.") {
t.Fatalf("derived identity annotation leaked into protocol fields: %s", field)
}
}
if len(sink.fields) != 1 || sink.fields[0].VIN != "LRESOLVED00000001" {
t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="error"} 1`,
`vehicle_gateway_identity_issues_total{message_id="MQTT",protocol="YUTONG_MQTT",reason="resolver_error",status="error"} 1`,
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="PARTIAL"} 1`,
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
@@ -193,6 +356,44 @@ func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
}
}
func TestMQTTClientPublishesUnifiedWhenFieldsPublishFails(t *testing.T) {
sink := &recordingSink{fieldsErr: errors.New("fields queue full")}
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
PublishUnified: true,
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 1 {
t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified))
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="publish_error"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="error"} 1`,
`vehicle_gateway_publish_total{kind="unified",protocol="YUTONG_MQTT",status="ok"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientBuildOptionsLoadsTLSCertificates(t *testing.T) {
dir := t.TempDir()
caPath, certPath, keyPath := writeTestTLSMaterial(t, dir)
+332 -36
View File
@@ -10,8 +10,10 @@ import (
"net"
"strings"
"sync"
"syscall"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
@@ -26,11 +28,12 @@ type FrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint string) (en
type FrameResponder func(raw []byte, env envelope.FrameEnvelope) (response []byte, ok bool, err error)
type TCPProtocol struct {
Protocol envelope.Protocol
Addr string
Extract FrameExtractor
Parse FrameParser
Respond FrameResponder
Protocol envelope.Protocol
Addr string
Extract FrameExtractor
Parse FrameParser
Authenticate authentication.Authenticator
Respond FrameResponder
}
type connectionState struct {
@@ -47,6 +50,9 @@ type TCPServer struct {
idleTimeout time.Duration
maxConnections int
publishUnified bool
delegateFields bool
activeMu sync.Mutex
activeConns map[net.Conn]struct{}
}
type TCPServerConfig struct {
@@ -59,11 +65,14 @@ type TCPServerConfig struct {
IdleTimeout time.Duration
MaxConnections int
PublishUnified bool
DelegateFields bool
}
const frameOperationTimeout = 30 * time.Second
var gatewayFrameDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayResponseDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayResponseE2ERecent = metrics.NewRecentLatencyByKey(512)
func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
if cfg.Protocol.Protocol == "" {
@@ -106,6 +115,8 @@ func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
idleTimeout: cfg.IdleTimeout,
maxConnections: cfg.MaxConnections,
publishUnified: cfg.PublishUnified,
delegateFields: cfg.DelegateFields,
activeConns: map[net.Conn]struct{}{},
}, nil
}
@@ -120,6 +131,7 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
go func() {
<-ctx.Done()
_ = listener.Close()
s.closeActiveConnections()
}()
s.logger.Info("tcp listener started", "protocol", s.protocol.Protocol, "addr", listener.Addr().String())
@@ -155,12 +167,15 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
s.trackConnection(conn)
defer s.untrackConnection(conn)
source := conn.RemoteAddr().String()
log := s.logger.With("protocol", s.protocol.Protocol, "remote", source)
s.recordConnectionMetric(1)
defer s.recordConnectionMetric(-1)
log.Info("tcp connection opened")
defer log.Info("tcp connection closed")
log.Debug("tcp connection opened")
defer log.Debug("tcp connection closed")
readBuffer := make([]byte, s.readBufferSize)
var pending []byte
@@ -182,16 +197,21 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
}
}
if err != nil {
if errors.Is(err, io.EOF) {
s.recordConnectionClose("eof")
if ctx.Err() != nil {
s.recordConnectionClose("context_cancelled")
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Warn("tcp connection idle timeout")
log.Debug("tcp connection idle timeout")
s.recordConnectionClose("read_timeout")
return
}
if isRoutineTCPReadClose(err) {
log.Debug("tcp connection closed by peer", "error", err)
s.recordConnectionClose("remote_closed")
return
}
log.Warn("tcp read failed", "error", err)
s.recordConnectionClose("read_error")
return
@@ -203,6 +223,55 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
}
}
func isRoutineTCPReadClose(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.ECONNRESET) ||
errors.Is(err, syscall.EPIPE) {
return true
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "connection reset by peer") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "use of closed network connection")
}
func (s *TCPServer) trackConnection(conn net.Conn) {
if conn == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if s.activeConns == nil {
s.activeConns = map[net.Conn]struct{}{}
}
s.activeConns[conn] = struct{}{}
}
func (s *TCPServer) untrackConnection(conn net.Conn) {
if conn == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
delete(s.activeConns, conn)
}
func (s *TCPServer) closeActiveConnections() {
s.activeMu.Lock()
conns := make([]net.Conn, 0, len(s.activeConns))
for conn := range s.activeConns {
conns = append(conns, conn)
}
s.activeMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte, source string, state *connectionState) {
started := time.Now()
frameStatus := envelope.ParseBadFrame
@@ -228,29 +297,53 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
env.EventID = env.StableEventID()
s.recordParseErrorMetric(err)
} else {
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
if resolveErr != nil {
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
if env.Parsed == nil {
env.Parsed = map[string]any{}
if s.protocol.Authenticate != nil {
result := s.protocol.Authenticate.Authenticate(env)
authentication.Apply(&env, result)
if result.Applicable {
s.recordAuthenticationMetric(result)
}
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
env.ParseStatus = envelope.ParsePartial
s.recordIdentityMetric("error")
} else {
env = resolved
annotateIdentityUnresolved(&env)
s.recordIdentityMetric(identityStatus(env))
}
enrichConnectionPlatform(&env, state)
authentication.RedactParsedCredentials(&env)
if envelope.RequiresVehicleIdentity(env) {
resolveStarted := time.Now()
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
if resolveErr != nil {
if resolved.Protocol != "" || strings.TrimSpace(resolved.VIN) != "" || strings.TrimSpace(resolved.Phone) != "" {
env = resolved
}
identityStatus := identityErrorStatus(resolveErr)
s.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
annotateIdentityError(&env, resolveErr)
env.ParseStatus = envelope.ParsePartial
s.recordIdentityMetric(identityStatus)
s.recordIdentityIssueMetric(identityStatus, env, identityIssueReason(identityStatus, resolveErr))
} else {
env = resolved
annotateIdentityUnresolved(&env)
identityStatus := identityStatus(env)
s.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
s.recordIdentityMetric(identityStatus)
if identityStatus != "resolved" {
s.recordIdentityIssueMetric(identityStatus, env, "no_binding")
}
recordGatewayIdentityCacheStatus(s.metrics, s.protocol.Protocol, env)
}
} else {
s.recordIdentitySkipMetric("non_vehicle_frame")
}
}
enrichConnectionPlatform(&env, state)
frameStatus = env.ParseStatus
env.EventKind = envelope.EventKindRaw
s.recordFrameMetric(env.ParseStatus)
if env.ParseStatus != envelope.ParseBadFrame {
realtime.EnsureParsedFields(&env)
}
if err := s.sink.PublishRaw(frameCtx, env); err != nil {
canonicalRaw := canonicalRawEnvelope(env)
if err := s.sink.PublishRaw(frameCtx, canonicalRaw); err != nil {
s.recordPublishMetric("raw", "error")
s.logger.Error("publish raw failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
@@ -259,37 +352,79 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
s.recordPublishMetric("fields", "error")
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
s.writeProtocolResponse(conn, raw, env, started)
if shouldCloseAfterAuthentication(env) && conn != nil {
_ = conn.Close()
return
}
if s.delegateFields {
fieldCount, fieldsStatus, ok := gatewayDelegatedFields(env)
s.recordFieldsMetric(fieldsStatus)
if ok {
s.recordFieldsCount(fieldsStatus, fieldCount)
s.recordPublishMetric("fields", "delegated")
}
} else {
fieldsEnv, fieldsStatus, ok := gatewayFieldsEnvelope(env)
if !ok {
s.recordFieldsMetric(fieldsStatus)
} else {
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
s.recordFieldsMetric(gatewayFieldsPublishError)
s.recordFieldsCount(gatewayFieldsPublishError, len(fieldsEnv.Fields))
s.recordPublishMetric("fields", "error")
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
} else {
s.recordFieldsMetric(fieldsStatus)
s.recordFieldsCount(fieldsStatus, len(fieldsEnv.Fields))
s.recordPublishMetric("fields", "ok")
}
}
s.recordPublishMetric("fields", "ok")
}
if s.publishUnified {
if err := s.sink.PublishUnified(frameCtx, env); err != nil {
if err := s.sink.PublishUnified(frameCtx, canonicalRaw); err != nil {
s.recordPublishMetric("unified", "error")
s.logger.Error("publish unified failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
} else {
s.recordPublishMetric("unified", "ok")
}
s.recordPublishMetric("unified", "ok")
}
}
func (s *TCPServer) writeProtocolResponse(conn net.Conn, raw []byte, env envelope.FrameEnvelope, frameStarted time.Time) {
if s.protocol.Respond == nil {
return
}
status := "skipped"
defer func() {
s.recordResponseDuration(env.MessageID, status, time.Since(frameStarted))
}()
response, ok, err := s.protocol.Respond(raw, env)
if err != nil {
status = "build_error"
s.recordResponseMetric(env.MessageID, "build_error")
s.logger.Warn("build protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
}
if !ok || len(response) == 0 {
s.recordResponseMetric(env.MessageID, "skipped")
return
}
if conn == nil {
status = "write_error"
s.recordResponseMetric(env.MessageID, "write_error")
s.logger.Warn("write protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", "nil connection")
return
}
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if _, err := conn.Write(response); err != nil {
status = "write_error"
s.recordResponseMetric(env.MessageID, "write_error")
s.logger.Warn("write protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
}
status = "ok"
s.recordResponseMetric(env.MessageID, "ok")
}
func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionState) {
@@ -299,6 +434,9 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
if env.Fields == nil {
env.Fields = map[string]any{}
}
if shouldCloseAfterAuthentication(*env) {
return
}
if current := strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])); current != "" && current != "<nil>" {
state.platformName = current
}
@@ -308,6 +446,15 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
if strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])) == "" || fmt.Sprint(env.Fields["platform_account"]) == "<nil>" {
env.Fields["platform_account"] = state.platformName
}
if strings.TrimSpace(env.PlatformName) == "" {
env.PlatformName = state.platformName
}
if strings.TrimSpace(env.SourceCode) == "" {
env.SourceCode = sourceCodeFromPlatformName(state.platformName)
}
if strings.TrimSpace(env.SourceKind) == "" || strings.EqualFold(strings.TrimSpace(env.SourceKind), "UNKNOWN") {
env.SourceKind = "PLATFORM"
}
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
@@ -316,14 +463,37 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
}
}
func sourceCodeFromPlatformName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-' || r == '.':
builder.WriteRune(r)
}
}
return builder.String()
}
func (s *TCPServer) recordFrameMetric(status envelope.ParseStatus) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"status": string(status),
})
}
s.metrics.IncCounter("vehicle_gateway_frames_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_frame_unix_seconds", labels)
}
func (s *TCPServer) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
@@ -346,10 +516,74 @@ func (s *TCPServer) recordPublishMetric(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"kind": kind,
"status": status,
}
s.metrics.IncCounter("vehicle_gateway_publish_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_publish_unix_seconds", labels)
}
func (s *TCPServer) recordFieldsMetric(status string) {
recordGatewayFieldsMetric(s.metrics, s.protocol.Protocol, status)
}
func (s *TCPServer) recordFieldsCount(status string, fieldCount int) {
recordGatewayFieldsCount(s.metrics, s.protocol.Protocol, status, fieldCount)
}
func (s *TCPServer) recordResponseMetric(messageID string, status string) {
if s.metrics == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
messageID = "unknown"
}
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"message_id": messageID,
"status": status,
}
s.metrics.IncCounter("vehicle_gateway_response_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_response_unix_seconds", labels)
}
func (s *TCPServer) recordResponseDuration(messageID string, status string, elapsed time.Duration) {
if s.metrics == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
messageID = "unknown"
}
elapsedMS := float64(elapsed.Microseconds()) / 1000
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"message_id": messageID,
"status": status,
}
s.metrics.ObserveHistogram("vehicle_gateway_response_duration_ms_histogram", labels, gatewayResponseDurationBucketsMS, elapsedMS)
if status != "ok" {
return
}
protocol := string(s.protocol.Protocol)
p99, samples := gatewayResponseE2ERecent.Observe(protocol, elapsedMS)
protocolLabels := metrics.Labels{"protocol": protocol}
s.metrics.SetGauge("vehicle_gateway_response_e2e_recent_p99_ms", protocolLabels, p99)
s.metrics.SetGauge("vehicle_gateway_response_e2e_recent_samples", protocolLabels, float64(samples))
}
func (s *TCPServer) recordAuthenticationMetric(result authentication.Result) {
if s.metrics == nil || !result.Applicable {
return
}
s.metrics.IncCounter("vehicle_gateway_authentication_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"mode": string(result.Mode),
"source": result.Source,
"status": result.Status,
})
}
@@ -373,6 +607,40 @@ func (s *TCPServer) recordIdentityMetric(status string) {
})
}
func (s *TCPServer) recordIdentitySkipMetric(reason string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_identity_skips_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"reason": reason,
})
}
func (s *TCPServer) recordIdentityIssueMetric(status string, env envelope.FrameEnvelope, reason string) {
if s.metrics == nil {
return
}
messageID := strings.TrimSpace(env.MessageID)
if messageID == "" {
messageID = "unknown"
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "unknown"
}
s.metrics.IncCounter("vehicle_gateway_identity_issues_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"status": status,
"message_id": messageID,
"reason": reason,
})
}
func (s *TCPServer) recordIdentityDuration(status string, elapsed time.Duration) {
recordGatewayIdentityDuration(s.metrics, s.protocol.Protocol, status, elapsed)
}
func (s *TCPServer) recordConnectionMetric(delta float64) {
if s.metrics == nil {
return
@@ -402,6 +670,12 @@ func (s *TCPServer) recordConnectionClose(reason string) {
})
}
func shouldCloseAfterAuthentication(env envelope.FrameEnvelope) bool {
return env.AuthenticationEnforced &&
strings.TrimSpace(env.AuthenticationStatus) != "" &&
env.AuthenticationStatus != authentication.StatusAccepted
}
func identityStatus(env envelope.FrameEnvelope) string {
if strings.TrimSpace(env.VIN) != "" {
return "resolved"
@@ -412,6 +686,28 @@ func identityStatus(env envelope.FrameEnvelope) string {
return "unresolved"
}
func identityIssueReason(status string, err error) string {
switch status {
case "timeout":
return "timeout"
case "error":
if err == nil {
return "resolver_error"
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(text, "connection"), strings.Contains(text, "tcp"), strings.Contains(text, "network"):
return "identity_store_connection"
case strings.Contains(text, "timeout"), strings.Contains(text, "deadline"):
return "timeout"
default:
return "resolver_error"
}
default:
return "no_binding"
}
}
func annotateIdentityUnresolved(env *envelope.FrameEnvelope) {
if env == nil || env.ParseStatus == envelope.ParseBadFrame || strings.TrimSpace(env.VIN) != "" {
return
@@ -3,20 +3,48 @@ package gateway
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"syscall"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808"
)
func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
func TestEnrichConnectionPlatformPromotesSourceMetadata(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x02",
ParseStatus: envelope.ParseOK,
Fields: map[string]any{},
Parsed: map[string]any{},
}
state := &connectionState{platformName: "Hyundai"}
enrichConnectionPlatform(&env, state)
if env.PlatformName != "Hyundai" || env.SourceCode != "Hyundai" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
if got := fmt.Sprint(env.Fields["platform_account"]); got != "Hyundai" {
t.Fatalf("platform_account = %q", got)
}
if got := fmt.Sprint(env.Parsed["platform_name"]); got != "Hyundai" {
t.Fatalf("parsed platform_name = %q", got)
}
}
func TestTCPServerPublishesGoodFrameToRawAndFieldsByDefault(t *testing.T) {
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
if err != nil {
t.Fatal(err)
@@ -28,6 +56,7 @@ func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
}, sink)
server.resolver = vinResolver{vin: "LNBVIN00000000001"}
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
@@ -39,11 +68,71 @@ func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
}
if len(sink.fields) != 1 {
t.Fatalf("fields=%d, want 1", len(sink.fields))
}
if sink.raw[0].Phone != "13307795425" {
t.Fatalf("phone = %q", sink.raw[0].Phone)
}
if sink.raw[0].Fields[envelope.FieldTotalMileageKM] != 10241.2 {
t.Fatalf("total mileage = %#v", sink.raw[0].Fields[envelope.FieldTotalMileageKM])
if sink.raw[0].EventKind != envelope.EventKindRaw {
t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw)
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
if got := sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got == nil {
t.Fatalf("raw parsed fields missing total mileage: %#v", sink.raw[0].ParsedFields)
}
if got, want := sink.fields[0].Fields["jt808.location.total_mileage_km"], sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got != want {
t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want)
}
if sink.fields[0].EventKind != envelope.EventKindFields {
t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields)
}
if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want {
t.Fatalf("fields source event id = %#v, want %s", got, want)
}
if sink.fields[0].FieldMapping == "" {
t.Fatal("fields event should expose field mapping version")
}
if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 {
t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields)
}
}
func TestTCPServerRecordsFieldsPublishedMetric(t *testing.T) {
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
if err != nil {
t.Fatal(err)
}
sink := &recordingSink{}
registry := metrics.NewRegistry()
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = vinResolver{vin: "LNBVIN00000000001"}
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
t.Fatalf("client.Write() error = %v", err)
}
_ = client.Close()
<-done
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="JT808",status="published"} 1`,
`vehicle_gateway_fields_count{protocol="JT808",status="published"} `,
`vehicle_gateway_fields_count_histogram_count{protocol="JT808",status="published"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="ok"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
@@ -79,8 +168,16 @@ func TestTCPServerRecordsFrameMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_frames_total{protocol="JT808",status="OK"} 1`,
`vehicle_gateway_last_frame_unix_seconds{protocol="JT808",status="OK"} `,
`vehicle_gateway_identity_total{protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="no_binding",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="JT808",status="unresolved"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_sum{protocol="JT808",status="unresolved"}`,
`vehicle_gateway_publish_total{kind="raw",protocol="JT808",status="ok"} 1`,
`vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="JT808",status="ok"} `,
`vehicle_gateway_fields_total{protocol="JT808",status="skipped_non_realtime"} 1`,
`vehicle_gateway_frame_duration_ms{protocol="JT808",status="OK"}`,
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="OK"} 1`,
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="JT808",status="OK"} 1`,
@@ -95,6 +192,244 @@ func TestTCPServerRecordsFrameMetrics(t *testing.T) {
}
}
func TestTCPServerRecordsNonRealtimeFieldsSkipMetric(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.handleFrame(context.Background(), nil, buildGBFrame(0x07, 0xfe, "LNBSCB3D4R1234567", nil), "127.0.0.1:32960", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if len(sink.fields) != 0 {
t.Fatalf("fields=%d, want 0", len(sink.fields))
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_fields_total{protocol="GB32960",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestTCPServerSkipsIdentityForGB32960PlatformControlFrame(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
resolver := &countingResolver{vin: "LNBVIN00000000001"}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = resolver
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "PLATFORMLOGIN0001", nil), "127.0.0.1:32960", &connectionState{})
if resolver.calls != 0 {
t.Fatalf("resolver calls = %d, want 0 for platform control frame", resolver.calls)
}
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
text := registry.Render()
if strings.Contains(text, "vehicle_gateway_identity_total") {
t.Fatalf("identity metric should not be recorded for platform control frame:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="GB32960",reason="non_vehicle_frame"} 1`) {
t.Fatalf("identity skip metric missing:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="GB32960",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestTCPServerAuthenticatesAndRedactsGB32960PlatformLogin(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
Authenticate: authentication.NewGB32960PlatformAuthenticator(authentication.ModeObserve, map[string][]string{"platform-a": {"secret-a"}}),
}, sink)
server.metrics = registry
body := []byte{0x1a, 0x07, 0x0d, 0x14, 0x00, 0x00, 0x00, 0x01}
body = append(body, fixedASCIIBytes("platform-a", 12)...)
body = append(body, fixedASCIIBytes("secret-a", 20)...)
body = append(body, 0x01)
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "12345678901234567", body), "127.0.0.1:32960", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
raw := sink.raw[0]
if raw.AuthenticationMode != "observe" || raw.AuthenticationStatus != authentication.StatusAccepted || raw.AuthenticationEnforced {
t.Fatalf("authentication metadata = mode:%q status:%q enforced:%v", raw.AuthenticationMode, raw.AuthenticationStatus, raw.AuthenticationEnforced)
}
if _, exists := raw.ParsedFields["gb32960.platform_login.password"]; exists {
t.Fatalf("plaintext password leaked into parsed fields: %#v", raw.ParsedFields)
}
if got := raw.ParsedFields["gb32960.platform_login.password_present"]; got != "true" {
t.Fatalf("password presence marker = %#v", got)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_authentication_total{mode="observe",protocol="GB32960",source="configured",status="accepted"} 1`) {
t.Fatalf("authentication metric missing:\n%s", text)
}
}
func TestTCPServerResolvesIdentityForGB32960RealtimeFrame(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
resolver := &countingResolver{vin: "LNBVIN00000000001"}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = resolver
server.handleFrame(context.Background(), nil, buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil), "127.0.0.1:32960", &connectionState{})
if resolver.calls != 1 {
t.Fatalf("resolver calls = %d, want 1 for realtime frame", resolver.calls)
}
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LNBVIN00000000001" {
t.Fatalf("raw vin = %q, want resolved vin", sink.raw[0].VIN)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_identity_total{protocol="GB32960",status="resolved"} 1`) {
t.Fatalf("identity resolved metric missing:\n%s", text)
}
}
func TestTCPServerRecordsIdentityTimeoutMetrics(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParseStatus: envelope.ParseOK,
}, nil
},
},
Sink: &recordingSink{},
Resolver: errorResolver{err: context.DeadlineExceeded},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="timeout",status="timeout"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="JT808",status="timeout"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_frames_total{protocol="JT808",status="PARTIAL"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
func TestTCPServerPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
wantErr := errors.New("registration upsert failed")
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
},
ParseStatus: envelope.ParseOK,
}, nil
},
},
Sink: sink,
Resolver: resolvedErrorResolver{vin: "LNBVIN00000000001", err: wantErr},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw count = %d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LNBVIN00000000001" {
t.Fatalf("raw vin = %q, want resolved vin", sink.raw[0].VIN)
}
if sink.raw[0].ParseStatus != envelope.ParsePartial {
t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus)
}
if len(sink.raw[0].Parsed) != 0 {
t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed)
}
if got := sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got != "10241.2" {
t.Fatalf("protocol field lost after identity side-effect failure: %#v", got)
}
for field := range sink.raw[0].ParsedFields {
if strings.HasPrefix(field, "jt808.identity.") {
t.Fatalf("derived identity annotation leaked into protocol fields: %s", field)
}
}
if len(sink.fields) != 1 || sink.fields[0].VIN != "LNBVIN00000000001" {
t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="JT808",status="error"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="resolver_error",status="error"} 1`,
`vehicle_gateway_frames_total{protocol="JT808",status="PARTIAL"} 1`,
`vehicle_gateway_fields_total{protocol="JT808",status="published"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
@@ -140,6 +475,45 @@ func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
}
}
func TestTCPServerClosesActiveConnectionWhenContextCancelled(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
},
Sink: &recordingSink{},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
IdleTimeout: time.Minute,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
client, srv := net.Pipe()
done := make(chan struct{})
go func() {
server.handleConnection(ctx, srv)
close(done)
}()
waitForMetric(t, registry, `vehicle_gateway_active_connections{protocol="JT808"} 1`)
cancel()
server.closeActiveConnections()
defer client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection handler did not exit after context cancellation")
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_connection_closes_total{protocol="JT808",reason="context_cancelled"} 1`) {
t.Fatalf("context cancellation close metric missing:\n%s", text)
}
}
func TestTCPServerRecordsConnectionRejectionMetric(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
@@ -188,6 +562,57 @@ func TestTCPServerRecordsConnectionCloseReasonMetric(t *testing.T) {
}
}
func TestTCPServerConnectionLifecycleLogsStayDebug(t *testing.T) {
source, err := os.ReadFile("tcp_server.go")
if err != nil {
t.Fatal(err)
}
text := string(source)
for _, forbidden := range []string{
`Info("tcp connection opened"`,
`Info("tcp connection closed"`,
`Warn("tcp connection idle timeout"`,
`Warn("tcp connection closed by peer"`,
} {
if strings.Contains(text, forbidden) {
t.Fatalf("high-volume connection lifecycle log should not be info/warn: %s", forbidden)
}
}
for _, want := range []string{
`Debug("tcp connection opened"`,
`Debug("tcp connection closed"`,
`Debug("tcp connection idle timeout"`,
`Debug("tcp connection closed by peer"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("connection lifecycle log should remain debug: %s", want)
}
}
}
func TestIsRoutineTCPReadCloseClassifiesRemoteCloseNoise(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "eof", err: io.EOF, want: true},
{name: "net closed", err: net.ErrClosed, want: true},
{name: "reset wrapped", err: fmt.Errorf("read tcp: %w", syscall.ECONNRESET), want: true},
{name: "pipe wrapped", err: fmt.Errorf("write tcp: %w", syscall.EPIPE), want: true},
{name: "reset text", err: errors.New("read tcp 172.17.111.55:808->117.132.196.176:22187: read: connection reset by peer"), want: true},
{name: "unexpected", err: errors.New("checksum parser exploded"), want: false},
{name: "nil", err: nil, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRoutineTCPReadClose(tt.err); got != tt.want {
t.Fatalf("isRoutineTCPReadClose(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestTCPServerPublishesBadFrameOnlyToRaw(t *testing.T) {
good := buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil)
good[len(good)-1] ^= 0xff
@@ -248,14 +673,14 @@ func TestTCPServerAnnotatesUnresolvedIdentity(t *testing.T) {
if len(sink.raw) != 1 {
t.Fatalf("raw count = %d", len(sink.raw))
}
identity, ok := sink.raw[0].Parsed["identity"].(map[string]any)
if !ok || identity["resolved"] != false || identity["reason"] != "no_binding" {
t.Fatalf("identity metadata = %#v", sink.raw[0].Parsed["identity"])
if len(sink.raw[0].Parsed) != 0 || len(sink.raw[0].ParsedFields) != 0 {
t.Fatalf("unresolved derived metadata must not enter canonical protocol payload: parsed=%#v fields=%#v", sink.raw[0].Parsed, sink.raw[0].ParsedFields)
}
}
func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
frame := buildGBFrame(0x07, 0xfe, "LNBSCB3D4R1234567", nil)
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
@@ -269,6 +694,7 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
return []byte("ACK"), true, nil
},
}, sink)
server.metrics = registry
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
@@ -283,6 +709,79 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
}
_ = client.Close()
<-done
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_response_total{message_id="0x07",protocol="GB32960",status="ok"} 1`,
`vehicle_gateway_last_response_unix_seconds{message_id="0x07",protocol="GB32960",status="ok"} `,
`vehicle_gateway_response_duration_ms_histogram_count{message_id="0x07",protocol="GB32960",status="ok"} 1`,
`vehicle_gateway_response_e2e_recent_p99_ms{protocol="GB32960"} `,
`vehicle_gateway_response_e2e_recent_samples{protocol="GB32960"} `,
} {
if !strings.Contains(text, want) {
t.Fatalf("response metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerWritesProtocolResponseWhenFieldsPublishFails(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{fieldsErr: errors.New("fields queue full")}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: func(raw []byte) ([][]byte, []byte, error) {
return [][]byte{raw}, nil, nil
},
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "LNBVIN00000000001",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
},
ParseStatus: envelope.ParseOK,
}, nil
},
Respond: func(_ []byte, env envelope.FrameEnvelope) ([]byte, bool, error) {
if len(sink.raw) != 1 || sink.raw[0].EventID != env.EventID {
t.Fatalf("response built before raw publish: raw=%d", len(sink.raw))
}
return []byte("ACK"), true, nil
},
}, sink)
server.metrics = registry
client, done := runPipe(t, server)
if _, err := client.Write([]byte{0x01}); err != nil {
t.Fatalf("client.Write() error = %v", err)
}
buf := make([]byte, 3)
if _, err := io.ReadFull(client, buf); err != nil {
t.Fatalf("read response error = %v", err)
}
if string(buf) != "ACK" {
t.Fatalf("response = %q", string(buf))
}
_ = client.Close()
<-done
if len(sink.fields) != 1 {
t.Fatalf("fields publish attempts = %d, want 1", len(sink.fields))
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_response_total{message_id="0x0200",protocol="JT808",status="ok"} 1`,
`vehicle_gateway_fields_total{protocol="JT808",status="publish_error"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="error"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerUsesUncancelledFrameContextForAlreadyReadFrame(t *testing.T) {
@@ -368,6 +867,49 @@ func (r *contextCheckingResolver) Resolve(ctx context.Context, env envelope.Fram
return env, r.ctxErr
}
type vinResolver struct {
vin string
}
func (r vinResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
env.VIN = r.vin
return env, nil
}
type countingResolver struct {
calls int
vin string
}
func (r *countingResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
r.calls++
env.VIN = r.vin
return env, nil
}
type errorResolver struct {
err error
}
func (r errorResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
return env, r.err
}
type resolvedErrorResolver struct {
vin string
err error
}
func (r resolvedErrorResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
env.VIN = r.vin
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
env.Parsed["identity"] = map[string]any{"resolved": true, "source": "test"}
env.EventID = env.StableEventID()
return env, r.err
}
type contextCheckingSink struct {
rawCtxErr error
unifiedCtxErr error
@@ -426,25 +968,40 @@ func runPipe(t *testing.T, server *TCPServer) (net.Conn, <-chan struct{}) {
return client, done
}
func waitForMetric(t *testing.T, registry *metrics.Registry, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(registry.Render(), want) {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("metric missing %s:\n%s", want, registry.Render())
}
type recordingSink struct {
raw []envelope.FrameEnvelope
unified []envelope.FrameEnvelope
fields []envelope.FrameEnvelope
raw []envelope.FrameEnvelope
unified []envelope.FrameEnvelope
fields []envelope.FrameEnvelope
rawErr error
unifiedErr error
fieldsErr error
}
func (s *recordingSink) PublishRaw(_ context.Context, env envelope.FrameEnvelope) error {
s.raw = append(s.raw, env)
return nil
return s.rawErr
}
func (s *recordingSink) PublishUnified(_ context.Context, env envelope.FrameEnvelope) error {
s.unified = append(s.unified, env)
return nil
return s.unifiedErr
}
func (s *recordingSink) PublishFields(_ context.Context, env envelope.FrameEnvelope) error {
s.fields = append(s.fields, env)
return nil
return s.fieldsErr
}
func (s *recordingSink) Close() error {
@@ -475,3 +1032,9 @@ func buildGBFrame(command byte, response byte, vin string, body []byte) []byte {
}
return append(frame, bcc)
}
func fixedASCIIBytes(value string, size int) []byte {
out := make([]byte, size)
copy(out, []byte(value))
return out
}
@@ -40,6 +40,7 @@ func NewMux(service string, checks []Check, registry *metrics.Registry) *http.Se
mux.Handle("/healthz", handler)
mux.Handle("/readyz", handler)
if registry != nil {
metrics.RegisterServiceInfo(registry, handler.service)
mux.Handle("/metrics", metrics.NewHandler(registry))
}
return mux
@@ -80,6 +80,13 @@ func TestNewMuxRegistersHealthAndReadinessRoutes(t *testing.T) {
t.Fatalf("%s status = %d body=%s", path, response.Code, response.Body.String())
}
}
request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if !strings.Contains(response.Body.String(), `vehicle_service_info{service="vehicle-stat-writer"} 1`) {
t.Fatalf("service info metric missing: %s", response.Body.String())
}
}
func TestNewServerReturnsNilWhenAddressIsEmpty(t *testing.T) {
+7 -3
View File
@@ -76,6 +76,7 @@ type LocationRow struct {
Latitude float64 `json:"latitude"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
SpeedKMH *float64 `json:"speed_kmh,omitempty"`
SOCPercent *float64 `json:"soc_percent,omitempty"`
DirectionDeg *int64 `json:"direction_deg,omitempty"`
AlarmFlag *int64 `json:"alarm_flag,omitempty"`
StatusFlag *int64 `json:"status_flag,omitempty"`
@@ -218,6 +219,7 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]
var receivedAt scanDateTime
var altitude sql.NullFloat64
var speed sql.NullFloat64
var soc sql.NullFloat64
var direction sql.NullInt64
var alarm sql.NullInt64
var status sql.NullInt64
@@ -230,6 +232,7 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]
&row.Latitude,
&altitude,
&speed,
&soc,
&direction,
&alarm,
&status,
@@ -243,6 +246,7 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]
row.ReceivedAt = receivedAt.String
row.AltitudeM = nullableFloat(altitude)
row.SpeedKMH = nullableFloat(speed)
row.SOCPercent = nullableFloat(soc)
row.DirectionDeg = nullableInt(direction)
row.AlarmFlag = nullableInt(alarm)
row.StatusFlag = nullableInt(status)
@@ -563,7 +567,7 @@ func quotedList(values []string) string {
func buildLocationSQL(table string, query LocationQuery) (string, []any) {
where := locationWhere(query)
sqlText := `SELECT ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vin FROM ` + table
sqlText := `SELECT ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh, soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vin FROM ` + table
if len(where) > 0 {
sqlText += " WHERE " + strings.Join(where, " AND ")
}
@@ -954,11 +958,11 @@ func normalizeDateTimeLiteral(value string) string {
shanghai := time.FixedZone("Asia/Shanghai", 8*3600)
for _, layout := range []string{"2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
return parsed.In(shanghai).Format("2006-01-02 15:04:05")
}
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.UTC().Format("2006-01-02 15:04:05")
return parsed.In(shanghai).Format("2006-01-02 15:04:05")
}
return value
}
@@ -391,10 +391,10 @@ func TestLocationHandlerReturnsLocationsByVIN(t *testing.T) {
mock.ExpectQuery("vin = 'LKLG7C4E3NA774736'").
WillReturnRows(sqlmock.NewRows([]string{
"ts", "event_id", "received_at", "longitude", "latitude", "altitude_m", "speed_kmh",
"direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
"soc_percent", "direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
}).AddRow(
"2026-07-02 00:18:22", "event-3", "2026-07-02 00:22:43",
121.07764, 30.585928, 11.0, 8.0, 171, 0, 4718595, 8792.8,
121.07764, 30.585928, 11.0, 8.0, 82.5, 171, 0, 4718595, 8792.8,
"JT808", "LKLG7C4E3NA774736",
))
@@ -408,7 +408,7 @@ func TestLocationHandlerReturnsLocationsByVIN(t *testing.T) {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"vin":"LKLG7C4E3NA774736"`, `"longitude":121.07764`, `"total_mileage_km":8792.8`, `"total":17`} {
for _, want := range []string{`"vin":"LKLG7C4E3NA774736"`, `"longitude":121.07764`, `"soc_percent":82.5`, `"total_mileage_km":8792.8`, `"total":17`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
@@ -432,10 +432,10 @@ func TestLocationHandlerSkipsTotalCountByDefault(t *testing.T) {
mock.ExpectQuery("vin = 'LKLG7C4E3NA774736'").
WillReturnRows(sqlmock.NewRows([]string{
"ts", "event_id", "received_at", "longitude", "latitude", "altitude_m", "speed_kmh",
"direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
"soc_percent", "direction_deg", "alarm_flag", "status_flag", "total_mileage_km", "protocol", "vin",
}).AddRow(
"2026-07-02 00:18:22", "event-3", "2026-07-02 00:22:43",
121.07764, 30.585928, 11.0, 8.0, 171, 0, 4718595, 8792.8,
121.07764, 30.585928, 11.0, 8.0, 82.5, 171, 0, 4718595, 8792.8,
"JT808", "LKLG7C4E3NA774736",
))
@@ -476,17 +476,17 @@ func TestParseRawFrameQueryAcceptsDatetimeLocalValues(t *testing.T) {
if err != nil {
t.Fatalf("parseRawFrameQuery() error = %v", err)
}
if query.DateFrom != "2026-06-30 16:00:00" || query.DateTo != "2026-07-01 16:00:00" {
if query.DateFrom != "2026-07-01 00:00:00" || query.DateTo != "2026-07-02 00:00:00" {
t.Fatalf("date range = %q -> %q", query.DateFrom, query.DateTo)
}
}
func TestNormalizeDateTimeLiteralConvertsInputToTDengineUTCTime(t *testing.T) {
func TestNormalizeDateTimeLiteralUsesAsiaShanghaiQueryTime(t *testing.T) {
for raw, want := range map[string]string{
"2026-07-01T00:00:00": "2026-06-30 16:00:00",
"2026-07-01 00:00:00": "2026-06-30 16:00:00",
"2026-07-01T00:00:00+08:00": "2026-06-30 16:00:00",
"2026-06-30T16:00:00Z": "2026-06-30 16:00:00",
"2026-07-01T00:00:00": "2026-07-01 00:00:00",
"2026-07-01 00:00:00": "2026-07-01 00:00:00",
"2026-07-01T00:00:00+08:00": "2026-07-01 00:00:00",
"2026-06-30T16:00:00Z": "2026-07-01 00:00:00",
} {
if got := normalizeDateTimeLiteral(raw); got != want {
t.Fatalf("normalizeDateTimeLiteral(%q) = %q, want %q", raw, got, want)
@@ -522,7 +522,7 @@ func TestBuildLocationSQLUsesLiteralsForTDengine(t *testing.T) {
for _, want := range []string{
"protocol = 'JT808'",
"vin = 'LKLG7C4E3NA774736'",
"ts >= '2026-07-01 16:00:00'",
"ts >= '2026-07-02 00:00:00'",
"LIMIT 20 OFFSET 5",
} {
if !strings.Contains(sqlText, want) {
@@ -556,8 +556,8 @@ func TestBuildRawFrameSQLUsesLiteralsForTDengine(t *testing.T) {
"vehicle_key = 'JT808:013307811350'",
"vin = 'VIN''1'",
"message_id = 512",
"ts >= '2026-06-30 16:00:00'",
"ts <= '2026-07-01 15:59:59'",
"ts >= '2026-07-01 00:00:00'",
"ts <= '2026-07-01 23:59:59'",
"LIMIT 20 OFFSET 5",
} {
if !strings.Contains(sqlText, want) {
@@ -54,6 +54,7 @@ func SchemaStatements(database string) []string {
latitude DOUBLE,
altitude_m DOUBLE,
speed_kmh DOUBLE,
soc_percent DOUBLE,
direction_deg INT,
alarm_flag BIGINT,
status_flag BIGINT,
@@ -64,3 +65,13 @@ func SchemaStatements(database string) []string {
)`,
}
}
// SchemaMigrationStatements contains additive TDengine changes that must also
// be applied to an already existing stable. The writer treats duplicate-column
// errors as success so startup remains idempotent across releases.
func SchemaMigrationStatements(database string) []string {
if database == "" {
database = DefaultDatabase
}
return []string{"ALTER STABLE " + database + ".vehicle_locations ADD COLUMN soc_percent DOUBLE"}
}
+210 -129
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"sync"
@@ -15,6 +16,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
type Execer interface {
@@ -27,9 +29,23 @@ type Writer struct {
cache tableCache
}
type AppendResult struct {
RawRows int
LocationRows int
LocationError error
}
const (
LocationStatusOK = "ok"
LocationStatusSkippedNonRealtime = "skipped_non_realtime"
LocationStatusSkippedMissingVIN = "skipped_missing_vin"
LocationStatusSkippedMissingCoordinates = "skipped_missing_coordinates"
)
const (
rawFramePayloadInlineLimit = 12_000
rawFramePayloadChunkSize = 16_000
tdengineInsertSoftLimit = 6 * 1024 * 1024
)
type payloadChunk struct {
@@ -100,9 +116,22 @@ func (w *Writer) EnsureSchema(ctx context.Context, database string) error {
return err
}
}
for _, statement := range SchemaMigrationStatements(database) {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isDuplicateTDengineColumnError(err) {
return err
}
}
return nil
}
func isDuplicateTDengineColumnError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate column") || strings.Contains(message, "duplicated column") || strings.Contains(message, "column already exists")
}
func (w *Writer) qualify(table string) string {
table = normalizeIdentifier(table)
if w.database == "" {
@@ -112,20 +141,52 @@ func (w *Writer) qualify(table string) string {
}
func (w *Writer) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error {
if err := w.AppendRawFrame(ctx, env); err != nil {
result, err := w.AppendAllWithResult(ctx, env)
if err != nil {
return err
}
return w.AppendLocation(ctx, env)
return result.LocationError
}
func (w *Writer) AppendAllWithResult(ctx context.Context, env envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if err := w.AppendRawFrame(ctx, env); err != nil {
return result, err
}
result.RawRows = 1
rows, err := w.appendLocationWithCount(ctx, env)
if err != nil {
result.LocationError = err
return result, nil
}
result.LocationRows = rows
return result, nil
}
func (w *Writer) AppendAllBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
if len(envelopes) == 0 {
return nil
}
if err := w.AppendRawFrameBatch(ctx, envelopes); err != nil {
result, err := w.AppendAllBatchWithResult(ctx, envelopes)
if err != nil {
return err
}
return w.AppendLocationBatch(ctx, envelopes)
return result.LocationError
}
func (w *Writer) AppendAllBatchWithResult(ctx context.Context, envelopes []envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if len(envelopes) == 0 {
return result, nil
}
if err := w.AppendRawFrameBatch(ctx, envelopes); err != nil {
return result, err
}
result.RawRows = len(envelopes)
rows, err := w.appendLocationBatchWithCount(ctx, envelopes)
if err != nil {
result.LocationError = err
return result, nil
}
result.LocationRows = rows
return result, nil
}
func (w *Writer) AppendRawFrame(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -165,7 +226,6 @@ VALUES (%s)`, w.qualify(chunkTable), joinLiterals(chunkValues(env, chunk)))); er
func (w *Writer) AppendRawFrameBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
rowsByTable := map[string][]string{}
chunkRowsByTable := map[string][]string{}
chunkEnvByTable := map[string]envelope.FrameEnvelope{}
for _, env := range envelopes {
table := tableName("raw", env)
if err := w.ensureRawChild(ctx, table, "raw_frames", env); err != nil {
@@ -184,87 +244,141 @@ func (w *Writer) AppendRawFrameBatch(ctx context.Context, envelopes []envelope.F
if err := w.ensureRawChild(ctx, chunkTable, "raw_frame_payload_chunks", env); err != nil {
return err
}
chunkEnvByTable[chunkTable] = env
for _, chunk := range chunks {
chunkRowsByTable[chunkTable] = append(chunkRowsByTable[chunkTable], "("+joinLiterals(chunkValues(env, chunk))+")")
}
}
for table, rows := range rowsByTable {
if len(rows) == 0 {
continue
}
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes,
raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint)
VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
return err
}
if err := w.execMultiTableInsert(ctx, rowsByTable, `ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes,
raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint`); err != nil {
return err
}
for table, rows := range chunkRowsByTable {
if len(rows) == 0 {
continue
}
_ = chunkEnvByTable[table]
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, frame_id, received_at, payload_kind, chunk_index, chunk_count, chunk_text)
VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
return err
}
if err := w.execMultiTableInsert(ctx, chunkRowsByTable, `ts, event_id, frame_id, received_at, payload_kind, chunk_index, chunk_count, chunk_text`); err != nil {
return err
}
return nil
}
func (w *Writer) AppendLocation(ctx context.Context, env envelope.FrameEnvelope) error {
if strings.TrimSpace(env.VIN) == "" {
return nil
}
longitude, okLon := floatField(env, envelope.FieldLongitude)
latitude, okLat := floatField(env, envelope.FieldLatitude)
if !okLon || !okLat {
return nil
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return err
}
_, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES (%s)`, w.qualify(table), joinLiterals(locationValues(env, longitude, latitude))))
_, err := w.appendLocationWithCount(ctx, env)
return err
}
func (w *Writer) appendLocationWithCount(ctx context.Context, env envelope.FrameEnvelope) (int, error) {
longitude, latitude, status := locationCandidate(env)
if status != LocationStatusOK {
return 0, nil
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return 0, err
}
_, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES (%s)`, w.qualify(table), joinLiterals(locationValues(env, longitude, latitude))))
if err != nil {
return 0, err
}
return 1, nil
}
func (w *Writer) AppendLocationBatch(ctx context.Context, envelopes []envelope.FrameEnvelope) error {
_, err := w.appendLocationBatchWithCount(ctx, envelopes)
return err
}
func (w *Writer) appendLocationBatchWithCount(ctx context.Context, envelopes []envelope.FrameEnvelope) (int, error) {
rowsByTable := map[string][]string{}
rowCount := 0
for _, env := range envelopes {
if strings.TrimSpace(env.VIN) == "" {
continue
}
longitude, okLon := floatField(env, envelope.FieldLongitude)
latitude, okLat := floatField(env, envelope.FieldLatitude)
if !okLon || !okLat {
longitude, latitude, status := locationCandidate(env)
if status != LocationStatusOK {
continue
}
table := locationTableName(env)
if err := w.ensureLocationChild(ctx, table, env); err != nil {
return err
return rowCount, err
}
rowsByTable[table] = append(rowsByTable[table], "("+joinLiterals(locationValues(env, longitude, latitude))+")")
rowCount++
}
for table, rows := range rowsByTable {
if len(rows) == 0 {
continue
}
if _, err := w.exec.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s
(ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
direction_deg, alarm_flag, status_flag, total_mileage_km)
VALUES %s`, w.qualify(table), strings.Join(rows, ","))); err != nil {
if err := w.execMultiTableInsert(ctx, rowsByTable, `ts, event_id, received_at, longitude, latitude, altitude_m, speed_kmh,
soc_percent, direction_deg, alarm_flag, status_flag, total_mileage_km`); err != nil {
return rowCount, err
}
return rowCount, nil
}
func (w *Writer) execMultiTableInsert(ctx context.Context, rowsByTable map[string][]string, columns string) error {
statements := buildMultiTableInsertStatements(rowsByTable, w.qualify, columns, tdengineInsertSoftLimit)
for _, statement := range statements {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func buildMultiTableInsertStatements(rowsByTable map[string][]string, qualify func(string) string, columns string, softLimit int) []string {
if len(rowsByTable) == 0 {
return nil
}
keys := make([]string, 0, len(rowsByTable))
for table, rows := range rowsByTable {
if len(rows) > 0 {
keys = append(keys, table)
}
}
sort.Strings(keys)
if len(keys) == 0 {
return nil
}
if qualify == nil {
qualify = func(table string) string { return table }
}
var statements []string
current := "INSERT INTO "
parts := 0
for _, table := range keys {
part := fmt.Sprintf(`%s
(%s)
VALUES %s`, qualify(table), columns, strings.Join(rowsByTable[table], ","))
if parts > 0 && softLimit > 0 && len(current)+1+len(part) > softLimit {
statements = append(statements, current)
current = "INSERT INTO "
parts = 0
}
if parts > 0 {
current += " "
}
current += part
parts++
}
if parts > 0 {
statements = append(statements, current)
}
return statements
}
func LocationStatus(env envelope.FrameEnvelope) string {
_, _, status := locationCandidate(env)
return status
}
func locationCandidate(env envelope.FrameEnvelope) (float64, float64, string) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return 0, 0, LocationStatusSkippedNonRealtime
}
if strings.TrimSpace(env.VIN) == "" {
return 0, 0, LocationStatusSkippedMissingVIN
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
if !ok {
return 0, 0, LocationStatusSkippedMissingCoordinates
}
return location.Longitude, location.Latitude, LocationStatusOK
}
func (w *Writer) ensureRawChild(ctx context.Context, table string, stable string, env envelope.FrameEnvelope) error {
key := stable + "." + table
return w.cache.doOnce(key, func() error {
@@ -325,7 +439,7 @@ func rawValues(env envelope.FrameEnvelope, rawHex string, rawText string, parsed
func chunkValues(env envelope.FrameEnvelope, chunk payloadChunk) []any {
received := millis(env.ReceivedAtMS)
return []any{
received,
received.Add(time.Duration(chunk.Index) * time.Millisecond),
env.StableEventID(),
frameID(env),
received,
@@ -379,18 +493,21 @@ func safeChunkEnd(value string, start int, maxBytes int) int {
func locationValues(env envelope.FrameEnvelope, longitude float64, latitude float64) []any {
received := millis(env.ReceivedAtMS)
location, _ := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
totalMileageKM, hasTotalMileage := telemetry.TotalMileageKM(env.Protocol, env.ParsedFields)
return []any{
eventTimeOrReceived(env),
env.StableEventID(),
received,
longitude,
latitude,
floatFieldOrNil(env, "altitude_m"),
floatFieldOrNil(env, envelope.FieldSpeedKMH),
intFieldOrNil(env, "direction_deg"),
intFieldOrNil(env, "alarm_flag"),
intFieldOrNil(env, "status_flag"),
floatFieldOrNil(env, envelope.FieldTotalMileageKM),
optionalFloat(location.AltitudeM),
optionalFloat(location.SpeedKMH),
optionalFloat(location.SOCPercent),
optionalInt(location.DirectionDeg),
optionalInt64(location.AlarmFlag),
optionalInt64(location.StatusFlag),
optionalPositiveFloat(totalMileageKM, hasTotalMileage),
}
}
@@ -463,76 +580,37 @@ func parsedFieldsJSONString(env envelope.FrameEnvelope) string {
return jsonString(fields)
}
func floatField(env envelope.FrameEnvelope, key string) (float64, bool) {
if env.Fields == nil {
return 0, false
}
value, ok := env.Fields[key]
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
func optionalFloat(value *float64) any {
if value == nil {
return nil
}
return *value
}
func floatFieldOrNil(env envelope.FrameEnvelope, key string) any {
value, ok := floatField(env, key)
if !ok {
func optionalInt(value *float64) any {
if value == nil {
return nil
}
return int64(*value)
}
func optionalInt64(value *int64) any {
if value == nil {
return nil
}
return *value
}
func optionalPositiveFloat(value float64, ok bool) any {
if !ok || value <= 0 {
return nil
}
return value
}
func intFieldOrNil(env envelope.FrameEnvelope, key string) any {
if env.Fields == nil {
return nil
}
value, ok := env.Fields[key]
if !ok || value == nil {
return nil
}
switch typed := value.(type) {
case int:
return typed
case int64:
return typed
case uint16:
return int64(typed)
case uint32:
return int64(typed)
case float64:
return int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed
}
}
return nil
}
func eventTimeOrReceived(env envelope.FrameEnvelope) time.Time {
if env.EventTimeMS > 0 {
return millis(env.EventTimeMS)
}
return millis(env.ReceivedAtMS)
eventMS, _ := envelope.NormalizedEventTimeMS(env)
return millis(eventMS)
}
func millis(value int64) time.Time {
@@ -543,7 +621,10 @@ func millis(value int64) time.Time {
}
func quote(value string) string {
return strings.ReplaceAll(value, "'", "''")
return strings.NewReplacer(
`\`, `\\`,
`'`, `''`,
).Replace(value)
}
func joinLiterals(values []any) string {
@@ -3,6 +3,8 @@ package history
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"sync"
"testing"
@@ -32,6 +34,18 @@ func TestSchemaStatementsCreateCoreStables(t *testing.T) {
}
}
func TestSchemaMigrationAddsTrackSOCIdempotently(t *testing.T) {
statements := strings.Join(SchemaMigrationStatements("test_ts"), "\n")
if !strings.Contains(statements, "ALTER STABLE test_ts.vehicle_locations ADD COLUMN soc_percent DOUBLE") {
t.Fatalf("SOC migration missing: %s", statements)
}
for _, message := range []string{"duplicate column name", "Duplicated column names", "column already exists"} {
if !isDuplicateTDengineColumnError(errors.New(message)) {
t.Fatalf("duplicate TDengine column error should be idempotent: %s", message)
}
}
}
func TestWriterAppendsRawAndLocationOnly(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
@@ -130,6 +144,16 @@ func TestWriterChunksOversizedParsedFields(t *testing.T) {
if got := countSQL(exec.calls, "INSERT INTO chunk_"); got < 2 {
t.Fatalf("chunk insert count = %d, calls=%v", got, exec.calls)
}
chunkInserts := matchingSQL(exec.calls, "INSERT INTO chunk_")
if len(chunkInserts) != 2 {
t.Fatalf("chunk inserts = %d, calls=%v", len(chunkInserts), exec.calls)
}
if !strings.Contains(chunkInserts[0], "VALUES (1782745114999, '") {
t.Fatalf("first chunk ts should use received_at: %s", chunkInserts[0])
}
if !strings.Contains(chunkInserts[1], "VALUES (1782745115000, '") {
t.Fatalf("second chunk ts should be offset by chunk_index ms: %s", chunkInserts[1])
}
}
func TestWriterUsesEnvelopeParsedFieldsWithoutReflattening(t *testing.T) {
@@ -185,11 +209,20 @@ func TestTimeLiteralsUseEpochMilliseconds(t *testing.T) {
}
}
func TestStringLiteralPreservesJSONEscapesForTDengine(t *testing.T) {
value := `{"bits":"{\"abs\":false}"}`
want := `'{"bits":"{\\"abs\\":false}"}'`
if got := literal(value); got != want {
t.Fatalf("literal() = %q, want %q", got, want)
}
}
func TestWriterSkipsSparseDerivedRows(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
env := sampleEnvelope()
env.Fields = map[string]any{}
env.ParsedFields = map[string]any{}
if err := writer.AppendAll(context.Background(), env); err != nil {
t.Fatalf("AppendAll() error = %v", err)
@@ -227,6 +260,86 @@ func TestWriterSkipsLocationWhenVINIsMissing(t *testing.T) {
}
}
func TestLocationStatusClassifiesDerivedLocationEligibility(t *testing.T) {
realtimeWithoutCoordinates := sampleEnvelope()
realtimeWithoutCoordinates.ParsedFields = map[string]any{
"jt808.location.speed_kmh": 30,
}
bareFieldsOnly := realtimeWithoutCoordinates
bareFieldsOnly.ParsedFields = nil
bareFieldsOnly.Fields = map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
}
withoutVIN := sampleEnvelope()
withoutVIN.VIN = ""
nonRealtimeWithCoordinates := sampleEnvelope()
nonRealtimeWithCoordinates.MessageID = "0x0100"
tests := []struct {
name string
env envelope.FrameEnvelope
want string
}{
{name: "ok", env: sampleEnvelope(), want: LocationStatusOK},
{name: "non realtime", env: nonRealtimeWithCoordinates, want: LocationStatusSkippedNonRealtime},
{name: "missing vin", env: withoutVIN, want: LocationStatusSkippedMissingVIN},
{name: "missing coordinates", env: realtimeWithoutCoordinates, want: LocationStatusSkippedMissingCoordinates},
{name: "bare fields are not canonical", env: bareFieldsOnly, want: LocationStatusSkippedMissingCoordinates},
}
for _, test := range tests {
if got := LocationStatus(test.env); got != test.want {
t.Fatalf("%s LocationStatus() = %q, want %q", test.name, got, test.want)
}
}
}
func TestWriterSkipsLocationForNonRealtimeFrame(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
env := sampleEnvelope()
env.MessageID = "0x0100"
env.Parsed = map[string]any{"registration": map[string]any{"plate": "沪A12345"}}
if err := writer.AppendAll(context.Background(), env); err != nil {
t.Fatalf("AppendAll() error = %v", err)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw insert count = %d", got)
}
if got := countSQL(exec.calls, "USING vehicle_locations"); got != 0 {
t.Fatalf("location child create count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO loc_"); got != 0 {
t.Fatalf("location insert count = %d", got)
}
}
func TestWriterAppendAllWithResultKeepsRawSuccessWhenLocationFails(t *testing.T) {
locationErr := errors.New("location insert failed")
exec := &recordingExec{errs: []error{nil, nil, nil, nil, locationErr}}
writer := NewWriter(exec)
env := sampleEnvelope()
result, err := writer.AppendAllWithResult(context.Background(), env)
if err != nil {
t.Fatalf("AppendAllWithResult() raw error = %v", err)
}
if !errors.Is(result.LocationError, locationErr) {
t.Fatalf("location error = %v, want %v", result.LocationError, locationErr)
}
if result.RawRows != 1 || result.LocationRows != 0 {
t.Fatalf("result = %+v, want raw row retained and no location rows", result)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw insert count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO loc_"); got != 1 {
t.Fatalf("location insert attempted count = %d", got)
}
}
func TestWriterAppendsBatchRowsByChildTable(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
@@ -262,6 +375,94 @@ func TestWriterAppendsBatchRowsByChildTable(t *testing.T) {
}
}
func TestWriterAppendsBatchAcrossChildTablesWithSingleMultiTableInsert(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
first := sampleEnvelope()
second := sampleEnvelope()
second.Sequence = 2
second.EventID = "second-event"
second.VIN = "LNBVIN00000000002"
second.Phone = "013307795426"
second.EventTimeMS += 1000
second.ReceivedAtMS += 1000
if err := writer.AppendAllBatch(context.Background(), []envelope.FrameEnvelope{first, second}); err != nil {
t.Fatalf("AppendAllBatch() error = %v", err)
}
if got := countSQL(exec.calls, "USING raw_frames"); got != 2 {
t.Fatalf("raw child create count = %d", got)
}
if got := countSQL(exec.calls, "USING vehicle_locations"); got != 2 {
t.Fatalf("location child create count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw multi-table insert count = %d", got)
}
if got := countSQL(exec.calls, "INSERT INTO loc_"); got != 1 {
t.Fatalf("location multi-table insert count = %d", got)
}
rawInsert := findSQL(exec.calls, "INSERT INTO raw_")
if got := strings.Count(rawInsert, "\nVALUES "); got != 2 {
t.Fatalf("raw multi-table VALUES sections = %d, sql=%s", got, rawInsert)
}
locationInsert := findSQL(exec.calls, "INSERT INTO loc_")
if got := strings.Count(locationInsert, "\nVALUES "); got != 2 {
t.Fatalf("location multi-table VALUES sections = %d, sql=%s", got, locationInsert)
}
}
func TestWriterAppendAllBatchSkipsLocationForNonRealtimeFrames(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
first := sampleEnvelope()
first.MessageID = "0x0100"
first.Parsed = map[string]any{"registration": map[string]any{"plate": "沪A12345"}}
second := sampleEnvelope()
second.Sequence = 2
second.EventTimeMS += 1000
second.ReceivedAtMS += 1000
if err := writer.AppendAllBatch(context.Background(), []envelope.FrameEnvelope{first, second}); err != nil {
t.Fatalf("AppendAllBatch() error = %v", err)
}
if got := countSQL(exec.calls, "INSERT INTO raw_"); got != 1 {
t.Fatalf("raw batch insert count = %d", got)
}
locationInsert := findSQL(exec.calls, "INSERT INTO loc_")
if got := strings.Count(locationInsert, "),(") + 1; got != 1 {
t.Fatalf("location batch row count = %d, sql=%s", got, locationInsert)
}
}
func TestWriterNormalizesFarFutureEventTimeForLocationButKeepsRawEvidence(t *testing.T) {
exec := &recordingExec{}
writer := NewWriter(exec)
env := sampleEnvelope()
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC)
futureEvent := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC)
env.ReceivedAtMS = received.UnixMilli()
env.EventTimeMS = futureEvent.UnixMilli()
if err := writer.AppendAll(context.Background(), env); err != nil {
t.Fatalf("AppendAll() error = %v", err)
}
rawInsert := findSQL(exec.calls, "INSERT INTO raw_")
locationInsert := findSQL(exec.calls, "INSERT INTO loc_")
if !strings.Contains(rawInsert, strconv.FormatInt(futureEvent.UnixMilli(), 10)) {
t.Fatalf("raw insert should keep original event time %d: %s", futureEvent.UnixMilli(), rawInsert)
}
if strings.Contains(locationInsert, strconv.FormatInt(futureEvent.UnixMilli(), 10)) {
t.Fatalf("location insert should not use far future event time: %s", locationInsert)
}
if !strings.Contains(locationInsert, strconv.FormatInt(received.UnixMilli(), 10)) {
t.Fatalf("location insert should use received time %d: %s", received.UnixMilli(), locationInsert)
}
}
func TestWriterWithDatabaseQualifiesTDengineTables(t *testing.T) {
exec := &recordingExec{}
writer := NewWriterWithDatabase(exec, "vehicle_ts")
@@ -372,14 +573,14 @@ func sampleEnvelope() envelope.FrameEnvelope {
ReceivedAtMS: 1782745114999,
RawHex: "7E0200",
Parsed: map[string]any{"message": "location"},
Fields: map[string]any{
envelope.FieldLongitude: 121.069881,
envelope.FieldLatitude: 30.590151,
envelope.FieldSpeedKMH: 23.0,
envelope.FieldTotalMileageKM: 10241.2,
"direction_deg": uint16(79),
"alarm_flag": uint32(0),
"status_flag": uint32(72),
ParsedFields: map[string]any{
"jt808.location.longitude": 121.069881,
"jt808.location.latitude": 30.590151,
"jt808.location.speed_kmh": 23.0,
"jt808.location.total_mileage_km": 10241.2,
"jt808.location.direction_deg": uint16(79),
"jt808.location.alarm_flag": uint32(0),
"jt808.location.status_flag": uint32(72),
},
ParseStatus: envelope.ParseOK,
}
@@ -404,6 +605,16 @@ func findSQL(calls []execCall, pattern string) string {
return ""
}
func matchingSQL(calls []execCall, pattern string) []string {
out := []string{}
for _, call := range calls {
if strings.Contains(call.query, pattern) {
out = append(out, call.query)
}
}
return out
}
func containsSQL(calls []execCall, pattern string) bool {
return findSQL(calls, pattern) != ""
}
@@ -428,10 +639,16 @@ type execCall struct {
type recordingExec struct {
calls []execCall
errs []error
}
func (e *recordingExec) ExecContext(_ context.Context, query string, args ...any) (sql.Result, error) {
e.calls = append(e.calls, execCall{query: query, args: args})
if len(e.errs) > 0 {
err := e.errs[0]
e.errs = e.errs[1:]
return nil, err
}
return nil, nil
}
@@ -0,0 +1,773 @@
package identity
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
const (
IdentifierTypeJT808Phone = "JT808_PHONE"
IdentifierTypePlate = "PLATE"
)
type MappingRecord struct {
File string `json:"file"`
Sheet string `json:"sheet"`
Row int `json:"row"`
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Protocol string `json:"protocol"`
IdentifierType string `json:"identifier_type"`
IdentifierValue string `json:"identifier_value"`
RawValue string `json:"raw_value,omitempty"`
Plate string `json:"plate,omitempty"`
OEM string `json:"oem,omitempty"`
}
type MappingScanReport struct {
Files int `json:"files"`
Sheets int `json:"sheets"`
Rows int `json:"rows"`
Records int `json:"records"`
Skipped int `json:"skipped"`
UnsupportedFiles int `json:"unsupported_files,omitempty"`
Sources []MappingSourceScanReport `json:"sources,omitempty"`
UnsupportedItems []MappingUnsupportedFileReport `json:"unsupported_items,omitempty"`
Errors []string `json:"errors,omitempty"`
}
type MappingSourceScanReport struct {
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Files int `json:"files"`
Sheets int `json:"sheets"`
Rows int `json:"rows"`
Records int `json:"records"`
PhoneRecords int `json:"phone_records"`
PlateRecords int `json:"plate_records"`
Skipped int `json:"skipped"`
}
type MappingUnsupportedFileReport struct {
File string `json:"file"`
Ext string `json:"ext"`
Reason string `json:"reason"`
}
type MappingImportOptions struct {
Apply bool
LegacyTable string
ReportItemLimit int
}
type MappingImportReport struct {
Scan MappingScanReport `json:"scan"`
Records int `json:"records"`
Deduplicated int `json:"deduplicated"`
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
Conflicts int `json:"conflicts"`
WouldInsert int `json:"would_insert,omitempty"`
WouldUpdate int `json:"would_update,omitempty"`
Inserted int `json:"inserted,omitempty"`
Updated int `json:"updated,omitempty"`
SourceResults []MappingSourceImportStat `json:"source_results,omitempty"`
UnresolvedItems []MappingRecord `json:"unresolved_items,omitempty"`
ConflictItems []MappingConflict `json:"conflict_items,omitempty"`
}
type MappingSourceImportStat struct {
SourceCode string `json:"source_code"`
SourceName string `json:"source_name"`
Records int `json:"records"`
Deduplicated int `json:"deduplicated"`
Resolved int `json:"resolved"`
Unresolved int `json:"unresolved"`
Conflicts int `json:"conflicts"`
WouldInsert int `json:"would_insert,omitempty"`
WouldUpdate int `json:"would_update,omitempty"`
Inserted int `json:"inserted,omitempty"`
Updated int `json:"updated,omitempty"`
}
type MappingConflict struct {
Record MappingRecord `json:"record"`
ExistingVIN string `json:"existing_vin,omitempty"`
NewVIN string `json:"new_vin,omitempty"`
Reason string `json:"reason"`
}
type resolvedMappingRecord struct {
MappingRecord
VIN string
}
type mappingStore interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
var digitPattern = regexp.MustCompile(`\D+`)
func EnsureVehicleIdentifierSchema(ctx context.Context, db *sql.DB) error {
if db == nil {
return errors.New("identity db must not be nil")
}
if _, err := db.ExecContext(ctx, vehicleTableSQL); err != nil {
return err
}
_, err := db.ExecContext(ctx, vehicleIdentifierTableSQL)
return err
}
func ReadMappingDirectory(root string) ([]MappingRecord, MappingScanReport, error) {
root = strings.TrimSpace(root)
if root == "" {
return nil, MappingScanReport{}, errors.New("mapping input directory is empty")
}
var records []MappingRecord
report := MappingScanReport{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
report.Errors = append(report.Errors, err.Error())
return nil
}
if entry == nil || entry.IsDir() {
return nil
}
name := entry.Name()
if strings.HasPrefix(name, "~$") || strings.HasPrefix(name, "._") {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if !isSupportedMappingWorkbookExt(ext) {
if isUnsupportedMappingWorkbookExt(ext) {
report.UnsupportedFiles++
report.UnsupportedItems = append(report.UnsupportedItems, MappingUnsupportedFileReport{
File: path,
Ext: ext,
Reason: "convert legacy workbook to .xlsx before import",
})
}
return nil
}
fileRecords, fileReport, err := readMappingWorkbook(root, path)
report.Files++
report.Sheets += fileReport.Sheets
report.Rows += fileReport.Rows
report.Records += fileReport.Records
report.Skipped += fileReport.Skipped
report.Errors = append(report.Errors, fileReport.Errors...)
for _, source := range fileReport.Sources {
mergeMappingSourceScan(&report, source)
}
if err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("%s: %v", path, err))
return nil
}
records = append(records, fileRecords...)
return nil
})
if err != nil {
return records, report, err
}
sortMappingSourceScans(report.Sources)
return records, report, nil
}
func readMappingWorkbook(root string, path string) ([]MappingRecord, MappingScanReport, error) {
workbook, err := excelize.OpenFile(path)
if err != nil {
return nil, MappingScanReport{}, err
}
defer func() { _ = workbook.Close() }()
sourceCode, sourceName := mappingSource(root, path)
sourceReport := MappingSourceScanReport{
SourceCode: sourceCode,
SourceName: sourceName,
Files: 1,
}
var records []MappingRecord
report := MappingScanReport{}
for _, sheet := range workbook.GetSheetList() {
rows, err := workbook.GetRows(sheet)
if err != nil {
report.Errors = append(report.Errors, fmt.Sprintf("%s/%s: %v", path, sheet, err))
continue
}
report.Sheets++
sourceReport.Sheets++
report.Rows += len(rows)
sourceReport.Rows += len(rows)
if len(rows) == 0 {
continue
}
header, dataStart := mappingHeader(rows)
for rowIndex := dataStart; rowIndex < len(rows); rowIndex++ {
row := rows[rowIndex]
plate := normalizePlate(cellByHeader(row, header, "plate"))
rawPhone := cellByHeader(row, header, "phone")
phone := normalizeMappingPhone(rawPhone)
if len(header) == 0 {
plate = normalizePlate(cell(row, 0))
rawPhone = cell(row, 1)
phone = normalizeMappingPhone(rawPhone)
}
if plate == "" && phone == "" {
report.Skipped++
sourceReport.Skipped++
continue
}
if phone != "" {
records = append(records, MappingRecord{
File: path,
Sheet: sheet,
Row: rowIndex + 1,
SourceCode: sourceCode,
SourceName: sourceName,
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: phone,
RawValue: strings.TrimSpace(rawPhone),
Plate: plate,
OEM: sourceName,
})
sourceReport.PhoneRecords++
sourceReport.Records++
}
if plate != "" {
records = append(records, MappingRecord{
File: path,
Sheet: sheet,
Row: rowIndex + 1,
SourceCode: sourceCode,
SourceName: sourceName,
Protocol: "JT808",
IdentifierType: IdentifierTypePlate,
IdentifierValue: plate,
RawValue: plate,
Plate: plate,
OEM: sourceName,
})
sourceReport.PlateRecords++
sourceReport.Records++
}
}
}
report.Records = len(records)
report.Sources = []MappingSourceScanReport{sourceReport}
return records, report, nil
}
func mergeMappingSourceScan(report *MappingScanReport, source MappingSourceScanReport) {
if report == nil || strings.TrimSpace(source.SourceCode) == "" {
return
}
for index := range report.Sources {
if report.Sources[index].SourceCode != source.SourceCode {
continue
}
report.Sources[index].Files += source.Files
report.Sources[index].Sheets += source.Sheets
report.Sources[index].Rows += source.Rows
report.Sources[index].Records += source.Records
report.Sources[index].PhoneRecords += source.PhoneRecords
report.Sources[index].PlateRecords += source.PlateRecords
report.Sources[index].Skipped += source.Skipped
if report.Sources[index].SourceName == "" {
report.Sources[index].SourceName = source.SourceName
}
return
}
report.Sources = append(report.Sources, source)
}
func sortMappingSourceScans(sources []MappingSourceScanReport) {
sort.SliceStable(sources, func(i, j int) bool {
return sources[i].SourceCode < sources[j].SourceCode
})
}
func isSupportedMappingWorkbookExt(ext string) bool {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".xlsx", ".xlsm":
return true
default:
return false
}
}
func isUnsupportedMappingWorkbookExt(ext string) bool {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".xls", ".xlsb":
return true
default:
return false
}
}
func ImportMappingRecords(ctx context.Context, db *sql.DB, records []MappingRecord, scan MappingScanReport, opts MappingImportOptions) (MappingImportReport, error) {
if db == nil {
return MappingImportReport{}, errors.New("identity db must not be nil")
}
legacyTable := strings.TrimSpace(opts.LegacyTable)
if legacyTable == "" || !safeIdentifier(legacyTable) {
legacyTable = "vehicle_identity_binding"
}
report := MappingImportReport{
Scan: scan,
Records: len(records),
}
sourceStats := map[string]*MappingSourceImportStat{}
for _, record := range records {
sourceImportStat(sourceStats, record).Records++
}
reportLimit := opts.ReportItemLimit
if reportLimit == 0 {
reportLimit = 50
}
deduped, conflicts := dedupeMappingRecords(records)
report.Deduplicated = len(deduped)
report.Conflicts += len(conflicts)
for _, conflict := range conflicts {
sourceImportStat(sourceStats, conflict.Record).Conflicts++
appendConflictItem(&report, conflict, reportLimit)
}
store := mappingStore(db)
var tx *sql.Tx
if opts.Apply {
var err error
tx, err = db.BeginTx(ctx, nil)
if err != nil {
return report, err
}
store = tx
defer func() {
if tx != nil {
_ = tx.Rollback()
}
}()
}
for _, record := range deduped {
sourceStat := sourceImportStat(sourceStats, record)
sourceStat.Deduplicated++
vin, err := resolveMappingVIN(ctx, store, legacyTable, record)
if err != nil {
return report, err
}
if vin == "" {
report.Unresolved++
sourceStat.Unresolved++
appendUnresolvedItem(&report, record, reportLimit)
continue
}
resolved := resolvedMappingRecord{MappingRecord: record, VIN: vin}
existingVIN, exists, err := existingIdentifierVIN(ctx, store, resolved)
if err != nil {
return report, err
}
if exists && !strings.EqualFold(existingVIN, vin) {
report.Conflicts++
sourceStat.Conflicts++
appendConflictItem(&report, MappingConflict{
Record: record,
ExistingVIN: existingVIN,
NewVIN: vin,
Reason: "identifier already points to another vin",
}, reportLimit)
continue
}
report.Resolved++
sourceStat.Resolved++
if exists {
if opts.Apply {
if err := updateVehicleIdentifier(ctx, store, resolved); err != nil {
return report, err
}
report.Updated++
sourceStat.Updated++
} else {
report.WouldUpdate++
sourceStat.WouldUpdate++
}
continue
}
if opts.Apply {
if err := upsertVehicle(ctx, store, resolved); err != nil {
return report, err
}
if err := insertVehicleIdentifier(ctx, store, resolved); err != nil {
return report, err
}
report.Inserted++
sourceStat.Inserted++
} else {
report.WouldInsert++
sourceStat.WouldInsert++
}
}
if tx != nil {
if err := tx.Commit(); err != nil {
return report, err
}
tx = nil
}
report.SourceResults = sortedMappingSourceImportStats(sourceStats)
return report, nil
}
func sourceImportStat(stats map[string]*MappingSourceImportStat, record MappingRecord) *MappingSourceImportStat {
sourceCode := strings.TrimSpace(record.SourceCode)
if sourceCode == "" {
sourceCode = "unknown"
}
stat := stats[sourceCode]
if stat != nil {
if stat.SourceName == "" {
stat.SourceName = strings.TrimSpace(record.SourceName)
}
return stat
}
stat = &MappingSourceImportStat{
SourceCode: sourceCode,
SourceName: strings.TrimSpace(record.SourceName),
}
stats[sourceCode] = stat
return stat
}
func sortedMappingSourceImportStats(stats map[string]*MappingSourceImportStat) []MappingSourceImportStat {
if len(stats) == 0 {
return nil
}
keys := make([]string, 0, len(stats))
for key := range stats {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]MappingSourceImportStat, 0, len(keys))
for _, key := range keys {
out = append(out, *stats[key])
}
return out
}
func appendUnresolvedItem(report *MappingImportReport, record MappingRecord, limit int) {
if report == nil {
return
}
if limit >= 0 && len(report.UnresolvedItems) >= limit {
return
}
report.UnresolvedItems = append(report.UnresolvedItems, record)
}
func appendConflictItem(report *MappingImportReport, conflict MappingConflict, limit int) {
if report == nil {
return
}
if limit >= 0 && len(report.ConflictItems) >= limit {
return
}
report.ConflictItems = append(report.ConflictItems, conflict)
}
func dedupeMappingRecords(records []MappingRecord) ([]MappingRecord, []MappingConflict) {
seen := map[string]MappingRecord{}
indexByKey := map[string]int{}
var out []MappingRecord
var conflicts []MappingConflict
for _, record := range records {
record.IdentifierValue = normalizeIdentifierValue(record.IdentifierType, record.IdentifierValue)
record.Plate = normalizePlate(record.Plate)
if record.Protocol == "" {
record.Protocol = "JT808"
}
if record.IdentifierValue == "" || record.IdentifierType == "" {
continue
}
key := strings.Join([]string{record.Protocol, record.SourceCode, record.IdentifierType, record.IdentifierValue}, "\x00")
existing, ok := seen[key]
if !ok {
seen[key] = record
indexByKey[key] = len(out)
out = append(out, record)
continue
}
if existing.Plate != "" && record.Plate != "" && existing.Plate != record.Plate {
conflicts = append(conflicts, MappingConflict{
Record: record,
Reason: fmt.Sprintf("same source identifier maps to multiple plates: %s/%s", existing.Plate, record.Plate),
})
continue
}
merged := mergeMappingRecord(existing, record)
seen[key] = merged
if index, ok := indexByKey[key]; ok && index >= 0 && index < len(out) {
out[index] = merged
}
}
return out, conflicts
}
func mergeMappingRecord(existing MappingRecord, incoming MappingRecord) MappingRecord {
merged := existing
if merged.File == "" {
merged.File = incoming.File
}
if merged.Sheet == "" {
merged.Sheet = incoming.Sheet
}
if merged.Row == 0 {
merged.Row = incoming.Row
}
if merged.SourceName == "" {
merged.SourceName = incoming.SourceName
}
if merged.Protocol == "" {
merged.Protocol = incoming.Protocol
}
if merged.IdentifierType == "" {
merged.IdentifierType = incoming.IdentifierType
}
if merged.IdentifierValue == "" {
merged.IdentifierValue = incoming.IdentifierValue
}
if merged.RawValue == "" {
merged.RawValue = incoming.RawValue
}
if merged.Plate == "" {
merged.Plate = incoming.Plate
}
if merged.OEM == "" {
merged.OEM = incoming.OEM
}
return merged
}
func resolveMappingVIN(ctx context.Context, db mappingStore, legacyTable string, record MappingRecord) (string, error) {
if record.Plate != "" {
vin, err := lookupLegacyVIN(ctx, db, legacyTable, "plate", record.Plate)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
if vin != "" {
return vin, nil
}
}
if record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue != "" {
vin, err := lookupLegacyVIN(ctx, db, legacyTable, "phone", record.IdentifierValue)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
return vin, nil
}
return "", nil
}
func lookupLegacyVIN(ctx context.Context, db mappingStore, table string, column string, value string) (string, error) {
if !safeIdentifier(table) || !safeIdentifier(column) {
return "", sql.ErrNoRows
}
query := "SELECT vin FROM " + table + " WHERE " + column + " = ? AND vin IS NOT NULL AND vin <> '' LIMIT 1"
var vin string
err := db.QueryRowContext(ctx, query, value).Scan(&vin)
if err != nil {
return "", err
}
return strings.TrimSpace(vin), nil
}
func existingIdentifierVIN(ctx context.Context, db mappingStore, record resolvedMappingRecord) (string, bool, error) {
var vin string
err := db.QueryRowContext(ctx, `SELECT vin FROM vehicle_identifier
WHERE protocol = ? AND source_code = ? AND identifier_type = ? AND identifier_value = ?`,
record.Protocol, record.SourceCode, record.IdentifierType, record.IdentifierValue).Scan(&vin)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return strings.TrimSpace(vin), true, nil
}
func upsertVehicle(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `INSERT INTO vehicle (vin, plate, oem, enabled)
VALUES (?, ?, ?, 1)
ON DUPLICATE KEY UPDATE
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
oem = IF(VALUES(oem) <> '', VALUES(oem), oem),
enabled = 1`,
record.VIN, record.Plate, record.OEM)
return err
}
func insertVehicleIdentifier(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `INSERT INTO vehicle_identifier
(protocol, source_code, identifier_type, identifier_value, vin, plate, oem, raw_value, enabled, latest_import_file)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
record.Protocol,
record.SourceCode,
record.IdentifierType,
record.IdentifierValue,
record.VIN,
record.Plate,
record.OEM,
record.RawValue,
record.File,
)
return err
}
func updateVehicleIdentifier(ctx context.Context, db mappingStore, record resolvedMappingRecord) error {
_, err := db.ExecContext(ctx, `UPDATE vehicle_identifier
SET plate = IF(? <> '', ?, plate),
oem = IF(? <> '', ?, oem),
raw_value = IF(? <> '', ?, raw_value),
latest_import_file = ?,
enabled = 1
WHERE protocol = ? AND source_code = ? AND identifier_type = ? AND identifier_value = ?`,
record.Plate, record.Plate,
record.OEM, record.OEM,
record.RawValue, record.RawValue,
record.File,
record.Protocol,
record.SourceCode,
record.IdentifierType,
record.IdentifierValue,
)
return err
}
func mappingSource(root string, path string) (string, string) {
rel, err := filepath.Rel(root, path)
if err != nil {
rel = filepath.Base(path)
}
parts := strings.Split(filepath.ToSlash(rel), "/")
name := strings.TrimSpace(parts[0])
if name == "" || strings.EqualFold(name, ".") {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
code := sourceCode(name)
return code, name
}
func sourceCode(name string) string {
switch strings.TrimSpace(strings.ToLower(name)) {
case "g7s":
return "g7s"
case "信达":
return "xinda"
case "广安北斗", "广安车联":
return "guangan_beidou"
case "东方北斗":
return "dongfang_beidou"
case "赛格":
return "saige"
default:
return normalizeASCIIKey(name)
}
}
func mappingHeader(rows [][]string) (map[string]int, int) {
for index, row := range rows {
header := map[string]int{}
for columnIndex, value := range row {
key := normalizeHeader(value)
switch key {
case "车牌", "车牌号", "车牌号码":
header["plate"] = columnIndex
case "sim", "sim卡号", "手机号", "终端手机号", "终端id", "终端标识":
header["phone"] = columnIndex
}
}
if len(header) > 0 {
return header, index + 1
}
if index >= 5 {
break
}
}
return nil, 0
}
func cellByHeader(row []string, header map[string]int, key string) string {
if len(header) == 0 {
return ""
}
index, ok := header[key]
if !ok {
return ""
}
return cell(row, index)
}
func cell(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
func normalizeHeader(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
value = strings.ReplaceAll(value, "", "(")
value = strings.ReplaceAll(value, "", ")")
return value
}
func normalizePlate(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, " ", "")
value = strings.ReplaceAll(value, "\t", "")
return value
}
func normalizeMappingPhone(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.ContainsAny(value, ".eE") {
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
return normalizePhone(strconv.FormatFloat(parsed, 'f', 0, 64))
}
}
digits := digitPattern.ReplaceAllString(value, "")
return normalizePhone(digits)
}
func normalizeASCIIKey(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
var b strings.Builder
lastUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
@@ -0,0 +1,345 @@
package identity
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/xuri/excelize/v2"
)
func TestReadMappingDirectoryExtractsPhoneAndPlate(t *testing.T) {
dir := t.TempDir()
writeWorkbook(t, filepath.Join(dir, "G7s", "宇速全量.xlsx"), [][]string{
{"车牌号", "sim卡号", "设备号"},
{"粤AG18312", "013307795425", "DEV001"},
})
writeWorkbook(t, filepath.Join(dir, "东方北斗", "无标题0703.xlsx"), [][]string{
{"沪A01559F", "64341233712"},
})
if err := os.MkdirAll(filepath.Join(dir, "信达"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "信达", "旧格式.xls"), []byte("legacy xls"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "信达", "说明.txt"), []byte("ignored"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
records, report, err := ReadMappingDirectory(dir)
if err != nil {
t.Fatalf("ReadMappingDirectory() error = %v", err)
}
if report.Files != 2 {
t.Fatalf("files = %d, report=%#v", report.Files, report)
}
if report.UnsupportedFiles != 1 || len(report.UnsupportedItems) != 1 {
t.Fatalf("unsupported files = %d, items=%#v", report.UnsupportedFiles, report.UnsupportedItems)
}
if got := report.UnsupportedItems[0]; got.Ext != ".xls" || got.Reason == "" {
t.Fatalf("unsupported item = %#v", got)
}
sources := map[string]MappingSourceScanReport{}
for _, source := range report.Sources {
sources[source.SourceCode] = source
}
if got := sources["g7s"]; got.Files != 1 || got.Sheets != 1 || got.PhoneRecords != 1 || got.PlateRecords != 1 || got.Records != 2 {
t.Fatalf("g7s source report = %#v", got)
}
if got := sources["dongfang_beidou"]; got.Files != 1 || got.Sheets != 1 || got.PhoneRecords != 1 || got.PlateRecords != 1 || got.Records != 2 {
t.Fatalf("dongfang source report = %#v", got)
}
var phoneSeen bool
var plateSeen bool
var headerlessSeen bool
for _, record := range records {
if record.SourceCode == "g7s" && record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue == "13307795425" && record.Plate == "粤AG18312" {
phoneSeen = true
}
if record.SourceCode == "g7s" && record.IdentifierType == IdentifierTypePlate && record.IdentifierValue == "粤AG18312" {
plateSeen = true
}
if record.SourceCode == "dongfang_beidou" && record.IdentifierType == IdentifierTypeJT808Phone && record.IdentifierValue == "64341233712" && record.Plate == "沪A01559F" {
headerlessSeen = true
}
}
if !phoneSeen || !plateSeen || !headerlessSeen {
t.Fatalf("records missing expected mappings: %#v", records)
}
}
func TestImportMappingRecordsDryRunResolvesVINFromLegacyPlate(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "013307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Resolved != 1 || report.WouldInsert != 1 || report.Unresolved != 0 {
t.Fatalf("report = %#v", report)
}
if len(report.SourceResults) != 1 {
t.Fatalf("source results = %#v, want one source", report.SourceResults)
}
if got := report.SourceResults[0]; got.SourceCode != "g7s" || got.Records != 1 || got.Deduplicated != 1 || got.Resolved != 1 || got.WouldInsert != 1 {
t.Fatalf("source result = %#v", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsReportsSourceResults(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤B00000").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("14400000000").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤B99999",
OEM: "G7s",
},
{
SourceCode: "xinda",
SourceName: "信达",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "14400000000",
Plate: "粤B00000",
OEM: "信达",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Records != 3 || report.Deduplicated != 2 || report.Resolved != 1 || report.Unresolved != 1 || report.Conflicts != 1 || report.WouldInsert != 1 {
t.Fatalf("report = %#v", report)
}
got := map[string]MappingSourceImportStat{}
for _, source := range report.SourceResults {
got[source.SourceCode] = source
}
if source := got["g7s"]; source.Records != 2 || source.Deduplicated != 1 || source.Resolved != 1 || source.Conflicts != 1 || source.WouldInsert != 1 {
t.Fatalf("g7s source result = %#v", source)
}
if source := got["xinda"]; source.Records != 1 || source.Deduplicated != 1 || source.Unresolved != 1 {
t.Fatalf("xinda source result = %#v", source)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsMergesDuplicateIdentifierDetails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
File: "G7s/no-plate.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
OEM: "G7s",
},
{
File: "G7s/with-plate.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{LegacyTable: "vehicle_identity_binding"})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Records != 2 || report.Deduplicated != 1 || report.Resolved != 1 || report.WouldInsert != 1 || report.Unresolved != 0 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsApplyCommitsSingleTransaction(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec("INSERT INTO vehicle").
WithArgs("LB9A32A22P0LS1230", "粤AG18312", "G7s").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425", "LB9A32A22P0LS1230", "粤AG18312", "G7s", "13307795425", "G7s/example.xlsx").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
File: "G7s/example.xlsx",
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
RawValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{
Apply: true,
LegacyTable: "vehicle_identity_binding",
})
if err != nil {
t.Fatalf("ImportMappingRecords() error = %v", err)
}
if report.Inserted != 1 || report.Resolved != 1 || report.WouldInsert != 0 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestImportMappingRecordsApplyRollsBackOnWriteError(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
errWrite := errors.New("insert vehicle failed")
mock.ExpectBegin()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18312").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LB9A32A22P0LS1230"))
mock.ExpectQuery("SELECT vin FROM vehicle_identifier").
WithArgs("JT808", "g7s", IdentifierTypeJT808Phone, "13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec("INSERT INTO vehicle").
WithArgs("LB9A32A22P0LS1230", "粤AG18312", "G7s").
WillReturnError(errWrite)
mock.ExpectRollback()
report, err := ImportMappingRecords(context.Background(), db, []MappingRecord{
{
SourceCode: "g7s",
SourceName: "G7s",
Protocol: "JT808",
IdentifierType: IdentifierTypeJT808Phone,
IdentifierValue: "13307795425",
RawValue: "13307795425",
Plate: "粤AG18312",
OEM: "G7s",
},
}, MappingScanReport{}, MappingImportOptions{
Apply: true,
LegacyTable: "vehicle_identity_binding",
})
if !errors.Is(err, errWrite) {
t.Fatalf("ImportMappingRecords() error = %v, want %v", err, errWrite)
}
if report.Inserted != 0 || report.Resolved != 1 {
t.Fatalf("report = %#v", report)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeMappingPhoneHandlesExcelNumericFormats(t *testing.T) {
tests := map[string]string{
"013307795425": "13307795425",
"13307795425.0": "13307795425",
"1.3307795425E10": "13307795425",
}
for input, want := range tests {
if got := normalizeMappingPhone(input); got != want {
t.Fatalf("normalizeMappingPhone(%q) = %q, want %q", input, got, want)
}
}
}
func writeWorkbook(t *testing.T, path string, rows [][]string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
workbook := excelize.NewFile()
sheet := "Sheet1"
for rowIndex, row := range rows {
for columnIndex, value := range row {
cellName, err := excelize.CoordinatesToCellName(columnIndex+1, rowIndex+1)
if err != nil {
t.Fatalf("CoordinatesToCellName() error = %v", err)
}
if err := workbook.SetCellValue(sheet, cellName, value); err != nil {
t.Fatalf("SetCellValue() error = %v", err)
}
}
}
if err := workbook.SaveAs(path); err != nil {
t.Fatalf("SaveAs() error = %v", err)
}
if err := workbook.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
@@ -0,0 +1,357 @@
package identity
import (
"context"
"database/sql"
"fmt"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
JT808RegisterMessageID = "0x0100"
JT808AuthMessageID = "0x0102"
JT808LocationMessageID = "0x0200"
)
// JT808RegistrationFact is the durable identity projection carried by a raw
// JT808 envelope. SeenAt uses gateway receive time so replay cannot move a
// terminal to a future date because its device clock was wrong.
type JT808RegistrationFact struct {
Phone string
DeviceID string
Plate string
VIN string
Province string
City string
Manufacturer string
DeviceType string
PlateColor string
AuthToken string
AuthIMEI string
AuthSoftwareVersion string
SourceEndpoint string
SourceIP string
FirstRegisteredAt *time.Time
LatestRegisteredAt *time.Time
LatestAuthenticated *time.Time
SeenAt time.Time
}
// JT808RegistrationProjector throttles ordinary location touches in memory.
// Registration and authentication frames are never throttled.
type JT808RegistrationProjector struct {
location *time.Location
touchInterval time.Duration
retention time.Duration
mu sync.Mutex
lastTouches map[string]time.Time
nextCleanup time.Time
}
func NewJT808RegistrationProjector(location *time.Location, touchInterval time.Duration) *JT808RegistrationProjector {
if location == nil {
location = time.Local
}
if touchInterval <= 0 {
touchInterval = 10 * time.Minute
}
return &JT808RegistrationProjector{
location: location,
touchInterval: touchInterval,
retention: 24 * time.Hour,
lastTouches: map[string]time.Time{},
}
}
// ProjectBatch returns at most one merged fact per phone. Call MarkPersisted
// only after the database transaction succeeds; otherwise replay must remain
// eligible immediately.
func (p *JT808RegistrationProjector) ProjectBatch(envelopes []envelope.FrameEnvelope) []JT808RegistrationFact {
if p == nil || len(envelopes) == 0 {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
byPhone := make(map[string]JT808RegistrationFact)
order := make([]string, 0, len(envelopes))
for _, env := range envelopes {
fact, ok := p.projectLocked(env)
if !ok {
continue
}
if current, exists := byPhone[fact.Phone]; exists {
byPhone[fact.Phone] = mergeJT808RegistrationFact(current, fact)
continue
}
byPhone[fact.Phone] = fact
order = append(order, fact.Phone)
}
result := make([]JT808RegistrationFact, 0, len(order))
for _, phone := range order {
result = append(result, byPhone[phone])
}
return result
}
func (p *JT808RegistrationProjector) MarkPersisted(facts []JT808RegistrationFact) {
if p == nil || len(facts) == 0 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
for _, fact := range facts {
phone := normalizePhone(fact.Phone)
if phone == "" || fact.SeenAt.IsZero() {
continue
}
if current := p.lastTouches[phone]; fact.SeenAt.After(current) {
p.lastTouches[phone] = fact.SeenAt
}
}
p.cleanupLocked(time.Now())
}
func (p *JT808RegistrationProjector) projectLocked(env envelope.FrameEnvelope) (JT808RegistrationFact, bool) {
if env.Protocol != envelope.ProtocolJT808 || env.ParseStatus == envelope.ParseBadFrame {
return JT808RegistrationFact{}, false
}
messageID := strings.TrimSpace(env.MessageID)
if messageID != JT808RegisterMessageID && messageID != JT808AuthMessageID && messageID != JT808LocationMessageID {
return JT808RegistrationFact{}, false
}
phone := normalizePhone(env.Phone)
if phone == "" {
return JT808RegistrationFact{}, false
}
seenAt := p.receivedAt(env)
if seenAt.IsZero() {
return JT808RegistrationFact{}, false
}
if messageID == JT808LocationMessageID {
if last := p.lastTouches[phone]; !last.IsZero() && seenAt.Before(last.Add(p.touchInterval)) {
return JT808RegistrationFact{}, false
}
}
registration := mapValue(env.Parsed, "registration")
authentication := mapValue(env.Parsed, "authentication")
authenticationAccepted := messageID != JT808AuthMessageID ||
!env.AuthenticationEnforced || env.AuthenticationStatus == "accepted"
vin := strings.TrimSpace(env.VIN)
if vin == "" {
vin = "unknown"
}
fact := JT808RegistrationFact{
Phone: phone,
DeviceID: firstNonEmpty(env.DeviceID, textValue(registration, "device_id"), parsedFieldText(env.ParsedFields, "jt808.registration.device_id")),
Plate: firstNonEmpty(env.Plate, textValue(registration, "plate"), parsedFieldText(env.ParsedFields, "jt808.registration.plate")),
VIN: vin,
Province: firstNonEmpty(textValue(registration, "province"), parsedFieldText(env.ParsedFields, "jt808.registration.province")),
City: firstNonEmpty(textValue(registration, "city"), parsedFieldText(env.ParsedFields, "jt808.registration.city")),
Manufacturer: firstNonEmpty(textValue(registration, "manufacturer"), parsedFieldText(env.ParsedFields, "jt808.registration.manufacturer")),
DeviceType: firstNonEmpty(textValue(registration, "device_type"), parsedFieldText(env.ParsedFields, "jt808.registration.device_type")),
PlateColor: firstNonEmpty(textValue(registration, "plate_color"), parsedFieldText(env.ParsedFields, "jt808.registration.plate_color")),
AuthToken: firstNonEmpty(textValue(authentication, "token"), parsedFieldText(env.ParsedFields, "jt808.authentication.token")),
AuthIMEI: firstNonEmpty(textValue(authentication, "imei"), parsedFieldText(env.ParsedFields, "jt808.authentication.imei")),
AuthSoftwareVersion: firstNonEmpty(textValue(authentication, "software_version"), parsedFieldText(env.ParsedFields, "jt808.authentication.software_version")),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
SourceIP: normalizeEndpointIP(env.SourceEndpoint),
SeenAt: seenAt,
}
if !authenticationAccepted {
fact.AuthToken = ""
fact.AuthIMEI = ""
fact.AuthSoftwareVersion = ""
}
if messageID == JT808RegisterMessageID {
fact.FirstRegisteredAt = timePointer(seenAt)
fact.LatestRegisteredAt = timePointer(seenAt)
}
if messageID == JT808AuthMessageID && authenticationAccepted {
fact.LatestAuthenticated = timePointer(seenAt)
}
return fact, true
}
func parsedFieldText(fields map[string]any, key string) string {
value, ok := fields[key]
if !ok || value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
func (p *JT808RegistrationProjector) receivedAt(env envelope.FrameEnvelope) time.Time {
milliseconds := env.ReceivedAtMS
if milliseconds <= 0 {
milliseconds = env.EventTimeMS
}
if milliseconds <= 0 {
return time.Time{}
}
return time.UnixMilli(milliseconds).In(p.location).Truncate(time.Second)
}
func (p *JT808RegistrationProjector) cleanupLocked(now time.Time) {
if !p.nextCleanup.IsZero() && now.Before(p.nextCleanup) {
return
}
p.nextCleanup = now.Add(time.Hour)
cutoff := now.Add(-p.retention)
for phone, touchedAt := range p.lastTouches {
if touchedAt.Before(cutoff) {
delete(p.lastTouches, phone)
}
}
}
func mergeJT808RegistrationFact(current JT808RegistrationFact, candidate JT808RegistrationFact) JT808RegistrationFact {
if candidate.SeenAt.After(current.SeenAt) || candidate.SeenAt.Equal(current.SeenAt) {
current.DeviceID = firstNonEmpty(candidate.DeviceID, current.DeviceID)
current.Plate = firstNonEmpty(candidate.Plate, current.Plate)
current.VIN = preferKnownVIN(candidate.VIN, current.VIN)
current.Province = firstNonEmpty(candidate.Province, current.Province)
current.City = firstNonEmpty(candidate.City, current.City)
current.Manufacturer = firstNonEmpty(candidate.Manufacturer, current.Manufacturer)
current.DeviceType = firstNonEmpty(candidate.DeviceType, current.DeviceType)
current.PlateColor = firstNonEmpty(candidate.PlateColor, current.PlateColor)
current.AuthToken = firstNonEmpty(candidate.AuthToken, current.AuthToken)
current.AuthIMEI = firstNonEmpty(candidate.AuthIMEI, current.AuthIMEI)
current.AuthSoftwareVersion = firstNonEmpty(candidate.AuthSoftwareVersion, current.AuthSoftwareVersion)
current.SourceEndpoint = firstNonEmpty(candidate.SourceEndpoint, current.SourceEndpoint)
current.SourceIP = firstNonEmpty(candidate.SourceIP, current.SourceIP)
current.SeenAt = candidate.SeenAt
} else {
current.DeviceID = firstNonEmpty(current.DeviceID, candidate.DeviceID)
current.Plate = firstNonEmpty(current.Plate, candidate.Plate)
current.VIN = preferKnownVIN(current.VIN, candidate.VIN)
current.Province = firstNonEmpty(current.Province, candidate.Province)
current.City = firstNonEmpty(current.City, candidate.City)
current.Manufacturer = firstNonEmpty(current.Manufacturer, candidate.Manufacturer)
current.DeviceType = firstNonEmpty(current.DeviceType, candidate.DeviceType)
current.PlateColor = firstNonEmpty(current.PlateColor, candidate.PlateColor)
current.AuthToken = firstNonEmpty(current.AuthToken, candidate.AuthToken)
current.AuthIMEI = firstNonEmpty(current.AuthIMEI, candidate.AuthIMEI)
current.AuthSoftwareVersion = firstNonEmpty(current.AuthSoftwareVersion, candidate.AuthSoftwareVersion)
}
current.FirstRegisteredAt = earlierTimePointer(current.FirstRegisteredAt, candidate.FirstRegisteredAt)
current.LatestRegisteredAt = laterTimePointer(current.LatestRegisteredAt, candidate.LatestRegisteredAt)
current.LatestAuthenticated = laterTimePointer(current.LatestAuthenticated, candidate.LatestAuthenticated)
return current
}
func timePointer(value time.Time) *time.Time {
copy := value
return &copy
}
func earlierTimePointer(left *time.Time, right *time.Time) *time.Time {
if left == nil {
return right
}
if right == nil || left.Before(*right) || left.Equal(*right) {
return left
}
return right
}
func laterTimePointer(left *time.Time, right *time.Time) *time.Time {
if left == nil {
return right
}
if right == nil || left.After(*right) || left.Equal(*right) {
return left
}
return right
}
type JT808RegistrationStore struct {
db *sql.DB
}
func NewJT808RegistrationStore(db *sql.DB) *JT808RegistrationStore {
if db == nil {
panic("jt808 registration db must not be nil")
}
return &JT808RegistrationStore{db: db}
}
func EnsureJT808RegistrationSchema(ctx context.Context, db *sql.DB) error {
if db == nil {
return fmt.Errorf("jt808 registration db is nil")
}
if _, err := db.ExecContext(ctx, jt808RegistrationTableSQL); err != nil {
return err
}
for _, statement := range jt808RegistrationAlterSQL {
if _, err := db.ExecContext(ctx, statement); err != nil && !isIgnoredJT808RegistrationAlterError(err) {
return err
}
}
_, err := db.ExecContext(ctx, jt808RegistrationSourceIPBackfillSQL)
return err
}
func (s *JT808RegistrationStore) UpsertBatch(ctx context.Context, facts []JT808RegistrationFact) error {
if s == nil || s.db == nil || len(facts) == 0 {
return nil
}
const columns = `phone, device_id, plate, vin, province, city, manufacturer, device_type, plate_color,
auth_token, auth_imei, auth_software_version, source_endpoint, source_ip,
first_registered_at, latest_registered_at, latest_authenticated_at, latest_seen_at`
values := make([]string, 0, len(facts))
args := make([]any, 0, len(facts)*18)
for _, fact := range facts {
phone := normalizePhone(fact.Phone)
if phone == "" || fact.SeenAt.IsZero() {
continue
}
vin := strings.TrimSpace(fact.VIN)
if vin == "" {
vin = "unknown"
}
values = append(values, "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
args = append(args,
phone, strings.TrimSpace(fact.DeviceID), strings.TrimSpace(fact.Plate), vin,
strings.TrimSpace(fact.Province), strings.TrimSpace(fact.City), strings.TrimSpace(fact.Manufacturer),
strings.TrimSpace(fact.DeviceType), strings.TrimSpace(fact.PlateColor), strings.TrimSpace(fact.AuthToken),
strings.TrimSpace(fact.AuthIMEI), strings.TrimSpace(fact.AuthSoftwareVersion),
strings.TrimSpace(fact.SourceEndpoint), strings.TrimSpace(fact.SourceIP),
fact.FirstRegisteredAt, fact.LatestRegisteredAt, fact.LatestAuthenticated, fact.SeenAt,
)
}
if len(values) == 0 {
return nil
}
query := `INSERT INTO jt808_registration (` + columns + `) VALUES ` + strings.Join(values, ",") + `
ON DUPLICATE KEY UPDATE
device_id = IF(VALUES(device_id) <> '' AND (device_id IS NULL OR device_id = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(device_id), device_id),
plate = IF(VALUES(plate) <> '' AND (plate IS NULL OR plate = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(plate), plate),
vin = IF(VALUES(vin) <> '' AND VALUES(vin) <> 'unknown' AND (vin IS NULL OR vin = '' OR vin = 'unknown' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(vin), vin),
province = IF(VALUES(province) <> '' AND (province IS NULL OR province = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(province), province),
city = IF(VALUES(city) <> '' AND (city IS NULL OR city = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(city), city),
manufacturer = IF(VALUES(manufacturer) <> '' AND (manufacturer IS NULL OR manufacturer = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(manufacturer), manufacturer),
device_type = IF(VALUES(device_type) <> '' AND (device_type IS NULL OR device_type = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(device_type), device_type),
plate_color = IF(VALUES(plate_color) <> '' AND (plate_color IS NULL OR plate_color = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(plate_color), plate_color),
auth_token = IF(VALUES(auth_token) <> '' AND (auth_token IS NULL OR auth_token = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_token), auth_token),
auth_imei = IF(VALUES(auth_imei) <> '' AND (auth_imei IS NULL OR auth_imei = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_imei), auth_imei),
auth_software_version = IF(VALUES(auth_software_version) <> '' AND (auth_software_version IS NULL OR auth_software_version = '' OR latest_authenticated_at IS NULL OR VALUES(latest_authenticated_at) >= latest_authenticated_at), VALUES(auth_software_version), auth_software_version),
source_endpoint = IF(VALUES(source_endpoint) <> '' AND (source_endpoint IS NULL OR source_endpoint = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(source_endpoint), source_endpoint),
source_ip = IF(VALUES(source_ip) <> '' AND (source_ip IS NULL OR source_ip = '' OR VALUES(latest_seen_at) >= latest_seen_at), VALUES(source_ip), source_ip),
first_registered_at = CASE WHEN VALUES(first_registered_at) IS NULL THEN first_registered_at WHEN first_registered_at IS NULL THEN VALUES(first_registered_at) ELSE LEAST(first_registered_at, VALUES(first_registered_at)) END,
latest_registered_at = CASE WHEN VALUES(latest_registered_at) IS NULL THEN latest_registered_at WHEN latest_registered_at IS NULL THEN VALUES(latest_registered_at) ELSE GREATEST(latest_registered_at, VALUES(latest_registered_at)) END,
latest_authenticated_at = CASE WHEN VALUES(latest_authenticated_at) IS NULL THEN latest_authenticated_at WHEN latest_authenticated_at IS NULL THEN VALUES(latest_authenticated_at) ELSE GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at)) END,
latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))`
_, err := s.db.ExecContext(ctx, query, args...)
return err
}
@@ -0,0 +1,221 @@
package identity
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestJT808RegistrationProjectorProjectsRegistrationUsingReceiveTime(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*60*60)
projector := NewJT808RegistrationProjector(loc, 10*time.Minute)
receivedAt := time.Date(2026, 7, 13, 17, 20, 30, 987000000, loc)
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "0013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ReceivedAtMS: receivedAt.UnixMilli(),
EventTimeMS: receivedAt.Add(24 * time.Hour).UnixMilli(),
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.registration.province": "44",
"jt808.registration.city": "1",
"jt808.registration.manufacturer": "YUTNG",
"jt808.registration.device_type": "TBOX-1",
"jt808.registration.plate_color": "2",
},
}
facts := projector.ProjectBatch([]envelope.FrameEnvelope{env})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1", len(facts))
}
fact := facts[0]
if fact.Phone != "13307795425" || fact.VIN != env.VIN || fact.Plate != env.Plate {
t.Fatalf("identity fact = %+v", fact)
}
wantTime := receivedAt.Truncate(time.Second)
if !fact.SeenAt.Equal(wantTime) || fact.FirstRegisteredAt == nil || !fact.FirstRegisteredAt.Equal(wantTime) {
t.Fatalf("fact times = seen %s first %#v, want %s", fact.SeenAt, fact.FirstRegisteredAt, wantTime)
}
if fact.SourceIP != "115.231.168.135" || fact.Manufacturer != "YUTNG" || fact.PlateColor != "2" {
t.Fatalf("registration details = %+v", fact)
}
}
func TestJT808RegistrationProjectorThrottlesLocationOnlyAfterPersist(t *testing.T) {
loc := time.UTC
projector := NewJT808RegistrationProjector(loc, 10*time.Minute)
base := time.Date(2026, 7, 13, 8, 0, 0, 0, loc)
location := func(at time.Time) envelope.FrameEnvelope {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808LocationMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
ReceivedAtMS: at.UnixMilli(),
ParseStatus: envelope.ParseOK,
}
}
first := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)})
if len(first) != 1 {
t.Fatalf("first facts = %d, want 1", len(first))
}
// A failed database attempt must remain immediately replayable.
if replay := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)}); len(replay) != 1 {
t.Fatalf("uncommitted replay facts = %d, want 1", len(replay))
}
projector.MarkPersisted(first)
if throttled := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(9 * time.Minute))}); len(throttled) != 0 {
t.Fatalf("throttled facts = %d, want 0", len(throttled))
}
if due := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(10 * time.Minute))}); len(due) != 1 {
t.Fatalf("due facts = %d, want 1", len(due))
}
}
func TestJT808RegistrationProjectorMergesRegisterAndAuthForPhone(t *testing.T) {
projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute)
base := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
register := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
Plate: "粤A00001",
ReceivedAtMS: base.UnixMilli(),
ParseStatus: envelope.ParseOK,
}
auth := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "13307795425",
ReceivedAtMS: base.Add(time.Second).UnixMilli(),
ParseStatus: envelope.ParseOK,
ParsedFields: map[string]any{
"jt808.authentication.token": "g7gps",
"jt808.authentication.imei": "123456789012345",
},
}
facts := projector.ProjectBatch([]envelope.FrameEnvelope{register, auth})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1", len(facts))
}
fact := facts[0]
if fact.VIN != register.VIN || fact.Plate != register.Plate || fact.AuthToken != "g7gps" {
t.Fatalf("merged fact = %+v", fact)
}
if fact.FirstRegisteredAt == nil || fact.LatestRegisteredAt == nil || fact.LatestAuthenticated == nil {
t.Fatalf("merged timestamps missing: %+v", fact)
}
}
func TestJT808RegistrationProjectorDoesNotTrustEnforcedRejectedAuth(t *testing.T) {
projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute)
seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
facts := projector.ProjectBatch([]envelope.FrameEnvelope{{
Protocol: envelope.ProtocolJT808,
MessageID: JT808AuthMessageID,
Phone: "13307795425",
ReceivedAtMS: seenAt.UnixMilli(),
ParseStatus: envelope.ParseOK,
AuthenticationEnforced: true,
AuthenticationStatus: "rejected",
Parsed: map[string]any{
"authentication": map[string]any{
"token": "untrusted-token",
"imei": "untrusted-imei",
"software_version": "untrusted-version",
},
},
}})
if len(facts) != 1 {
t.Fatalf("facts = %d, want 1 audit touch", len(facts))
}
fact := facts[0]
if fact.AuthToken != "" || fact.AuthIMEI != "" || fact.AuthSoftwareVersion != "" || fact.LatestAuthenticated != nil {
t.Fatalf("rejected credential leaked into registration fact: %+v", fact)
}
}
func TestJT808RegistrationStoreUsesIdempotentEventTimeUpsert(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewJT808RegistrationStore(db)
seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO jt808_registration")).
WithArgs(
"13307795425", "DEV-1", "粤A00001", "LTESTVIN000000001", "", "", "YUTNG", "", "",
"g7gps", "", "", "115.231.168.135:43625", "115.231.168.135",
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), seenAt,
).
WillReturnResult(sqlmock.NewResult(1, 1))
err = store.UpsertBatch(context.Background(), []JT808RegistrationFact{{
Phone: "013307795425",
DeviceID: "DEV-1",
Plate: "粤A00001",
VIN: "LTESTVIN000000001",
Manufacturer: "YUTNG",
AuthToken: "g7gps",
SourceEndpoint: "115.231.168.135:43625",
SourceIP: "115.231.168.135",
FirstRegisteredAt: timePointer(seenAt),
LatestRegisteredAt: timePointer(seenAt),
LatestAuthenticated: timePointer(seenAt),
SeenAt: seenAt,
}})
if err != nil {
t.Fatalf("UpsertBatch() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestRegistrationUpsertProtectsLatestValuesFromOldReplay(t *testing.T) {
var query string
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(_ string, actual string) error {
query = actual
return nil
})))
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewJT808RegistrationStore(db)
mock.ExpectExec("INSERT INTO jt808_registration").WillReturnResult(sqlmock.NewResult(1, 1))
if err := store.UpsertBatch(context.Background(), []JT808RegistrationFact{{
Phone: "13307795425",
VIN: "unknown",
SeenAt: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC),
}}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
for _, required := range []string{
"VALUES(latest_seen_at) >= latest_seen_at",
"LEAST(first_registered_at, VALUES(first_registered_at))",
"GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at))",
"latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))",
} {
if !regexp.MustCompile(regexp.QuoteMeta(required)).MatchString(query) {
t.Fatalf("upsert SQL missing %q", required)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -57,9 +58,7 @@ func TestCandidateKeysNormalizesPhone(t *testing.T) {
func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
@@ -74,7 +73,7 @@ func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "phone" {
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
@@ -82,9 +81,254 @@ func TestMySQLResolverFillsVINFromPhone(t *testing.T) {
}
}
func TestMySQLResolverUsesDataSourceCodeForJT808IdentifierLookup(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierHitForSource(mock, "xinda", "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverVehicleIdentifierSourceOverridesDataSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "115.159.85.149").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("dongfang_beidou", "G7易流", "PLATFORM"))
expectVehicleIdentifierMissForSource(mock, "dongfang_beidou", "JT808_PHONE", "64341232682")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "64341232682", "LB9A32A23R0LS1045", "g7s", "G7s")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "064341232682",
SourceEndpoint: "115.159.85.149:42823",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LB9A32A23R0LS1045" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "g7s" || env.PlatformName != "G7s" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.g7s" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "g7s" || identity["platform_name"] != "G7s" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesStaleIdentifierCacheWhenLookupFails(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Nanosecond,
StaleLookupTTL: time.Hour,
})
first, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first Resolve() error = %v", err)
}
if first.VIN != "LNBVIN00000000001" {
t.Fatalf("first vin = %q", first.VIN)
}
time.Sleep(time.Millisecond)
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", "JT808_PHONE", "13307795425").
WillReturnError(errors.New("mysql temporarily unavailable"))
second, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second Resolve() error = %v", err)
}
if second.VIN != "LNBVIN00000000001" {
t.Fatalf("second vin = %q, want stale cached vin", second.VIN)
}
identity, ok := second.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", second.Parsed["identity"])
}
if identity["cache_status"] != "stale" {
t.Fatalf("identity cache_status = %#v, want stale", identity["cache_status"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestNormalizeEndpointIPUsesSharedSourceEndpointKey(t *testing.T) {
tests := map[string]string{
"115.231.168.135:43625": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
}
for input, want := range tests {
if got := normalizeEndpointIP(input); got != want {
t.Fatalf("normalizeEndpointIP(%q) = %q, want %q", input, got, want)
}
}
}
func TestMySQLResolverKeepsDirectSourceKindWithoutSourceCode(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", "39.144.3.22").
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow("", "", "DIRECT"))
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307765812", "LA9GG64L7PBAF4001")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307765812",
SourceEndpoint: "39.144.3.22:60177",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LA9GG64L7PBAF4001" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "" || env.SourceKind != "DIRECT" {
t.Fatalf("source metadata = code:%q kind:%q", env.SourceCode, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source_kind"] != "DIRECT" {
t.Fatalf("identity source metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverFallsBackToGlobalIdentifierWhenSourceCodeMisses(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeHit(mock, "115.231.168.135", "xinda")
expectVehicleIdentifierMissForSource(mock, "xinda", "JT808_PHONE", "13307795425")
expectVehicleIdentifierHit(mock, "JT808_PHONE", "13307795425", "LNBVIN00000000002")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNBVIN00000000002" {
t.Fatalf("vin = %q", env.VIN)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverUsesSingleGlobalIdentifierSourceCodeForJT808SourceMetadata(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectSourceCodeMiss(mock, "117.132.196.119")
expectVehicleIdentifierHitWithSource(mock, "JT808_PHONE", "41456413943", "LNXNEGRR0SR321372", "xinda", "信达")
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SourceCodeLookup: true,
LookupCacheTTL: time.Hour,
})
env, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "41456413943",
SourceEndpoint: "117.132.196.119:3275",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if env.VIN != "LNXNEGRR0SR321372" {
t.Fatalf("vin = %q", env.VIN)
}
if env.SourceCode != "xinda" || env.PlatformName != "信达" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok || identity["source"] != "vehicle_identifier.JT808_PHONE.xinda" {
t.Fatalf("identity metadata = %#v", env.Parsed["identity"])
}
if identity["source_code"] != "xinda" || identity["platform_name"] != "信达" || identity["source_kind"] != "PLATFORM" {
t.Fatalf("identity source metadata = %#v", identity)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\? AND vin IS NOT NULL AND vin <> ''$").
WithArgs("13307795425").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNBVIN00000000001"))
@@ -109,6 +353,7 @@ func TestMySQLResolverLooksUpVINByUniqueKeyWithoutSort(t *testing.T) {
func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -133,9 +378,11 @@ func TestMySQLResolverDoesNotLookupBindingByDeviceID(t *testing.T) {
func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13079963379")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13079963379").
WillReturnError(sql.ErrNoRows)
expectVehicleIdentifierMiss(mock, "PLATE", "TEST123")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("TEST123").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LKLG7C4E3NA774736"))
@@ -176,6 +423,7 @@ func TestMySQLResolverTracksJT808RegistrationWithoutWritingBinding(t *testing.T)
func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -206,12 +454,14 @@ func TestMySQLResolverTracksFirstJT808LocationThenThrottles(t *testing.T) {
func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnRows(sqlmock.NewRows([]string{"vin", "device_id", "plate"}).AddRow("unknown", "18285", "粤AG18285"))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
@@ -248,9 +498,352 @@ func TestMySQLResolverUsesJT808RegistrationPlateForLocationVIN(t *testing.T) {
}
}
func TestMySQLResolverRefreshesRegistrationCacheAfterRegisterFrame(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "40692934322")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT vin, device_id, plate FROM jt808_registration WHERE phone = \\?").
WithArgs("40692934322").
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
expectVehicleIdentifierMiss(mock, "PLATE", "粤AG18285")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE plate = \\?").
WithArgs("粤AG18285").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("LNXNEGRR7SR318212"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
firstLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("first location Resolve() error = %v", err)
}
if firstLocation.VIN != "" {
t.Fatalf("first location vin = %q, want unresolved", firstLocation.VIN)
}
registered, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0100",
Phone: "040692934322",
DeviceID: "18285",
Plate: "粤AG18285",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{
"registration": map[string]any{
"device_id": "18285",
"plate": "粤AG18285",
},
},
})
if err != nil {
t.Fatalf("registration Resolve() error = %v", err)
}
if registered.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("registered vin = %q", registered.VIN)
}
secondLocation, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("second location Resolve() error = %v", err)
}
if secondLocation.VIN != "LNXNEGRR7SR318212" {
t.Fatalf("second location vin = %q, want cache-refreshed registration vin", secondLocation.VIN)
}
if secondLocation.DeviceID != "18285" || secondLocation.Plate != "粤AG18285" {
t.Fatalf("second location identity not copied: device=%q plate=%q", secondLocation.DeviceID, secondLocation.Plate)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverDelegatesRegistrationPersistenceButKeepsLocalSession(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
var results []RegistrationWriteResult
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
DisableRegistrationWrites: true,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results = append(results, result)
},
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: JT808RegisterMessageID,
Phone: "013307795425",
VIN: "LTESTVIN000000001",
DeviceID: "DEV-1",
Plate: "粤A00001",
SourceEndpoint: "115.231.168.135:43625",
ParseStatus: envelope.ParseOK,
}
resolved, err := resolver.Resolve(context.Background(), env)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != env.VIN {
t.Fatalf("resolved vin = %q, want %q", resolved.VIN, env.VIN)
}
entry, ok := resolver.registrationCache["13307795425"]
if !ok || entry.vin != env.VIN || entry.plate != env.Plate || entry.deviceID != env.DeviceID {
t.Fatalf("local session = %+v exists=%v", entry, ok)
}
if len(results) != 1 || results[0].Mode != "delegated" || results[0].Status != "ok" {
t.Fatalf("registration write results = %#v", results)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unexpected mysql access: %v", err)
}
}
func TestMySQLResolverRetriesRegistrationUpsertTransientFailure(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection: read tcp: connection reset by peer"))
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
RegistrationWriteAttempts: 2,
RegistrationWriteRetryDelay: -1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, want internal retry to recover", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWritesDrainOnClose(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnResult(sqlmock.NewResult(1, 1))
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
LocationTouchInterval: time.Hour,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:ok"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue/background ok", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverAsyncRegistrationWriteFailureMarksLocationRetry(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
errs := make(chan error, 1)
results := make(chan RegistrationWriteResult, 2)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
AsyncRegistrationWrites: true,
RegistrationWriteQueueSize: 8,
RegistrationWriteWorkers: 1,
RegistrationWriteTimeout: time.Second,
OnRegistrationWriteResult: func(result RegistrationWriteResult) {
results <- result
},
OnRegistrationWriteError: func(err error) {
errs <- err
},
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("Resolve() error = %v, async write failure should be reported out-of-band", err)
}
if err := resolver.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
select {
case err := <-errs:
if err == nil {
t.Fatal("async error callback received nil")
}
default:
t.Fatal("async write failure was not reported")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
close(results)
got := map[string]int{}
for result := range results {
got[result.Mode+":"+result.Status]++
}
if got["async_enqueue:ok"] != 1 || got["async_background:error"] != 1 {
t.Fatalf("registration write results = %#v, want enqueue ok/background error", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestMySQLResolverBacksOffLocationTouchAfterExhaustedUpsert(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
mock.ExpectExec("INSERT INTO jt808_registration").
WillReturnError(errors.New("driver: bad connection"))
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LocationTouchInterval: time.Hour,
LocationTouchRetryInterval: time.Hour,
RegistrationWriteAttempts: 1,
LookupCacheTTL: time.Hour,
})
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "040692934322",
VIN: "LNXNEGRR7SR318212",
SourceEndpoint: "115.231.168.135:47822",
Parsed: map[string]any{},
}
if _, err := resolver.Resolve(context.Background(), env); err == nil {
t.Fatal("first Resolve() error = nil, want exhausted transient registration upsert failure")
}
stats := resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries = %d, want 1", stats.LocationTouchFailureEntries)
}
if _, err := resolver.Resolve(context.Background(), env); err != nil {
t.Fatalf("second Resolve() error = %v, want short backoff to skip immediate retry", err)
}
stats = resolver.CacheStats()
if stats.LocationTouchFailureEntries != 1 {
t.Fatalf("location touch failure cache entries after backoff = %d, want 1", stats.LocationTouchFailureEntries)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestIsTransientMySQLIdentityWriteError(t *testing.T) {
for _, err := range []error{
errors.New("dial tcp 127.0.0.1:3306: connection refused"),
errors.New("read tcp: connection reset by peer"),
errors.New("write tcp: broken pipe"),
errors.New("driver: bad connection"),
errors.New("invalid connection"),
errors.New("i/o timeout"),
errors.New("EOF"),
errors.New("server is down"),
errors.New("network is unreachable"),
} {
if !isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%q) = false, want true", err.Error())
}
}
for _, err := range []error{
context.Canceled,
context.DeadlineExceeded,
errors.New("duplicate key conflict"),
nil,
} {
if isTransientMySQLIdentityWriteError(err) {
t.Fatalf("isTransientMySQLIdentityWriteError(%v) = true, want false", err)
}
}
}
func TestTimeoutResolverAppliesDeadlineToDelegate(t *testing.T) {
delegate := &deadlineCheckingResolver{}
resolver := TimeoutResolver{Delegate: delegate, Timeout: 50 * time.Millisecond}
if _, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808}); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if delegate.deadline.IsZero() {
t.Fatal("delegate did not receive a deadline")
}
if remaining := time.Until(delegate.deadline); remaining <= 0 || remaining > time.Second {
t.Fatalf("deadline remaining = %s", remaining)
}
}
func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectVehicleIdentifierMiss(mock, "JT808_PHONE", "13307795425")
mock.ExpectQuery("SELECT vin FROM vehicle_identity_binding WHERE phone = \\?").
WithArgs("13307795425").
WillReturnError(sql.ErrNoRows)
@@ -274,6 +867,95 @@ func TestMySQLResolverCachesIdentityMissesForHighFrequencyFrames(t *testing.T) {
}
}
func TestMySQLResolverBoundsIdentityLookupCaches(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
for i, key := range []string{"lookup-1", "lookup-2", "lookup-3"} {
entryNow := now.Add(time.Duration(i) * time.Second)
resolver.cacheLookup(key, lookupCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheRegistration("phone-"+key, registrationCacheEntry{
vin: key,
expiresAt: entryNow.Add(time.Hour),
}, entryNow)
resolver.cacheSourceMetadata("source-"+key, sourceMetadata{SourceCode: key}, false, entryNow)
}
stats := resolver.CacheStats()
if stats.LookupEntries != 2 || stats.RegistrationEntries != 2 || stats.SourceCodeEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want all identity caches capped at 2", stats)
}
resolver.lookupMu.Lock()
_, hasOldLookup := resolver.lookupCache["lookup-1"]
_, hasOldRegistration := resolver.registrationCache["phone-lookup-1"]
_, hasOldSource := resolver.sourceCodeCache["source-lookup-1"]
resolver.lookupMu.Unlock()
if hasOldLookup || hasOldRegistration || hasOldSource {
t.Fatalf("oldest cache entries should be evicted")
}
}
func TestMySQLResolverBoundsLocationTouchCache(t *testing.T) {
db, _ := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
LookupCacheTTL: time.Hour,
CacheCleanupInterval: time.Hour,
MaxCacheEntries: 2,
LocationTouchInterval: time.Hour,
})
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
resolver.touchMu.Lock()
resolver.locationTouches["old-expired"] = now.Add(-2 * time.Hour)
resolver.locationTouches["phone-1"] = now.Add(-2 * time.Minute)
resolver.locationTouches["phone-2"] = now.Add(-time.Minute)
resolver.locationTouches["phone-3"] = now
resolver.locationTouchFailures["old-failure"] = now.Add(-time.Minute)
resolver.locationTouchFailures["phone-2"] = now.Add(time.Minute)
resolver.locationTouchFailures["phone-3"] = now.Add(2 * time.Minute)
resolver.locationTouchFailures["phone-4"] = now.Add(3 * time.Minute)
resolver.cleanupLocationTouchesLocked(now, false)
resolver.touchMu.Unlock()
stats := resolver.CacheStats()
if stats.LocationTouchEntries != 2 || stats.LocationTouchFailureEntries != 2 || stats.MaxEntries != 2 {
t.Fatalf("cache stats = %+v, want location touch caches capped at 2", stats)
}
resolver.touchMu.Lock()
_, hasExpired := resolver.locationTouches["old-expired"]
_, hasOldest := resolver.locationTouches["phone-1"]
_, hasExpiredFailure := resolver.locationTouchFailures["old-failure"]
_, hasOldestFailure := resolver.locationTouchFailures["phone-2"]
resolver.touchMu.Unlock()
if hasExpired || hasOldest || hasExpiredFailure || hasOldestFailure {
t.Fatalf("expired and oldest location touch entries should be evicted")
}
}
type deadlineCheckingResolver struct {
mu sync.Mutex
deadline time.Time
}
func (r *deadlineCheckingResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
deadline, _ := ctx.Deadline()
r.mu.Lock()
r.deadline = deadline
r.mu.Unlock()
return env, nil
}
func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
@@ -285,8 +967,18 @@ func TestMySQLResolverEnsuresMinimalIdentitySchema(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
@@ -308,8 +1000,18 @@ func TestMySQLResolverIgnoresExistingOEMColumn(t *testing.T) {
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'uk_identity_device'; check that column/key exists"))
mock.ExpectExec("ALTER TABLE vehicle_identity_binding DROP COLUMN device_id").
WillReturnError(errors.New("Error 1091 (42000): Can't DROP 'device_id'; check that column/key exists"))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle \\(").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_identifier").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE TABLE IF NOT EXISTS jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE jt808_registration ADD COLUMN source_ip").
WillReturnError(errors.New("Error 1060 (42S21): Duplicate column name 'source_ip'"))
mock.ExpectExec("ALTER TABLE jt808_registration ADD KEY idx_jt808_registration_source_ip").
WillReturnError(errors.New("Error 1061 (42000): Duplicate key name 'idx_jt808_registration_source_ip'"))
mock.ExpectExec("UPDATE jt808_registration").
WillReturnResult(sqlmock.NewResult(0, 0))
resolver := NewMySQLResolver(db, "vehicle_identity_binding")
if err := resolver.EnsureSchema(context.Background()); err != nil {
@@ -339,6 +1041,15 @@ func TestIdentitySchemaUsesBusinessKeysOnly(t *testing.T) {
if !strings.Contains(registration, "phone VARCHAR(32) PRIMARY KEY") {
t.Fatalf("registration table should key by phone:\n%s", registration)
}
if !strings.Contains(registration, "source_ip VARCHAR(64)") || !strings.Contains(registration, "idx_jt808_registration_source_ip") {
t.Fatalf("registration table should keep indexed source_ip:\n%s", registration)
}
if !strings.Contains(vehicleIdentifierTableSQL, "PRIMARY KEY (protocol, source_code, identifier_type, identifier_value)") {
t.Fatalf("vehicle identifier should use protocol/source/type/value as key:\n%s", vehicleIdentifierTableSQL)
}
if strings.Contains(vehicleIdentifierTableSQL, "AUTO_INCREMENT") {
t.Fatalf("vehicle identifier should not use surrogate auto increment id:\n%s", vehicleIdentifierTableSQL)
}
}
func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
@@ -349,3 +1060,43 @@ func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) {
}
return db, mock
}
func expectVehicleIdentifierMiss(mock sqlmock.Sqlmock, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHit(mock sqlmock.Sqlmock, identifierType string, value string, vin string) {
expectVehicleIdentifierHitWithSource(mock, identifierType, value, vin, "", "")
}
func expectVehicleIdentifierHitWithSource(mock sqlmock.Sqlmock, identifierType string, value string, vin string, sourceCode string, platformName string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, platformName))
}
func expectSourceCodeHit(mock sqlmock.Sqlmock, sourceIP string, sourceCode string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}).AddRow(sourceCode, "G7s", "PLATFORM"))
}
func expectSourceCodeMiss(mock sqlmock.Sqlmock, sourceIP string) {
mock.ExpectQuery("SELECT source_code").
WithArgs("JT808", sourceIP).
WillReturnRows(sqlmock.NewRows([]string{"source_code", "platform_name", "source_kind"}))
}
func expectVehicleIdentifierMissForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}))
}
func expectVehicleIdentifierHitForSource(mock sqlmock.Sqlmock, sourceCode string, identifierType string, value string, vin string) {
mock.ExpectQuery("SELECT DISTINCT vin").
WithArgs("JT808", identifierType, value, sourceCode).
WillReturnRows(sqlmock.NewRows([]string{"vin", "source_code", "platform_name"}).AddRow(vin, sourceCode, "G7s"))
}
@@ -0,0 +1,325 @@
package identity
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
type SnapshotRefreshResult struct {
BindingEntries int
IdentifierEntries int
RegistrationEntries int
SourceEntries int
RefreshedAt time.Time
}
// identitySnapshot is immutable after atomic publication, so frame handling
// performs only local map lookups and never waits for MySQL or a refresh lock.
type identitySnapshot struct {
bindings map[string]string
identifiers map[string]vehicleIdentifierMatch
registrations map[string]registrationCacheEntry
sources map[string]sourceMetadata
refreshedAt time.Time
}
func (r *MySQLResolver) RefreshSnapshot(ctx context.Context) (SnapshotRefreshResult, error) {
if r == nil || r.db == nil {
return SnapshotRefreshResult{}, fmt.Errorf("identity snapshot database is not configured")
}
r.snapshotRefreshMu.Lock()
defer r.snapshotRefreshMu.Unlock()
next := &identitySnapshot{
bindings: map[string]string{},
identifiers: map[string]vehicleIdentifierMatch{},
registrations: map[string]registrationCacheEntry{},
sources: map[string]sourceMetadata{},
}
if err := r.loadBindingSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadIdentifierSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadRegistrationSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
if err := r.loadSourceSnapshot(ctx, next); err != nil {
return SnapshotRefreshResult{}, err
}
next.refreshedAt = time.Now()
r.snapshot.Store(next)
return SnapshotRefreshResult{
BindingEntries: len(next.bindings),
IdentifierEntries: len(next.identifiers),
RegistrationEntries: len(next.registrations),
SourceEntries: len(next.sources),
RefreshedAt: next.refreshedAt,
}, nil
}
func (r *MySQLResolver) loadBindingSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, "SELECT vin, plate, phone FROM "+r.table+" WHERE vin IS NOT NULL AND TRIM(vin) <> ''")
if err != nil {
return fmt.Errorf("load identity binding snapshot: %w", err)
}
defer rows.Close()
ambiguous := map[string]struct{}{}
for rows.Next() {
var vin, plate, phone sql.NullString
if err := rows.Scan(&vin, &plate, &phone); err != nil {
return fmt.Errorf("scan identity binding snapshot: %w", err)
}
vinValue := strings.TrimSpace(vin.String)
if vinValue == "" {
continue
}
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("vin", vinValue), vinValue)
if value := strings.TrimSpace(plate.String); value != "" {
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("plate", value), vinValue)
}
if value := normalizePhone(phone.String); value != "" {
addSnapshotBinding(target.bindings, ambiguous, bindingSnapshotKey("phone", value), vinValue)
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate identity binding snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadIdentifierSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT protocol, source_code, identifier_type, identifier_value,
vin, COALESCE(NULLIF(TRIM(oem), ''), source_code) AS platform_name
FROM vehicle_identifier
WHERE enabled = 1 AND vin IS NOT NULL AND TRIM(vin) <> ''
AND identifier_value IS NOT NULL AND TRIM(identifier_value) <> ''`)
if err != nil {
return fmt.Errorf("load vehicle identifier snapshot: %w", err)
}
defer rows.Close()
ambiguous := map[string]struct{}{}
for rows.Next() {
var protocol, sourceCode, identifierType, identifierValue, vin, platformName sql.NullString
if err := rows.Scan(&protocol, &sourceCode, &identifierType, &identifierValue, &vin, &platformName); err != nil {
return fmt.Errorf("scan vehicle identifier snapshot: %w", err)
}
protocolValue := strings.TrimSpace(protocol.String)
typeValue := strings.ToUpper(strings.TrimSpace(identifierType.String))
value := normalizeIdentifierValue(typeValue, identifierValue.String)
vinValue := strings.TrimSpace(vin.String)
if protocolValue == "" || typeValue == "" || value == "" || vinValue == "" {
continue
}
match := vehicleIdentifierMatch{
VIN: vinValue,
SourceCode: strings.TrimSpace(sourceCode.String),
PlatformName: strings.TrimSpace(platformName.String),
}
scopedKey := vehicleIdentifierSnapshotKey(protocolValue, match.SourceCode, typeValue, value)
addSnapshotIdentifier(target.identifiers, ambiguous, scopedKey, match)
globalKey := vehicleIdentifierSnapshotKey(protocolValue, "", typeValue, value)
addSnapshotIdentifier(target.identifiers, ambiguous, globalKey, match)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate vehicle identifier snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadRegistrationSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT phone, vin, device_id, plate, auth_token
FROM jt808_registration
WHERE phone IS NOT NULL AND TRIM(phone) <> ''`)
if err != nil {
return fmt.Errorf("load jt808 registration snapshot: %w", err)
}
defer rows.Close()
for rows.Next() {
var phone, vin, deviceID, plate, authToken sql.NullString
if err := rows.Scan(&phone, &vin, &deviceID, &plate, &authToken); err != nil {
return fmt.Errorf("scan jt808 registration snapshot: %w", err)
}
phoneValue := normalizePhone(phone.String)
if phoneValue == "" {
continue
}
target.registrations[phoneValue] = registrationCacheEntry{
vin: strings.TrimSpace(vin.String),
deviceID: strings.TrimSpace(deviceID.String),
plate: strings.TrimSpace(plate.String),
authToken: strings.TrimSpace(authToken.String),
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate jt808 registration snapshot: %w", err)
}
return nil
}
func (r *MySQLResolver) loadSourceSnapshot(ctx context.Context, target *identitySnapshot) error {
rows, err := r.db.QueryContext(ctx, `SELECT protocol, source_ip, source_code, platform_name, source_kind
FROM vehicle_data_source
WHERE enabled = 1 AND source_ip IS NOT NULL AND TRIM(source_ip) <> ''
AND (
(source_code IS NOT NULL AND TRIM(source_code) <> '')
OR source_kind IN ('PLATFORM', 'DIRECT')
OR (platform_name IS NOT NULL AND TRIM(platform_name) <> '')
)`)
if err != nil {
if isOptionalSourceCodeLookupError(err) {
return nil
}
return fmt.Errorf("load vehicle data source snapshot: %w", err)
}
defer rows.Close()
for rows.Next() {
var protocol, sourceIP, sourceCode, platformName, sourceKind sql.NullString
if err := rows.Scan(&protocol, &sourceIP, &sourceCode, &platformName, &sourceKind); err != nil {
return fmt.Errorf("scan vehicle data source snapshot: %w", err)
}
key := sourceSnapshotKey(protocol.String, sourceIP.String)
if key == "" {
continue
}
target.sources[key] = sourceMetadata{
SourceCode: strings.TrimSpace(sourceCode.String),
PlatformName: strings.TrimSpace(platformName.String),
SourceKind: strings.TrimSpace(sourceKind.String),
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate vehicle data source snapshot: %w", err)
}
return nil
}
func addSnapshotBinding(values map[string]string, ambiguous map[string]struct{}, key string, vin string) {
if key == "" {
return
}
if _, exists := ambiguous[key]; exists {
return
}
if current, exists := values[key]; exists && !strings.EqualFold(current, vin) {
delete(values, key)
ambiguous[key] = struct{}{}
return
}
values[key] = vin
}
func addSnapshotIdentifier(values map[string]vehicleIdentifierMatch, ambiguous map[string]struct{}, key string, match vehicleIdentifierMatch) {
if key == "" {
return
}
if _, exists := ambiguous[key]; exists {
return
}
current, exists := values[key]
if !exists {
values[key] = match
return
}
if !strings.EqualFold(current.VIN, match.VIN) {
delete(values, key)
ambiguous[key] = struct{}{}
return
}
if !strings.EqualFold(current.SourceCode, match.SourceCode) {
current.SourceCode = ""
current.PlatformName = ""
} else if current.PlatformName != match.PlatformName {
current.PlatformName = ""
}
values[key] = current
}
func (r *MySQLResolver) snapshotBinding(column string, value string) (string, bool) {
key := bindingSnapshotKey(column, value)
if key == "" {
return "", false
}
snapshot := r.snapshot.Load()
if snapshot == nil {
return "", false
}
vin, ok := snapshot.bindings[key]
return vin, ok
}
func (r *MySQLResolver) snapshotIdentifier(protocolValue string, sourceCode string, identifierType string, value string) (vehicleIdentifierMatch, bool) {
key := vehicleIdentifierSnapshotKey(protocolValue, sourceCode, identifierType, value)
snapshot := r.snapshot.Load()
if snapshot == nil {
return vehicleIdentifierMatch{}, false
}
match, ok := snapshot.identifiers[key]
return match, ok
}
func (r *MySQLResolver) snapshotRegistration(phone string) (registrationCacheEntry, bool) {
phone = normalizePhone(phone)
snapshot := r.snapshot.Load()
if snapshot == nil {
return registrationCacheEntry{}, false
}
entry, ok := snapshot.registrations[phone]
return entry, ok
}
func (r *MySQLResolver) snapshotSource(protocolValue string, sourceIP string) (sourceMetadata, bool) {
key := sourceSnapshotKey(protocolValue, sourceIP)
snapshot := r.snapshot.Load()
if snapshot == nil {
return sourceMetadata{}, false
}
metadata, ok := snapshot.sources[key]
return metadata, ok
}
// JT808AuthToken serves authentication from the same immutable snapshot used
// by identity resolution. It deliberately never falls back to a per-frame SQL
// query because authentication is on the protocol response hot path.
func (r *MySQLResolver) JT808AuthToken(phone string) (string, bool) {
entry, ok := r.snapshotRegistration(phone)
token := strings.TrimSpace(entry.authToken)
return token, ok && token != ""
}
func bindingSnapshotKey(column string, value string) string {
column = strings.ToLower(strings.TrimSpace(column))
value = strings.TrimSpace(value)
if column == "phone" {
value = normalizePhone(value)
}
if value == "" || (column != "vin" && column != "plate" && column != "phone") {
return ""
}
return column + "\x00" + value
}
func vehicleIdentifierSnapshotKey(protocolValue string, sourceCode string, identifierType string, value string) string {
protocolValue = strings.TrimSpace(protocolValue)
sourceCode = strings.TrimSpace(sourceCode)
identifierType = strings.ToUpper(strings.TrimSpace(identifierType))
value = normalizeIdentifierValue(identifierType, value)
if protocolValue == "" || identifierType == "" || value == "" {
return ""
}
return "vehicle_identifier\x00" + protocolValue + "\x00" + sourceCode + "\x00" + identifierType + "\x00" + value
}
func sourceSnapshotKey(protocolValue string, sourceIP string) string {
protocolValue = strings.TrimSpace(protocolValue)
sourceIP = normalizeEndpointIP(sourceIP)
if protocolValue == "" || sourceIP == "" {
return ""
}
return protocolValue + "\x00" + sourceIP
}
@@ -0,0 +1,166 @@
package identity
import (
"context"
"errors"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestRefreshSnapshotResolvesKnownJT808WithoutPerFrameQueries(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
result, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
if result.BindingEntries != 3 || result.IdentifierEntries != 2 || result.RegistrationEntries != 1 || result.SourceEntries != 1 {
t.Fatalf("snapshot result = %+v", result)
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
SourceEndpoint: "115.231.168.135:43625",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != "LNBVIN00000000001" {
t.Fatalf("vin = %q", resolved.VIN)
}
if resolved.SourceCode != "g7s" || resolved.PlatformName != "G7s" || resolved.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", resolved.SourceCode, resolved.PlatformName, resolved.SourceKind)
}
identityMetadata, _ := resolved.Parsed["identity"].(map[string]any)
if identityMetadata["cache_status"] != "snapshot" {
t.Fatalf("identity metadata = %#v, want snapshot cache status", identityMetadata)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSnapshotOnlyResolverMissDoesNotQueryMySQL(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307700000",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.VIN != "" {
t.Fatalf("vin = %q, want unresolved", resolved.VIN)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("snapshot-only miss should not query mysql: %v", err)
}
}
func TestRefreshSnapshotFailureKeepsLastKnownGoodSnapshot(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
first, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("first RefreshSnapshot() error = %v", err)
}
mock.ExpectQuery("SELECT vin, plate, phone").
WillReturnError(errors.New("mysql unavailable"))
if _, err := resolver.RefreshSnapshot(context.Background()); err == nil {
t.Fatal("second RefreshSnapshot() error = nil, want failure")
}
resolved, err := resolver.Resolve(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "013307795425",
Parsed: map[string]any{},
})
if err != nil {
t.Fatalf("Resolve() after failed refresh error = %v", err)
}
if resolved.VIN != "LNBVIN00000000001" {
t.Fatalf("vin after failed refresh = %q", resolved.VIN)
}
stats := resolver.CacheStats()
if !stats.SnapshotReady || stats.SnapshotRefreshedAt.IsZero() || !stats.SnapshotRefreshedAt.Equal(first.RefreshedAt) {
t.Fatalf("snapshot stats after failed refresh = %+v, first = %+v", stats, first)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func expectIdentitySnapshot(mock sqlmock.Sqlmock) {
mock.ExpectQuery("SELECT vin, plate, phone").
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "phone"}).
AddRow("LNBVIN00000000001", "粤A00001", "13307795425"))
mock.ExpectQuery("SELECT protocol, source_code, identifier_type, identifier_value").
WillReturnRows(sqlmock.NewRows([]string{"protocol", "source_code", "identifier_type", "identifier_value", "vin", "platform_name"}).
AddRow("JT808", "g7s", "JT808_PHONE", "13307795425", "LNBVIN00000000001", "G7s"))
mock.ExpectQuery("SELECT phone, vin, device_id, plate, auth_token").
WillReturnRows(sqlmock.NewRows([]string{"phone", "vin", "device_id", "plate", "auth_token"}).
AddRow("13307795425", "LNBVIN00000000001", "DEVICE-1", "粤A00001", "device-code"))
mock.ExpectQuery("SELECT protocol, source_ip, source_code, platform_name, source_kind").
WillReturnRows(sqlmock.NewRows([]string{"protocol", "source_ip", "source_code", "platform_name", "source_kind"}).
AddRow("JT808", "115.231.168.135", "g7s", "G7s", "PLATFORM"))
}
func TestSnapshotServesJT808AuthenticationTokenByNormalizedPhone(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
if _, err := resolver.RefreshSnapshot(context.Background()); err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
token, ok := resolver.JT808AuthToken("0013307795425")
if !ok || token != "device-code" {
t.Fatalf("JT808AuthToken() = %q, %v", token, ok)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSnapshotResultRefreshedAtUsesCurrentTime(t *testing.T) {
db, mock := newMockDB(t)
defer db.Close()
expectIdentitySnapshot(mock)
resolver := NewMySQLResolverWithOptions(db, "vehicle_identity_binding", MySQLResolverOptions{
SnapshotOnlyLookups: true,
})
before := time.Now()
result, err := resolver.RefreshSnapshot(context.Background())
if err != nil {
t.Fatalf("RefreshSnapshot() error = %v", err)
}
if result.RefreshedAt.Before(before) || result.RefreshedAt.After(time.Now()) {
t.Fatalf("refreshed_at = %v, want current time", result.RefreshedAt)
}
}
+27 -8
View File
@@ -14,6 +14,9 @@ const (
ProtocolGB32960 Protocol = "gb32960"
ProtocolJT808 Protocol = "jt808"
ProtocolYutongMQTT Protocol = "yutong-mqtt"
DefaultJT808PhoneBase int64 = 139000000000
maxJT808Phone int64 = 999999999999
)
type Config struct {
@@ -25,17 +28,21 @@ type Config struct {
Duration time.Duration
Template string
SendFrames bool
JT808PhoneBase int64
DrainResponses bool
}
type FlagConfig struct {
protocol string
addr string
connections int
connectRate int
sendInterval time.Duration
duration time.Duration
template string
sendFrames bool
protocol string
addr string
connections int
connectRate int
sendInterval time.Duration
duration time.Duration
template string
sendFrames bool
jt808PhoneBase int64
drainResponses bool
}
func RegisterFlags(fs *flag.FlagSet) *FlagConfig {
@@ -48,6 +55,8 @@ func RegisterFlags(fs *flag.FlagSet) *FlagConfig {
fs.DurationVar(&cfg.duration, "duration", 10*time.Minute, "load test duration")
fs.StringVar(&cfg.template, "template", "", "frame template name")
fs.BoolVar(&cfg.sendFrames, "send", true, "send protocol frames while connections are open")
fs.Int64Var(&cfg.jt808PhoneBase, "jt808-phone-base", DefaultJT808PhoneBase, "first synthetic JT808 phone; one consecutive phone is assigned per connection")
fs.BoolVar(&cfg.drainResponses, "drain-responses", true, "continuously read protocol responses while frames are being sent")
return cfg
}
@@ -73,6 +82,14 @@ func (c *FlagConfig) Build() (Config, error) {
if c.duration <= 0 {
return Config{}, errors.New("duration must be positive")
}
if protocol == ProtocolJT808 {
if c.jt808PhoneBase <= 0 || c.jt808PhoneBase > maxJT808Phone {
return Config{}, errors.New("jt808-phone-base must be a positive 12-digit-or-shorter number")
}
if int64(c.connections-1) > maxJT808Phone-c.jt808PhoneBase {
return Config{}, errors.New("jt808 synthetic phone range exceeds 12 digits")
}
}
return Config{
Protocol: protocol,
Addr: strings.TrimSpace(c.addr),
@@ -82,5 +99,7 @@ func (c *FlagConfig) Build() (Config, error) {
Duration: c.duration,
Template: strings.TrimSpace(c.template),
SendFrames: c.sendFrames,
JT808PhoneBase: c.jt808PhoneBase,
DrainResponses: c.drainResponses,
}, nil
}
@@ -18,6 +18,7 @@ func TestConfigFromFlagsParsesCapacityKnobs(t *testing.T) {
"-send-interval", "5s",
"-duration", "30m",
"-template", "0200",
"-jt808-phone-base", "138900000000",
"-send=false",
})
if err != nil {
@@ -53,6 +54,12 @@ func TestConfigFromFlagsParsesCapacityKnobs(t *testing.T) {
if got.SendFrames {
t.Fatal("SendFrames = true, want false")
}
if got.JT808PhoneBase != 138900000000 {
t.Fatalf("JT808PhoneBase = %d, want 138900000000", got.JT808PhoneBase)
}
if !got.DrainResponses {
t.Fatal("DrainResponses = false, want true by default")
}
}
func TestConfigFromFlagsRejectsUnsafeValues(t *testing.T) {
@@ -62,6 +69,7 @@ func TestConfigFromFlagsRejectsUnsafeValues(t *testing.T) {
"zero connections": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "0"},
"zero connect rate": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "1", "-connect-rate", "0"},
"zero interval": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "1", "-send-interval", "0s"},
"phone overflow": {"-protocol", "jt808", "-addr", "127.0.0.1:808", "-connections", "2", "-jt808-phone-base", "999999999999"},
} {
t.Run(name, func(t *testing.T) {
fs := flag.NewFlagSet("load-sim", flag.ContinueOnError)
+14 -6
View File
@@ -7,22 +7,30 @@ import (
)
type FrameFactory struct {
protocol Protocol
base []byte
protocol Protocol
base []byte
jt808PhoneBase int64
}
func NewFrameFactory(protocol Protocol, template string) (*FrameFactory, error) {
return NewFrameFactoryWithJT808PhoneBase(protocol, template, DefaultJT808PhoneBase)
}
func NewFrameFactoryWithJT808PhoneBase(protocol Protocol, template string, phoneBase int64) (*FrameFactory, error) {
base, err := FrameTemplate(protocol, template)
if err != nil {
return nil, err
}
return &FrameFactory{protocol: protocol, base: base}, nil
if phoneBase <= 0 {
phoneBase = DefaultJT808PhoneBase
}
return &FrameFactory{protocol: protocol, base: base, jt808PhoneBase: phoneBase}, nil
}
func (f *FrameFactory) Frame(connectionIndex int, frameIndex int64) ([]byte, error) {
switch f.protocol {
case ProtocolJT808:
return mutateJT808Frame(f.base, connectionIndex, frameIndex)
return mutateJT808Frame(f.base, f.jt808PhoneBase, connectionIndex, frameIndex)
case ProtocolGB32960:
return mutateGB32960Frame(f.base, connectionIndex, frameIndex)
default:
@@ -31,7 +39,7 @@ func (f *FrameFactory) Frame(connectionIndex int, frameIndex int64) ([]byte, err
}
}
func mutateJT808Frame(base []byte, connectionIndex int, frameIndex int64) ([]byte, error) {
func mutateJT808Frame(base []byte, phoneBase int64, connectionIndex int, frameIndex int64) ([]byte, error) {
if len(base) < 2 || base[0] != 0x7e || base[len(base)-1] != 0x7e {
return nil, fmt.Errorf("jt808 template must include 0x7e delimiters")
}
@@ -42,7 +50,7 @@ func mutateJT808Frame(base []byte, connectionIndex int, frameIndex int64) ([]byt
if len(payload) < 13 {
return nil, fmt.Errorf("jt808 template too short: %d", len(payload))
}
phone := fmt.Sprintf("%012d", 139000000000+connectionIndex%100000000)
phone := fmt.Sprintf("%012d", phoneBase+int64(connectionIndex))
copy(payload[4:10], encodeBCD(phone, 6))
binary.BigEndian.PutUint16(payload[10:12], uint16((int(frameIndex)+connectionIndex)%65536))
bodySize := int(binary.BigEndian.Uint16(payload[2:4]) & 0x03ff)
@@ -38,6 +38,20 @@ func TestFrameFactoryGeneratesUniqueParsableJT808Frames(t *testing.T) {
}
}
func TestFrameFactoryUsesConfiguredJT808PhoneBase(t *testing.T) {
factory, err := NewFrameFactoryWithJT808PhoneBase(ProtocolJT808, "0200", 138900000000)
if err != nil {
t.Fatalf("NewFrameFactoryWithJT808PhoneBase() error = %v", err)
}
frame, err := factory.Frame(42, 0)
if err != nil {
t.Fatalf("Frame() error = %v", err)
}
if got := parseJT808Frame(t, frame).Phone; got != "138900000042" {
t.Fatalf("phone = %q, want 138900000042", got)
}
}
func TestFrameFactoryGeneratesUniqueParsableGB32960Frames(t *testing.T) {
factory, err := NewFrameFactory(ProtocolGB32960, "realtime")
if err != nil {
+53 -9
View File
@@ -2,6 +2,8 @@ package loadsim
import (
"context"
"errors"
"io"
"net"
"sync"
"sync/atomic"
@@ -19,24 +21,32 @@ type Stats struct {
ConnectionsFailed int64
FramesWritten int64
WriteErrors int64
ResponseBytes int64
ReadErrors int64
}
func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) {
phoneBase := cfg.JT808PhoneBase
if phoneBase <= 0 {
phoneBase = DefaultJT808PhoneBase
}
if _, err := (&FlagConfig{
protocol: string(cfg.Protocol),
addr: cfg.Addr,
connections: cfg.Connections,
connectRate: cfg.ConnectRatePerSecond,
sendInterval: cfg.SendInterval,
duration: cfg.Duration,
template: cfg.Template,
protocol: string(cfg.Protocol),
addr: cfg.Addr,
connections: cfg.Connections,
connectRate: cfg.ConnectRatePerSecond,
sendInterval: cfg.SendInterval,
duration: cfg.Duration,
template: cfg.Template,
jt808PhoneBase: phoneBase,
drainResponses: cfg.DrainResponses,
}).Build(); err != nil {
return Stats{}, err
}
var factory *FrameFactory
if cfg.SendFrames {
var err error
factory, err = NewFrameFactory(cfg.Protocol, cfg.Template)
factory, err = NewFrameFactoryWithJT808PhoneBase(cfg.Protocol, cfg.Template, phoneBase)
if err != nil {
return Stats{}, err
}
@@ -74,12 +84,24 @@ func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) {
connectionIndex++
go func() {
defer wg.Done()
defer conn.Close()
if cfg.SendFrames {
var readDone chan struct{}
if cfg.DrainResponses {
readDone = make(chan struct{})
go func() {
drainResponses(runCtx, conn, &stats)
close(readDone)
}()
}
writeLoop(runCtx, conn, factory, connIndex, cfg.SendInterval, &stats)
_ = conn.Close()
if readDone != nil {
<-readDone
}
return
}
<-runCtx.Done()
_ = conn.Close()
}()
}
}
@@ -88,6 +110,28 @@ func (r Runner) Run(ctx context.Context, cfg Config) (Stats, error) {
return stats, nil
}
func drainResponses(ctx context.Context, conn net.Conn, stats *Stats) {
buffer := make([]byte, 4096)
for {
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
read, err := conn.Read(buffer)
if read > 0 {
atomic.AddInt64(&stats.ResponseBytes, int64(read))
}
if err == nil {
continue
}
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
return
}
if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
continue
}
atomic.AddInt64(&stats.ReadErrors, 1)
return
}
}
func writeLoop(ctx context.Context, conn net.Conn, factory *FrameFactory, connectionIndex int, interval time.Duration, stats *Stats) {
for frameIndex := int64(0); ; frameIndex++ {
payload, err := factory.Frame(connectionIndex, frameIndex)
@@ -131,6 +131,48 @@ func TestRunnerCanHoldConnectionsWithoutWritingFrames(t *testing.T) {
}
}
func TestRunnerDrainsProtocolResponses(t *testing.T) {
runner := Runner{
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
buffer := make([]byte, 4096)
for {
if _, err := server.Read(buffer); err != nil {
return
}
if _, err := server.Write([]byte{0x7e, 0x80, 0x01, 0x7e}); err != nil {
return
}
}
}()
return client, nil
},
}
stats, err := runner.Run(context.Background(), Config{
Protocol: ProtocolJT808,
Addr: "127.0.0.1:808",
Connections: 1,
ConnectRatePerSecond: 1000,
SendInterval: time.Millisecond,
Duration: 10 * time.Millisecond,
Template: "0200",
SendFrames: true,
DrainResponses: true,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if stats.ResponseBytes == 0 {
t.Fatalf("ResponseBytes = 0, want drained protocol responses")
}
if stats.ReadErrors != 0 {
t.Fatalf("ReadErrors = %d, want 0", stats.ReadErrors)
}
}
type recordingConn struct {
write func([]byte) (int, error)
close func() error
@@ -4,12 +4,16 @@ import (
"fmt"
"math"
"net/http"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
var processStartedAt = time.Now()
type Labels map[string]string
type Registry struct {
@@ -33,6 +37,56 @@ func NewRegistry() *Registry {
}
}
func RegisterServiceInfo(registry *Registry, service string) {
if registry == nil {
return
}
service = strings.TrimSpace(service)
if service == "" {
service = "vehicle-service"
}
registry.SetGauge("vehicle_service_info", Labels{"service": service}, 1)
RecordProcessRuntime(registry)
}
func RecordProcessRuntime(registry *Registry) {
if registry == nil {
return
}
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
registry.SetGauge("vehicle_process_start_time_unix_seconds", nil, float64(processStartedAt.Unix()))
registry.SetGauge("vehicle_process_uptime_seconds", nil, time.Since(processStartedAt).Seconds())
registry.SetGauge("vehicle_process_heap_alloc_bytes", nil, float64(mem.HeapAlloc))
registry.SetGauge("vehicle_process_heap_sys_bytes", nil, float64(mem.HeapSys))
registry.SetGauge("vehicle_process_goroutines", nil, float64(runtime.NumGoroutine()))
}
func RegisterKafkaConsumerInfo(registry *Registry, service string, group string, topics []string) {
if registry == nil {
return
}
service = strings.TrimSpace(service)
if service == "" {
service = "vehicle-service"
}
group = strings.TrimSpace(group)
if group == "" {
return
}
for _, topic := range topics {
topic = strings.TrimSpace(topic)
if topic == "" {
continue
}
registry.SetGauge("vehicle_kafka_consumer_info", Labels{
"service": service,
"group": group,
"topic": topic,
}, 1)
}
}
func (r *Registry) IncCounter(name string, labels Labels) {
r.AddCounter(name, labels, 1)
}
@@ -90,6 +144,13 @@ func (r *Registry) SetKafkaLag(name string, topic string, partition int, offset
}, float64(lag))
}
func RecordLastActivity(registry *Registry, name string, labels Labels) {
if registry == nil {
return
}
registry.SetGauge(name, labels, float64(time.Now().Unix()))
}
func (r *Registry) ObserveHistogram(name string, labels Labels, buckets []float64, value float64) {
name = strings.TrimSpace(name)
if r == nil || name == "" {
@@ -265,6 +326,7 @@ func NewHandler(registry *Registry) http.Handler {
http.NotFound(w, r)
return
}
RecordProcessRuntime(registry)
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_, _ = w.Write([]byte(registry.Render()))
})
@@ -85,6 +85,65 @@ func TestRegistryRecordsKafkaLagGauge(t *testing.T) {
}
}
func TestRecordLastActivityRendersUnixGauge(t *testing.T) {
registry := NewRegistry()
RecordLastActivity(registry, "vehicle_stat_last_message_unix_seconds", Labels{"topic": "vehicle.fields.go.jt808.v1", "status": "received"})
text := registry.Render()
if !strings.Contains(text, `# TYPE vehicle_stat_last_message_unix_seconds gauge`) ||
!strings.Contains(text, `vehicle_stat_last_message_unix_seconds{status="received",topic="vehicle.fields.go.jt808.v1"} `) {
t.Fatalf("last activity gauge missing:\n%s", text)
}
}
func TestRegisterServiceInfoRendersStableGauge(t *testing.T) {
registry := NewRegistry()
RegisterServiceInfo(registry, "vehicle-realtime-api")
text := registry.Render()
if !strings.Contains(text, `vehicle_service_info{service="vehicle-realtime-api"} 1`) {
t.Fatalf("service info metric missing:\n%s", text)
}
for _, want := range []string{
`# TYPE vehicle_process_start_time_unix_seconds gauge`,
`# TYPE vehicle_process_uptime_seconds gauge`,
`# TYPE vehicle_process_heap_alloc_bytes gauge`,
`# TYPE vehicle_process_heap_sys_bytes gauge`,
`# TYPE vehicle_process_goroutines gauge`,
`vehicle_process_start_time_unix_seconds `,
`vehicle_process_uptime_seconds `,
`vehicle_process_heap_alloc_bytes `,
`vehicle_process_heap_sys_bytes `,
`vehicle_process_goroutines `,
} {
if !strings.Contains(text, want) {
t.Fatalf("process runtime metric missing %s:\n%s", want, text)
}
}
}
func TestRegisterKafkaConsumerInfoRendersTopics(t *testing.T) {
registry := NewRegistry()
RegisterKafkaConsumerInfo(registry, "vehicle-stat-writer", "go-stat-writer", []string{
"vehicle.fields.go.gb32960.v1",
"vehicle.fields.go.jt808.v1",
"",
})
text := registry.Render()
for _, want := range []string{
`vehicle_kafka_consumer_info{group="go-stat-writer",service="vehicle-stat-writer",topic="vehicle.fields.go.gb32960.v1"} 1`,
`vehicle_kafka_consumer_info{group="go-stat-writer",service="vehicle-stat-writer",topic="vehicle.fields.go.jt808.v1"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("consumer info metric missing %s:\n%s", want, text)
}
}
}
func TestHandlerServesPrometheusText(t *testing.T) {
registry := NewRegistry()
registry.IncCounter("vehicle_kafka_commits_total", Labels{"service": "history"})
@@ -0,0 +1,35 @@
package metrics
import "sync"
// PendingPairGauge keeps process-wide in-flight totals correct when a service
// has multiple workers updating the same pair of pending gauges.
type PendingPairGauge struct {
mu sync.Mutex
first int
second int
}
func (g *PendingPairGauge) Add(registry *Registry, firstMetric string, secondMetric string, firstDelta int, secondDelta int) {
if g == nil {
return
}
g.mu.Lock()
g.first += firstDelta
g.second += secondDelta
if g.first < 0 {
g.first = 0
}
if g.second < 0 {
g.second = 0
}
first := g.first
second := g.second
g.mu.Unlock()
if registry == nil {
return
}
registry.SetGauge(firstMetric, nil, float64(first))
registry.SetGauge(secondMetric, nil, float64(second))
}
@@ -0,0 +1,79 @@
package metrics
import (
"math"
"sort"
"strings"
"sync"
)
// RecentLatencyByKey tracks a bounded in-memory latency window per logical key.
// It is intentionally small and process-local; Prometheus histograms keep the
// lifetime view, while this gives capacity-check a recent-window signal.
type RecentLatencyByKey struct {
mu sync.Mutex
size int
windows map[string]*recentLatencyWindow
}
type recentLatencyWindow struct {
values []float64
next int
count int
}
func NewRecentLatencyByKey(size int) *RecentLatencyByKey {
if size <= 0 {
size = 1
}
return &RecentLatencyByKey{
size: size,
windows: map[string]*recentLatencyWindow{},
}
}
func (r *RecentLatencyByKey) Observe(key string, value float64) (p99 float64, samples int) {
if r == nil {
return 0, 0
}
key = strings.TrimSpace(key)
if key == "" {
key = "unknown"
}
if value < 0 || math.IsNaN(value) {
value = 0
}
if math.IsInf(value, 1) {
value = math.MaxFloat64
}
r.mu.Lock()
defer r.mu.Unlock()
window := r.windows[key]
if window == nil {
window = &recentLatencyWindow{values: make([]float64, r.size)}
r.windows[key] = window
}
window.values[window.next] = value
window.next = (window.next + 1) % len(window.values)
if window.count < len(window.values) {
window.count++
}
return window.quantileLocked(0.99), window.count
}
func (w *recentLatencyWindow) quantileLocked(q float64) float64 {
if w == nil || w.count == 0 {
return 0
}
snapshot := append([]float64(nil), w.values[:w.count]...)
sort.Float64s(snapshot)
index := int(math.Ceil(q*float64(len(snapshot)))) - 1
if index < 0 {
index = 0
}
if index >= len(snapshot) {
index = len(snapshot) - 1
}
return snapshot[index]
}
@@ -0,0 +1,34 @@
package metrics
import "testing"
func TestRecentLatencyByKeyTracksBoundedP99PerKey(t *testing.T) {
tracker := NewRecentLatencyByKey(3)
p99, samples := tracker.Observe("a", 10)
if p99 != 10 || samples != 1 {
t.Fatalf("first p99=%v samples=%d", p99, samples)
}
tracker.Observe("a", 20)
p99, samples = tracker.Observe("a", 30)
if p99 != 30 || samples != 3 {
t.Fatalf("filled p99=%v samples=%d", p99, samples)
}
p99, samples = tracker.Observe("a", 5)
if p99 != 30 || samples != 3 {
t.Fatalf("bounded p99=%v samples=%d", p99, samples)
}
p99, samples = tracker.Observe("b", 7)
if p99 != 7 || samples != 1 {
t.Fatalf("second key p99=%v samples=%d", p99, samples)
}
}
func TestRecentLatencyByKeySanitizesBadValues(t *testing.T) {
tracker := NewRecentLatencyByKey(2)
p99, samples := tracker.Observe("", -10)
if p99 != 0 || samples != 1 {
t.Fatalf("p99=%v samples=%d", p99, samples)
}
}
@@ -3,11 +3,28 @@ package observability
import (
"log/slog"
"os"
"strings"
)
// NewLogger returns a JSON logger with a stable service field so ECS logs from
// different Go processes can be filtered without relying on container names.
func NewLogger(service string) *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: parseLogLevel(os.Getenv("LOG_LEVEL")),
})
return slog.New(handler).With("service", service)
}
func parseLogLevel(value string) slog.Level {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
@@ -0,0 +1,27 @@
package observability
import (
"log/slog"
"testing"
)
func TestParseLogLevel(t *testing.T) {
tests := []struct {
name string
value string
want slog.Level
}{
{name: "empty defaults to info", value: "", want: slog.LevelInfo},
{name: "unknown defaults to info", value: "trace", want: slog.LevelInfo},
{name: "debug", value: "debug", want: slog.LevelDebug},
{name: "warn alias", value: "warning", want: slog.LevelWarn},
{name: "error trims and lowercases", value: " ERROR ", want: slog.LevelError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseLogLevel(tt.value); got != tt.want {
t.Fatalf("parseLogLevel(%q) = %v, want %v", tt.value, got, tt.want)
}
})
}
}
@@ -124,6 +124,28 @@ func TestAutoResponseEchoesRawVINAndOriginalTime(t *testing.T) {
}
}
func TestAutoResponseRejectsEnforcedInvalidPlatformLogin(t *testing.T) {
body := []byte{0x1a, 0x07, 0x02, 0x00, 0x26, 0x0f, 0x00, 0x01}
body = append(body, fixedASCII("platform-a", 12)...)
body = append(body, fixedASCII("wrong-password", 20)...)
body = append(body, 0x01)
request := buildFrame(0x05, 0xfe, "12345678901234567", body)
env, err := ParseFrame(request, 1782914969584, "127.0.0.1:32960")
if err != nil {
t.Fatal(err)
}
env.AuthenticationEnforced = true
env.AuthenticationStatus = "rejected"
response, ok, err := AutoResponse(request, env)
if err != nil || !ok {
t.Fatalf("AutoResponse() ok=%v err=%v", ok, err)
}
if response[3] != responseError {
t.Fatalf("response flag = 0x%02x, want 0x%02x", response[3], responseError)
}
}
func TestParseFrameExtractsRealtimeVehicleMileageAndPosition(t *testing.T) {
body := []byte{0x1a, 0x06, 0x1e, 0x16, 0x17, 0x39}
body = append(body, 0x01)
@@ -12,6 +12,7 @@ var ErrResponseFrameTooShort = errors.New("gb32960 response frame too short")
const (
responseSuccess = byte(0x01)
responseError = byte(0x02)
responseCommand = byte(0xfe)
encryptNone = byte(0x01)
)
@@ -37,7 +38,11 @@ func AutoResponse(raw []byte, env envelope.FrameEnvelope) ([]byte, bool, error)
if timestamp := responseTime(raw, command); !timestamp.IsZero() {
body = encodeGBTime(timestamp)
}
return buildResponse(raw[0], command, responseSuccess, raw[4:21], body), true, nil
responseFlag := responseSuccess
if command == 0x05 && env.AuthenticationEnforced && env.AuthenticationStatus != "accepted" {
responseFlag = responseError
}
return buildResponse(raw[0], command, responseFlag, raw[4:21], body), true, nil
}
func shouldRespond(command byte) bool {
@@ -416,6 +416,32 @@ func TestAutoResponderBuildsVersionedGeneralAck(t *testing.T) {
}
}
func TestAutoResponderRejectsEnforcedInvalidAuthentication(t *testing.T) {
request := buildFrame(0x0102, "064646848757", 13, []byte("wrong-code"))
env, err := ParseFrame(request, 1782918600000, "127.0.0.1:808")
if err != nil {
t.Fatal(err)
}
env.AuthenticationEnforced = true
env.AuthenticationStatus = "rejected"
response, ok, err := NewAutoResponder("issued-code").Respond(request, env)
if err != nil || !ok {
t.Fatalf("Respond() ok=%v err=%v", ok, err)
}
frames, remainder, err := ExtractFrames(response)
if err != nil || len(frames) != 1 || len(remainder) != 0 {
t.Fatalf("ExtractFrames() frames=%d remainder=%x err=%v", len(frames), remainder, err)
}
payload := frames[0]
if got := binary.BigEndian.Uint16(payload[0:2]); got != msgPlatformGeneralResponse {
t.Fatalf("message id = 0x%04x", got)
}
if result := payload[len(payload)-2]; result != 1 {
t.Fatalf("authentication response result = %d, want 1", result)
}
}
func TestExtractFramesHandlesEscapedPayload(t *testing.T) {
payload := []byte{0x02, 0x00, 0x00, 0x02, 0x01, 0x33, 0x07, 0x79, 0x54, 0x25, 0x00, 0x01, 0x7e, 0x7d}
frame := append([]byte{0x7e}, escape(append(payload, checksum(payload)))...)
@@ -49,6 +49,9 @@ func (r AutoResponder) Respond(raw []byte, env envelope.FrameEnvelope) ([]byte,
binary.BigEndian.PutUint16(body[0:2], header.sequence)
binary.BigEndian.PutUint16(body[2:4], header.messageID)
body[4] = 0
if header.messageID == 0x0102 && env.AuthenticationEnforced && env.AuthenticationStatus != "accepted" {
body[4] = 1
}
return encodeResponse(msgPlatformGeneralResponse, header, body), true, nil
}
@@ -52,6 +52,9 @@ func ParseMessage(endpoint string, topic string, payload []byte, receivedAtMS in
DeviceID: deviceID,
Plate: plate,
SourceEndpoint: "mqtt://" + endpoint + topic,
SourceCode: sourceCodeFromEndpoint(endpoint),
PlatformName: platformNameFromEndpoint(endpoint),
SourceKind: "PLATFORM",
EventTimeMS: eventTimeMS,
ReceivedAtMS: receivedAtMS,
RawText: string(payload),
@@ -63,6 +66,32 @@ func ParseMessage(endpoint string, topic string, payload []byte, receivedAtMS in
return env, nil
}
func platformNameFromEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if strings.EqualFold(endpoint, "yutong") {
return "宇通"
}
return endpoint
}
func sourceCodeFromEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
var builder strings.Builder
for _, r := range endpoint {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-' || r == '.':
builder.WriteRune(r)
}
}
return builder.String()
}
func fieldsFromData(data map[string]any) map[string]any {
fields := map[string]any{}
if speed, ok := firstFloat(data, "METER_SPEED", "speed", "speed_kmh"); ok {
@@ -47,6 +47,20 @@ func TestParseMessageMapsYutongPayloadToEnvelope(t *testing.T) {
if env.RawText == "" || env.Parsed["data"] == nil {
t.Fatalf("raw/parsed missing: %#v", env)
}
if env.SourceCode != "endpoint-a" || env.PlatformName != "endpoint-a" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
}
func TestParseMessageUsesYutongEndpointDisplayName(t *testing.T) {
payload := []byte(`{"device":"LMRKH9AC3R1004101","time":"20260413100000","data":{"TOTAL_MILEAGE":56905000}}`)
env, err := ParseMessage("yutong", "/ytforward/shln/1", payload, 1782745114999)
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if env.SourceCode != "yutong" || env.PlatformName != "宇通" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
}
func TestParseMessageUsesDeviceAsVehicleKeyWhenNotVIN(t *testing.T) {
+109 -34
View File
@@ -8,6 +8,7 @@ import (
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
type RealtimeKVField struct {
@@ -26,32 +27,36 @@ func BuildFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, bo
if !envelope.IsRealtimeTelemetryFrame(env) {
return envelope.FrameEnvelope{}, false
}
fields, fieldTypes, ok := ParsedFieldsForEnvelope(env)
fields, _, ok := ParsedFieldsForEnvelope(env)
if !ok {
return envelope.FrameEnvelope{}, false
}
filterInvalidRealtimeMeasurementFields(fields)
if len(fields) == 0 || !telemetry.HasRealtimeFields(env.Protocol, fields) {
return envelope.FrameEnvelope{}, false
}
out := envelope.FrameEnvelope{
EventID: env.StableEventID() + ":fields",
TraceID: env.TraceID,
Protocol: env.Protocol,
MessageID: env.MessageID,
Sequence: env.Sequence,
VIN: env.VIN,
VehicleKeyHint: env.VehicleKeyHint,
Phone: env.Phone,
DeviceID: env.DeviceID,
Plate: env.Plate,
SourceEndpoint: env.SourceEndpoint,
EventTimeMS: env.EventTimeMS,
ReceivedAtMS: env.ReceivedAtMS,
Fields: fields,
ParsedFields: fields,
ParsedFieldTypes: fieldTypes,
Parsed: map[string]any{
"source_event_id": env.StableEventID(),
"field_mapping": realtimeFieldMappingVersion,
},
ParseStatus: envelope.ParseOK,
EventID: env.StableEventID() + ":fields",
TraceID: env.TraceID,
EventKind: envelope.EventKindFields,
SourceEventID: env.StableEventID(),
FieldMapping: realtimeFieldMappingVersion,
Protocol: env.Protocol,
MessageID: env.MessageID,
Sequence: env.Sequence,
VIN: env.VIN,
VehicleKeyHint: env.VehicleKeyHint,
Phone: env.Phone,
DeviceID: env.DeviceID,
Plate: env.Plate,
SourceEndpoint: env.SourceEndpoint,
SourceCode: env.SourceCode,
PlatformName: env.PlatformName,
SourceKind: env.SourceKind,
EventTimeMS: env.EventTimeMS,
ReceivedAtMS: env.ReceivedAtMS,
Fields: fields,
ParseStatus: envelope.ParseOK,
}
return out, true
}
@@ -61,12 +66,34 @@ func EnsureParsedFields(env *envelope.FrameEnvelope) bool {
return false
}
if len(env.ParsedFields) > 0 {
if derived, derivedTypes, ok := computeParsedFieldsFromParsed(*env); ok {
for field, value := range derived {
if _, exists := env.ParsedFields[field]; !exists {
env.ParsedFields[field] = value
}
}
if env.ParsedFieldTypes == nil {
env.ParsedFieldTypes = map[string]string{}
}
for field, valueType := range derivedTypes {
if _, exists := env.ParsedFieldTypes[field]; !exists {
env.ParsedFieldTypes[field] = valueType
}
}
}
inferredTypes := inferParsedFieldTypes(env.ParsedFields)
if env.ParsedFieldTypes == nil {
env.ParsedFieldTypes = inferParsedFieldTypes(env.ParsedFields)
env.ParsedFieldTypes = inferredTypes
} else {
for field, valueType := range inferredTypes {
if _, exists := env.ParsedFieldTypes[field]; !exists {
env.ParsedFieldTypes[field] = valueType
}
}
}
return true
}
fields, fieldTypes, ok := ParsedFieldsForEnvelope(*env)
fields, fieldTypes, ok := ComputeParsedFieldsForEnvelope(*env)
if !ok {
return false
}
@@ -76,14 +103,27 @@ func EnsureParsedFields(env *envelope.FrameEnvelope) bool {
}
func ParsedFieldsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]string, bool) {
if len(env.ParsedFields) > 0 {
fields := cloneAnyMap(env.ParsedFields)
fieldTypes := cloneStringMap(env.ParsedFieldTypes)
if len(fieldTypes) == 0 {
fieldTypes = inferParsedFieldTypes(fields)
}
if len(env.ParsedFields) == 0 {
return nil, nil, false
}
fields := cloneAnyMap(env.ParsedFields)
fieldTypes := cloneStringMap(env.ParsedFieldTypes)
if len(fieldTypes) == 0 {
fieldTypes = inferParsedFieldTypes(fields)
}
return fields, fieldTypes, true
}
// ComputeParsedFieldsForEnvelope is reserved for ingress and offline backfills.
// Runtime projections must consume the precomputed ParsedFields contract instead.
func ComputeParsedFieldsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]string, bool) {
if fields, fieldTypes, ok := ParsedFieldsForEnvelope(env); ok {
return fields, fieldTypes, true
}
return computeParsedFieldsFromParsed(env)
}
func computeParsedFieldsFromParsed(env envelope.FrameEnvelope) (map[string]any, map[string]string, bool) {
rows := realtimeKVFields(env, env.Parsed)
if len(rows) == 0 {
return nil, nil, false
@@ -119,6 +159,22 @@ func inferParsedFieldTypes(fields map[string]any) map[string]string {
return out
}
func filterInvalidRealtimeMeasurementFields(fields map[string]any) {
for field, value := range fields {
if isRealtimeTotalMileageField(field) && !positiveNumber(value) {
delete(fields, field)
}
}
}
func isRealtimeTotalMileageField(field string) bool {
field = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(field), "/", "."))
return field == envelope.FieldTotalMileageKM ||
field == "total_mileage" ||
strings.HasSuffix(field, "."+envelope.FieldTotalMileageKM) ||
strings.HasSuffix(field, ".total_mileage")
}
func cloneAnyMap(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
@@ -191,8 +247,9 @@ const realtimeFieldMappingVersion = "2026-07-03.v1"
func realtimeKVFields(env envelope.FrameEnvelope, parsed map[string]any) []RealtimeKVField {
vin := strings.TrimSpace(env.VIN)
if vin == "" {
return nil
if env.Protocol == envelope.ProtocolGB32960 && len(parsed) > 0 {
parsed = cloneMap(parsed)
normalizeGB32960RealtimeParsed(parsed)
}
eventID := env.StableEventID()
mapping := realtimeMapping(env.Protocol)
@@ -205,6 +262,9 @@ func realtimeKVFields(env envelope.FrameEnvelope, parsed map[string]any) []Realt
}
sort.Strings(names)
for _, name := range names {
if isRealtimeTotalMileageField(realtimeKVFieldPath(domain, name)) && !positiveNumber(flat[name]) {
continue
}
value, valueType, ok := stringifyKVValue(flat[name])
if !ok {
continue
@@ -255,6 +315,9 @@ func gb32960KVFields(env envelope.FrameEnvelope, parsed map[string]any, mapping
}
sort.Strings(names)
for _, name := range names {
if isRealtimeTotalMileageField(realtimeKVFieldPath(domain, name)) && !positiveNumber(flat[name]) {
continue
}
value, valueType, ok := stringifyKVValue(flat[name])
if !ok {
continue
@@ -335,6 +398,12 @@ func flattenKV(prefix string, value any, out map[string]any, mapping protocolFie
}
flattenKV(next, typed[key], out, mapping)
}
case map[string]bool:
normalized := make(map[string]any, len(typed))
for key, item := range typed {
normalized[key] = item
}
flattenKV(prefix, normalized, out, mapping)
case []any:
for index, item := range typed {
itemMap, ok := item.(map[string]any)
@@ -397,8 +466,14 @@ func mappedTopLevelDomain(mapping protocolFieldMapping, key string) string {
if key == "" {
return ""
}
name := mapping.TopLevelName[key]
if name == "" {
name := ""
if len(mapping.TopLevelName) > 0 {
var ok bool
name, ok = mapping.TopLevelName[key]
if !ok {
return ""
}
} else {
name = sanitizeFieldPart(key)
}
if name == "" {
+255 -3
View File
@@ -1,6 +1,8 @@
package realtime
import (
"encoding/json"
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
@@ -40,7 +42,7 @@ func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) {
}
func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
VIN: "VIN001",
MessageID: "0x02",
@@ -61,10 +63,17 @@ func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
}},
},
},
})
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
if len(fieldsEnv.ParsedFields) != 0 || len(fieldsEnv.ParsedFieldTypes) != 0 || len(fieldsEnv.Parsed) != 0 {
t.Fatalf("fields envelope should keep only slim fields payload, parsed=%#v parsed_fields=%#v parsed_field_types=%#v", fieldsEnv.Parsed, fieldsEnv.ParsedFields, fieldsEnv.ParsedFieldTypes)
}
for _, bareKey := range []string{"charge_status", "soc_percent", "total_mileage_km"} {
if _, exists := fieldsEnv.Fields[bareKey]; exists {
t.Fatalf("fields envelope should not expose non-protocol bare key %q: %#v", bareKey, fieldsEnv.Fields)
@@ -81,6 +90,240 @@ func TestBuildFieldsEnvelopeUsesOnlyProtocolMappedFieldNames(t *testing.T) {
}
}
func TestEnsureParsedFieldsKeepsUnknownVINProtocolFieldsAndExcludesAnnotations(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
Phone: "13307795425",
Parsed: map[string]any{
"registration": map[string]any{
"manufacturer": "YUTNG",
},
"identity": map[string]any{
"resolved": false,
"reason": "no_binding",
},
},
ParsedFields: map[string]any{
"jt808.location.speed_kmh": "12.3",
},
}
if !EnsureParsedFields(&env) {
t.Fatal("unknown VIN raw frame should still retain parsed fields")
}
if got := env.ParsedFields["jt808.location.speed_kmh"]; got != "12.3" {
t.Fatalf("precomputed field = %#v", got)
}
if got := env.ParsedFields["jt808.registration.manufacturer"]; got != "YUTNG" {
t.Fatalf("merged registration field = %#v", got)
}
if _, exists := env.ParsedFields["jt808.identity.resolved"]; exists {
t.Fatalf("derived identity annotations must not enter protocol fields: %#v", env.ParsedFields)
}
for _, field := range []string{"jt808.location.speed_kmh", "jt808.registration.manufacturer"} {
if env.ParsedFieldTypes[field] == "" {
t.Fatalf("field type missing for %s: %#v", field, env.ParsedFieldTypes)
}
}
}
func TestBuildFieldsEnvelopeCopiesSourceMetadata(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC3R1004101",
MessageID: "MQTT",
SourceEndpoint: "mqtt://yutong/ytforward/shln/1",
SourceCode: "yutong",
PlatformName: "宇通",
SourceKind: "PLATFORM",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-yutong",
Fields: map[string]any{
envelope.FieldTotalMileageKM: 56905,
},
Parsed: map[string]any{
"data": map[string]any{"TOTAL_MILEAGE": 56905000},
},
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
if fieldsEnv.SourceCode != "yutong" || fieldsEnv.PlatformName != "宇通" || fieldsEnv.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", fieldsEnv.SourceCode, fieldsEnv.PlatformName, fieldsEnv.SourceKind)
}
}
func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
MessageID: "0x0200",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-1",
ParsedFields: map[string]any{
"jt808.location.latitude": "31.259555",
"jt808.location.longitude": "119.892413",
"jt808.location.total_mileage_km": "0",
},
})
if !ok {
t.Fatal("BuildFieldsEnvelope() should still emit valid fields")
}
if _, exists := fieldsEnv.Fields["jt808.location.total_mileage_km"]; exists {
t.Fatalf("fields envelope should drop non-positive mileage: %#v", fieldsEnv.Fields)
}
if fieldsEnv.Fields["jt808.location.latitude"] != "31.259555" {
t.Fatalf("fields envelope should keep valid location fields: %#v", fieldsEnv.Fields)
}
}
func TestBuildFieldsEnvelopeDropsNonPositiveRawTotalMileage(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC3R1004101",
MessageID: "MQTT",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-yutong-zero-mileage",
Parsed: map[string]any{
"data": map[string]any{
"LATITUDE": 30.590921,
"LONGITUDE": 121.075044,
"TOTAL_MILEAGE": 0,
},
},
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should still emit non-mileage fields")
}
if _, exists := fieldsEnv.Fields["yutong_mqtt.data.total_mileage"]; exists {
t.Fatalf("fields envelope should drop non-positive raw mileage: %#v", fieldsEnv.Fields)
}
if fieldsEnv.Fields["yutong_mqtt.data.latitude"] != "30.590921" {
t.Fatalf("fields envelope should keep valid location fields: %#v", fieldsEnv.Fields)
}
}
func TestBuildFieldsEnvelopeJSONOmitsDuplicateParsedPayload(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
MessageID: "0x0200",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
EventID: "event-1",
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
"jt808.location.speed_kmh": "23",
},
ParsedFieldTypes: map[string]string{
"jt808.location.total_mileage_km": "number",
"jt808.location.speed_kmh": "number",
},
})
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
data, err := json.Marshal(fieldsEnv)
if err != nil {
t.Fatalf("Marshal(fieldsEnv) error = %v", err)
}
text := string(data)
for _, duplicateKey := range []string{`"parsed_fields"`, `"parsed_field_types"`, `"parsed"`} {
if strings.Contains(text, duplicateKey) {
t.Fatalf("fields JSON should omit duplicate %s payload: %s", duplicateKey, text)
}
}
if !strings.Contains(text, `"fields"`) || !strings.Contains(text, `"source_event_id"`) || !strings.Contains(text, `"field_mapping"`) {
t.Fatalf("fields JSON missing slim payload metadata: %s", text)
}
}
func TestBuildFieldsEnvelopeOmitsGB32960VendorFragmentFields(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
VIN: "VIN001",
MessageID: "0x02",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
Parsed: map[string]any{
"data_units": []any{
map[string]any{
"type": "0x30",
"name": "gd_fc_stack",
"value": map[string]any{
"stack_count": 1,
"summaries": []any{
map[string]any{
"cell_count": 432,
"stack_water_outlet_temp_c": 63,
"frame_cell_start": 401,
"frame_cell_count": 32,
"frame_max_cell_voltage_v": 1,
"frame_min_cell_voltage_v": 1,
"hydrogen_inlet_pressure_kpa": 130,
"air_inlet_pressure_kpa": 150,
"stack_water_outlet_temp_extra": "kept",
},
},
},
},
},
},
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit mapped fields")
}
for _, fragmentField := range []string{
"gb32960.gd_fc_stack.frame_cell_start",
"gb32960.gd_fc_stack.frame_cell_count",
"gb32960.gd_fc_stack.frame_max_cell_voltage_v",
"gb32960.gd_fc_stack.frame_min_cell_voltage_v",
} {
if _, exists := fieldsEnv.Fields[fragmentField]; exists {
t.Fatalf("fields envelope should omit fragment-only field %q: %#v", fragmentField, fieldsEnv.Fields)
}
}
for _, keptField := range []string{
"gb32960.gd_fc_stack.stack_count",
"gb32960.gd_fc_stack.stack_water_outlet_temp_c",
"gb32960.gd_fc_stack.hydrogen_inlet_pressure_kpa",
} {
if _, exists := fieldsEnv.Fields[keptField]; !exists {
t.Fatalf("fields envelope should keep current-state field %q: %#v", keptField, fieldsEnv.Fields)
}
}
}
func TestBuildFieldsEnvelopeRequiresIngressParsedFields(t *testing.T) {
_, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
MessageID: "0x0200",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
Parsed: map[string]any{
"location": map[string]any{"speed_kmh": 99},
},
})
if ok {
t.Fatal("BuildFieldsEnvelope() must not re-flatten parsed payload downstream")
}
}
func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
jtRows := realtimeKVFields(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
@@ -97,6 +340,10 @@ func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
"latitude": 30.2,
"total_mileage_km": 10241.2,
"additional": []any{map[string]any{"id": "0x01", "value_hex": "00077235"}},
"io_status": map[string]any{
"value": uint16(2),
"bits": map[string]bool{"deep_sleep": false, "sleep": true},
},
},
})
jtValues := kvMap(jtRows)
@@ -105,9 +352,14 @@ func TestRealtimeKVFieldsFromJT808ParsedFields(t *testing.T) {
}
if jtValues["jt808.location/total_mileage_km"] != "10241.2" ||
jtValues["jt808.location/longitude"] != "121.1" ||
jtValues["jt808.location/additional.additional_1.id"] != "0x01" {
jtValues["jt808.location/additional.additional_1.id"] != "0x01" ||
jtValues["jt808.location/io_status.bits.deep_sleep"] != "false" ||
jtValues["jt808.location/io_status.bits.sleep"] != "true" {
t.Fatalf("jt808 location kv missing: %#v", jtValues)
}
if _, exists := jtValues["jt808.location/io_status.bits"]; exists {
t.Fatalf("JT808 bit fields must be flattened instead of embedded JSON: %#v", jtValues)
}
if jtValues["jt808.location/soc_percent"] != "" {
t.Fatalf("jt808 kv should not include standardized env.Fields-only values: %#v", jtValues)
}
@@ -46,6 +46,7 @@ type LocationRow struct {
Longitude float64 `json:"longitude"`
SpeedKMH *float64 `json:"speed_kmh,omitempty"`
TotalMileageKM *float64 `json:"total_mileage_km,omitempty"`
TotalMileageAt string `json:"total_mileage_event_time,omitempty"`
SOCPercent *float64 `json:"soc_percent,omitempty"`
AltitudeM *float64 `json:"altitude_m,omitempty"`
DirectionDeg *float64 `json:"direction_deg,omitempty"`
@@ -124,7 +125,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
query = normalizeRealtimeTableQuery(query)
sqlText, args := buildRealtimeSelectSQL(
"vehicle_realtime_location",
"protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at",
"protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at",
query,
)
rows, err := r.db.QueryContext(ctx, sqlText, args...)
@@ -136,7 +137,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
out := make([]LocationRow, 0)
for rows.Next() {
var row LocationRow
var eventTime, receivedAt, updatedAt scanSQLDateTime
var eventTime, mileageAt, receivedAt, updatedAt scanSQLDateTime
var speed, mileage, soc, altitude, direction sql.NullFloat64
var alarm, status sql.NullInt64
if err := rows.Scan(
@@ -148,6 +149,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
&row.Longitude,
&speed,
&mileage,
&mileageAt,
&soc,
&altitude,
&direction,
@@ -162,6 +164,7 @@ func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTable
row.EventTime = eventTime.String
row.SpeedKMH = nullableFloat(speed)
row.TotalMileageKM = nullableFloat(mileage)
row.TotalMileageAt = mileageAt.String
row.SOCPercent = nullableFloat(soc)
row.AltitudeM = nullableFloat(altitude)
row.DirectionDeg = nullableFloat(direction)
@@ -61,14 +61,14 @@ func TestLocationQueryHandlerReturnsRealtimeLocations(t *testing.T) {
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_realtime_location").
WithArgs("JT808", "粤B98765").
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
WithArgs("JT808", "粤B98765", 10, 10).
WillReturnRows(sqlmock.NewRows([]string{
"protocol", "vin", "plate", "event_time", "latitude", "longitude", "speed_kmh", "total_mileage_km",
"soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
"total_mileage_event_time", "soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
}).AddRow(
"JT808", "LKLG7C4E3NA774736", "粤B98765", "2026-07-02 16:11:02.000", 30.123456, 120.654321, 54.3, 48798.9,
nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
"2026-07-02 16:11:02.000", nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
))
handler := NewLocationQueryHandler(NewLocationQueryRepository(db))
@@ -81,7 +81,7 @@ func TestLocationQueryHandlerReturnsRealtimeLocations(t *testing.T) {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"JT808"`, `"latitude":30.123456`, `"longitude":120.654321`, `"total_mileage_km":48798.9`, `"offset":10`} {
for _, want := range []string{`"protocol":"JT808"`, `"latitude":30.123456`, `"longitude":120.654321`, `"total_mileage_km":48798.9`, `"total_mileage_event_time":"2026-07-02 16:11:02.000"`, `"offset":10`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
@@ -133,14 +133,14 @@ func TestLocationQueryHandlerSkipsTotalCountByDefault(t *testing.T) {
}
defer db.Close()
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
mock.ExpectQuery("SELECT protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time, soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id, updated_at FROM vehicle_realtime_location").
WithArgs("JT808", 1, 0).
WillReturnRows(sqlmock.NewRows([]string{
"protocol", "vin", "plate", "event_time", "latitude", "longitude", "speed_kmh", "total_mileage_km",
"soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
"total_mileage_event_time", "soc_percent", "altitude_m", "direction_deg", "alarm_flag", "status_flag", "received_at", "event_id", "updated_at",
}).AddRow(
"JT808", "LKLG7C4E3NA774736", "粤B98765", "2026-07-02 16:11:02.000", 30.123456, 120.654321, 54.3, 48798.9,
nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
"2026-07-02 16:11:02.000", nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:04",
))
handler := NewLocationQueryHandler(NewLocationQueryRepository(db))
+719 -17
View File
@@ -79,11 +79,358 @@ func realtimeOpenAPISpec() map[string]any {
},
},
},
"/api/stats/daily-metrics": map[string]any{
"get": map[string]any{
"summary": "查询车辆每日里程",
"description": "从 vehicle_daily_mileage 查询最终选举后的每日里程,并通过 source_id 关联 vehicle_data_source 返回来源证据。默认不执行 COUNT(*),total 默认表示本页返回条数;需要精确总数时传 includeTotal=true。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage,vehicle_data_source",
"parameters": dailyMetricParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/sources": map[string]any{
"get": map[string]any{
"summary": "查询每日里程候选来源",
"description": "从 vehicle_daily_mileage_source 查询每辆车、每天、每个协议下各来源独立计算出的里程候选,并关联 vehicle_data_source 返回平台、来源类型和可信优先级,用于解释最终日里程为什么选中某个来源。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage_source,vehicle_data_source",
"parameters": dailyMetricSourceParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric source page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricSourcePage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/sources/quality": map[string]any{
"get": map[string]any{
"summary": "汇总每日里程候选质量",
"description": "按日期、协议、quality_status、quality_reason 汇总 vehicle_daily_mileage_source,用于发现某协议或某来源类型是否正在批量产生无基线、异常跳变等候选里程质量问题。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage_source",
"parameters": dailyMetricSourceParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric source quality summary page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricSourceQualityPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/sources/selection": map[string]any{
"get": map[string]any{
"summary": "汇总每日里程来源选举",
"description": "按日期、协议、selection_status、selection_reason 汇总 vehicle_daily_mileage_source,并结合 vehicle_data_source 解释各来源被选中、质量淘汰、禁用淘汰或低优先级未选中的原因。",
"tags": []string{"Stats API"},
"x-table": "vehicle_daily_mileage_source,vehicle_data_source",
"parameters": dailyMetricSourceParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric source selection summary page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricSourceSelectionPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics": map[string]any{
"get": map[string]any{
"summary": "诊断实时在线但日里程缺失",
"description": "从 vehicle_realtime_snapshot/location 按 event_time/received_at 找到指定日期活跃车辆,并关联 vehicle_daily_mileage 与 vehicle_daily_mileage_source,解释车辆有实时数据但日里程缺失的原因。默认不执行 COUNT(*),total 默认表示本页返回条数;需要精确总数时传 includeTotal=true。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics/summary": map[string]any{
"get": map[string]any{
"summary": "汇总每日里程诊断",
"description": "按协议汇总指定日期 event_time/received_at 活跃车辆的日里程诊断结果,返回 OK、MISSING_DAILY、NO_SOURCE_SAMPLE、NO_TOTAL_MILEAGE 等分类数量,用于大屏、告警和排障入口。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisSummaryParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics summary",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisSummaryPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics/reasons": map[string]any{
"get": map[string]any{
"summary": "按原因汇总每日里程诊断",
"description": "按协议、诊断分类和 reason 汇总指定日期 event_time/received_at 活跃车辆的日里程诊断结果,用于大屏和告警直接区分源头未上报、总里程为 0、旧总里程未更新、统计抽取缺失等问题。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisReasonSummaryParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics reason summary",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisReasonSummaryPage"},
},
},
},
},
},
},
"/api/stats/daily-metrics/diagnostics/field-status": map[string]any{
"get": map[string]any{
"summary": "按字段状态汇总每日里程诊断",
"description": "按协议、诊断分类、reason 和实时总里程字段状态汇总,直接区分源头没有可用总里程、疑似字段未映射、标准总里程为 0、标准总里程停留在旧日期、统计消费缺失等问题。",
"tags": []string{"Stats API"},
"x-table": "vehicle_realtime_snapshot,vehicle_realtime_location,vehicle_daily_mileage,vehicle_daily_mileage_source",
"parameters": dailyMetricDiagnosisReasonSummaryParameters(),
"responses": map[string]any{
"200": map[string]any{
"description": "Daily metric diagnostics field status summary",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisFieldStatusSummaryPage"},
},
},
},
},
},
},
"/api/stats/data-sources": map[string]any{
"get": map[string]any{
"summary": "查询数据来源",
"description": "查询 vehicle_data_source 中自动发现的协议来源,用于人工维护平台名称、source_code、可信优先级和启停状态。",
"tags": []string{"Data Source API"},
"x-table": "vehicle_data_source",
"parameters": []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCode", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceKind", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"UNKNOWN", "PLATFORM", "DIRECT"}}, "required": false},
{"name": "sourceCodeMissing", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
{"name": "enabled", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Data source page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourcePage"},
},
},
},
},
},
},
"/api/stats/data-sources/diagnostics": map[string]any{
"get": map[string]any{
"summary": "诊断 JT808 来源映射",
"description": "按来源 IP 汇总 jt808_registration 与 vehicle_identifier 的匹配情况,帮助判断缺失 source_code 的 808 来源应维护到哪个平台。",
"tags": []string{"Data Source API"},
"x-table": "vehicle_data_source,jt808_registration,vehicle_identifier",
"parameters": []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"JT808"}, "default": "JT808"}, "required": false},
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCodeMissing", "in": "query", "schema": map[string]any{"type": "boolean", "default": true}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Data source diagnostics page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourceDiagnosticsPage"},
},
},
},
},
},
},
"/api/stats/data-sources/kind-suggestions": map[string]any{
"get": map[string]any{
"summary": "建议 JT808 来源类型",
"description": "基于来源活跃时长、注册手机号、vehicle_identifier 匹配和 source_code 情况,对 UNKNOWN 来源给出 PLATFORM/DIRECT/UNKNOWN 的只读建议。",
"tags": []string{"Data Source API"},
"x-table": "vehicle_data_source,jt808_registration,vehicle_identifier",
"parameters": []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"JT808"}, "default": "JT808"}, "required": false},
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceKind", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"UNKNOWN", "PLATFORM", "DIRECT"}, "default": "UNKNOWN"}, "required": false},
{"name": "sourceCodeMissing", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Data source kind suggestion page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourceDiagnosticsPage"},
},
},
},
},
},
},
"/api/stats/data-sources/jt808-identity-gaps": map[string]any{
"get": map[string]any{
"summary": "诊断 JT808 未绑定设备",
"description": "列出 jt808_registration 中仍无法通过 phone/plate 关联 VIN 的设备,用于定位 0x0200 no_binding 和补充 vehicle_identifier 映射。",
"tags": []string{"Data Source API"},
"x-table": "jt808_registration,vehicle_identifier,vehicle_identity_binding,vehicle_data_source",
"parameters": []map[string]any{
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCode", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "recentSeconds", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 2592000, "default": 86400}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "JT808 identity gap page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/JT808IdentityGapPage"},
},
},
},
},
},
},
"/api/stats/data-sources/jt808-mapping-gaps": map[string]any{
"get": map[string]any{
"summary": "诊断 JT808 来源映射缺口",
"description": "列出已配置来源平台但缺少当前 source_code 下 JT808_PHONE 到 VIN 映射的注册手机号,用于维护 vehicle_identifier 并提升 VIN 解析、在线统计和里程来源选举覆盖率。",
"tags": []string{"Data Source API"},
"x-table": "jt808_registration,vehicle_identifier,vehicle_data_source",
"parameters": []map[string]any{
{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "sourceCode", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "recentSeconds", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 2592000, "default": 86400}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
},
"responses": map[string]any{
"200": map[string]any{
"description": "JT808 mapping gap page",
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/JT808MappingGapPage"},
},
},
},
},
},
},
"/api/stats/data-sources/{id}": map[string]any{
"patch": map[string]any{
"summary": "维护数据来源人工字段",
"description": "只允许更新 platform_name、source_code、source_kind、trust_priority、enabled、remarksource_ip、latest_seen_at 等运行态字段由接入链路自动维护。",
"tags": []string{"Data Source API"},
"parameters": []map[string]any{
{"name": "id", "in": "path", "schema": map[string]any{"type": "integer"}, "required": true},
},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/DataSourceUpdate"},
},
},
},
"responses": map[string]any{
"200": map[string]any{"description": "Updated"},
},
},
},
},
"components": map[string]any{
"schemas": map[string]any{
"SnapshotPage": pageSchema("#/components/schemas/SnapshotRow"),
"LocationPage": pageSchema("#/components/schemas/LocationRow"),
"SnapshotPage": pageSchema("#/components/schemas/SnapshotRow"),
"LocationPage": pageSchema("#/components/schemas/LocationRow"),
"DailyMetricPage": pageSchema("#/components/schemas/DailyMetricRow"),
"DailyMetricSourcePage": pageSchema("#/components/schemas/DailyMetricSourceRow"),
"DailyMetricSourceQualityPage": pageSchema("#/components/schemas/DailyMetricSourceQualityRow"),
"DailyMetricSourceSelectionPage": pageSchema("#/components/schemas/DailyMetricSourceSelectionRow"),
"DailyMetricDiagnosisPage": pageSchema("#/components/schemas/DailyMetricDiagnosisRow"),
"DailyMetricDiagnosisSummaryPage": map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisSummaryRow"}},
"total": integerSchema(3),
"active_total": integerSchema(256),
"actionable_issue_total": integerSchema(6),
},
},
"DailyMetricDiagnosisReasonSummaryPage": map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisReasonSummaryRow"}},
"total": integerSchema(5),
"vehicle_total": integerSchema(347),
"actionable_issue_total": integerSchema(9),
"pipeline_issue_total": integerSchema(0),
"source_data_issue_total": integerSchema(9),
},
},
"DailyMetricDiagnosisFieldStatusSummaryPage": map[string]any{
"type": "object",
"properties": map[string]any{
"items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/DailyMetricDiagnosisFieldStatusSummaryRow"}},
"total": integerSchema(7),
"vehicle_total": integerSchema(347),
"actionable_issue_total": integerSchema(9),
"pipeline_issue_total": integerSchema(2),
"source_data_issue_total": integerSchema(7),
},
},
"DataSourcePage": pageSchema("#/components/schemas/DataSourceRow"),
"DataSourceDiagnosticsPage": pageSchema("#/components/schemas/DataSourceDiagnosticRow"),
"JT808IdentityGapPage": pageSchema("#/components/schemas/JT808IdentityGapRow"),
"JT808MappingGapPage": pageSchema("#/components/schemas/JT808MappingGapRow"),
"SnapshotRow": map[string]any{
"type": "object",
"properties": map[string]any{
@@ -99,22 +446,291 @@ func realtimeOpenAPISpec() map[string]any {
"LocationRow": map[string]any{
"type": "object",
"properties": map[string]any{
"protocol": stringSchema("JT808"),
"vin": stringSchema("LKLG7C4E3NA774736"),
"plate": stringSchema("粤B98765"),
"event_time": stringSchema("2026-07-02 16:11:02.000"),
"latitude": numberSchema(30.123456),
"longitude": numberSchema(120.654321),
"speed_kmh": numberSchema(54.3),
"total_mileage_km": numberSchema(48798.9),
"total_mileage_event_time": stringSchema("2026-07-02 16:11:02.000"),
"soc_percent": numberSchema(81.5),
"altitude_m": numberSchema(19.0),
"direction_deg": numberSchema(88.0),
"alarm_flag": integerSchema(0),
"status_flag": integerSchema(3),
"received_at": stringSchema("2026-07-02 16:11:03.000"),
"event_id": stringSchema("event id"),
"updated_at": stringSchema("2026-07-02 16:11:04"),
},
},
"DailyMetricRow": map[string]any{
"type": "object",
"properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-08"),
"protocol": stringSchema("JT808"),
"source_id": integerSchema(3),
"source_ip": stringSchema("115.231.168.135"),
"latest_source_endpoint": stringSchema("115.231.168.135:41561"),
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"daily_mileage_km": numberSchema(23.1),
"latest_total_mileage_km": numberSchema(4123.9),
"updated_at": stringSchema("2026-07-08 13:30:57"),
},
},
"DailyMetricSourceRow": map[string]any{
"type": "object",
"properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-08"),
"protocol": stringSchema("JT808"),
"source_key": stringSchema("JT808:13307795425@115.231.168.135"),
"source_ip": stringSchema("115.231.168.135"),
"source_endpoint": stringSchema("115.231.168.135:41561"),
"phone": stringSchema("13307795425"),
"platform_name": stringSchema("信达"),
"source_id": integerSchema(5),
"source_code": stringSchema("xinda"),
"source_kind": stringSchema("PLATFORM"),
"source_enabled": map[string]any{"type": "boolean", "example": true},
"trust_priority": integerSchema(10),
"first_total_mileage_km": numberSchema(4100.8),
"latest_total_mileage_km": numberSchema(4123.9),
"daily_mileage_km": numberSchema(23.1),
"sample_count": integerSchema(128),
"first_event_time": stringSchema("2026-07-08 00:01:00"),
"latest_event_time": stringSchema("2026-07-08 23:59:00"),
"quality_status": stringSchema("OK"),
"quality_reason": stringSchema("historical_source_baseline"),
"is_selected": map[string]any{"type": "boolean", "example": true},
"selection_status": stringSchema("selected"),
"selection_reason": stringSchema("selected_current_projection"),
"selection_action": stringSchema("当前来源已被投影到最终日里程表"),
"updated_at": stringSchema("2026-07-08 23:59:10"),
},
},
"DailyMetricSourceQualityRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"vin": stringSchema("LKLG7C4E3NA774736"),
"plate": stringSchema("粤B98765"),
"event_time": stringSchema("2026-07-02 16:11:02.000"),
"latitude": numberSchema(30.123456),
"longitude": numberSchema(120.654321),
"speed_kmh": numberSchema(54.3),
"total_mileage_km": numberSchema(48798.9),
"soc_percent": numberSchema(81.5),
"altitude_m": numberSchema(19.0),
"direction_deg": numberSchema(88.0),
"alarm_flag": integerSchema(0),
"status_flag": integerSchema(3),
"received_at": stringSchema("2026-07-02 16:11:03.000"),
"event_id": stringSchema("event id"),
"updated_at": stringSchema("2026-07-02 16:11:04"),
"quality_status": stringSchema("INVALID_DELTA"),
"quality_reason": stringSchema("outside_daily_range"),
"source_count": integerSchema(3),
"vehicle_count": integerSchema(3),
"selected_count": integerSchema(0),
"sample_count": integerSchema(128),
"daily_mileage_km": numberSchema(12435.2),
},
},
"DailyMetricSourceSelectionRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"selection_status": stringSchema("not_selected"),
"selection_reason": stringSchema("lower_trust_priority_or_sample_count"),
"selection_action": stringSchema("来源质量可用,但被更高可信优先级、更多样本或更新时间更新的来源覆盖"),
"source_count": integerSchema(36),
"vehicle_count": integerSchema(31),
"selected_count": integerSchema(0),
"sample_count": integerSchema(3200),
"daily_mileage_km": numberSchema(812.5),
},
},
"DailyMetricDiagnosisRow": map[string]any{
"type": "object",
"properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"plate": stringSchema("粤A12345"),
"platform_name": stringSchema("信达"),
"peer": stringSchema("115.231.168.135:47849"),
"snapshot_event_time": stringSchema("2026-07-12 10:01:02.000"),
"location_event_time": stringSchema("2026-07-12 10:01:02.000"),
"snapshot_updated_at": stringSchema("2026-07-12 10:01:03"),
"location_updated_at": stringSchema("2026-07-12 10:01:03"),
"realtime_total_mileage_km": numberSchema(25246.6),
"realtime_total_mileage_event_time": stringSchema("2026-07-12 10:01:02.000"),
"daily_mileage_km": numberSchema(0.3),
"daily_latest_total_mileage_km": numberSchema(25246.6),
"source_sample_count": integerSchema(36),
"ok_source_count": integerSchema(1),
"selectable_source_count": integerSchema(1),
"latest_stat_event_time": stringSchema("2026-07-12 10:01:02"),
"quality_statuses": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"OK"}},
"realtime_field_count": integerSchema(32),
"realtime_sample_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"yutong_mqtt.data.latitude", "yutong_mqtt.data.longitude", "yutong_mqtt.data.meter_speed"}},
"realtime_mileage_field_status": map[string]any{"type": "string", "enum": dailyMetricMileageFieldStatusEnum(), "example": "no_candidate_mileage_field"},
"realtime_mileage_candidate_fields": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"yutong_mqtt.data.odometer"}},
"realtime_mileage_evidence": stringSchema("实时快照已有字段,但没有发现 mileage/odometer/odo 等疑似总里程字段"),
"diagnosis": stringSchema("OK"),
"reason": stringSchema("daily_metric_exists"),
"severity": stringSchema("ok"),
},
},
"DailyMetricDiagnosisSummaryRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("JT808"),
"active_count": integerSchema(244),
"ok_count": integerSchema(244),
"missing_daily_count": integerSchema(0),
"no_source_sample_count": integerSchema(0),
"no_total_mileage_count": integerSchema(0),
"actionable_issue_count": integerSchema(0),
},
},
"DailyMetricDiagnosisReasonSummaryRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("YUTONG_MQTT"),
"diagnosis": stringSchema("NO_TOTAL_MILEAGE"),
"reason": stringSchema("realtime_total_mileage_not_reported_on_stat_date"),
"count": integerSchema(7),
"severity": stringSchema("source_data"),
},
},
"DailyMetricDiagnosisFieldStatusSummaryRow": map[string]any{
"type": "object",
"properties": map[string]any{
"stat_date": stringSchema("2026-07-12"),
"protocol": stringSchema("YUTONG_MQTT"),
"diagnosis": stringSchema("NO_TOTAL_MILEAGE"),
"reason": stringSchema("realtime_total_mileage_missing"),
"realtime_mileage_field_status": map[string]any{"type": "string", "enum": dailyMetricMileageFieldStatusEnum(), "example": "no_candidate_mileage_field"},
"count": integerSchema(7),
"severity": stringSchema("source_data"),
"field_status_severity": stringSchema("source_data"),
"recommended_operator_action": stringSchema("当日实时数据缺少总里程字段,核对平台是否上报该字段以及协议字段映射是否覆盖"),
"field_status_action": stringSchema("实时字段中没有疑似里程字段,优先向源平台核对是否上报总里程"),
},
},
"DataSourceRow": map[string]any{
"type": "object",
"properties": map[string]any{
"id": integerSchema(3),
"protocol": stringSchema("JT808"),
"source_ip": stringSchema("115.231.168.135"),
"latest_source_endpoint": stringSchema("115.231.168.135:41561"),
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"trust_priority": integerSchema(10),
"enabled": map[string]any{"type": "boolean", "example": true},
"first_seen_at": stringSchema("2026-07-09 14:48:30"),
"latest_seen_at": stringSchema("2026-07-12 01:16:04"),
"remark": stringSchema("可信来源"),
"updated_at": stringSchema("2026-07-12 01:16:05"),
},
},
"DataSourceDiagnosticRow": map[string]any{
"type": "object",
"properties": map[string]any{
"id": integerSchema(242190),
"protocol": stringSchema("JT808"),
"source_ip": stringSchema("117.132.194.31"),
"latest_source_endpoint": stringSchema("117.132.194.31:20471"),
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"first_seen_at": stringSchema("2026-07-12 01:00:00"),
"latest_seen_at": stringSchema("2026-07-12 01:36:26"),
"active_span_seconds": integerSchema(2186),
"latest_seen_age_seconds": integerSchema(1800),
"registration_rows": integerSchema(5),
"phone_count": integerSchema(4),
"unknown_vin_rows": integerSchema(2),
"identifier_matched_phones": integerSchema(3),
"unmapped_phone_count": integerSchema(1),
"identifier_match_ratio": map[string]any{"type": "number", "example": 0.75},
"configured_source_code_matched_phones": integerSchema(3),
"configured_source_code_platform_name": stringSchema("东方北斗"),
"configured_source_code_conflict": map[string]any{"type": "boolean", "example": false},
"source_platform_name_mismatch": map[string]any{"type": "boolean", "example": true},
"matched_source_code_count": integerSchema(1),
"candidate_source_code": stringSchema("g7s"),
"candidate_platform_name": stringSchema("G7s"),
"matched_source_codes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"g7s"}},
"matched_platform_names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"G7s"}},
"sample_phones": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"13307795425"}},
"reason": stringSchema("candidate_available"),
"recommended_operator_action": stringSchema("可用候选 source_code,执行 identity-import -sync-data-sources -apply 或在来源管理中确认"),
"suggested_source_kind": stringSchema("PLATFORM"),
"suggestion_confidence": stringSchema("HIGH"),
"suggestion_reason": stringSchema("single_source_code_with_many_phones_or_long_activity"),
},
},
"JT808IdentityGapRow": map[string]any{
"type": "object",
"properties": map[string]any{
"phone": stringSchema("13307795425"),
"device_id": stringSchema("TERM-001"),
"plate": stringSchema("沪A63305F"),
"vin": stringSchema("unknown"),
"source_ip": stringSchema("117.132.194.31"),
"source_endpoint": stringSchema("117.132.194.31:20471"),
"source_code": stringSchema("guangan_beidou"),
"platform_name": stringSchema("广安北斗"),
"source_kind": stringSchema("PLATFORM"),
"first_registered_at": stringSchema("2026-07-12 09:00:00"),
"latest_registered_at": stringSchema("2026-07-12 09:00:00"),
"latest_authenticated_at": stringSchema("2026-07-12 09:00:03"),
"latest_seen_at": stringSchema("2026-07-12 22:24:53"),
"latest_seen_age_seconds": integerSchema(32),
"reason": stringSchema("missing_phone_and_plate_binding"),
"recommended_operator_action": stringSchema("将该 phone 或 plate 维护到 vehicle_identifier,并确认 VIN、source_code、oem"),
"raw_frame_query_path": stringSchema("/api/history/raw-frames?protocol=JT808&phone=13307795425&includeFields=true"),
"data_source_query_path": stringSchema("/api/stats/data-sources?protocol=JT808&sourceIP=117.132.194.31"),
},
},
"JT808MappingGapRow": map[string]any{
"type": "object",
"properties": map[string]any{
"phone": stringSchema("64646848246"),
"device_id": stringSchema("TERM-001"),
"plate": stringSchema("粤AG18312"),
"vin": stringSchema("LKLG7C4E8NA774778"),
"source_ip": stringSchema("115.159.85.149"),
"source_endpoint": stringSchema("115.159.85.149:16885"),
"source_code": stringSchema("dongfang_beidou"),
"platform_name": stringSchema("G7易流"),
"source_kind": stringSchema("PLATFORM"),
"identifier_vin": stringSchema(""),
"identifier_plate": stringSchema(""),
"matched_source_codes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"g7s"}},
"matched_platform_names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "example": []string{"G7s"}},
"latest_seen_at": stringSchema("2026-07-12 23:38:22"),
"latest_seen_age_seconds": integerSchema(32),
"reason": stringSchema("missing_source_phone_identifier"),
"recommended_action": stringSchema("按当前来源 source_code 维护 JT808_PHONE 到 VIN 的映射;如平台名与 source_code 不一致,先修正来源配置"),
"suggested_source_code": stringSchema("dongfang_beidou"),
"suggested_platform_name": stringSchema("G7易流"),
"suggested_identifier_type": stringSchema("JT808_PHONE"),
"suggested_identifier_value": stringSchema("64646848246"),
"suggested_vin": stringSchema("LKLG7C4E8NA774778"),
"suggested_plate": stringSchema("粤AG18312"),
"raw_frame_query_path": stringSchema("/api/history/raw-frames?protocol=JT808&phone=64646848246&includeFields=true"),
"data_source_query_path": stringSchema("/api/stats/data-sources?protocol=JT808&sourceIP=115.159.85.149"),
"vehicle_identifier_example": stringSchema("protocol=JT808, source_code=dongfang_beidou, identifier_type=JT808_PHONE, identifier_value=64646848246, vin=LKLG7C4E8NA774778, plate=粤AG18312, oem=G7易流"),
},
},
"DataSourceUpdate": map[string]any{
"type": "object",
"properties": map[string]any{
"platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"),
"trust_priority": integerSchema(10),
"enabled": map[string]any{"type": "boolean", "example": true},
"remark": stringSchema("可信来源"),
},
},
},
@@ -122,6 +738,92 @@ func realtimeOpenAPISpec() map[string]any {
}
}
func dailyMetricSourceParameters() []map[string]any {
parameters := dailyMetricParameters()
parameters = append(parameters,
map[string]any{"name": "sourceIP", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
map[string]any{"name": "qualityStatus", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"OK", "NO_PREVIOUS_BASELINE", "INVALID_DELTA"}}, "required": false},
map[string]any{"name": "qualityReason", "in": "query", "schema": map[string]any{"type": "string", "example": "historical_source_baseline"}, "required": false},
map[string]any{"name": "selected", "in": "query", "schema": map[string]any{"type": "boolean"}, "required": false},
)
return parameters
}
func dailyMetricDiagnosisParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "date", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false, "description": "按东八区业务日期诊断;不传时默认服务当前日期。"},
{"name": "diagnosis", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"OK", "MISSING_DAILY", "NO_SOURCE_SAMPLE", "NO_TOTAL_MILEAGE"}}, "required": false},
{"name": "reason", "in": "query", "schema": map[string]any{"type": "string", "enum": dailyMetricDiagnosisReasonEnum()}, "required": false},
{"name": "severity", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"ok", "pipeline", "source_data"}}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false, "description": "默认 false,不执行 COUNT(*)true 时返回精确总数。"},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
}
}
func dailyMetricDiagnosisSummaryParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "date", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false, "description": "按东八区业务日期诊断;不传时默认服务当前日期。"},
}
}
func dailyMetricDiagnosisReasonSummaryParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "date", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false, "description": "按东八区业务日期诊断;不传时默认服务当前日期。"},
{"name": "diagnosis", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"OK", "MISSING_DAILY", "NO_SOURCE_SAMPLE", "NO_TOTAL_MILEAGE"}}, "required": false},
{"name": "reason", "in": "query", "schema": map[string]any{"type": "string", "enum": dailyMetricDiagnosisReasonEnum()}, "required": false},
{"name": "severity", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"ok", "pipeline", "source_data"}}, "required": false},
}
}
func dailyMetricDiagnosisReasonEnum() []string {
return []string{
"daily_metric_exists",
"source_samples_all_invalid",
"source_samples_all_excluded",
"source_samples_exist_but_final_metric_missing",
"realtime_location_has_total_mileage_but_no_stat_sample",
"realtime_total_mileage_missing",
"realtime_total_mileage_non_positive",
"realtime_total_mileage_time_missing",
"realtime_total_mileage_not_reported_on_stat_date",
"realtime_active_without_total_mileage",
}
}
func dailyMetricMileageFieldStatusEnum() []string {
return []string{
"daily_metric_exists",
"source_sample_exists",
"standard_field_not_consumed",
"standard_field_non_positive",
"standard_field_time_missing",
"standard_field_stale",
"candidate_field_unmapped",
"mapped_protocol_field_without_fresh_evidence",
"no_candidate_mileage_field",
"no_realtime_fields",
}
}
func dailyMetricParameters() []map[string]any {
return []map[string]any{
{"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false},
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
{"name": "dateFrom", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false},
{"name": "dateTo", "in": "query", "schema": map[string]any{"type": "string", "format": "date"}, "required": false},
{"name": "includeTotal", "in": "query", "schema": map[string]any{"type": "boolean", "default": false}, "required": false, "description": "默认 false,不执行 COUNT(*)true 时返回精确总数。"},
{"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false},
{"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false},
}
}
func commonRealtimeParameters() []map[string]any {
return []map[string]any{
{"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false},
@@ -17,17 +17,46 @@ func TestOpenAPIHandlerDocumentsRealtimeSnapshotAndLocation(t *testing.T) {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"/api/realtime/snapshots"`, `"/api/realtime/locations"`, `"vehicle_realtime_snapshot"`, `"vehicle_realtime_location"`} {
for _, want := range []string{
`"/api/realtime/snapshots"`,
`"/api/realtime/locations"`,
`"/api/stats/daily-metrics"`,
`"/api/stats/daily-metrics/sources"`,
`"/api/stats/daily-metrics/sources/quality"`,
`"/api/stats/daily-metrics/sources/selection"`,
`"/api/stats/daily-metrics/diagnostics"`,
`"/api/stats/daily-metrics/diagnostics/summary"`,
`"/api/stats/daily-metrics/diagnostics/reasons"`,
`"/api/stats/daily-metrics/diagnostics/field-status"`,
`"/api/stats/data-sources"`,
`"/api/stats/data-sources/diagnostics"`,
`"/api/stats/data-sources/kind-suggestions"`,
`"/api/stats/data-sources/jt808-identity-gaps"`,
`"/api/stats/data-sources/jt808-mapping-gaps"`,
`"/api/stats/data-sources/{id}"`,
`"vehicle_realtime_snapshot"`,
`"vehicle_realtime_location"`,
`vehicle_daily_mileage`,
`vehicle_daily_mileage_source`,
`"vehicle_data_source"`,
`"Stats API"`,
`"Data Source API"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("openapi missing %s: %s", want, body)
}
}
for _, want := range []string{`"daily_mileage_km"`, `"latest_total_mileage_km"`, `"source_key"`, `"source_count"`, `"vehicle_count"`, `"selected_count"`, `"sample_count"`, `"quality_status"`, `"quality_reason"`, `"is_selected"`, `"selection_status"`, `"selection_reason"`, `"selection_action"`, `"lower_trust_priority_or_sample_count"`, `"platform_name"`, `"source_code"`, `"source_kind"`, `"sourceKind"`, `"trust_priority"`, `"enabled"`, `"latest_seen_at"`, `"candidate_source_code"`, `"recommended_operator_action"`, `"suggested_source_kind"`, `"suggestion_confidence"`, `"NO_SOURCE_SAMPLE"`, `"NO_TOTAL_MILEAGE"`, `"source_sample_count"`, `"realtime_total_mileage_km"`, `"total_mileage_event_time"`, `"realtime_total_mileage_event_time"`, `"active_count"`, `"actionable_issue_count"`, `"vehicle_total"`, `"pipeline_issue_total"`, `"source_data_issue_total"`, `"severity"`, `"field_status_severity"`, `"realtime_total_mileage_not_reported_on_stat_date"`, `"realtime_mileage_field_status"`, `"field_status_action"`, `"candidate_field_unmapped"`, `"standard_field_stale"`, `"missing_phone_and_plate_binding"`, `"missing_source_phone_identifier"`, `"configured_source_code_platform_name"`, `"source_platform_name_mismatch"`, `"suggested_identifier_value"`, `"vehicle_identifier_example"`, `"raw_frame_query_path"`, `"data_source_query_path"`} {
if !strings.Contains(body, want) {
t.Fatalf("openapi should document data source field %s: %s", want, body)
}
}
for _, removed := range []string{`"/api/realtime/kv"`, `"vehicle_realtime_kv"`, `"Realtime KV API"`} {
if strings.Contains(body, removed) {
t.Fatalf("openapi should not expose removed MySQL realtime kv API %s: %s", removed, body)
}
}
for _, want := range []string{`"includeTotal"`, `默认不执行 COUNT(*)`, `total 默认表示本页返回条数`} {
for _, want := range []string{`"includeTotal"`, `"sourceCodeMissing"`, `默认不执行 COUNT(*)`, `total 默认表示本页返回条数`} {
if !strings.Contains(body, want) {
t.Fatalf("openapi should document lightweight total semantics, missing %s: %s", want, body)
}
@@ -20,6 +20,32 @@ type Repository struct {
cfg Config
}
type FastUpdateResult struct {
EnvelopesSeen int
EnvelopesUpdated int
EnvelopesSkippedNonRealtime int
EnvelopesSkippedMissingVIN int
EnvelopesSkippedMissingVehicleKey int
EnvelopesSkippedMissingFields int
FieldsSeen int
FieldsWritten int
FieldsSkippedStale int
}
func (r FastUpdateResult) Add(other FastUpdateResult) FastUpdateResult {
return FastUpdateResult{
EnvelopesSeen: r.EnvelopesSeen + other.EnvelopesSeen,
EnvelopesUpdated: r.EnvelopesUpdated + other.EnvelopesUpdated,
EnvelopesSkippedNonRealtime: r.EnvelopesSkippedNonRealtime + other.EnvelopesSkippedNonRealtime,
EnvelopesSkippedMissingVIN: r.EnvelopesSkippedMissingVIN + other.EnvelopesSkippedMissingVIN,
EnvelopesSkippedMissingVehicleKey: r.EnvelopesSkippedMissingVehicleKey + other.EnvelopesSkippedMissingVehicleKey,
EnvelopesSkippedMissingFields: r.EnvelopesSkippedMissingFields + other.EnvelopesSkippedMissingFields,
FieldsSeen: r.FieldsSeen + other.FieldsSeen,
FieldsWritten: r.FieldsWritten + other.FieldsWritten,
FieldsSkippedStale: r.FieldsSkippedStale + other.FieldsSkippedStale,
}
}
func NewRepository(client *redis.Client, cfg Config) *Repository {
if client == nil {
panic("redis client must not be nil")
@@ -28,48 +54,82 @@ func NewRepository(client *redis.Client, cfg Config) *Repository {
}
func (r *Repository) FastUpdate(ctx context.Context, env envelope.FrameEnvelope) error {
_, err := r.FastUpdateWithResult(ctx, env)
return err
}
func (r *Repository) FastUpdateWithResult(ctx context.Context, env envelope.FrameEnvelope) (FastUpdateResult, error) {
result := FastUpdateResult{EnvelopesSeen: 1}
if !envelope.IsRealtimeTelemetryFrame(env) {
return nil
result.EnvelopesSkippedNonRealtime = 1
return result, nil
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
return nil
result.EnvelopesSkippedMissingVIN = 1
return result, nil
}
vehicleKey := strings.TrimSpace(env.VehicleKey())
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
return nil
result.EnvelopesSkippedMissingVehicleKey = 1
return result, nil
}
if len(env.ParsedFields) == 0 {
result.EnvelopesSkippedMissingFields = 1
return result, nil
}
return r.setFastProjection(ctx, vehicleKey, vin, env)
}
func (r *Repository) FastUpdateBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
_, err := r.FastUpdateBatchWithResult(ctx, envs)
return err
}
func (r *Repository) FastUpdateBatchWithResult(ctx context.Context, envs []envelope.FrameEnvelope) (FastUpdateResult, error) {
if len(envs) == 0 {
return nil
return FastUpdateResult{}, nil
}
pipe := r.client.Pipeline()
queued := 0
var result FastUpdateResult
queued := make([]queuedFastProjection, 0, len(envs))
for _, env := range envs {
result.EnvelopesSeen++
if !envelope.IsRealtimeTelemetryFrame(env) {
result.EnvelopesSkippedNonRealtime++
continue
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
result.EnvelopesSkippedMissingVIN++
continue
}
vehicleKey := strings.TrimSpace(env.VehicleKey())
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
result.EnvelopesSkippedMissingVehicleKey++
continue
}
if err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env); err != nil {
return err
if len(env.ParsedFields) == 0 {
result.EnvelopesSkippedMissingFields++
continue
}
queued++
queuedProjection, err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env)
if err != nil {
return FastUpdateResult{}, err
}
queued = append(queued, queuedProjection)
}
if queued == 0 {
return nil
if len(queued) == 0 {
return result, nil
}
_, err := pipe.Exec(ctx)
return err
if err != nil {
return FastUpdateResult{}, err
}
for _, item := range queued {
result = result.Add(item.result())
}
return result, nil
}
func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) error {
@@ -85,10 +145,7 @@ func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) err
return nil
}
nowMS := time.Now().UnixMilli()
eventMS := env.EventTimeMS
if eventMS <= 0 {
eventMS = env.ReceivedAtMS
}
eventMS, _ := envelope.NormalizedEventTimeMS(env)
protocolSnapshot := Snapshot{
VehicleKey: vehicleKey,
VIN: vin,
@@ -129,7 +186,7 @@ func (r *Repository) Update(ctx context.Context, env envelope.FrameEnvelope) err
if err := r.setJSON(ctx, realtimeRawKey(vehicleKey, env.Protocol), protocolSnapshot.Parsed, r.cfg.ttl()); err != nil {
return err
}
if err := r.setKV(ctx, vin, env, protocolSnapshot.Parsed); err != nil {
if err := r.setKV(ctx, vin, env); err != nil {
return err
}
@@ -377,10 +434,7 @@ func (r *Repository) setJSON(ctx context.Context, key string, value any, ttl tim
return r.client.Set(ctx, key, payload, ttl).Err()
}
func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEnvelope, parsed map[string]any) error {
if len(env.ParsedFields) == 0 && len(parsed) > 0 {
env.Parsed = parsed
}
func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEnvelope) error {
values, types := realtimeKVMapsForEnvelope(env)
if len(values) == 0 {
return nil
@@ -394,24 +448,43 @@ func (r *Repository) setKV(ctx context.Context, vin string, env envelope.FrameEn
"source_endpoint": env.SourceEndpoint,
"field_mapping": realtimeFieldMappingVersion,
}
pipe := r.client.Pipeline()
pipe.HSet(ctx, realtimeKVValuesKey(env.Protocol, vin), values)
pipe.HSet(ctx, realtimeKVTypesKey(env.Protocol, vin), types)
pipe.HSet(ctx, realtimeKVMetaKey(env.Protocol, vin), meta)
_, err := pipe.Exec(ctx)
return err
return evalGuardedRealtimeKV(ctx, r.client, env.Protocol, vin, eventTimeOrReceivedMS(env), values, types, meta).Err()
}
func (r *Repository) setFastProjection(ctx context.Context, vehicleKey string, vin string, env envelope.FrameEnvelope) error {
func (r *Repository) setFastProjection(ctx context.Context, vehicleKey string, vin string, env envelope.FrameEnvelope) (FastUpdateResult, error) {
pipe := r.client.Pipeline()
if err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env); err != nil {
return err
queued, err := r.queueFastProjection(ctx, pipe, vehicleKey, vin, env)
if err != nil {
return FastUpdateResult{}, err
}
_, err := pipe.Exec(ctx)
return err
if _, err := pipe.Exec(ctx); err != nil {
return FastUpdateResult{}, err
}
result := queued.result()
result.EnvelopesSeen = 1
return result, nil
}
func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipeliner, vehicleKey string, vin string, env envelope.FrameEnvelope) error {
type queuedFastProjection struct {
fieldsSeen int
writeCmd *redis.Cmd
}
func (q queuedFastProjection) result() FastUpdateResult {
written := redisCmdInt(q.writeCmd)
skipped := q.fieldsSeen - written
if skipped < 0 {
skipped = 0
}
return FastUpdateResult{
EnvelopesUpdated: 1,
FieldsSeen: q.fieldsSeen,
FieldsWritten: written,
FieldsSkippedStale: skipped,
}
}
func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipeliner, vehicleKey string, vin string, env envelope.FrameEnvelope) (queuedFastProjection, error) {
values, types := realtimeKVMapsForEnvelope(env)
eventTimeMS := eventTimeOrReceivedMS(env)
offlineAfterMS := env.ReceivedAtMS + r.cfg.ttl().Milliseconds()
@@ -428,7 +501,7 @@ func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipelin
}
payload, err := json.Marshal(online)
if err != nil {
return err
return queuedFastProjection{}, err
}
meta := map[string]any{
"event_time_ms": strconv.FormatInt(eventTimeMS, 10),
@@ -449,16 +522,171 @@ func (r *Repository) queueFastProjection(ctx context.Context, pipe redis.Pipelin
"ttl_seconds": strconv.FormatInt(int64(r.cfg.ttl().Seconds()), 10),
"source_endpoint": env.SourceEndpoint,
}
queued := queuedFastProjection{fieldsSeen: len(values)}
if len(values) > 0 {
pipe.HSet(ctx, realtimeKVValuesKey(env.Protocol, vin), values)
pipe.HSet(ctx, realtimeKVTypesKey(env.Protocol, vin), types)
pipe.HSet(ctx, realtimeKVMetaKey(env.Protocol, vin), meta)
queued.writeCmd = evalGuardedRealtimeKV(ctx, pipe, env.Protocol, vin, eventTimeMS, values, types, meta)
}
pipe.Set(ctx, onlineKey(env.Protocol, vin), payload, r.cfg.ttl())
pipe.HSet(ctx, onlineStateKey(env.Protocol, vin), state)
pipe.ZAdd(ctx, "vehicle:last_seen", redis.Z{Score: float64(env.ReceivedAtMS), Member: onlineMember(env.Protocol, vin)})
pipe.SAdd(ctx, realtimeIndexKey(env.Protocol), vin)
return nil
if _, err := evalGuardedOnlineStatus(ctx, pipe, online, state, payload, r.cfg.ttl()); err != nil {
return queuedFastProjection{}, err
}
return queued, nil
}
const guardedRealtimeKVScript = `
local incoming = tonumber(ARGV[1]) or 0
local value_count = tonumber(ARGV[2]) or 0
local idx = 3
local written = 0
for i = 1, value_count do
local field = ARGV[idx]
local value = ARGV[idx + 1]
idx = idx + 2
local current = tonumber(redis.call('HGET', KEYS[3], field) or '') or 0
if current <= incoming then
redis.call('HSET', KEYS[1], field, value)
redis.call('HSET', KEYS[3], field, incoming)
written = written + 1
end
end
local type_count = tonumber(ARGV[idx]) or 0
idx = idx + 1
for i = 1, type_count do
local field = ARGV[idx]
local value_type = ARGV[idx + 1]
idx = idx + 2
local current = tonumber(redis.call('HGET', KEYS[3], field) or '') or 0
if current <= incoming then
redis.call('HSET', KEYS[2], field, value_type)
end
end
local meta_count = tonumber(ARGV[idx]) or 0
idx = idx + 1
local current_meta = tonumber(redis.call('HGET', KEYS[4], 'event_time_ms') or '') or 0
if current_meta <= incoming then
for i = 1, meta_count do
redis.call('HSET', KEYS[4], ARGV[idx], ARGV[idx + 1])
idx = idx + 2
end
end
return written
`
const guardedOnlineStatusScript = `
local incoming = tonumber(ARGV[1]) or 0
local ttl_ms = tonumber(ARGV[2]) or 60000
local payload = ARGV[3]
local member = ARGV[4]
local vin = ARGV[5]
local state_count = tonumber(ARGV[6]) or 0
local idx = 7
local current = tonumber(redis.call('HGET', KEYS[2], 'last_seen_ms') or '') or 0
redis.call('SADD', KEYS[4], vin)
if current <= incoming then
redis.call('SET', KEYS[1], payload, 'PX', ttl_ms)
for i = 1, state_count do
redis.call('HSET', KEYS[2], ARGV[idx], ARGV[idx + 1])
idx = idx + 2
end
redis.call('ZADD', KEYS[3], incoming, member)
return 1
end
return 0
`
type redisEvaler interface {
Eval(ctx context.Context, script string, keys []string, args ...any) *redis.Cmd
}
func redisCmdInt(cmd *redis.Cmd) int {
if cmd == nil {
return 0
}
value, err := cmd.Int64()
if err != nil {
return 0
}
return int(value)
}
func evalGuardedRealtimeKV(ctx context.Context, evaler redisEvaler, protocol envelope.Protocol, vin string, eventTimeMS int64, values map[string]any, types map[string]any, meta map[string]any) *redis.Cmd {
keys := []string{
realtimeKVValuesKey(protocol, vin),
realtimeKVTypesKey(protocol, vin),
realtimeKVTimesKey(protocol, vin),
realtimeKVMetaKey(protocol, vin),
}
args := guardedRealtimeKVArgs(eventTimeMS, values, types, meta)
return evaler.Eval(ctx, guardedRealtimeKVScript, keys, args...)
}
func evalGuardedOnlineStatus(ctx context.Context, evaler redisEvaler, online OnlineStatus, state map[string]any, payload []byte, ttl time.Duration) (*redis.Cmd, error) {
protocol := online.Protocol
if protocol == "" && len(online.Protocols) > 0 {
protocol = online.Protocols[0]
}
vin := strings.TrimSpace(online.VIN)
if protocol == "" || vin == "" {
return nil, nil
}
if len(payload) == 0 {
var err error
payload, err = json.Marshal(online)
if err != nil {
return nil, err
}
}
if ttl <= 0 {
ttl = time.Minute
}
keys := []string{
onlineKey(protocol, vin),
onlineStateKey(protocol, vin),
"vehicle:last_seen",
realtimeIndexKey(protocol),
}
args := guardedOnlineStatusArgs(online, protocol, state, payload, ttl)
return evaler.Eval(ctx, guardedOnlineStatusScript, keys, args...), nil
}
func guardedOnlineStatusArgs(online OnlineStatus, protocol envelope.Protocol, state map[string]any, payload []byte, ttl time.Duration) []any {
args := []any{
strconv.FormatInt(online.LastSeenMS, 10),
strconv.FormatInt(ttl.Milliseconds(), 10),
string(payload),
onlineMember(protocol, online.VIN),
strings.TrimSpace(online.VIN),
}
return appendMapPairs(args, state)
}
func guardedRealtimeKVArgs(eventTimeMS int64, values map[string]any, types map[string]any, meta map[string]any) []any {
args := []any{strconv.FormatInt(eventTimeMS, 10)}
args = appendMapPairs(args, values)
args = appendMapPairs(args, types)
args = appendMapPairs(args, meta)
return args
}
func appendMapPairs(args []any, values map[string]any) []any {
keys := make([]string, 0, len(values))
for key := range values {
key = strings.TrimSpace(key)
if key == "" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
args = append(args, strconv.Itoa(len(keys)))
for _, key := range keys {
args = append(args, key, fmt.Sprint(values[key]))
}
return args
}
func realtimeKVMapsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[string]any) {
@@ -469,6 +697,9 @@ func realtimeKVMapsForEnvelope(env envelope.FrameEnvelope) (map[string]any, map[
if strings.TrimSpace(field) == "" {
continue
}
if isRealtimeTotalMileageField(field) && !positiveNumber(value) {
continue
}
stringValue, valueType, ok := stringifyKVValue(value)
if !ok {
continue
@@ -528,7 +759,6 @@ func (r *Repository) setOnlineStatus(ctx context.Context, online OnlineStatus) e
if online.OfflineAfterMS <= 0 {
online.OfflineAfterMS = online.LastSeenMS + r.cfg.ttl().Milliseconds()
}
member := onlineMember(protocol, online.VIN)
state := map[string]any{
"vehicle_key": online.VehicleKey,
"vin": online.VIN,
@@ -544,10 +774,9 @@ func (r *Repository) setOnlineStatus(ctx context.Context, online OnlineStatus) e
if err != nil {
return err
}
pipe.Set(ctx, onlineKey(protocol, online.VIN), payload, r.cfg.ttl())
pipe.HSet(ctx, onlineStateKey(protocol, online.VIN), state)
pipe.ZAdd(ctx, "vehicle:last_seen", redis.Z{Score: float64(online.LastSeenMS), Member: member})
pipe.SAdd(ctx, realtimeIndexKey(protocol), online.VIN)
if _, err := evalGuardedOnlineStatus(ctx, pipe, online, state, payload, r.cfg.ttl()); err != nil {
return err
}
_, err = pipe.Exec(ctx)
return err
}
@@ -879,6 +1108,10 @@ func realtimeKVTypesKey(protocol envelope.Protocol, vin string) string {
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":types"
}
func realtimeKVTimesKey(protocol envelope.Protocol, vin string) string {
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":times"
}
func realtimeKVMetaKey(protocol envelope.Protocol, vin string) string {
return "vehicle:rt-kv:" + string(protocol) + ":" + strings.TrimSpace(vin) + ":meta"
}
@@ -897,10 +1130,8 @@ func realtimeKVFieldPath(domain string, field string) string {
}
func eventTimeOrReceivedMS(env envelope.FrameEnvelope) int64 {
if env.EventTimeMS > 0 {
return env.EventTimeMS
}
return env.ReceivedAtMS
eventMS, _ := envelope.NormalizedEventTimeMS(env)
return eventMS
}
func onlineKey(protocol envelope.Protocol, vin string) string {
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -133,6 +134,50 @@ func TestRepositoryStoresFullParsedOnlyInRealtimeRawAndMergesGB32960Units(t *tes
}
}
func TestRepositoryNormalizesFarFutureEventTime(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC).UnixMilli()
futureEvent := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC).UnixMilli()
if err := repo.Update(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
EventTimeMS: futureEvent,
ReceivedAtMS: received,
Parsed: map[string]any{"location": map[string]any{"longitude": 121.1}},
ParsedFields: map[string]any{
"jt808.location.longitude": 121.1,
"jt808.location.latitude": 30.1,
"jt808.location.total_mileage_km": 10241.2,
},
Fields: map[string]any{
envelope.FieldLongitude: 121.1,
envelope.FieldLatitude: 30.1,
envelope.FieldTotalMileageKM: 10241.2,
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
protocol, err := repo.GetProtocol(ctx, "VIN001", envelope.ProtocolJT808)
if err != nil {
t.Fatalf("GetProtocol() error = %v", err)
}
if protocol.EventTimeMS != received {
t.Fatalf("protocol event time = %d, want received %d", protocol.EventTimeMS, received)
}
meta, err := repo.client.HGetAll(ctx, realtimeKVMetaKey(envelope.ProtocolJT808, "VIN001")).Result()
if err != nil {
t.Fatalf("HGetAll meta error = %v", err)
}
if meta["event_time_ms"] != strconv.FormatInt(received, 10) {
t.Fatalf("rt-kv event_time_ms = %q, want received %d", meta["event_time_ms"], received)
}
}
func TestRepositoryMergesNestedGB32960MotorSlicesBySerialNo(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
@@ -337,6 +382,13 @@ func TestRepositoryWritesRealtimeKVHashes(t *testing.T) {
}},
},
},
ParsedFields: map[string]any{
"gb32960.vehicle.type": "0x01",
"gb32960.vehicle.soc_percent": 88.0,
"gb32960.gd_fc_stack.type": "0x30",
"gb32960.gd_fc_stack.stack_count": 1,
"gb32960.gd_fc_stack.stack_water_outlet_temp_c": 63,
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
@@ -358,6 +410,13 @@ func TestRepositoryWritesRealtimeKVHashes(t *testing.T) {
if typeKV["gb32960.vehicle.soc_percent"] != "number" || typeKV["gb32960.gd_fc_stack.type"] != "string" {
t.Fatalf("types kv = %#v", typeKV)
}
timeKV, err := repo.client.HGetAll(ctx, realtimeKVTimesKey(envelope.ProtocolGB32960, "VIN001")).Result()
if err != nil {
t.Fatalf("times kv HGetAll error = %v", err)
}
if timeKV["gb32960.vehicle.soc_percent"] != "1000" || timeKV["gb32960.gd_fc_stack.stack_water_outlet_temp_c"] != "1000" {
t.Fatalf("times kv = %#v", timeKV)
}
metaKV, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:meta").Result()
if err != nil {
t.Fatalf("meta kv HGetAll error = %v", err)
@@ -388,6 +447,10 @@ func TestRepositoryFastUpdateOnlyWritesPermanentKVAndMinuteOnline(t *testing.T)
map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}},
},
},
ParsedFields: map[string]any{
"gb32960.vehicle.type": "0x01",
"gb32960.vehicle.soc_percent": 88.0,
},
}); err != nil {
t.Fatalf("FastUpdate() error = %v", err)
}
@@ -406,9 +469,19 @@ func TestRepositoryFastUpdateOnlyWritesPermanentKVAndMinuteOnline(t *testing.T)
if typeKV["gb32960.vehicle.soc_percent"] != "number" {
t.Fatalf("types kv = %#v", typeKV)
}
timeKV, err := repo.client.HGetAll(ctx, realtimeKVTimesKey(envelope.ProtocolGB32960, "VIN001")).Result()
if err != nil {
t.Fatalf("times kv HGetAll error = %v", err)
}
if timeKV["gb32960.vehicle.soc_percent"] != "1000" {
t.Fatalf("times kv = %#v", timeKV)
}
if ttl := repo.client.TTL(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Val(); ttl != -1 {
t.Fatalf("kv ttl should not expire, got %v", ttl)
}
if ttl := repo.client.TTL(ctx, realtimeKVTimesKey(envelope.ProtocolGB32960, "VIN001")).Val(); ttl != -1 {
t.Fatalf("kv times ttl should not expire, got %v", ttl)
}
if ttl := repo.client.TTL(ctx, "vehicle:online:GB32960:VIN001").Val(); ttl <= 0 || ttl > time.Minute {
t.Fatalf("online ttl = %v, want within 1 minute", ttl)
}
@@ -430,12 +503,138 @@ func TestRepositoryFastUpdateOnlyWritesPermanentKVAndMinuteOnline(t *testing.T)
}
}
func TestRepositoryFastUpdateDoesNotLetOlderFramesOverwriteNewerFields(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
firstResult, err := repo.FastUpdateWithResult(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
EventTimeMS: 2000,
ReceivedAtMS: 2100,
ParsedFields: map[string]any{
"jt808.location.speed_kmh": 50,
},
})
if err != nil {
t.Fatalf("newer FastUpdate() error = %v", err)
}
if firstResult.FieldsSeen != 1 || firstResult.FieldsWritten != 1 || firstResult.FieldsSkippedStale != 0 {
t.Fatalf("newer result = %#v, want 1/1/0", firstResult)
}
if firstResult.EnvelopesSeen != 1 || firstResult.EnvelopesUpdated != 1 {
t.Fatalf("newer envelope result = %#v, want seen=1 updated=1", firstResult)
}
secondResult, err := repo.FastUpdateWithResult(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
EventTimeMS: 1000,
ReceivedAtMS: 2200,
ParsedFields: map[string]any{
"jt808.location.speed_kmh": 10,
"jt808.location.latitude": 30.5,
},
})
if err != nil {
t.Fatalf("older FastUpdate() error = %v", err)
}
if secondResult.FieldsSeen != 2 || secondResult.FieldsWritten != 1 || secondResult.FieldsSkippedStale != 1 {
t.Fatalf("older result = %#v, want 2/1/1", secondResult)
}
if secondResult.EnvelopesSeen != 1 || secondResult.EnvelopesUpdated != 1 {
t.Fatalf("older envelope result = %#v, want seen=1 updated=1", secondResult)
}
values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolJT808, "VIN001")).Result()
if err != nil {
t.Fatalf("values HGetAll error = %v", err)
}
if values["jt808.location.speed_kmh"] != "50" {
t.Fatalf("older frame overwrote speed: %#v", values)
}
if values["jt808.location.latitude"] != "30.5" {
t.Fatalf("older frame should still add previously unseen latitude: %#v", values)
}
times, err := repo.client.HGetAll(ctx, realtimeKVTimesKey(envelope.ProtocolJT808, "VIN001")).Result()
if err != nil {
t.Fatalf("times HGetAll error = %v", err)
}
if times["jt808.location.speed_kmh"] != "2000" || times["jt808.location.latitude"] != "1000" {
t.Fatalf("field times = %#v", times)
}
meta, err := repo.client.HGetAll(ctx, realtimeKVMetaKey(envelope.ProtocolJT808, "VIN001")).Result()
if err != nil {
t.Fatalf("meta HGetAll error = %v", err)
}
if meta["event_time_ms"] != "2000" {
t.Fatalf("meta should keep latest frame time: %#v", meta)
}
}
func TestRepositoryFastUpdateDoesNotLetOlderReceivedFrameOverwriteOnlineState(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
SourceEndpoint: "newer.example:808",
EventTimeMS: 2000,
ReceivedAtMS: 5000,
ParsedFields: map[string]any{
"jt808.location.speed_kmh": 50,
},
}); err != nil {
t.Fatalf("newer FastUpdate() error = %v", err)
}
if err := repo.FastUpdate(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
SourceEndpoint: "older.example:808",
EventTimeMS: 1000,
ReceivedAtMS: 4000,
ParsedFields: map[string]any{
"jt808.location.latitude": 30.5,
},
}); err != nil {
t.Fatalf("older FastUpdate() error = %v", err)
}
state, err := repo.client.HGetAll(ctx, "vehicle:online-state:JT808:VIN001").Result()
if err != nil {
t.Fatalf("online state HGetAll error = %v", err)
}
if state["last_seen_ms"] != "5000" || state["offline_after_ms"] != "65000" || state["source_endpoint"] != "newer.example:808" {
t.Fatalf("older received frame should not overwrite online state: %#v", state)
}
score, err := repo.client.ZScore(ctx, "vehicle:last_seen", "JT808:VIN001").Result()
if err != nil {
t.Fatalf("last_seen ZScore error = %v", err)
}
if score != 5000 {
t.Fatalf("last_seen score = %v, want 5000", score)
}
status, err := repo.IsOnline(ctx, "VIN001")
if err != nil {
t.Fatalf("IsOnline() error = %v", err)
}
if status.LastSeenMS != 5000 || status.SourceEndpoint != "newer.example:808" {
t.Fatalf("online status should keep newer received frame: %#v", status)
}
}
func TestRepositoryFastUpdateBatchWritesMultipleRealtimeProjections(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
err := repo.FastUpdateBatch(ctx, []envelope.FrameEnvelope{
result, err := repo.FastUpdateBatchWithResult(ctx, []envelope.FrameEnvelope{
{
Protocol: envelope.ProtocolGB32960,
VIN: "VIN001",
@@ -444,6 +643,10 @@ func TestRepositoryFastUpdateBatchWritesMultipleRealtimeProjections(t *testing.T
Parsed: map[string]any{
"data_units": []any{map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}},
},
ParsedFields: map[string]any{
"gb32960.vehicle.type": "0x01",
"gb32960.vehicle.soc_percent": 88.0,
},
},
{
Protocol: envelope.ProtocolJT808,
@@ -452,12 +655,17 @@ func TestRepositoryFastUpdateBatchWritesMultipleRealtimeProjections(t *testing.T
ReceivedAtMS: 2200,
MessageID: "0x0200",
Parsed: map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
ParsedFields: map[string]any{"jt808.location.longitude": 121.1, "jt808.location.latitude": 30.2},
Fields: map[string]any{envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2},
},
})
if err != nil {
t.Fatalf("FastUpdateBatch() error = %v", err)
}
if result.FieldsSeen != 4 || result.FieldsWritten != 4 || result.FieldsSkippedStale != 0 ||
result.EnvelopesSeen != 2 || result.EnvelopesUpdated != 2 {
t.Fatalf("FastUpdateBatchWithResult() result = %#v, want fields 4/4/0 and envelopes 2/2", result)
}
gbValues, err := repo.client.HGetAll(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Result()
if err != nil {
@@ -481,6 +689,55 @@ func TestRepositoryFastUpdateBatchWritesMultipleRealtimeProjections(t *testing.T
}
}
func TestRepositoryFastUpdateReportsSkippedEnvelopesWithoutWritingRedis(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
result, err := repo.FastUpdateBatchWithResult(ctx, []envelope.FrameEnvelope{
{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x01",
VIN: "VIN001",
EventTimeMS: 1000,
ReceivedAtMS: 1000,
Parsed: map[string]any{"platform_login": map[string]any{"username": "Hyundai"}},
},
{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
EventTimeMS: 2000,
ReceivedAtMS: 2000,
ParsedFields: map[string]any{"jt808.location.speed_kmh": 30},
},
{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN002",
EventTimeMS: 3000,
ReceivedAtMS: 3000,
Parsed: map[string]any{"location": map[string]any{"speed_kmh": 30}},
},
})
if err != nil {
t.Fatalf("FastUpdateBatchWithResult() error = %v", err)
}
if result.EnvelopesSeen != 3 || result.EnvelopesUpdated != 0 ||
result.EnvelopesSkippedNonRealtime != 1 || result.EnvelopesSkippedMissingVIN != 1 ||
result.EnvelopesSkippedMissingFields != 1 {
t.Fatalf("result = %#v, want non-realtime, missing vin and missing parsed fields skips", result)
}
if result.FieldsSeen != 0 || result.FieldsWritten != 0 || result.FieldsSkippedStale != 0 {
t.Fatalf("field result = %#v, want no field writes", result)
}
if repo.client.Exists(ctx, "vehicle:rt-kv:GB32960:VIN001:values").Val() != 0 {
t.Fatal("non-realtime frame should not write realtime kv")
}
if repo.client.Exists(ctx, "vehicle:online:JT808:VIN002").Val() != 0 {
t.Fatal("missing parsed fields frame should not mark vehicle online")
}
}
func TestRepositoryListsOnlineStatusesAndPipelineSummary(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
@@ -495,6 +752,7 @@ func TestRepositoryListsOnlineStatusesAndPipelineSummary(t *testing.T) {
Parsed: map[string]any{
"data_units": []any{map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}},
},
ParsedFields: map[string]any{"gb32960.vehicle.soc_percent": 88.0},
},
{
Protocol: envelope.ProtocolJT808,
@@ -503,6 +761,7 @@ func TestRepositoryListsOnlineStatusesAndPipelineSummary(t *testing.T) {
ReceivedAtMS: 2200,
MessageID: "0x0200",
Parsed: map[string]any{"location": map[string]any{"longitude": 121.1, "latitude": 30.2}},
ParsedFields: map[string]any{"jt808.location.longitude": 121.1, "jt808.location.latitude": 30.2},
Fields: map[string]any{envelope.FieldLongitude: 121.1, envelope.FieldLatitude: 30.2},
},
} {
@@ -548,6 +807,7 @@ func TestOnlineIndexHandlerReturnsPagedOnlineStatuses(t *testing.T) {
Parsed: map[string]any{
"data_units": []any{map[string]any{"type": "0x01", "name": "vehicle", "value": map[string]any{"soc_percent": 88.0}}},
},
ParsedFields: map[string]any{"gb32960.vehicle.soc_percent": 88.0},
}); err != nil {
t.Fatalf("FastUpdate() error = %v", err)
}
@@ -579,6 +839,7 @@ func TestPipelineDebugHandlerReturnsProtocolSummary(t *testing.T) {
EventTimeMS: 3000,
ReceivedAtMS: 3300,
Parsed: map[string]any{"data": map[string]any{"TOTAL_MILEAGE": 1}},
ParsedFields: map[string]any{"yutong_mqtt.data.total_mileage": 1},
Fields: map[string]any{envelope.FieldTotalMileageKM: 1},
}); err != nil {
t.Fatalf("FastUpdate() error = %v", err)
@@ -728,6 +989,82 @@ func TestRepositoryUsesEnvelopeParsedFieldsForRealtimeKV(t *testing.T) {
}
}
func TestRepositoryDropsNonPositiveTotalMileageFromRealtimeKV(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
if err := repo.Update(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "VIN001",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
MessageID: "0x0200",
ParsedFields: map[string]any{
"jt808.location.latitude": "31.259555",
"jt808.location.longitude": "119.892413",
"jt808.location.total_mileage_km": "0",
},
Fields: map[string]any{
envelope.FieldLatitude: 31.259555,
envelope.FieldLongitude: 119.892413,
envelope.FieldTotalMileageKM: 0,
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolJT808, "VIN001")).Result()
if err != nil {
t.Fatalf("HGetAll() error = %v", err)
}
if _, exists := values["jt808.location.total_mileage_km"]; exists {
t.Fatalf("realtime kv should drop non-positive mileage: %#v", values)
}
if values["jt808.location.latitude"] != "31.259555" {
t.Fatalf("realtime kv should keep valid fields: %#v", values)
}
}
func TestRepositoryDropsNonPositiveRawTotalMileageFromRealtimeKV(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
ctx := context.Background()
if err := repo.Update(ctx, envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC3R1004101",
EventTimeMS: 1000,
ReceivedAtMS: 1100,
MessageID: "MQTT",
Parsed: map[string]any{
"data": map[string]any{
"LATITUDE": 30.590921,
"LONGITUDE": 121.075044,
"TOTAL_MILEAGE": 0,
},
},
ParsedFields: map[string]any{
"yutong_mqtt.data.latitude": 30.590921,
"yutong_mqtt.data.longitude": 121.075044,
"yutong_mqtt.data.total_mileage": 0,
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
values, err := repo.client.HGetAll(ctx, realtimeKVValuesKey(envelope.ProtocolYutongMQTT, "LMRKH9AC3R1004101")).Result()
if err != nil {
t.Fatalf("HGetAll() error = %v", err)
}
if _, exists := values["yutong_mqtt.data.total_mileage"]; exists {
t.Fatalf("realtime kv should drop non-positive raw mileage: %#v", values)
}
if values["yutong_mqtt.data.latitude"] != "30.590921" {
t.Fatalf("realtime kv should keep valid fields: %#v", values)
}
}
func TestRepositorySkipsFramesWithoutVIN(t *testing.T) {
repo, closeFn := newTestRepository(t)
defer closeFn()
@@ -10,6 +10,7 @@ import (
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
type SnapshotExecer interface {
@@ -21,11 +22,20 @@ type PlateResolver interface {
}
type CachedPlateResolver struct {
delegate PlateResolver
ttl time.Duration
now func() time.Time
mu sync.Mutex
entries map[string]cachedPlateEntry
delegate PlateResolver
ttl time.Duration
maxEntries int
now func() time.Time
observe func(PlateCacheStats)
mu sync.Mutex
entries map[string]cachedPlateEntry
evictions int
}
type PlateCacheStats struct {
Entries int
MaxEntries int
Evictions int
}
type cachedPlateEntry struct {
@@ -35,18 +45,38 @@ type cachedPlateEntry struct {
}
func NewCachedPlateResolver(delegate PlateResolver, ttl time.Duration) *CachedPlateResolver {
return NewCachedPlateResolverWithMaxEntries(delegate, ttl, 200000)
}
func NewCachedPlateResolverWithMaxEntries(delegate PlateResolver, ttl time.Duration, maxEntries int) *CachedPlateResolver {
if delegate == nil {
panic("cached plate resolver delegate must not be nil")
}
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &CachedPlateResolver{
delegate: delegate,
ttl: ttl,
now: time.Now,
entries: map[string]cachedPlateEntry{},
if maxEntries < 0 {
maxEntries = 0
}
return &CachedPlateResolver{
delegate: delegate,
ttl: ttl,
maxEntries: maxEntries,
now: time.Now,
entries: map[string]cachedPlateEntry{},
}
}
func (r *CachedPlateResolver) SetStatsObserver(observe func(PlateCacheStats)) {
r.mu.Lock()
r.observe = observe
r.mu.Unlock()
}
func (r *CachedPlateResolver) CacheStats() PlateCacheStats {
r.mu.Lock()
defer r.mu.Unlock()
return PlateCacheStats{Entries: len(r.entries), MaxEntries: r.maxEntries, Evictions: r.evictions}
}
func (r *CachedPlateResolver) PlateByVIN(ctx context.Context, vin string) (string, error) {
@@ -76,13 +106,46 @@ func (r *CachedPlateResolver) PlateByVIN(ctx context.Context, vin string) (strin
notFound: errors.Is(err, sql.ErrNoRows),
expiresAt: now.Add(r.ttl),
}
r.enforceLimitLocked(now)
stats := PlateCacheStats{Entries: len(r.entries), MaxEntries: r.maxEntries, Evictions: r.evictions}
observe := r.observe
r.mu.Unlock()
if observe != nil {
observe(stats)
}
if err != nil {
return "", err
}
return strings.TrimSpace(plate), nil
}
func (r *CachedPlateResolver) enforceLimitLocked(now time.Time) {
if r.maxEntries <= 0 || len(r.entries) <= r.maxEntries {
return
}
for vin, entry := range r.entries {
if now.After(entry.expiresAt) || now.Equal(entry.expiresAt) {
delete(r.entries, vin)
r.evictions++
}
}
for len(r.entries) > r.maxEntries {
victim := ""
var victimExpiresAt time.Time
for vin, entry := range r.entries {
if victim == "" || entry.expiresAt.Before(victimExpiresAt) {
victim = vin
victimExpiresAt = entry.expiresAt
}
}
if victim == "" {
return
}
delete(r.entries, victim)
r.evictions++
}
}
type SnapshotWriter struct {
exec SnapshotExecer
plateResolver PlateResolver
@@ -111,6 +174,19 @@ func (w *SnapshotWriter) EnsureSchema(ctx context.Context) error {
if _, err := w.exec.ExecContext(ctx, realtimeLocationTableSQL); err != nil {
return err
}
for _, statement := range realtimeLocationCompatibilitySQL {
if _, err := w.exec.ExecContext(ctx, statement); err != nil && !isDuplicateColumnError(err) {
return err
}
}
for _, statement := range cleanupInvalidRealtimeTotalMileageSQL {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
}
}
if _, err := w.exec.ExecContext(ctx, backfillRealtimeAccessProjectionSQL); err != nil {
return err
}
return nil
}
@@ -129,13 +205,11 @@ func (w *SnapshotWriter) Update(ctx context.Context, env envelope.FrameEnvelope)
if err != nil {
return err
}
eventTime := nullableTime(env.EventTimeMS)
eventTimeMS, _ := envelope.NormalizedEventTimeMS(env)
eventTime := nullableTime(eventTimeMS)
receivedAt := nullableTime(env.ReceivedAtMS)
platformName := platformNameFromEnvelope(env)
parsed, err := w.snapshotFieldsForEnvelope(ctx, env, vin)
if err != nil {
return err
}
parsed := snapshotFieldsForEnvelope(env)
if _, err = w.exec.ExecContext(ctx, upsertRealtimeSnapshotSQL,
string(env.Protocol),
vin,
@@ -146,6 +220,9 @@ func (w *SnapshotWriter) Update(ctx context.Context, env envelope.FrameEnvelope)
eventTime,
receivedAt,
env.StableEventID(),
receivedAt,
receivedAt,
env.StableEventID(),
); err != nil {
return err
}
@@ -162,6 +239,7 @@ func (w *SnapshotWriter) Update(ctx context.Context, env envelope.FrameEnvelope)
location.Longitude,
location.SpeedKMH,
location.TotalMileageKM,
location.TotalMileageAt,
location.SOCPercent,
location.AltitudeM,
location.DirectionDeg,
@@ -173,87 +251,25 @@ func (w *SnapshotWriter) Update(ctx context.Context, env envelope.FrameEnvelope)
return err
}
func (w *SnapshotWriter) snapshotFieldsForEnvelope(ctx context.Context, env envelope.FrameEnvelope, vin string) (map[string]any, error) {
if len(env.Parsed) == 0 {
return nil, nil
}
parsed := cloneMap(env.Parsed)
incoming := realtimeSnapshotFlatFields(env, parsed)
if queryer, ok := w.exec.(Queryer); ok {
existing, err := realtimeSnapshotParsedJSON(ctx, queryer, env.Protocol, vin)
if err != nil {
return nil, err
}
if len(existing) > 0 {
if isStructuredSnapshotParsed(env.Protocol, existing) {
parsed = mergeParsedForProtocol(env.Protocol, existing, env.Parsed)
mergedEnv := env
mergedEnv.ParsedFields = nil
mergedEnv.ParsedFieldTypes = nil
return realtimeSnapshotFlatFields(mergedEnv, parsed), nil
}
return mergeRealtimeSnapshotFields(existing, incoming), nil
}
}
return incoming, nil
}
func realtimeSnapshotFlatFields(env envelope.FrameEnvelope, parsed map[string]any) map[string]any {
if len(env.ParsedFields) > 0 {
fields := cloneMap(env.ParsedFields)
if len(fields) > 0 {
return fields
}
}
rows := realtimeKVFields(env, parsed)
if len(rows) == 0 {
func snapshotFieldsForEnvelope(env envelope.FrameEnvelope) map[string]any {
if len(env.ParsedFields) == 0 {
return nil
}
fields := make(map[string]any, len(rows))
for _, row := range rows {
key := realtimeKVFieldPath(row.Domain, row.Field)
if key == "" {
continue
}
fields[key] = row.Value
return realtimeSnapshotFlatFields(env)
}
func realtimeSnapshotFlatFields(env envelope.FrameEnvelope) map[string]any {
fields := cloneMap(env.ParsedFields)
filterInvalidRealtimeMeasurementFields(fields)
if len(fields) == 0 {
return nil
}
return fields
}
func mergeRealtimeSnapshotFields(existing map[string]any, incoming map[string]any) map[string]any {
if len(existing) == 0 {
return cloneMap(incoming)
}
merged := cloneMap(existing)
for key, value := range incoming {
merged[key] = value
}
return merged
}
func isStructuredSnapshotParsed(protocol envelope.Protocol, parsed map[string]any) bool {
if len(parsed) == 0 {
return false
}
if protocol == envelope.ProtocolGB32960 {
if _, ok := parsed["data_units"]; ok {
return true
}
}
mapping := realtimeMapping(protocol)
for key := range mapping.TopLevelName {
if _, ok := parsed[key]; ok {
return true
}
}
return false
}
func platformNameFromEnvelope(env envelope.FrameEnvelope) string {
if env.Fields != nil {
if value := strings.TrimSpace(stringValue(env.Fields["platform_account"])); value != "" {
return value
}
if value := strings.TrimSpace(env.PlatformName); value != "" {
return value
}
if env.Parsed != nil {
if value := strings.TrimSpace(stringValue(env.Parsed["platform_name"])); value != "" {
@@ -275,25 +291,6 @@ func stringValue(value any) string {
}
}
func realtimeSnapshotParsedJSON(ctx context.Context, queryer Queryer, protocol envelope.Protocol, vin string) (map[string]any, error) {
var raw sql.NullString
err := queryer.QueryRowContext(ctx, selectRealtimeSnapshotParsedJSONSQL, string(protocol), vin).Scan(&raw)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
if !raw.Valid || strings.TrimSpace(raw.String) == "" {
return nil, nil
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw.String), &parsed); err != nil {
return nil, nil
}
return parsed, nil
}
func marshalParsedJSON(parsed map[string]any) any {
if len(parsed) == 0 {
return nil
@@ -335,6 +332,7 @@ type realtimeLocationRow struct {
Longitude float64
SpeedKMH any
TotalMileageKM any
TotalMileageAt any
SOCPercent any
AltitudeM any
DirectionDeg any
@@ -345,71 +343,53 @@ type realtimeLocationRow struct {
}
func realtimeLocationFromEnvelope(env envelope.FrameEnvelope, vin string, plate string) (realtimeLocationRow, bool) {
latitude, okLat := numberField(env.Fields, envelope.FieldLatitude)
longitude, okLon := numberField(env.Fields, envelope.FieldLongitude)
if !okLat || !okLon {
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
if !ok {
return realtimeLocationRow{}, false
}
eventTimeMS, _ := envelope.NormalizedEventTimeMS(env)
totalMileageKM, hasTotalMileage := telemetry.TotalMileageKM(env.Protocol, env.ParsedFields)
if totalMileageKM <= 0 {
hasTotalMileage = false
}
var totalMileageValue any
var totalMileageAt any
if hasTotalMileage {
totalMileageValue = totalMileageKM
totalMileageAt = nullableTime(eventTimeMS)
}
return realtimeLocationRow{
Protocol: string(env.Protocol),
VIN: vin,
Plate: plate,
EventTime: nullableTime(env.EventTimeMS),
Latitude: latitude,
Longitude: longitude,
SpeedKMH: nullableNumberField(env.Fields, envelope.FieldSpeedKMH),
TotalMileageKM: nullableNumberField(env.Fields, envelope.FieldTotalMileageKM),
SOCPercent: nullableNumberField(env.Fields, envelope.FieldSOCPercent),
AltitudeM: nullableNumberField(env.Fields, "altitude_m"),
DirectionDeg: nullableNumberField(env.Fields, "direction_deg"),
AlarmFlag: nullableNumberField(env.Fields, "alarm_flag"),
StatusFlag: nullableNumberField(env.Fields, "status_flag"),
EventTime: nullableTime(eventTimeMS),
Latitude: location.Latitude,
Longitude: location.Longitude,
SpeedKMH: optionalFloatValue(location.SpeedKMH),
TotalMileageKM: totalMileageValue,
TotalMileageAt: totalMileageAt,
SOCPercent: optionalFloatValue(location.SOCPercent),
AltitudeM: optionalFloatValue(location.AltitudeM),
DirectionDeg: optionalFloatValue(location.DirectionDeg),
AlarmFlag: optionalInt64Value(location.AlarmFlag),
StatusFlag: optionalInt64Value(location.StatusFlag),
ReceivedAt: nullableTime(env.ReceivedAtMS),
EventID: env.StableEventID(),
}, true
}
func nullableNumberField(fields map[string]any, key string) any {
value, ok := numberField(fields, key)
if !ok {
func optionalFloatValue(value *float64) any {
if value == nil {
return nil
}
return value
return *value
}
func numberField(fields map[string]any, key string) (float64, bool) {
value, ok := fields[key]
if !ok {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int8:
return float64(typed), true
case int16:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case uint:
return float64(typed), true
case uint8:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case uint64:
return float64(typed), true
default:
return 0, false
func optionalInt64Value(value *int64) any {
if value == nil {
return nil
}
return *value
}
type BindingPlateResolver struct {
@@ -469,30 +449,19 @@ func nullableTime(ms int64) any {
func isRealtimeSnapshotEvent(env envelope.FrameEnvelope) bool {
switch env.Protocol {
case envelope.ProtocolGB32960:
return env.MessageID == "0x02" && hasGB32960RealtimeDataUnits(env)
return (env.MessageID == "0x02" || env.MessageID == "0x03") && telemetry.HasRealtimeFields(env.Protocol, env.ParsedFields)
case envelope.ProtocolJT808:
return env.MessageID == "0x0200" && hasLocationFields(env)
case envelope.ProtocolYutongMQTT:
return len(env.Fields) > 0
return telemetry.HasRealtimeFields(env.Protocol, env.ParsedFields)
default:
return false
}
}
func hasGB32960RealtimeDataUnits(env envelope.FrameEnvelope) bool {
if units, ok := env.Parsed["data_units"].([]any); ok && len(units) > 0 {
return true
}
if units, ok := env.Parsed["data_units"].([]map[string]any); ok && len(units) > 0 {
return true
}
return false
}
func hasLocationFields(env envelope.FrameEnvelope) bool {
_, okLat := numberField(env.Fields, envelope.FieldLatitude)
_, okLon := numberField(env.Fields, envelope.FieldLongitude)
return okLat && okLon
_, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.ParsedFields)
return ok
}
const realtimeSnapshotTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_snapshot (
@@ -505,16 +474,31 @@ const realtimeSnapshotTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_sn
event_time DATETIME(3) NULL,
received_at DATETIME(3) NULL,
event_id VARCHAR(64) NOT NULL DEFAULT '',
access_first_seen_at DATETIME(3) NULL,
access_previous_received_at DATETIME(3) NULL,
access_latest_received_at DATETIME(3) NULL,
access_report_interval_ms BIGINT UNSIGNED NULL,
access_sample_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
access_latest_event_id VARCHAR(64) NOT NULL DEFAULT '',
access_first_seen_source VARCHAR(32) NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (protocol, vin),
KEY idx_vin (vin),
KEY idx_protocol_updated (protocol, updated_at)
KEY idx_protocol_updated (protocol, updated_at),
KEY idx_access_latest (access_latest_received_at, vin)
)`
var realtimeSnapshotCompatibilitySQL = []string{
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN platform_name VARCHAR(64) NOT NULL DEFAULT '' AFTER plate",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN peer VARCHAR(128) NOT NULL DEFAULT '' AFTER platform_name",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN parsed_json LONGTEXT NULL AFTER peer",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_first_seen_at DATETIME(3) NULL AFTER event_id",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_previous_received_at DATETIME(3) NULL AFTER access_first_seen_at",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_latest_received_at DATETIME(3) NULL AFTER access_previous_received_at",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_report_interval_ms BIGINT UNSIGNED NULL AFTER access_latest_received_at",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_sample_count BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER access_report_interval_ms",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_latest_event_id VARCHAR(64) NOT NULL DEFAULT '' AFTER access_sample_count",
"ALTER TABLE vehicle_realtime_snapshot ADD COLUMN access_first_seen_source VARCHAR(32) NOT NULL DEFAULT '' AFTER access_latest_event_id",
}
func isDuplicateColumnError(err error) bool {
@@ -524,21 +508,27 @@ func isDuplicateColumnError(err error) bool {
const upsertRealtimeSnapshotSQL = `
INSERT INTO vehicle_realtime_snapshot
(protocol, vin, plate, platform_name, peer, parsed_json, event_time, received_at, event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(protocol, vin, plate, platform_name, peer, parsed_json, event_time, received_at, event_id,
access_first_seen_at, access_latest_received_at, access_sample_count, access_latest_event_id, access_first_seen_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, 'live_writer')
ON DUPLICATE KEY UPDATE
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
platform_name = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), VALUES(platform_name), platform_name),
peer = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), VALUES(peer), peer),
parsed_json = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), VALUES(parsed_json), parsed_json),
parsed_json = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time),
IF(VALUES(parsed_json) IS NULL OR VALUES(parsed_json) = '', parsed_json, JSON_MERGE_PATCH(COALESCE(NULLIF(vehicle_realtime_snapshot.parsed_json, ''), JSON_OBJECT()), VALUES(parsed_json))),
parsed_json),
event_time = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), VALUES(event_time), event_time),
received_at = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), VALUES(received_at), received_at),
event_id = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), VALUES(event_id), event_id),
updated_at = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), CURRENT_TIMESTAMP, updated_at)
updated_at = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_snapshot.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_snapshot.event_time), CURRENT_TIMESTAMP, updated_at),
access_previous_received_at = IF(VALUES(access_latest_received_at) IS NOT NULL AND (vehicle_realtime_snapshot.access_latest_received_at IS NULL OR VALUES(access_latest_received_at) > vehicle_realtime_snapshot.access_latest_received_at) AND VALUES(access_latest_event_id) <> vehicle_realtime_snapshot.access_latest_event_id, vehicle_realtime_snapshot.access_latest_received_at, access_previous_received_at),
access_report_interval_ms = IF(VALUES(access_latest_received_at) IS NOT NULL AND vehicle_realtime_snapshot.access_latest_received_at IS NOT NULL AND VALUES(access_latest_received_at) > vehicle_realtime_snapshot.access_latest_received_at AND VALUES(access_latest_event_id) <> vehicle_realtime_snapshot.access_latest_event_id, TIMESTAMPDIFF(MICROSECOND, vehicle_realtime_snapshot.access_latest_received_at, VALUES(access_latest_received_at)) DIV 1000, access_report_interval_ms),
access_sample_count = IF(VALUES(access_latest_received_at) IS NOT NULL AND (vehicle_realtime_snapshot.access_latest_received_at IS NULL OR VALUES(access_latest_received_at) > vehicle_realtime_snapshot.access_latest_received_at) AND VALUES(access_latest_event_id) <> vehicle_realtime_snapshot.access_latest_event_id, access_sample_count + 1, access_sample_count),
access_latest_received_at = IF(VALUES(access_latest_received_at) IS NOT NULL AND (vehicle_realtime_snapshot.access_latest_received_at IS NULL OR VALUES(access_latest_received_at) > vehicle_realtime_snapshot.access_latest_received_at) AND VALUES(access_latest_event_id) <> vehicle_realtime_snapshot.access_latest_event_id, VALUES(access_latest_received_at), access_latest_received_at),
access_latest_event_id = IF(VALUES(access_latest_received_at) IS NOT NULL AND VALUES(access_latest_received_at) = vehicle_realtime_snapshot.access_latest_received_at AND VALUES(access_latest_event_id) <> vehicle_realtime_snapshot.access_latest_event_id, VALUES(access_latest_event_id), access_latest_event_id)
`
const selectRealtimeSnapshotParsedJSONSQL = `SELECT parsed_json FROM vehicle_realtime_snapshot WHERE protocol = ? AND vin = ?`
const realtimeLocationTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_location (
protocol VARCHAR(32) NOT NULL,
vin VARCHAR(32) NOT NULL DEFAULT '',
@@ -548,6 +538,7 @@ const realtimeLocationTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_lo
longitude DECIMAL(12,6) NOT NULL,
speed_kmh DECIMAL(10,3) NULL,
total_mileage_km DECIMAL(18,3) NULL,
total_mileage_event_time DATETIME(3) NULL,
soc_percent DECIMAL(6,2) NULL,
altitude_m DECIMAL(10,3) NULL,
direction_deg DECIMAL(10,3) NULL,
@@ -561,11 +552,50 @@ const realtimeLocationTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_lo
KEY idx_protocol_updated (protocol, updated_at)
)`
var realtimeLocationCompatibilitySQL = []string{
"ALTER TABLE vehicle_realtime_location ADD COLUMN total_mileage_event_time DATETIME(3) NULL AFTER total_mileage_km",
}
var cleanupInvalidRealtimeTotalMileageSQL = []string{
`UPDATE vehicle_realtime_location
SET total_mileage_km = NULL,
total_mileage_event_time = NULL
WHERE total_mileage_km IS NOT NULL
AND total_mileage_km <= 0`,
`UPDATE vehicle_realtime_snapshot
SET parsed_json = JSON_REMOVE(parsed_json, '$."jt808.location.total_mileage_km"')
WHERE protocol = 'JT808'
AND parsed_json IS NOT NULL
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(parsed_json, '$."jt808.location.total_mileage_km"')) AS DECIMAL(18,3)) <= 0`,
`UPDATE vehicle_realtime_snapshot
SET parsed_json = JSON_REMOVE(parsed_json, '$."gb32960.vehicle.total_mileage_km"')
WHERE protocol = 'GB32960'
AND parsed_json IS NOT NULL
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(parsed_json, '$."gb32960.vehicle.total_mileage_km"')) AS DECIMAL(18,3)) <= 0`,
`UPDATE vehicle_realtime_snapshot
SET parsed_json = JSON_REMOVE(parsed_json, '$."yutong_mqtt.data.total_mileage_km"')
WHERE protocol = 'YUTONG_MQTT'
AND parsed_json IS NOT NULL
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(parsed_json, '$."yutong_mqtt.data.total_mileage_km"')) AS DECIMAL(18,3)) <= 0`,
}
const backfillRealtimeAccessProjectionSQL = `UPDATE vehicle_realtime_snapshot
SET access_first_seen_at = COALESCE(access_first_seen_at, received_at, updated_at),
access_latest_received_at = COALESCE(access_latest_received_at, received_at, updated_at),
access_sample_count = IF(access_sample_count = 0, 1, access_sample_count),
access_latest_event_id = IF(access_latest_event_id = '', event_id, access_latest_event_id),
access_first_seen_source = IF(access_first_seen_source = '', 'snapshot_backfill', access_first_seen_source)
WHERE access_first_seen_at IS NULL
OR access_latest_received_at IS NULL
OR access_sample_count = 0
OR access_latest_event_id = ''
OR access_first_seen_source = ''`
const upsertRealtimeLocationSQL = `
INSERT INTO vehicle_realtime_location
(protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km,
(protocol, vin, plate, event_time, latitude, longitude, speed_kmh, total_mileage_km, total_mileage_event_time,
soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, received_at, event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
event_time = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), VALUES(event_time), event_time),
@@ -573,6 +603,7 @@ ON DUPLICATE KEY UPDATE
longitude = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), VALUES(longitude), longitude),
speed_kmh = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), COALESCE(VALUES(speed_kmh), speed_kmh), speed_kmh),
total_mileage_km = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), COALESCE(VALUES(total_mileage_km), total_mileage_km), total_mileage_km),
total_mileage_event_time = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), IF(VALUES(total_mileage_km) IS NOT NULL, COALESCE(VALUES(total_mileage_event_time), total_mileage_event_time), total_mileage_event_time), total_mileage_event_time),
soc_percent = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), COALESCE(VALUES(soc_percent), soc_percent), soc_percent),
altitude_m = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), COALESCE(VALUES(altitude_m), altitude_m), altitude_m),
direction_deg = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), COALESCE(VALUES(direction_deg), direction_deg), direction_deg),
@@ -37,26 +37,30 @@ func TestSnapshotWriterEnsuresSchemaAndUpsertsCoreSnapshot(t *testing.T) {
map[string]any{"name": "gd_fc_vendor_tlv", "type": "vendor", "value": map[string]any{"foo": "bar"}},
},
},
ParsedFields: map[string]any{
"gb32960.vehicle.soc_percent": "90",
"gb32960.gd_fc_vendor_tlv.foo": "bar",
},
Fields: map[string]any{envelope.FieldSOCPercent: 90},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
if len(exec.calls) != 6 {
t.Fatalf("exec calls = %d, want 6", len(exec.calls))
if len(exec.calls) != 19 {
t.Fatalf("exec calls = %d, want 19", len(exec.calls))
}
if !strings.Contains(exec.calls[0].query, "CREATE TABLE IF NOT EXISTS vehicle_realtime_snapshot") {
t.Fatalf("schema query = %s", exec.calls[0].query)
}
if !strings.Contains(exec.calls[4].query, "CREATE TABLE IF NOT EXISTS vehicle_realtime_location") {
t.Fatalf("location schema query = %s", exec.calls[4].query)
if !strings.Contains(exec.calls[11].query, "CREATE TABLE IF NOT EXISTS vehicle_realtime_location") {
t.Fatalf("location schema query = %s", exec.calls[11].query)
}
for _, call := range exec.calls {
if strings.Contains(call.query, "vehicle_realtime_kv") {
t.Fatalf("snapshot writer should not create or write MySQL realtime kv: %s", call.query)
}
}
for _, call := range []snapshotExecCall{exec.calls[0], exec.calls[4]} {
for _, call := range []snapshotExecCall{exec.calls[0], exec.calls[11]} {
if strings.Contains(call.query, "fields_json") {
t.Fatalf("realtime schema should not contain fields_json: %s", call.query)
}
@@ -74,10 +78,21 @@ func TestSnapshotWriterEnsuresSchemaAndUpsertsCoreSnapshot(t *testing.T) {
t.Fatalf("snapshot schema should contain %s: %s", want, exec.calls[0].query)
}
}
if strings.Contains(exec.calls[4].query, "idx_location") {
t.Fatalf("realtime location table should not keep unused geo index: %s", exec.calls[4].query)
if strings.Contains(exec.calls[11].query, "idx_location") {
t.Fatalf("realtime location table should not keep unused geo index: %s", exec.calls[11].query)
}
upsert := exec.calls[5]
if !strings.Contains(exec.calls[12].query, "total_mileage_event_time") {
t.Fatalf("location compatibility migration should add total mileage event time: %s", exec.calls[12].query)
}
for _, call := range exec.calls[13:17] {
if !strings.Contains(call.query, "total_mileage") {
t.Fatalf("schema bootstrap should clean invalid realtime mileage: %s", call.query)
}
}
if !strings.Contains(exec.calls[17].query, "snapshot_backfill") {
t.Fatalf("access projection baseline should be backfilled explicitly: %s", exec.calls[17].query)
}
upsert := exec.calls[18]
if !strings.Contains(upsert.query, "ON DUPLICATE KEY UPDATE") {
t.Fatalf("upsert query = %s", upsert.query)
}
@@ -101,8 +116,13 @@ func TestSnapshotWriterEnsuresSchemaAndUpsertsCoreSnapshot(t *testing.T) {
if got := upsert.args[5]; !strings.Contains(got.(string), `"gb32960.vehicle.soc_percent":"90"`) {
t.Fatalf("parsed json arg = %#v", got)
}
if len(upsert.args) != 9 {
t.Fatalf("snapshot upsert args = %d, want 9", len(upsert.args))
if len(upsert.args) != 12 {
t.Fatalf("snapshot upsert args = %d, want 12", len(upsert.args))
}
for _, want := range []string{"access_first_seen_at", "access_previous_received_at", "access_latest_received_at", "access_report_interval_ms", "access_sample_count", "access_latest_event_id", "live_writer", "TIMESTAMPDIFF(MICROSECOND"} {
if !strings.Contains(upsert.query, want) {
t.Fatalf("access projection upsert missing %q: %s", want, upsert.query)
}
}
}
@@ -122,8 +142,24 @@ func TestSnapshotWriterEnsureSchemaOnlyCreatesTargetTables(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_realtime_snapshot ADD COLUMN parsed_json").
WillReturnResult(sqlmock.NewResult(0, 0))
for _, column := range []string{"access_first_seen_at", "access_previous_received_at", "access_latest_received_at", "access_report_interval_ms", "access_sample_count", "access_latest_event_id", "access_first_seen_source"} {
mock.ExpectExec("ALTER TABLE vehicle_realtime_snapshot ADD COLUMN " + column).
WillReturnResult(sqlmock.NewResult(0, 0))
}
mock.ExpectExec("CREATE TABLE IF NOT EXISTS vehicle_realtime_location").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE vehicle_realtime_location ADD COLUMN total_mileage_event_time").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE vehicle_realtime_location").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE vehicle_realtime_snapshot").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE vehicle_realtime_snapshot").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE vehicle_realtime_snapshot").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE vehicle_realtime_snapshot").
WillReturnResult(sqlmock.NewResult(0, 0))
if err := writer.EnsureSchema(context.Background()); err != nil {
t.Fatalf("EnsureSchema() error = %v", err)
@@ -133,6 +169,41 @@ func TestSnapshotWriterEnsureSchemaOnlyCreatesTargetTables(t *testing.T) {
}
}
func TestRealtimeAccessProjectionSQLProtectsDuplicateAndOutOfOrderReceipts(t *testing.T) {
accessStart := strings.Index(upsertRealtimeSnapshotSQL, "access_previous_received_at =")
if accessStart < 0 {
t.Fatal("access projection assignments are missing")
}
accessSQL := upsertRealtimeSnapshotSQL[accessStart:]
for _, want := range []string{
"VALUES(access_latest_received_at) > vehicle_realtime_snapshot.access_latest_received_at",
"VALUES(access_latest_event_id) <> vehicle_realtime_snapshot.access_latest_event_id",
"TIMESTAMPDIFF(MICROSECOND",
"access_sample_count + 1",
} {
if !strings.Contains(accessSQL, want) {
t.Fatalf("access projection SQL missing %q:\n%s", want, accessSQL)
}
}
if strings.Contains(accessSQL, "VALUES(event_time)") {
t.Fatalf("access receipt projection must not be gated by device event time:\n%s", accessSQL)
}
ordered := []string{"access_previous_received_at =", "access_report_interval_ms =", "access_sample_count =", "access_latest_received_at =", "access_latest_event_id ="}
previous := -1
for _, assignment := range ordered {
position := strings.Index(accessSQL, assignment)
if position <= previous {
t.Fatalf("access assignment %q must preserve old latest receipt before advancing it: %s", assignment, accessSQL)
}
previous = position
}
for _, want := range []string{"COALESCE(access_first_seen_at, received_at, updated_at)", "snapshot_backfill", "access_sample_count = 0"} {
if !strings.Contains(backfillRealtimeAccessProjectionSQL, want) {
t.Fatalf("access baseline backfill missing %q: %s", want, backfillRealtimeAccessProjectionSQL)
}
}
}
func TestSnapshotWriterUpsertsRealtimeLocationWhenCoordinatesExist(t *testing.T) {
exec := &recordingSnapshotExec{}
writer := NewSnapshotWriter(exec)
@@ -150,16 +221,15 @@ func TestSnapshotWriterUpsertsRealtimeLocationWhenCoordinatesExist(t *testing.T)
ReceivedAtMS: 1782918601000,
EventID: "event-1",
Parsed: map[string]any{"location": map[string]any{"latitude": 30.590151, "longitude": 121.069881}},
Fields: map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
envelope.FieldSpeedKMH: 23.0,
envelope.FieldTotalMileageKM: 10241.2,
envelope.FieldSOCPercent: 88,
"altitude_m": uint16(5),
"direction_deg": uint16(79),
"alarm_flag": uint32(0),
"status_flag": uint32(786435),
ParsedFields: map[string]any{
"jt808.location.latitude": 30.590151,
"jt808.location.longitude": 121.069881,
"jt808.location.speed_kmh": 23.0,
"jt808.location.total_mileage_km": "10241.2",
"jt808.location.altitude_m": uint16(5),
"jt808.location.direction_deg": uint16(79),
"jt808.location.alarm_flag": uint32(0),
"jt808.location.status_flag": uint32(786435),
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
@@ -198,15 +268,190 @@ func TestSnapshotWriterUpsertsRealtimeLocationWhenCoordinatesExist(t *testing.T)
if got, want := locationUpsert.args[7], 10241.2; got != want {
t.Fatalf("mileage arg = %#v, want %v", got, want)
}
if got, want := locationUpsert.args[8], 88.0; got != want {
t.Fatalf("soc arg = %#v, want %v", got, want)
if _, ok := locationUpsert.args[8].(time.Time); !ok {
t.Fatalf("total mileage event time arg = %#v, want time.Time", locationUpsert.args[8])
}
if len(locationUpsert.args) != 15 {
t.Fatalf("location upsert args = %d, want 15", len(locationUpsert.args))
if got := locationUpsert.args[9]; got != nil {
t.Fatalf("JT808 location should not invent SOC, got %#v", got)
}
if len(locationUpsert.args) != 16 {
t.Fatalf("location upsert args = %d, want 16", len(locationUpsert.args))
}
}
func TestSnapshotWriterMergesGB32960ParsedJSONAcrossSplitRealtimeFrames(t *testing.T) {
func TestSnapshotWriterDropsNonPositiveTotalMileageFromRealtimeStores(t *testing.T) {
exec := &recordingSnapshotExec{}
writer := NewSnapshotWriter(exec)
if err := writer.Update(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
ParsedFields: map[string]any{
"jt808.location.latitude": "30.590151",
"jt808.location.longitude": "121.069881",
"jt808.location.speed_kmh": "23",
"jt808.location.total_mileage_km": "0",
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
if len(exec.calls) != 2 {
t.Fatalf("exec calls = %d, want snapshot + location", len(exec.calls))
}
parsedJSON, ok := exec.calls[0].args[5].(string)
if !ok {
t.Fatalf("snapshot parsed_json arg = %#v", exec.calls[0].args[5])
}
if strings.Contains(parsedJSON, "total_mileage_km") {
t.Fatalf("snapshot parsed_json should drop non-positive mileage: %s", parsedJSON)
}
if !strings.Contains(parsedJSON, "jt808.location.speed_kmh") {
t.Fatalf("snapshot parsed_json should keep valid fields: %s", parsedJSON)
}
if exec.calls[1].args[7] != nil {
t.Fatalf("location total mileage arg = %#v, want nil", exec.calls[1].args[7])
}
if exec.calls[1].args[8] != nil {
t.Fatalf("location total mileage time arg = %#v, want nil", exec.calls[1].args[8])
}
}
func TestSnapshotWriterNormalizesFarFutureEventTime(t *testing.T) {
exec := &recordingSnapshotExec{}
writer := NewSnapshotWriter(exec)
received := time.Date(2026, 7, 12, 9, 30, 0, 0, time.UTC)
futureEvent := time.Date(2026, 7, 13, 9, 30, 0, 0, time.UTC)
if err := writer.Update(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "VIN001",
EventTimeMS: futureEvent.UnixMilli(),
ReceivedAtMS: received.UnixMilli(),
Parsed: map[string]any{"location": map[string]any{"latitude": 30.590151, "longitude": 121.069881}},
ParsedFields: map[string]any{
"jt808.location.latitude": 30.590151,
"jt808.location.longitude": 121.069881,
"jt808.location.total_mileage_km": "10241.2",
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
if len(exec.calls) != 2 {
t.Fatalf("exec calls = %d, want snapshot + location", len(exec.calls))
}
if got, ok := exec.calls[0].args[6].(time.Time); !ok || !got.Equal(received) {
t.Fatalf("snapshot event_time arg = %#v, want received %s", exec.calls[0].args[6], received)
}
if got, ok := exec.calls[1].args[3].(time.Time); !ok || !got.Equal(received) {
t.Fatalf("location event_time arg = %#v, want received %s", exec.calls[1].args[3], received)
}
if got, ok := exec.calls[1].args[8].(time.Time); !ok || !got.Equal(received) {
t.Fatalf("total mileage event time arg = %#v, want received %s", exec.calls[1].args[8], received)
}
}
func TestRealtimeLocationUsesProtocolMileageMapping(t *testing.T) {
tests := []struct {
name string
protocol envelope.Protocol
fields map[string]any
wantMileage float64
}{
{
name: "gb32960 kilometers",
protocol: envelope.ProtocolGB32960,
fields: map[string]any{
"gb32960.position.latitude": 30.590151,
"gb32960.position.longitude": 121.069881,
"gb32960.vehicle.total_mileage_km": "4123.9",
},
wantMileage: 4123.9,
},
{
name: "jt808 kilometers",
protocol: envelope.ProtocolJT808,
fields: map[string]any{
"jt808.location.latitude": 30.590151,
"jt808.location.longitude": 121.069881,
"jt808.location.total_mileage_km": json.Number("10241.2"),
},
wantMileage: 10241.2,
},
{
name: "yutong meters",
protocol: envelope.ProtocolYutongMQTT,
fields: map[string]any{
"yutong_mqtt.data.latitude": 30.590151,
"yutong_mqtt.data.longitude": 121.069881,
"yutong_mqtt.data.total_mileage": "86737000",
},
wantMileage: 86737,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
row, ok := realtimeLocationFromEnvelope(envelope.FrameEnvelope{
Protocol: tc.protocol,
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
ParsedFields: tc.fields,
}, "VIN001", "")
if !ok {
t.Fatal("realtimeLocationFromEnvelope() should produce a location")
}
if got, ok := row.TotalMileageKM.(float64); !ok || got != tc.wantMileage {
t.Fatalf("total mileage = %#v, want %v", row.TotalMileageKM, tc.wantMileage)
}
if _, ok := row.TotalMileageAt.(time.Time); !ok {
t.Fatalf("total mileage event time = %#v, want time.Time", row.TotalMileageAt)
}
})
}
}
func TestRealtimeLocationDoesNotAdvanceMileageFromCoreFieldOnSparseFrame(t *testing.T) {
row, ok := realtimeLocationFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
ParsedFields: map[string]any{
"yutong_mqtt.data.latitude": "30.590151",
"yutong_mqtt.data.longitude": "121.069881",
},
}, "VIN001", "")
if !ok {
t.Fatal("realtimeLocationFromEnvelope() should produce a location")
}
if row.TotalMileageKM != nil {
t.Fatalf("sparse frame total mileage = %#v, want nil", row.TotalMileageKM)
}
if row.TotalMileageAt != nil {
t.Fatalf("sparse frame total mileage event time = %#v, want nil", row.TotalMileageAt)
}
}
func TestRealtimeLocationRejectsBareStandardizedFields(t *testing.T) {
_, ok := realtimeLocationFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
Fields: map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
},
}, "VIN001", "")
if ok {
t.Fatal("bare standardized fields must not drive the canonical location projection")
}
}
func TestSnapshotWriterMergesGB32960ParsedJSONAcrossSplitRealtimeFramesInUpsert(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
@@ -214,10 +459,6 @@ func TestSnapshotWriterMergesGB32960ParsedJSONAcrossSplitRealtimeFrames(t *testi
defer db.Close()
writer := NewSnapshotWriter(db)
existing := `{"data_units":[{"type":"0x01","name":"vehicle","value":{"soc_percent":88}}]}`
mock.ExpectQuery("SELECT parsed_json FROM vehicle_realtime_snapshot WHERE protocol = \\? AND vin = \\?").
WithArgs("GB32960", "VIN001").
WillReturnRows(sqlmock.NewRows([]string{"parsed_json"}).AddRow(existing))
mock.ExpectExec("INSERT INTO vehicle_realtime_snapshot").
WithArgs(
"GB32960",
@@ -226,14 +467,15 @@ func TestSnapshotWriterMergesGB32960ParsedJSONAcrossSplitRealtimeFrames(t *testi
"",
"",
jsonFlatFieldsArg{fields: map[string]string{
"gb32960.vehicle.soc_percent": "88",
"gb32960.vehicle.type": "0x01",
"gb32960.gd_fc_stack.stack_count": "1",
"gb32960.gd_fc_stack.type": "0x30",
}},
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -248,6 +490,10 @@ func TestSnapshotWriterMergesGB32960ParsedJSONAcrossSplitRealtimeFrames(t *testi
map[string]any{"type": "0x30", "name": "gd_fc_stack", "value": map[string]any{"stack_count": 1}},
},
},
ParsedFields: map[string]any{
"gb32960.gd_fc_stack.stack_count": "1",
"gb32960.gd_fc_stack.type": "0x30",
},
})
if err != nil {
t.Fatalf("Update() error = %v", err)
@@ -265,9 +511,6 @@ func TestSnapshotWriterUsesEnvelopeParsedFieldsWithoutReflattening(t *testing.T)
defer db.Close()
writer := NewSnapshotWriter(db)
mock.ExpectQuery("SELECT parsed_json FROM vehicle_realtime_snapshot WHERE protocol = \\? AND vin = \\?").
WithArgs("GB32960", "VIN001").
WillReturnRows(sqlmock.NewRows([]string{"parsed_json"}))
mock.ExpectExec("INSERT INTO vehicle_realtime_snapshot").
WithArgs(
"GB32960",
@@ -281,6 +524,9 @@ func TestSnapshotWriterUsesEnvelopeParsedFieldsWithoutReflattening(t *testing.T)
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -316,10 +562,6 @@ func TestSnapshotWriterCollapsesGB32960StackFragmentsWhenMergingParsedJSON(t *te
defer db.Close()
writer := NewSnapshotWriter(db)
existing := `{"data_units":[{"type":"0x30","name":"gd_fc_stack","value":{"stack_count":1,"summaries":[{"cell_count":432,"stack_water_outlet_temp_c":65,"frame_cell_start":201,"frame_cell_count":200}]}}]}`
mock.ExpectQuery("SELECT parsed_json FROM vehicle_realtime_snapshot WHERE protocol = \\? AND vin = \\?").
WithArgs("GB32960", "VIN001").
WillReturnRows(sqlmock.NewRows([]string{"parsed_json"}).AddRow(existing))
mock.ExpectExec("INSERT INTO vehicle_realtime_snapshot").
WithArgs(
"GB32960",
@@ -341,6 +583,9 @@ func TestSnapshotWriterCollapsesGB32960StackFragmentsWhenMergingParsedJSON(t *te
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -364,6 +609,9 @@ func TestSnapshotWriterCollapsesGB32960StackFragmentsWhenMergingParsedJSON(t *te
},
},
},
ParsedFields: map[string]any{
"gb32960.gd_fc_stack.stack_water_outlet_temp_c": "63",
},
})
if err != nil {
t.Fatalf("Update() error = %v", err)
@@ -397,6 +645,18 @@ func TestRealtimeUpsertsDoNotOverwriteWithOlderEventTime(t *testing.T) {
}
}
func TestRealtimeSnapshotUpsertMergesParsedJSONInSQL(t *testing.T) {
for _, want := range []string{
"JSON_MERGE_PATCH",
"COALESCE(NULLIF(vehicle_realtime_snapshot.parsed_json, ''), JSON_OBJECT())",
"VALUES(parsed_json)",
} {
if !strings.Contains(upsertRealtimeSnapshotSQL, want) {
t.Fatalf("snapshot upsert should merge parsed_json in SQL, missing %q:\n%s", want, upsertRealtimeSnapshotSQL)
}
}
}
func TestRealtimeLocationUpsertKeepsExistingSparseFieldsWhenMQTTOnlySendsCoordinates(t *testing.T) {
for _, column := range []string{
"speed_kmh",
@@ -412,6 +672,10 @@ func TestRealtimeLocationUpsertKeepsExistingSparseFieldsWhenMQTTOnlySendsCoordin
t.Fatalf("location upsert should keep existing %s when incoming sparse MQTT frame omits it; missing:\n%s\nin:\n%s", column, want, upsertRealtimeLocationSQL)
}
}
wantMileageAt := "total_mileage_event_time = IF(VALUES(event_time) IS NOT NULL AND (vehicle_realtime_location.event_time IS NULL OR VALUES(event_time) >= vehicle_realtime_location.event_time), IF(VALUES(total_mileage_km) IS NOT NULL, COALESCE(VALUES(total_mileage_event_time), total_mileage_event_time), total_mileage_event_time), total_mileage_event_time)"
if !strings.Contains(upsertRealtimeLocationSQL, wantMileageAt) {
t.Fatalf("location upsert should only advance total_mileage_event_time when the incoming frame carries mileage; missing:\n%s\nin:\n%s", wantMileageAt, upsertRealtimeLocationSQL)
}
}
func TestSnapshotWriterBackfillsPlateFromBindingByVIN(t *testing.T) {
@@ -426,10 +690,7 @@ func TestSnapshotWriterBackfillsPlateFromBindingByVIN(t *testing.T) {
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
Parsed: sampleGB32960RealtimeParsed(),
Fields: map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
},
ParsedFields: sampleGB32960LocationFields(),
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
@@ -481,10 +742,7 @@ func TestSnapshotWriterCachesBindingPlateByVIN(t *testing.T) {
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
Parsed: sampleGB32960RealtimeParsed(),
Fields: map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
},
ParsedFields: sampleGB32960LocationFields(),
}
if err := writer.Update(context.Background(), event); err != nil {
@@ -509,6 +767,61 @@ func TestSnapshotWriterCachesBindingPlateByVIN(t *testing.T) {
}
}
func TestCachedPlateResolverEvictsOldestEntryWhenLimitExceeded(t *testing.T) {
delegate := &mapPlateResolver{plates: map[string]string{
"VIN001": "沪A00001",
"VIN002": "沪A00002",
"VIN003": "沪A00003",
}}
resolver := NewCachedPlateResolverWithMaxEntries(delegate, time.Hour, 2)
now := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC)
resolver.now = func() time.Time { return now }
var observed PlateCacheStats
resolver.SetStatsObserver(func(stats PlateCacheStats) {
observed = stats
})
for _, vin := range []string{"VIN001", "VIN002", "VIN003"} {
if _, err := resolver.PlateByVIN(context.Background(), vin); err != nil {
t.Fatalf("PlateByVIN(%s) error = %v", vin, err)
}
now = now.Add(time.Second)
}
stats := resolver.CacheStats()
if stats.Entries != 2 || stats.MaxEntries != 2 || stats.Evictions != 1 {
t.Fatalf("cache stats = %+v, want entries=2 max=2 evictions=1", stats)
}
if observed != stats {
t.Fatalf("observed stats = %+v, want %+v", observed, stats)
}
if _, ok := resolver.entries["VIN001"]; ok {
t.Fatalf("oldest VIN should be evicted")
}
if _, ok := resolver.entries["VIN002"]; !ok {
t.Fatalf("VIN002 should remain cached")
}
if _, ok := resolver.entries["VIN003"]; !ok {
t.Fatalf("VIN003 should remain cached")
}
}
func TestCachedPlateResolverCanDisableEntryLimit(t *testing.T) {
delegate := &mapPlateResolver{plates: map[string]string{
"VIN001": "沪A00001",
"VIN002": "沪A00002",
}}
resolver := NewCachedPlateResolverWithMaxEntries(delegate, time.Hour, 0)
for _, vin := range []string{"VIN001", "VIN002"} {
if _, err := resolver.PlateByVIN(context.Background(), vin); err != nil {
t.Fatalf("PlateByVIN(%s) error = %v", vin, err)
}
}
stats := resolver.CacheStats()
if stats.Entries != 2 || stats.MaxEntries != 0 || stats.Evictions != 0 {
t.Fatalf("cache stats = %+v, want unlimited cache with two entries", stats)
}
}
func TestSnapshotWriterKeepsEventPlateWhenPresent(t *testing.T) {
exec := &recordingSnapshotExec{}
resolver := &recordingPlateResolver{plate: "沪B99999"}
@@ -521,9 +834,9 @@ func TestSnapshotWriterKeepsEventPlateWhenPresent(t *testing.T) {
Plate: "沪A12345",
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
Fields: map[string]any{
envelope.FieldLatitude: 30.590151,
envelope.FieldLongitude: 121.069881,
ParsedFields: map[string]any{
"jt808.location.latitude": 30.590151,
"jt808.location.longitude": 121.069881,
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
@@ -548,7 +861,7 @@ func TestSnapshotWriterIgnoresMissingBindingPlate(t *testing.T) {
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
Parsed: sampleGB32960RealtimeParsed(),
Fields: map[string]any{envelope.FieldSOCPercent: 90},
ParsedFields: map[string]any{"gb32960.vehicle.soc_percent": 90},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
@@ -568,7 +881,7 @@ func TestSnapshotWriterReturnsUnexpectedPlateLookupError(t *testing.T) {
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
Parsed: sampleGB32960RealtimeParsed(),
Fields: map[string]any{envelope.FieldSOCPercent: 90},
ParsedFields: map[string]any{"gb32960.vehicle.soc_percent": 90},
})
if err == nil || !strings.Contains(err.Error(), "db down") {
t.Fatalf("Update() error = %v, want db down", err)
@@ -701,6 +1014,9 @@ func TestSnapshotWriterSkipsMQTTWithoutActualDataFields(t *testing.T) {
"topic": "/vehicle/VIN001/state",
"data": map[string]any{},
},
ParsedFields: map[string]any{
"yutong_mqtt.metadata.topic": "/vehicle/VIN001/state",
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
@@ -709,6 +1025,27 @@ func TestSnapshotWriterSkipsMQTTWithoutActualDataFields(t *testing.T) {
}
}
func TestSnapshotWriterAcceptsGB32960RetransmissionFields(t *testing.T) {
exec := &recordingSnapshotExec{}
writer := NewSnapshotWriter(exec)
if err := writer.Update(context.Background(), envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x03",
VIN: "VIN001",
EventTimeMS: 1782918600000,
ReceivedAtMS: 1782918601000,
ParsedFields: map[string]any{
"gb32960.vehicle.soc_percent": 90,
},
}); err != nil {
t.Fatalf("Update() error = %v", err)
}
if len(exec.calls) != 1 || !strings.Contains(exec.calls[0].query, "vehicle_realtime_snapshot") {
t.Fatalf("GB32960 retransmission should update snapshot, calls=%#v", exec.calls)
}
}
func TestSnapshotWriterSkipsEmptyDataUnitsWithoutCoreFields(t *testing.T) {
exec := &recordingSnapshotExec{}
writer := NewSnapshotWriter(exec)
@@ -740,6 +1077,14 @@ func sampleGB32960RealtimeParsed() map[string]any {
}
}
func sampleGB32960LocationFields() map[string]any {
return map[string]any{
"gb32960.position.latitude": 30.590151,
"gb32960.position.longitude": 121.069881,
"gb32960.vehicle.soc_percent": 90,
}
}
type snapshotExecCall struct {
query string
args []any
@@ -763,6 +1108,18 @@ func (r *recordingPlateResolver) PlateByVIN(_ context.Context, vin string) (stri
return r.plate, r.err
}
type mapPlateResolver struct {
plates map[string]string
}
func (r *mapPlateResolver) PlateByVIN(_ context.Context, vin string) (string, error) {
plate := strings.TrimSpace(r.plates[strings.TrimSpace(vin)])
if plate == "" {
return "", sql.ErrNoRows
}
return plate, nil
}
type jsonFlatFieldsArg struct {
fields map[string]string
forbidden []string
+676 -94
View File
@@ -3,7 +3,6 @@ package stats
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
@@ -11,6 +10,15 @@ import (
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
const (
maxNegativeMileageJitterKM = 1.0
defaultCacheRetention = 72 * time.Hour
defaultCacheCleanupInterval = 10 * time.Minute
defaultBaselineMissTTL = time.Minute
defaultMaxCacheEntries = 1000000
)
type Execer interface {
@@ -18,14 +26,26 @@ type Execer interface {
}
type Writer struct {
exec Execer
query Queryer
loc *time.Location
sourceTouchInterval time.Duration
mu sync.Mutex
lastTotalMileage map[string]float64
lastSourceSeen map[string]time.Time
baselineCache map[string]sourceBaselineCacheEntry
exec Execer
query Queryer
loc *time.Location
sourceTouchInterval time.Duration
projectionInterval time.Duration
cacheRetention time.Duration
cacheCleanupInterval time.Duration
baselineMissTTL time.Duration
maxCacheEntries int
lastCacheCleanup time.Time
lastCacheCleanupStats cacheCleanupStats
cacheEvictions cacheCleanupStats
mu sync.Mutex
lastTotalMileage map[string]float64
lastSourceSeen map[string]time.Time
lastProjection map[string]projectionCacheEntry
baselineCache map[string]sourceBaselineCacheEntry
mileageKeysByPrefix map[string]map[string]struct{}
projectionKeysByPrefix map[string]map[string]struct{}
baselineKeysByPrefix map[string]map[string]struct{}
}
type MetricSample struct {
@@ -38,6 +58,53 @@ type MetricSample struct {
Phone string
DeviceID string
SourceEndpoint string
PlatformName string
}
type AppendResult struct {
SamplesFound int
SamplesWritten int
SamplesSkippedMissingFields int
SamplesSkippedMissingVIN int
SamplesSkippedMissingMileage int
SamplesSkippedNonMileageFrame int
SamplesSkippedNonPositiveMileage int
SamplesSkippedMissingTime int
SamplesSkippedSameMileage int
SamplesSkippedMissingSource int
SamplesAdjustedFutureEventTime int
SourceTouchesAttempted int
SourceTouchesWritten int
SourceTouchesSkippedThrottled int
SourceTouchesSkippedMissing int
SourceTouchesSkippedUnmanaged int
ProjectionsAttempted int
ProjectionsWritten int
ProjectionsSkippedThrottled int
}
type CacheStats struct {
LastTotalMileageEntries int
LastSourceSeenEntries int
LastProjectionEntries int
BaselineEntries int
MaxEntries int
LastCleanupAt time.Time
LastCleanupTotalMileage int
LastCleanupSourceSeen int
LastCleanupProjection int
LastCleanupBaseline int
TotalMileageEvictions int
SourceSeenEvictions int
ProjectionEvictions int
BaselineEvictions int
}
type cacheCleanupStats struct {
totalMileage int
sourceSeen int
projection int
baseline int
}
func NewWriter(exec Execer, loc *time.Location) *Writer {
@@ -48,12 +115,21 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
writer := &Writer{
exec: exec,
loc: loc,
sourceTouchInterval: time.Minute,
lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{},
baselineCache: map[string]sourceBaselineCacheEntry{},
exec: exec,
loc: loc,
sourceTouchInterval: time.Minute,
projectionInterval: 15 * time.Second,
cacheRetention: defaultCacheRetention,
cacheCleanupInterval: defaultCacheCleanupInterval,
baselineMissTTL: defaultBaselineMissTTL,
maxCacheEntries: defaultMaxCacheEntries,
lastTotalMileage: map[string]float64{},
lastSourceSeen: map[string]time.Time{},
lastProjection: map[string]projectionCacheEntry{},
baselineCache: map[string]sourceBaselineCacheEntry{},
mileageKeysByPrefix: map[string]map[string]struct{}{},
projectionKeysByPrefix: map[string]map[string]struct{}{},
baselineKeysByPrefix: map[string]map[string]struct{}{},
}
if query, ok := exec.(Queryer); ok {
writer.query = query
@@ -61,6 +137,82 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
return writer
}
func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.sourceTouchInterval = interval
}
func (w *Writer) SetProjectionInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.projectionInterval = interval
}
func (w *Writer) SetCacheRetention(retention time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if retention < 0 {
retention = 0
}
w.cacheRetention = retention
}
func (w *Writer) SetCacheCleanupInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.cacheCleanupInterval = interval
}
func (w *Writer) SetBaselineMissTTL(ttl time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if ttl < 0 {
ttl = 0
}
w.baselineMissTTL = ttl
}
func (w *Writer) SetMaxCacheEntries(maxEntries int) {
w.mu.Lock()
defer w.mu.Unlock()
if maxEntries < 0 {
maxEntries = 0
}
w.maxCacheEntries = maxEntries
w.enforceCacheLimitsLocked()
}
func (w *Writer) CacheStats() CacheStats {
w.mu.Lock()
defer w.mu.Unlock()
return CacheStats{
LastTotalMileageEntries: len(w.lastTotalMileage),
LastSourceSeenEntries: len(w.lastSourceSeen),
LastProjectionEntries: len(w.lastProjection),
BaselineEntries: len(w.baselineCache),
MaxEntries: w.maxCacheEntries,
LastCleanupAt: w.lastCacheCleanup,
LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage,
LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen,
LastCleanupProjection: w.lastCacheCleanupStats.projection,
LastCleanupBaseline: w.lastCacheCleanupStats.baseline,
TotalMileageEvictions: w.cacheEvictions.totalMileage,
SourceSeenEvictions: w.cacheEvictions.sourceSeen,
ProjectionEvictions: w.cacheEvictions.projection,
BaselineEvictions: w.cacheEvictions.baseline,
}
}
func (w *Writer) EnsureSchema(ctx context.Context) error {
for _, statement := range []string{
DataSourceTableSQL,
@@ -80,48 +232,121 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
}
func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
identity, hasSource := NewSourceIdentity(env.Protocol, env.SourceEndpoint)
if hasSource {
seenAt := w.sourceSeenAt(env)
_, err := w.AppendWithResult(ctx, env)
return err
}
func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelope) (AppendResult, error) {
var result AppendResult
if len(env.Fields) == 0 {
result.SamplesSkippedMissingFields = 1
return result, nil
}
seenAt := w.sourceSeenAt(env)
w.maybeCleanupCaches(seenAt)
identity, hasSource := NewSourceIdentityFromEnvelope(env)
var sourceResult AppendResult
if hasSource && ShouldManageDataSource(identity) {
if w.shouldTouchSource(identity, seenAt) {
sourceResult.SourceTouchesAttempted = 1
if err := UpsertDataSource(ctx, w.exec, identity, seenAt); err != nil {
return err
return sourceResult, err
}
w.markSourceTouched(identity, seenAt)
sourceResult.SourceTouchesWritten = 1
} else {
sourceResult.SourceTouchesSkippedThrottled = 1
}
} else if hasSource {
sourceResult.SourceTouchesSkippedUnmanaged = 1
}
samples, err := SamplesFromEnvelope(env, w.loc)
samples, extractionResult, err := samplesFromEnvelopeWithResult(env, w.loc)
result = extractionResult
result.SourceTouchesAttempted += sourceResult.SourceTouchesAttempted
result.SourceTouchesWritten += sourceResult.SourceTouchesWritten
result.SourceTouchesSkippedThrottled += sourceResult.SourceTouchesSkippedThrottled
result.SourceTouchesSkippedMissing += sourceResult.SourceTouchesSkippedMissing
result.SourceTouchesSkippedUnmanaged += sourceResult.SourceTouchesSkippedUnmanaged
if err != nil {
return err
return result, err
}
for _, sample := range samples {
if w.seenSameMileage(sample) {
if !hasSource {
result.SamplesSkippedMissingSource++
result.SourceTouchesSkippedMissing++
continue
}
if !hasSource {
if w.seenSameMileage(sample) {
result.SamplesSkippedSameMileage++
continue
}
candidate := SourceMileageSampleFromMetric(sample, identity)
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
return err
return result, err
}
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
return err
projectDaily := w.shouldProjectDailyMileage(sample)
if projectDaily {
result.ProjectionsAttempted++
} else {
result.ProjectionsSkippedThrottled++
}
if err := ProjectDailyMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
return err
if err := w.writeMileageSample(ctx, sample, candidate, projectDaily); err != nil {
return result, err
}
w.markBaselineWritten(sample, candidate)
if projectDaily {
w.markProjected(sample)
result.ProjectionsWritten++
}
w.markMileageWritten(sample)
result.SamplesWritten++
}
return result, nil
}
func (w *Writer) writeMileageSample(ctx context.Context, sample MetricSample, candidate SourceMileageSample, projectDaily bool) error {
if projectDaily {
if beginner, ok := w.exec.(txBeginner); ok {
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := UpsertSourceMileage(ctx, tx, candidate); err != nil {
_ = tx.Rollback()
return err
}
if ShouldNormalizePlatformSourceMileage(candidate) {
if err := NormalizePlatformSourceMileage(ctx, tx, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
_ = tx.Rollback()
return err
}
}
if err := projectDailyMileageWithExec(ctx, tx, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
}
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
return err
}
if projectDaily {
if ShouldNormalizePlatformSourceMileage(candidate) {
if err := NormalizePlatformSourceMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
return err
}
}
return ProjectDailyMileage(ctx, w.exec, sample.VIN, sample.StatDate, sample.Protocol)
}
return nil
}
func (w *Writer) sourceSeenAt(env envelope.FrameEnvelope) time.Time {
eventMS := env.EventTimeMS
if eventMS <= 0 {
eventMS = env.ReceivedAtMS
if env.ReceivedAtMS > 0 {
return time.UnixMilli(env.ReceivedAtMS).In(w.loc)
}
if eventMS > 0 {
if eventMS, ok := statEventTimeMS(env); ok {
return time.UnixMilli(eventMS).In(w.loc)
}
return time.Now().In(w.loc)
@@ -131,6 +356,9 @@ func (w *Writer) shouldTouchSource(identity SourceIdentity, seenAt time.Time) bo
key := string(identity.Protocol) + "|" + identity.SourceIP
w.mu.Lock()
defer w.mu.Unlock()
if w.sourceTouchInterval == 0 {
return true
}
last, ok := w.lastSourceSeen[key]
if ok && !seenAt.After(last.Add(w.sourceTouchInterval)) {
return false
@@ -142,9 +370,45 @@ func (w *Writer) markSourceTouched(identity SourceIdentity, seenAt time.Time) {
key := string(identity.Protocol) + "|" + identity.SourceIP
w.mu.Lock()
w.lastSourceSeen[key] = seenAt
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func (w *Writer) shouldProjectDailyMileage(sample MetricSample) bool {
key := projectionCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
interval := w.projectionInterval
if interval == 0 {
return true
}
entry, ok := w.lastProjection[key]
if !ok {
return true
}
if _, ok := entry.sourceKeys[sample.SourceKey]; !ok {
return true
}
return sample.EventTime.After(entry.projectedAt.Add(interval))
}
func (w *Writer) markProjected(sample MetricSample) {
key := projectionCacheKey(sample)
prefix := projectionCachePrefix(sample)
w.mu.Lock()
defer w.mu.Unlock()
w.deleteProjectionPrefixExceptLocked(prefix, key)
entry := w.lastProjection[key]
if entry.sourceKeys == nil {
entry.sourceKeys = map[string]struct{}{}
}
entry.sourceKeys[sample.SourceKey] = struct{}{}
entry.projectedAt = sample.EventTime
w.lastProjection[key] = entry
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
func (w *Writer) seenSameMileage(sample MetricSample) bool {
key := mileageCacheKey(sample)
w.mu.Lock()
@@ -160,17 +424,59 @@ func (w *Writer) markMileageWritten(sample MetricSample) {
key := mileageCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
for existing := range w.lastTotalMileage {
if strings.HasPrefix(existing, prefix) && existing != key {
delete(w.lastTotalMileage, existing)
}
}
for existing := range w.baselineCache {
if strings.HasPrefix(existing, prefix) && existing != key {
delete(w.baselineCache, existing)
}
}
w.deleteMileagePrefixExceptLocked(prefix, key)
w.deleteBaselinePrefixExceptLocked(prefix, key)
w.lastTotalMileage[key] = sample.TotalMileageKM
w.addCacheKeyLocked(w.mileageKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
func (w *Writer) maybeCleanupCaches(now time.Time) {
if now.IsZero() {
now = time.Now().In(w.loc)
}
w.mu.Lock()
defer w.mu.Unlock()
retention := w.cacheRetention
if retention <= 0 {
w.enforceCacheLimitsLocked()
return
}
interval := w.cacheCleanupInterval
if interval > 0 && !w.lastCacheCleanup.IsZero() && !now.After(w.lastCacheCleanup.Add(interval)) {
w.enforceCacheLimitsLocked()
return
}
w.lastCacheCleanup = now
cleanupStats := cacheCleanupStats{}
cutoff := now.Add(-retention)
cutoffDate := cutoff.In(w.loc).Format("2006-01-02")
for key, seenAt := range w.lastSourceSeen {
if !seenAt.IsZero() && seenAt.Before(cutoff) {
delete(w.lastSourceSeen, key)
cleanupStats.sourceSeen++
}
}
for key, entry := range w.lastProjection {
if entry.projectedAt.IsZero() || entry.projectedAt.Before(cutoff) || cacheKeyDateBefore(key, cutoffDate) {
w.deleteProjectionKeyLocked(key)
cleanupStats.projection++
}
}
for key := range w.lastTotalMileage {
if cacheKeyDateBefore(key, cutoffDate) {
w.deleteMileageKeyLocked(key)
cleanupStats.totalMileage++
}
}
for key := range w.baselineCache {
if cacheKeyDateBefore(key, cutoffDate) {
w.deleteBaselineKeyLocked(key)
cleanupStats.baseline++
}
}
w.lastCacheCleanupStats = cleanupStats
w.enforceCacheLimitsLocked()
}
func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMileageSample) error {
@@ -182,22 +488,22 @@ func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMil
return err
}
if !found {
// If no earlier odometer exists at all, use the first current-day sample.
// Later samples retain that boundary through the in-memory baseline cache.
candidate.DailyKM = 0
candidate.QualityStatus = QualityOK
candidate.QualityReason = "current_day_first_sample"
candidate.QualityReason = QualityReasonCurrentDayFirst
return nil
}
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.FirstEventTime = baseline.LatestEventTime
candidate.DailyKM = candidate.LatestTotalKM - baseline.LatestTotalKM
candidate.DailyKM = DailyMileageFromDayBoundary(baseline.LatestTotalKM, candidate.LatestTotalKM)
candidate.QualityStatus = QualityOK
candidate.QualityReason = baseline.QualityReason
if candidate.QualityReason == "" {
candidate.QualityReason = "historical_source_baseline"
}
if candidate.DailyKM < 0 || candidate.DailyKM > maxSelectedDailyMileageKM {
candidate.QualityStatus = QualityInvalidDelta
candidate.QualityReason = "outside_daily_range"
candidate.QualityReason = QualityReasonHistorical
}
ApplyMileageQualityRules(candidate)
return nil
}
@@ -208,10 +514,15 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
})
now := time.Now()
w.mu.Lock()
if cached, ok := w.baselineCache[cacheKey]; ok {
w.mu.Unlock()
return cached.baseline, cached.found, nil
missExpired := !cached.found && (w.baselineMissTTL == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= w.baselineMissTTL)
if !missExpired {
w.mu.Unlock()
return cached.baseline, cached.found, nil
}
w.deleteBaselineKeyLocked(cacheKey)
}
w.mu.Unlock()
@@ -219,21 +530,165 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
if err != nil {
return sourceBaseline{}, false, err
}
if !found {
baseline, found, err = lookupCurrentSourceBaseline(ctx, w.query, candidate.VIN, candidate.StatDate, candidate.Protocol, candidate.SourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
}
if found {
w.mu.Lock()
w.baselineCache[cacheKey] = sourceBaselineCacheEntry{baseline: baseline, found: true}
w.mu.Unlock()
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{baseline: baseline, found: true})
} else {
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{found: false})
}
return baseline, found, nil
}
func (w *Writer) markBaselineWritten(sample MetricSample, candidate SourceMileageSample) {
if candidate.QualityStatus != QualityOK {
return
}
baselineTotal := candidate.FirstTotalKM
if baselineTotal <= 0 {
baselineTotal = candidate.LatestTotalKM
}
baselineTime := candidate.FirstEventTime
if baselineTime.IsZero() {
baselineTime = candidate.LatestEventTime
}
if baselineTotal <= 0 || baselineTime.IsZero() {
return
}
cacheKey := mileageCacheKey(sample)
w.cacheBaseline(candidate, cacheKey, sourceBaselineCacheEntry{
baseline: sourceBaseline{
LatestTotalKM: baselineTotal,
LatestEventTime: baselineTime,
QualityReason: candidate.QualityReason,
},
found: true,
})
}
func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, entry sourceBaselineCacheEntry) {
if cacheKey == "" {
return
}
w.mu.Lock()
if entry.cachedAt.IsZero() {
entry.cachedAt = time.Now()
}
w.baselineCache[cacheKey] = entry
w.addCacheKeyLocked(w.baselineKeysByPrefix, mileageCachePrefix(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
SourceKey: candidate.SourceKey,
}), cacheKey)
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func (w *Writer) addCacheKeyLocked(index map[string]map[string]struct{}, prefix string, key string) {
if prefix == "" || key == "" {
return
}
keys := index[prefix]
if keys == nil {
keys = map[string]struct{}{}
index[prefix] = keys
}
keys[key] = struct{}{}
}
func (w *Writer) deleteMileagePrefixExceptLocked(prefix string, keep string) {
for key := range w.mileageKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteMileageKeyLocked(key)
}
}
func (w *Writer) deleteBaselinePrefixExceptLocked(prefix string, keep string) {
for key := range w.baselineKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteBaselineKeyLocked(key)
}
}
func (w *Writer) deleteProjectionPrefixExceptLocked(prefix string, keep string) {
for key := range w.projectionKeysByPrefix[prefix] {
if key == keep {
continue
}
w.deleteProjectionKeyLocked(key)
}
}
func (w *Writer) deleteMileageKeyLocked(key string) {
delete(w.lastTotalMileage, key)
w.deleteIndexedCacheKeyLocked(w.mileageKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteBaselineKeyLocked(key string) {
delete(w.baselineCache, key)
w.deleteIndexedCacheKeyLocked(w.baselineKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteProjectionKeyLocked(key string) {
delete(w.lastProjection, key)
w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key)
}
func (w *Writer) deleteIndexedCacheKeyLocked(index map[string]map[string]struct{}, prefix string, key string) {
if prefix == "" || key == "" {
return
}
keys := index[prefix]
if len(keys) == 0 {
return
}
delete(keys, key)
if len(keys) == 0 {
delete(index, prefix)
}
}
func (w *Writer) enforceCacheLimitsLocked() {
if w.maxCacheEntries <= 0 {
return
}
for len(w.lastTotalMileage) > w.maxCacheEntries {
victim := oldestCacheKeyByDate(w.lastTotalMileage)
if victim == "" {
break
}
w.deleteMileageKeyLocked(victim)
w.cacheEvictions.totalMileage++
}
for len(w.baselineCache) > w.maxCacheEntries {
victim := oldestCacheKeyByDate(w.baselineCache)
if victim == "" {
break
}
w.deleteBaselineKeyLocked(victim)
w.cacheEvictions.baseline++
}
for len(w.lastProjection) > w.maxCacheEntries {
victim := oldestProjectionCacheKey(w.lastProjection)
if victim == "" {
break
}
w.deleteProjectionKeyLocked(victim)
w.cacheEvictions.projection++
}
for len(w.lastSourceSeen) > w.maxCacheEntries {
victim := oldestSourceSeenKey(w.lastSourceSeen)
if victim == "" {
break
}
delete(w.lastSourceSeen, victim)
w.cacheEvictions.sourceSeen++
}
}
func mileageCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|%s|", sample.VIN, sample.Protocol, sample.SourceKey)
}
@@ -242,34 +697,138 @@ func mileageCacheKey(sample MetricSample) string {
return mileageCachePrefix(sample) + sample.StatDate
}
func projectionCacheKey(sample MetricSample) string {
return projectionCachePrefix(sample) + sample.StatDate
}
func projectionCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
}
func cacheKeyDateBefore(key string, cutoffDate string) bool {
date := cacheKeyDate(key)
if len(date) != len("2006-01-02") {
return false
}
return date < cutoffDate
}
func cacheKeyPrefix(key string) string {
index := strings.LastIndex(key, "|")
if index < 0 {
return ""
}
return key[:index+1]
}
func cacheKeyDate(key string) string {
index := strings.LastIndex(key, "|")
if index < 0 || index == len(key)-1 {
return ""
}
return key[index+1:]
}
func oldestCacheKeyByDate[T any](items map[string]T) string {
victim := ""
victimDate := ""
for key := range items {
date := cacheKeyDate(key)
if date == "" {
date = "9999-99-99"
}
if victim == "" || date < victimDate || (date == victimDate && key < victim) {
victim = key
victimDate = date
}
}
return victim
}
func oldestProjectionCacheKey(items map[string]projectionCacheEntry) string {
victim := ""
var victimTime time.Time
for key, entry := range items {
if victim == "" ||
(!entry.projectedAt.IsZero() && (victimTime.IsZero() || entry.projectedAt.Before(victimTime))) ||
(entry.projectedAt.Equal(victimTime) && key < victim) {
victim = key
victimTime = entry.projectedAt
}
}
if victim != "" {
return victim
}
return oldestCacheKeyByDate(items)
}
func oldestSourceSeenKey(items map[string]time.Time) string {
victim := ""
var victimTime time.Time
for key, seenAt := range items {
if victim == "" ||
(!seenAt.IsZero() && (victimTime.IsZero() || seenAt.Before(victimTime))) ||
(seenAt.Equal(victimTime) && key < victim) {
victim = key
victimTime = seenAt
}
}
return victim
}
type projectionCacheEntry struct {
projectedAt time.Time
sourceKeys map[string]struct{}
}
type sourceBaselineCacheEntry struct {
baseline sourceBaseline
found bool
cachedAt time.Time
}
func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, error) {
samples, _, err := samplesFromEnvelopeWithResult(env, loc)
return samples, err
}
func samplesFromEnvelopeWithResult(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, AppendResult, error) {
var result AppendResult
if len(env.Fields) == 0 {
result.SamplesSkippedMissingFields = 1
return nil, result, nil
}
vin := strings.TrimSpace(env.VIN)
if vin == "" {
return nil, nil
result.SamplesSkippedMissingVIN = 1
return nil, result, nil
}
totalMileage, ok := totalMileageKMFromEnvelope(env)
if !ok {
return nil, nil
if isMileageCandidateEnvelope(env) {
result.SamplesSkippedMissingMileage = 1
} else {
result.SamplesSkippedNonMileageFrame = 1
}
return nil, result, nil
}
if totalMileage <= 0 {
return nil, nil
result.SamplesSkippedNonPositiveMileage = 1
return nil, result, nil
}
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
eventMS := env.EventTimeMS
if eventMS <= 0 {
eventMS = env.ReceivedAtMS
eventMS, reason, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
result.SamplesSkippedMissingTime = 1
return nil, result, nil
}
if eventMS <= 0 {
return nil, errors.New("event or received time is required")
if reason == envelope.EventTimeReasonReceivedFutureEvent {
result.SamplesAdjustedFutureEventTime = 1
}
statDate := time.UnixMilli(eventMS).In(loc).Format("2006-01-02")
result.SamplesFound = 1
return []MetricSample{{
VIN: vin,
Protocol: env.Protocol,
@@ -280,11 +839,16 @@ func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]Metr
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
}}, nil
PlatformName: strings.TrimSpace(env.PlatformName),
}}, result, nil
}
func statEventTimeMS(env envelope.FrameEnvelope) (int64, bool) {
return envelope.NormalizedEventTimeMS(env)
}
func sourceKey(env envelope.FrameEnvelope) string {
return SourceKey(env.Protocol, env.Phone, env.DeviceID, NormalizeSourceIP(env.SourceEndpoint))
return SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, NormalizeSourceIP(env.SourceEndpoint), env.SourceKind, env.SourceCode)
}
func isIgnorableSchemaChangeError(err error) bool {
@@ -300,41 +864,59 @@ func isIgnorableSchemaChangeError(err error) bool {
strings.Contains(text, "1091")
}
type mileageFieldMapping struct {
key string
scale float64
}
type mileageFieldMapping = telemetry.MileageFieldMapping
func totalMileageKMFromEnvelope(env envelope.FrameEnvelope) (float64, bool) {
if value, ok := floatField(env, envelope.FieldTotalMileageKM); ok {
return value, true
}
for _, mapping := range mileageMappingsByProtocol(env.Protocol) {
value, ok := floatField(env, mapping.key)
if !ok {
continue
}
return value * mapping.scale, true
}
return 0, false
return telemetry.TotalMileageKM(env.Protocol, env.Fields)
}
func mileageMappingsByProtocol(protocol envelope.Protocol) []mileageFieldMapping {
switch protocol {
case envelope.ProtocolGB32960:
return []mileageFieldMapping{{key: "gb32960.vehicle.total_mileage_km", scale: 1}}
return telemetry.MileageFieldMappings(protocol)
}
func isMileageCandidateEnvelope(env envelope.FrameEnvelope) bool {
switch env.Protocol {
case envelope.ProtocolJT808:
return []mileageFieldMapping{{key: "jt808.location.total_mileage_km", scale: 1}}
messageID := strings.TrimSpace(env.MessageID)
return strings.EqualFold(messageID, "0x0200") ||
hasFieldPrefix(env.Fields, "jt808.location.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH)
case envelope.ProtocolGB32960:
return hasFieldPrefix(env.Fields, "gb32960.vehicle.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH, envelope.FieldSOCPercent)
case envelope.ProtocolYutongMQTT:
return []mileageFieldMapping{
{key: "yutong_mqtt.data.total_mileage", scale: 0.001},
{key: "yutong_mqtt.root.data.total_mileage", scale: 0.001},
}
return hasFieldPrefix(env.Fields, "yutong_mqtt.data.") ||
hasFieldPrefix(env.Fields, "yutong_mqtt.root.data.") ||
hasAnyField(env.Fields, envelope.FieldLatitude, envelope.FieldLongitude, envelope.FieldSpeedKMH, envelope.FieldSOCPercent)
default:
return nil
return true
}
}
func hasFieldPrefix(fields map[string]any, prefix string) bool {
if len(fields) == 0 || prefix == "" {
return false
}
for key := range fields {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}
func hasAnyField(fields map[string]any, keys ...string) bool {
if len(fields) == 0 {
return false
}
for _, key := range keys {
if _, ok := fields[key]; ok {
return true
}
}
return false
}
func floatField(env envelope.FrameEnvelope, key string) (float64, bool) {
if env.Fields == nil {
return 0, false
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,700 @@
package stats
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
const dataSourceSelectPattern = "SELECT id, protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, trust_priority, enabled, first_seen_at, latest_seen_at, remark, updated_at FROM vehicle_data_source"
func TestDataSourceRepositoryQueriesWithOperationalFilters(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dataSourceSelectPattern).
WithArgs("JT808", "115.231.168.135", "G7S", "PLATFORM", 1, 20, 10).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
}).AddRow(
3, "JT808", "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM",
10, 1,
time.Date(2026, 7, 8, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
time.Date(2026, 7, 12, 1, 16, 4, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
"trusted source",
time.Date(2026, 7, 12, 1, 16, 5, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
))
enabled := true
rows, err := NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{
Protocol: "jt808",
SourceIP: "115.231.168.135",
SourceCode: "G7S",
SourceKind: "platform",
Enabled: &enabled,
Limit: 20,
Offset: 10,
})
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.ID != 3 || row.Protocol != "JT808" || row.PlatformName != "G7 平台" || row.SourceCode != "G7S" || row.SourceKind != "PLATFORM" || !row.Enabled {
t.Fatalf("unexpected source row: %#v", row)
}
if row.LatestSeenAt != "2026-07-12 01:16:04" {
t.Fatalf("latest seen = %q", row.LatestSeenAt)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceRepositoryFiltersMissingSourceCode(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery(dataSourceSelectPattern).
WithArgs("JT808", 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
}))
missing := true
_, err = NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{
Protocol: "JT808",
SourceCodeMissing: &missing,
})
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceRepositoryDiagnosesJT808SourceMapping(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", "117.132.194.31", 1, 10, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil,
"UNKNOWN", "2026-07-11 23:00:00", "2026-07-12 01:36:26", 9386, 120,
5, 4, 2,
3, 1, 0.75, 0, nil, 2, "g7s", "G7s", "g7s,xinda", "G7s,信达", "13307795425,14400000000",
))
missing := true
rows, err := NewDataSourceRepository(db).QueryDiagnostics(context.Background(), DataSourceDiagnosticsQuery{
Protocol: "jt808",
SourceIP: "117.132.194.31",
SourceCodeMissing: &missing,
Limit: 10,
})
if err != nil {
t.Fatalf("QueryDiagnostics() error = %v", err)
}
if len(rows) != 1 {
t.Fatalf("row count = %d", len(rows))
}
row := rows[0]
if row.Reason != "ambiguous_source_code" || row.IdentifierMatchedPhones != 3 || len(row.MatchedSourceCodes) != 2 || len(row.SamplePhones) != 2 {
t.Fatalf("unexpected diagnostic row: %#v", row)
}
if row.SuggestedSourceKind != "UNKNOWN" || row.SuggestionConfidence != "HIGH" {
t.Fatalf("unexpected kind suggestion: %#v", row)
}
sqlText, _ := buildDataSourceDiagnosticsSQL(DataSourceDiagnosticsQuery{Protocol: "JT808", Limit: 10})
if !strings.Contains(sqlText, "ON ds.protocol = 'JT808'") || !strings.Contains(sqlText, "AND r.source_ip = ds.source_ip") {
t.Fatalf("diagnostics should join by indexed source_ip:\n%s", sqlText)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceDiagnosticsMappingIssueFilterUsesHaving(t *testing.T) {
missing := false
query := normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{
Protocol: "JT808",
SourceCodeMissing: &missing,
MappingIssueOnly: true,
Limit: 10,
})
sqlText, args := buildDataSourceDiagnosticsSQL(query)
if !strings.Contains(sqlText, "HAVING") || !strings.Contains(sqlText, "identifier_match_ratio < 0.8") || !strings.Contains(sqlText, "configured_source_code_platform_name") {
t.Fatalf("mapping issue query should include aggregate HAVING:\n%s", sqlText)
}
if strings.Contains(sqlText, "ds.platform_name IS NOT NULL") {
t.Fatalf("mapping issue HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", sqlText)
}
if len(args) != 4 {
t.Fatalf("mapping issue query args = %#v, want protocol/enabled/limit/offset", args)
}
countSQL, _ := buildDataSourceDiagnosticsCountSQL(query)
if !strings.Contains(countSQL, "FROM (") || !strings.Contains(countSQL, "HAVING") {
t.Fatalf("mapping issue count should wrap grouped diagnostics:\n%s", countSQL)
}
if strings.Contains(countSQL, "ds.platform_name IS NOT NULL") {
t.Fatalf("mapping issue count HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", countSQL)
}
}
func TestDiagnoseDataSourceHighlightsConfiguredMappingIssues(t *testing.T) {
lowCoverage := DataSourceDiagnosticRow{
Protocol: "JT808",
PlatformName: "东方北斗",
SourceCode: "dongfang_beidou",
RegistrationRows: 388,
PhoneCount: 388,
IdentifierMatchedPhones: 8,
UnmappedPhoneCount: 380,
IdentifierMatchRatio: 0.0206,
ConfiguredSourceCodeMatchedPhones: 8,
ConfiguredSourceCodePlatformName: "东方北斗",
MatchedSourceCodeCount: 1,
MatchedSourceCodes: []string{"dongfang_beidou"},
}
lowCoverage.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(lowCoverage)
lowCoverage.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverage)
reason, _ := diagnoseDataSource(lowCoverage)
if reason != "low_identifier_coverage" {
t.Fatalf("reason = %q, want low_identifier_coverage", reason)
}
lowCoverageMismatch := lowCoverage
lowCoverageMismatch.PlatformName = "G7易流"
lowCoverageMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverageMismatch)
reason, _ = diagnoseDataSource(lowCoverageMismatch)
if reason != "low_identifier_coverage" {
t.Fatalf("reason = %q, want low_identifier_coverage when coverage is weak even if platform name differs", reason)
}
nameMismatch := DataSourceDiagnosticRow{
Protocol: "JT808",
PlatformName: "G7易流",
SourceCode: "dongfang_beidou",
RegistrationRows: 388,
PhoneCount: 388,
IdentifierMatchedPhones: 8,
ConfiguredSourceCodePlatformName: "东方北斗",
MatchedSourceCodeCount: 1,
MatchedSourceCodes: []string{"dongfang_beidou"},
}
nameMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(nameMismatch)
reason, _ = diagnoseDataSource(nameMismatch)
if reason != "source_platform_name_mismatch" {
t.Fatalf("reason = %q, want source_platform_name_mismatch", reason)
}
conflict := DataSourceDiagnosticRow{
Protocol: "JT808",
SourceCode: "g7s",
RegistrationRows: 20,
PhoneCount: 20,
IdentifierMatchedPhones: 20,
IdentifierMatchRatio: 1,
ConfiguredSourceCodeMatchedPhones: 0,
MatchedSourceCodeCount: 1,
MatchedSourceCodes: []string{"dongfang_beidou"},
}
conflict.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(conflict)
reason, _ = diagnoseDataSource(conflict)
if reason != "source_code_conflict" {
t.Fatalf("reason = %q, want source_code_conflict", reason)
}
}
func TestDataSourceHandlerReturnsSourcePage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_data_source").
WithArgs("GB32960", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(3))
mock.ExpectQuery(dataSourceSelectPattern).
WithArgs("GB32960", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind",
"trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at",
}).AddRow(
7, "GB32960", "8.134.95.166", "8.134.95.166:56432", "现代 HTWO", "HYUNDAI", "PLATFORM",
5, 1, "2026-07-11 18:27:10", "2026-07-12 01:15:52", "", "2026-07-12 01:15:52",
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?protocol=gb32960&enabled=true&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":3`, `"source_ip":"8.134.95.166"`, `"platform_name":"现代 HTWO"`, `"source_code":"HYUNDAI"`, `"source_kind":"PLATFORM"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsDiagnosticsPage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_data_source ds").
WithArgs("JT808", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil,
"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
5, 4, 2,
0, 4, 0.0, 0, nil, 0, nil, nil, nil, nil, "13307795425",
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":1`, `"reason":"no_identifier_match"`, `"sample_phones":["13307795425"]`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsJT808IdentityGaps(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM jt808_registration r").
WithArgs("117.132.194.31", "guangan_beidou", 3600).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM jt808_registration r").
WithArgs("117.132.194.31", "guangan_beidou", 3600, 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"phone", "device_id", "plate", "vin", "source_ip", "source_endpoint",
"source_code", "platform_name", "source_kind",
"first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "latest_seen_age_seconds",
}).AddRow(
"13307795425", "", "沪A63305F", "unknown", "117.132.194.31", "117.132.194.31:20471",
"guangan_beidou", "广安北斗", "PLATFORM",
nil, nil, nil, "2026-07-12 22:24:53", 32,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-identity-gaps?sourceIP=117.132.194.31&sourceCode=guangan_beidou&recentSeconds=3600&includeTotal=true&limit=20", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`"total":1`,
`"phone":"13307795425"`,
`"reason":"missing_phone_and_plate_binding"`,
`"raw_frame_query_path":"/api/history/raw-frames?`,
`phone=13307795425`,
`dateFrom=2026-07-12+00%3A00%3A00`,
`"data_source_query_path":"/api/stats/data-sources?`,
`sourceIP=117.132.194.31`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsJT808MappingGaps(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM jt808_registration r").
WithArgs("115.159.85.149", "dongfang_beidou", 3600).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM jt808_registration r").
WithArgs("115.159.85.149", "dongfang_beidou", 3600, 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"phone", "device_id", "plate", "vin", "source_ip", "source_endpoint",
"source_code", "platform_name", "source_kind", "identifier_vin", "identifier_plate",
"matched_source_codes", "matched_platform_names", "latest_seen_at", "latest_seen_age_seconds",
}).AddRow(
"64646848246", "", "粤AG18312", "LKLG7C4E8NA774778", "115.159.85.149", "115.159.85.149:16885",
"dongfang_beidou", "G7易流", "PLATFORM", nil, nil,
"g7s", "G7s", "2026-07-12 23:38:22", 32,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-mapping-gaps?sourceIP=115.159.85.149&sourceCode=dongfang_beidou&recentSeconds=3600&includeTotal=true&limit=20", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`"total":1`,
`"phone":"64646848246"`,
`"suggested_source_code":"dongfang_beidou"`,
`"suggested_identifier_type":"JT808_PHONE"`,
`"suggested_vin":"LKLG7C4E8NA774778"`,
`"reason":"missing_source_phone_identifier"`,
`"matched_source_codes":["g7s"]`,
`"raw_frame_query_path":"/api/history/raw-frames?`,
`phone=64646848246`,
`"data_source_query_path":"/api/stats/data-sources?`,
`sourceIP=115.159.85.149`,
`"vehicle_identifier_example":"protocol=JT808, source_code=dongfang_beidou, identifier_type=JT808_PHONE, identifier_value=64646848246, vin=LKLG7C4E8NA774778, plate=粤AG18312, oem=G7易流"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestJT808MappingGapSQLUsesConfiguredSourceIdentifier(t *testing.T) {
sqlText, args := buildJT808MappingGapSQL(normalizeJT808MappingGapQuery(JT808MappingGapQuery{
SourceIP: "115.159.85.149",
SourceCode: "dongfang_beidou",
RecentSeconds: 3600,
Limit: 20,
}))
for _, want := range []string{
"JOIN vehicle_data_source ds",
"source_vi.source_code = ds.source_code",
"source_vi.identifier_type = 'JT808_PHONE'",
"source_vi.identifier_value = r.phone",
"source_vi.identifier_value IS NULL OR source_vi.vin IS NULL",
} {
if !strings.Contains(sqlText, want) {
t.Fatalf("mapping gap SQL missing %q:\n%s", want, sqlText)
}
}
if len(args) != 5 {
t.Fatalf("args = %#v, want sourceIP/sourceCode/recent/limit/offset", args)
}
}
func TestDataSourceDiagnosticsCanQueryRetiredSources(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", 0, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&enabled=false", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
sqlText, args := buildDataSourceDiagnosticsSQL(normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{Protocol: "JT808"}))
if !strings.Contains(sqlText, "ds.enabled = ?") {
t.Fatalf("diagnostics should default to enabled sources:\n%s", sqlText)
}
if len(args) < 2 || args[1] != 1 {
t.Fatalf("diagnostics enabled default args = %#v, want enabled=1 before limit", args)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsGenericDiagnosticsForNonJT808(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("GB32960", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", nil,
"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
0, 0, 0,
0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=GB32960", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"protocol":"GB32960"`, `"reason":"source_code_missing"`, `"suggested_source_kind":"PLATFORM"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsKindSuggestionsPage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_data_source ds").
WithArgs("JT808", "UNKNOWN", 1).
WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("JT808", "UNKNOWN", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
242190, "JT808", "117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou",
"UNKNOWN", "2026-07-12 00:00:00", "2026-07-12 00:05:00", 300, 7200,
0, 0, 0,
0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=jt808&includeTotal=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"total":1`, `"suggested_source_kind":"UNKNOWN"`, `"suggestion_confidence":"MEDIUM"`, `"suggestion_reason":"manual_source_without_registration_evidence"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerReturnsKindSuggestionsForNonJT808(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("FROM vehicle_data_source ds").
WithArgs("GB32960", "UNKNOWN", 1, 50, 0).
WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code",
"source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows",
"identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code",
"candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones",
}).AddRow(
7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI",
"UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800,
0, 0, 0,
0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil,
))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=GB32960", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{`"reason":"source_configured"`, `"suggested_source_kind":"PLATFORM"`, `"suggestion_reason":"non_jt808_configured_source"`} {
if !strings.Contains(body, want) {
t.Fatalf("response missing %s: %s", want, body)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestSuggestSourceKindMarksStaleUnclassifiedSourceAsDirect(t *testing.T) {
kind, confidence, reason := suggestSourceKind(DataSourceDiagnosticRow{
SourceKind: "UNKNOWN",
Reason: "no_registration",
LatestSeenAgeSeconds: 25 * 3600,
})
if kind != "DIRECT" || confidence != "LOW" || reason != "stale_unclassified_source_without_registration" {
t.Fatalf("unexpected suggestion: kind=%s confidence=%s reason=%s", kind, confidence, reason)
}
}
func TestDataSourceHandlerPatchesOnlyManualFields(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT 1 FROM vehicle_data_source WHERE id = \\? LIMIT 1").
WithArgs(int64(3)).
WillReturnRows(sqlmock.NewRows([]string{"one"}).AddRow(1))
mock.ExpectExec("UPDATE vehicle_data_source SET platform_name = \\?, source_code = \\?, source_kind = \\?, trust_priority = \\?, enabled = \\?, remark = \\?, updated_at = CURRENT_TIMESTAMP WHERE id = \\?").
WithArgs("G7 平台", "G7S", "PLATFORM", 10, 0, "可信 808 来源", int64(3)).
WillReturnResult(sqlmock.NewResult(0, 0))
handler := NewDataSourceHandler(NewDataSourceRepository(db))
body := strings.NewReader(`{"platform_name":"G7 平台","source_code":"G7S","source_kind":"platform","trust_priority":10,"enabled":false,"remark":"可信 808 来源","latest_seen_at":"2099-01-01 00:00:00"}`)
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/3", body)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"updated":true`) {
t.Fatalf("patch response should confirm update: %s", response.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestDataSourceHandlerRejectsConflictingSourceCodeFilters(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?sourceCode=g7s&sourceCodeMissing=true", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "sourceCode") {
t.Fatalf("response should mention sourceCode: %s", response.Body.String())
}
}
func TestDataSourceHandlerRejectsInvalidSourceCode(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_code":"G7 中文"}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "source_code") {
t.Fatalf("response should mention source_code: %s", response.Body.String())
}
}
func TestDataSourceHandlerRejectsInvalidSourceKind(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_kind":"temporary"}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "source_kind") {
t.Fatalf("response should mention source_kind: %s", response.Body.String())
}
}
func TestDataSourceHandlerRejectsEmptyPatch(t *testing.T) {
handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{}))
request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{}`))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "no mutable fields") {
t.Fatalf("response should mention no mutable fields: %s", response.Body.String())
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -15,6 +15,10 @@ const DailyMileageTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mileage (
)`
var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_data_source ADD COLUMN source_code VARCHAR(64) NULL AFTER platform_name",
"ALTER TABLE vehicle_data_source ADD COLUMN source_kind VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN' AFTER source_code",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_code (protocol, source_code)",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN source_id BIGINT NULL AFTER protocol",
"ALTER TABLE vehicle_daily_mileage ADD KEY idx_source_id (source_id)",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN first_total_mileage_km",
@@ -22,6 +26,10 @@ var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_phone",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_source_endpoint",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN sample_count",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_selected (protocol, stat_date, is_selected, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_source_ip_date (protocol, source_ip, stat_date)",
}
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
@@ -30,6 +38,8 @@ const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
source_ip VARCHAR(64) NOT NULL,
latest_source_endpoint VARCHAR(128) NULL,
platform_name VARCHAR(128) NULL,
source_code VARCHAR(64) NULL,
source_kind VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN',
trust_priority INT NOT NULL DEFAULT 100,
enabled TINYINT(1) NOT NULL DEFAULT 1,
first_seen_at DATETIME NULL,
@@ -39,6 +49,8 @@ const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_protocol_source_ip (protocol, source_ip),
KEY idx_protocol_source_code (protocol, source_code),
KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at),
KEY idx_protocol_enabled_priority (protocol, enabled, trust_priority)
)`
@@ -66,5 +78,9 @@ const DailyMileageSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mil
PRIMARY KEY (vin, stat_date, protocol, source_key),
KEY idx_protocol_date (protocol, stat_date),
KEY idx_source_ip (protocol, source_ip),
KEY idx_selected (stat_date, protocol, is_selected)
KEY idx_selected (stat_date, protocol, is_selected),
KEY idx_protocol_date_selected (protocol, stat_date, is_selected, vin),
KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin),
KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin),
KEY idx_source_ip_date (protocol, source_ip, stat_date)
)`
+79 -11
View File
@@ -12,6 +12,9 @@ type SourceIdentity struct {
Protocol envelope.Protocol
SourceIP string
SourceEndpoint string
SourceCode string
PlatformName string
SourceKind string
}
func NewSourceIdentity(protocol envelope.Protocol, endpoint string) (SourceIdentity, bool) {
@@ -26,15 +29,29 @@ func NewSourceIdentity(protocol envelope.Protocol, endpoint string) (SourceIdent
}, true
}
func NewSourceIdentityFromEnvelope(env envelope.FrameEnvelope) (SourceIdentity, bool) {
identity, ok := NewSourceIdentity(env.Protocol, env.SourceEndpoint)
if !ok {
return SourceIdentity{}, false
}
identity.SourceCode = strings.TrimSpace(env.SourceCode)
identity.PlatformName = strings.TrimSpace(env.PlatformName)
identity.SourceKind = normalizeSourceKindForRead(env.SourceKind)
return identity, true
}
func ShouldManageDataSource(identity SourceIdentity) bool {
if strings.TrimSpace(identity.SourceIP) == "" {
return false
}
if strings.TrimSpace(identity.SourceCode) != "" || strings.TrimSpace(identity.PlatformName) != "" {
return true
}
return normalizeSourceKindForRead(identity.SourceKind) == "PLATFORM"
}
func NormalizeSourceIP(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return ""
}
if host, _, ok := strings.Cut(endpoint, ":"); ok {
return strings.TrimSpace(host)
}
return endpoint
return envelope.NormalizeSourceEndpointKey(endpoint)
}
func UpsertDataSource(ctx context.Context, exec Execer, identity SourceIdentity, now time.Time) error {
@@ -51,18 +68,69 @@ func UpsertDataSource(ctx context.Context, exec Execer, identity SourceIdentity,
string(identity.Protocol),
identity.SourceIP,
identity.SourceEndpoint,
nullableTrimmedString(identity.PlatformName),
nullableTrimmedString(identity.SourceCode),
sourceKindForDataSourceWrite(identity),
now,
now,
)
return err
}
func sourceKindForDataSourceWrite(identity SourceIdentity) string {
kind := normalizeSourceKindForWrite(identity.SourceKind)
if kind != "UNKNOWN" {
return kind
}
if strings.TrimSpace(identity.SourceCode) != "" || strings.TrimSpace(identity.PlatformName) != "" {
return "PLATFORM"
}
return "UNKNOWN"
}
const upsertDataSourceSQL = `
INSERT INTO vehicle_data_source
(protocol, source_ip, latest_source_endpoint, first_seen_at, latest_seen_at)
VALUES (?, ?, ?, ?, ?)
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, first_seen_at, latest_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
latest_source_endpoint = VALUES(latest_source_endpoint),
latest_seen_at = VALUES(latest_seen_at),
platform_name = CASE
WHEN vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''
THEN VALUES(platform_name)
ELSE vehicle_data_source.platform_name
END,
source_code = CASE
WHEN vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''
THEN VALUES(source_code)
ELSE vehicle_data_source.source_code
END,
source_kind = CASE
WHEN vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'
THEN VALUES(source_kind)
ELSE vehicle_data_source.source_kind
END,
enabled = CASE
WHEN vehicle_data_source.enabled = 0
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
AND (
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
OR VALUES(source_kind) <> 'UNKNOWN'
)
THEN 1
ELSE vehicle_data_source.enabled
END,
remark = CASE
WHEN vehicle_data_source.enabled = 0
AND (vehicle_data_source.remark LIKE 'auto-retired:%' OR vehicle_data_source.remark = 'auto-reenabled: source evidence restored')
AND (
(VALUES(platform_name) IS NOT NULL AND TRIM(VALUES(platform_name)) <> '')
OR (VALUES(source_code) IS NOT NULL AND TRIM(VALUES(source_code)) <> '')
OR VALUES(source_kind) <> 'UNKNOWN'
)
THEN 'auto-reenabled: source evidence restored'
ELSE vehicle_data_source.remark
END,
latest_seen_at = GREATEST(COALESCE(latest_seen_at, VALUES(latest_seen_at)), VALUES(latest_seen_at)),
updated_at = CURRENT_TIMESTAMP
`
@@ -10,10 +10,16 @@ import (
)
const (
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
maxSelectedDailyMileageKM = 1000
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
QualityReasonHistorical = "historical_source_baseline"
QualityReasonCurrentDayFirst = "current_day_first_baseline"
maxSelectedDailyMileageKM = 2500
maxSelectedDailyMileageKMSQL = "2500"
maxNegativeMileageJitterKMSQL = "1"
directSourceKeySuffix = "@DIRECT"
platformSourceKeyPrefix = "@PLATFORM:"
)
type SourceMileageSample struct {
@@ -37,14 +43,37 @@ type SourceMileageSample struct {
}
func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string {
identity, _ := sourceKeyIdentity(phone, deviceID)
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
}
func sourceKeyIdentity(phone string, deviceID string) (string, bool) {
identity := strings.TrimSpace(phone)
if identity == "" {
identity = strings.TrimSpace(deviceID)
}
if identity == "" {
identity = "unknown"
return "unknown", false
}
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
return identity, true
}
func SourceKeyForKind(protocol envelope.Protocol, phone string, deviceID string, sourceIP string, sourceKind string) string {
return SourceKeyForSource(protocol, phone, deviceID, sourceIP, sourceKind, "")
}
func SourceKeyForSource(protocol envelope.Protocol, phone string, deviceID string, sourceIP string, sourceKind string, sourceCode string) string {
identity, hasIdentity := sourceKeyIdentity(phone, deviceID)
if strings.EqualFold(strings.TrimSpace(sourceKind), "DIRECT") && hasIdentity {
return string(protocol) + ":" + identity + directSourceKeySuffix
}
if strings.EqualFold(strings.TrimSpace(sourceKind), "PLATFORM") && hasIdentity {
sourceCode = strings.TrimSpace(sourceCode)
if sourceCode != "" {
return string(protocol) + ":" + identity + platformSourceKeyPrefix + sourceCode
}
}
return SourceKey(protocol, phone, deviceID, sourceIP)
}
func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample {
@@ -53,11 +82,12 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
VIN: sample.VIN,
StatDate: sample.StatDate,
Protocol: sample.Protocol,
SourceKey: SourceKey(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP),
SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
SourceIP: identity.SourceIP,
SourceEndpoint: identity.SourceEndpoint,
Phone: sample.Phone,
DeviceID: sample.DeviceID,
PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName),
FirstTotalKM: sample.TotalMileageKM,
LatestTotalKM: sample.TotalMileageKM,
DailyKM: 0,
@@ -69,6 +99,81 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func NormalizeDailyMileageDelta(deltaKM float64) (float64, bool, string) {
return NormalizeDailyMileageDeltaForWindow(deltaKM, time.Time{}, time.Time{})
}
// DailyMileageFromDayBoundary keeps the business formula explicit: the
// current day's latest cumulative odometer minus the nearest earlier
// cumulative odometer from the same source.
func DailyMileageFromDayBoundary(previousBaselineKM float64, currentDayLatestKM float64) float64 {
return currentDayLatestKM - previousBaselineKM
}
func NormalizeDailyMileageDeltaForWindow(deltaKM float64, firstEventTime time.Time, latestEventTime time.Time) (float64, bool, string) {
if deltaKM < 0 && deltaKM >= -maxNegativeMileageJitterKM {
return 0, true, "negative_jitter_clamped"
}
maxMileage := float64(MileageQualityLimitKM(firstEventTime, latestEventTime))
if deltaKM < 0 || deltaKM > maxMileage {
return deltaKM, false, "outside_daily_range"
}
return deltaKM, true, ""
}
func MileageQualityWindowDays(firstEventTime time.Time, latestEventTime time.Time) int {
if firstEventTime.IsZero() || latestEventTime.IsZero() || !latestEventTime.After(firstEventTime) {
return 1
}
location := latestEventTime.Location()
firstYear, firstMonth, firstDay := firstEventTime.In(location).Date()
latestYear, latestMonth, latestDay := latestEventTime.Date()
firstDate := time.Date(firstYear, firstMonth, firstDay, 0, 0, 0, 0, time.UTC)
latestDate := time.Date(latestYear, latestMonth, latestDay, 0, 0, 0, 0, time.UTC)
days := int(latestDate.Sub(firstDate) / (24 * time.Hour))
if days < 1 {
return 1
}
return days
}
func MileageQualityLimitKM(firstEventTime time.Time, latestEventTime time.Time) int {
// When the previous natural day is missing, the business baseline walks
// farther back. Scale the plausibility guard by that calendar-day gap while
// preserving the exact cumulative-odometer difference in the current row.
return maxSelectedDailyMileageKM * MileageQualityWindowDays(firstEventTime, latestEventTime)
}
func ApplyMileageQualityRules(sample *SourceMileageSample) {
if sample == nil {
return
}
if sample.QualityStatus == "" {
sample.QualityStatus = QualityOK
}
if sample.QualityStatus != QualityOK {
return
}
normalized, ok, reason := NormalizeDailyMileageDeltaForWindow(sample.DailyKM, sample.FirstEventTime, sample.LatestEventTime)
sample.DailyKM = normalized
if reason != "" {
sample.QualityReason = reason
}
if !ok {
sample.QualityStatus = QualityInvalidDelta
}
}
func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error {
if exec == nil {
panic("stats execer must not be nil")
@@ -79,6 +184,11 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
if sample.QualityStatus == "" {
sample.QualityStatus = QualityOK
}
// MySQL DATETIME(0) may round 23:59:59.xxx into the next natural day.
// Truncate protocol event timestamps before persistence so day boundaries
// remain stable and match the TDengine event_time predicate.
sample.FirstEventTime = truncateMileageEventTime(sample.FirstEventTime)
sample.LatestEventTime = truncateMileageEventTime(sample.LatestEventTime)
_, err := exec.ExecContext(ctx, upsertSourceMileageSQL,
sample.VIN,
sample.StatDate,
@@ -101,6 +211,75 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
return err
}
func truncateMileageEventTime(value time.Time) time.Time {
if value.IsZero() {
return value
}
return value.Truncate(time.Second)
}
func NormalizePlatformSourceMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if exec == nil {
panic("stats execer must not be nil")
}
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return nil
}
_, err := exec.ExecContext(ctx, normalizePlatformSourceMileageInsertSQL, vin, statDate, string(protocol))
if err != nil {
return err
}
_, err = exec.ExecContext(ctx, normalizePlatformSourceMileageDeleteSQL, vin, statDate, string(protocol))
return err
}
func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
Execer
Queryer
}, statDate string, protocol envelope.Protocol) (int, error) {
if db == nil {
panic("stats db must not be nil")
}
if strings.TrimSpace(statDate) == "" {
return 0, nil
}
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
if err != nil {
return 0, err
}
defer rows.Close()
var vins []string
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return 0, err
}
if strings.TrimSpace(vin) != "" {
vins = append(vins, vin)
}
}
if err := rows.Err(); err != nil {
return 0, err
}
normalized := 0
for _, vin := range vins {
if err := NormalizePlatformSourceMileage(ctx, db, vin, statDate, protocol); err != nil {
return normalized, err
}
if err := ProjectDailyMileage(ctx, db, vin, statDate, protocol); err != nil {
return normalized, err
}
normalized++
}
return normalized, nil
}
func ShouldNormalizePlatformSourceMileage(sample SourceMileageSample) bool {
return strings.Contains(sample.SourceKey, platformSourceKeyPrefix)
}
func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if exec == nil {
panic("stats execer must not be nil")
@@ -108,9 +287,25 @@ func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return nil
}
if _, err := exec.ExecContext(ctx, clearSelectedSourceSQL, vin, statDate, string(protocol)); err != nil {
return err
if beginner, ok := exec.(txBeginner); ok {
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := projectDailyMileageWithExec(ctx, tx, vin, statDate, protocol); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
return projectDailyMileageWithExec(ctx, exec, vin, statDate, protocol)
}
type txBeginner interface {
BeginTx(context.Context, *sql.TxOptions) (*sql.Tx, error)
}
func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if _, err := exec.ExecContext(ctx, projectDailyMileageSQL,
vin,
statDate,
@@ -142,6 +337,70 @@ func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate
return err
}
const upsertSourceMergedFirstTotalSQL = `CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
THEN VALUES(first_total_mileage_km)
ELSE first_total_mileage_km
END`
const upsertSourceMergedLatestTotalSQL = `CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
THEN VALUES(latest_total_mileage_km)
ELSE latest_total_mileage_km
END`
const upsertSourceMergedFirstEventSQL = `CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
THEN VALUES(first_event_time)
ELSE first_event_time
END`
const upsertSourceMergedLatestEventSQL = `CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
THEN VALUES(latest_event_time)
ELSE latest_event_time
END`
const upsertSourceMergedDeltaSQL = `(` + upsertSourceMergedLatestTotalSQL + ` - ` + upsertSourceMergedFirstTotalSQL + `)`
const upsertSourceMergedDailySQL = `CASE
WHEN ` + upsertSourceMergedDeltaSQL + ` < 0
AND ` + upsertSourceMergedDeltaSQL + ` >= -` + maxNegativeMileageJitterKMSQL + `
THEN 0
ELSE ` + upsertSourceMergedDeltaSQL + `
END`
const upsertSourceMergedOutsideDailyRangeSQL = `(` + upsertSourceMergedDailySQL + ` < 0 OR ` + upsertSourceMergedDailySQL + ` > ` + upsertSourceMergedMaxMileageSQL + `)`
const upsertSourceMergedMissingPreviousBaselineSQL = `(` + upsertSourceMergedFirstEventSQL + ` >= CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME))`
const upsertSourceMergedQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(` + upsertSourceMergedLatestEventSQL + `), DATE(` + upsertSourceMergedFirstEventSQL + `)))`
const upsertSourceMergedMaxMileageSQL = `(` + upsertSourceMergedQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY s.first_event_time ASC, s.updated_at ASC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
const normalizePlatformLatestTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_total_mileage_km ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
const normalizePlatformDeltaSQL = `(` + normalizePlatformLatestTotalSQL + ` - ` + normalizePlatformFirstTotalSQL + `)`
const normalizePlatformDailySQL = `CASE
WHEN ` + normalizePlatformDeltaSQL + ` < 0
AND ` + normalizePlatformDeltaSQL + ` >= -` + maxNegativeMileageJitterKMSQL + `
THEN 0
ELSE ` + normalizePlatformDeltaSQL + `
END`
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(MIN(s.first_event_time))))`
const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL + ` < 0 OR ` + normalizePlatformDailySQL + ` > ` + normalizePlatformMaxMileageSQL + `)`
const upsertSourceMileageSQL = `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
@@ -154,48 +413,186 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
phone = VALUES(phone),
device_id = VALUES(device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
first_total_mileage_km = CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
END,
latest_total_mileage_km = CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
ELSE GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
END,
daily_mileage_km = GREATEST(
CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
ELSE latest_total_mileage_km
END,
VALUES(latest_total_mileage_km)
) - CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
END,
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
sample_count = sample_count + VALUES(sample_count),
first_event_time = CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) < first_event_time
THEN VALUES(first_event_time)
ELSE first_event_time
first_event_time = ` + upsertSourceMergedFirstEventSQL + `,
latest_event_time = ` + upsertSourceMergedLatestEventSQL + `,
quality_status = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `'
END,
latest_event_time = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) > latest_event_time
THEN VALUES(latest_event_time)
ELSE latest_event_time
quality_reason = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
WHEN ` + upsertSourceMergedDailySQL + ` = 0 AND ` + upsertSourceMergedDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
WHEN ` + upsertSourceMergedMissingPreviousBaselineSQL + ` THEN '` + QualityReasonCurrentDayFirst + `'
ELSE '` + QualityReasonHistorical + `'
END,
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
updated_at = CURRENT_TIMESTAMP
`
const clearSelectedSourceSQL = `
UPDATE vehicle_daily_mileage_source
SET is_selected = 0
WHERE vin = ? AND stat_date = ? AND protocol = ?
const normalizePlatformSourceMileageInsertSQL = `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason)
SELECT
s.vin,
s.stat_date,
s.protocol,
CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) AS stable_source_key,
SUBSTRING_INDEX(GROUP_CONCAT(s.source_ip ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS source_ip,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.source_endpoint, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS source_endpoint,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.phone, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS phone,
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.device_id, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS device_id,
COALESCE(NULLIF(TRIM(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(TRIM(s.platform_name), ''), ds.platform_name, vi.platform_name, '') ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1)), ''), MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))) AS platform_name,
` + normalizePlatformFirstTotalSQL + ` AS first_total_mileage_km,
` + normalizePlatformLatestTotalSQL + ` AS latest_total_mileage_km,
` + normalizePlatformDailySQL + ` AS daily_mileage_km,
SUM(s.sample_count) AS sample_count,
MIN(s.first_event_time) AS first_event_time,
MAX(s.latest_event_time) AS latest_event_time,
CASE
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `'
END AS quality_status,
CASE
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
WHEN ` + normalizePlatformDailySQL + ` = 0 AND ` + normalizePlatformDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
THEN '` + QualityReasonHistorical + `'
ELSE '` + QualityReasonCurrentDayFirst + `'
END AS quality_reason
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
AND ds.enabled = 1
AND ds.source_kind = 'PLATFORM'
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN (
SELECT
identifier_value AS phone,
MIN(TRIM(source_code)) AS source_code,
MIN(COALESCE(NULLIF(TRIM(oem), ''), NULLIF(TRIM(source_code), ''))) AS platform_name
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY identifier_value
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
) vi
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key
ON DUPLICATE KEY UPDATE
source_ip = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(source_ip)
ELSE source_ip
END,
source_endpoint = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(source_endpoint)
ELSE source_endpoint
END,
phone = COALESCE(NULLIF(TRIM(VALUES(phone)), ''), phone),
device_id = COALESCE(NULLIF(TRIM(VALUES(device_id)), ''), device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
sample_count = sample_count + VALUES(sample_count),
first_event_time = CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time THEN VALUES(first_event_time)
ELSE first_event_time
END,
latest_event_time = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_event_time)
ELSE latest_event_time
END,
quality_status = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `'
END,
quality_reason = CASE
WHEN ` + upsertSourceMergedOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
WHEN ` + upsertSourceMergedDailySQL + ` = 0 AND ` + upsertSourceMergedDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
WHEN ` + upsertSourceMergedMissingPreviousBaselineSQL + ` THEN '` + QualityReasonCurrentDayFirst + `'
ELSE '` + QualityReasonHistorical + `'
END,
updated_at = CURRENT_TIMESTAMP
`
const normalizePlatformSourceMileageDeleteSQL = `
DELETE s
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
AND ds.enabled = 1
AND ds.source_kind = 'PLATFORM'
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN (
SELECT
identifier_value AS phone,
MIN(TRIM(source_code)) AS source_code
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY identifier_value
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
) vi
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
`
const selectPlatformSourceMileageLegacyVINSQL = `
SELECT DISTINCT s.vin
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
AND ds.enabled = 1
AND ds.source_kind = 'PLATFORM'
AND ds.source_code IS NOT NULL
AND TRIM(ds.source_code) <> ''
LEFT JOIN (
SELECT
identifier_value AS phone,
MIN(TRIM(source_code)) AS source_code
FROM vehicle_identifier
WHERE protocol = 'JT808'
AND identifier_type = 'JT808_PHONE'
AND enabled = 1
AND source_code IS NOT NULL
AND TRIM(source_code) <> ''
GROUP BY identifier_value
HAVING COUNT(DISTINCT TRIM(source_code)) = 1
) vi
ON s.protocol = 'JT808' AND vi.phone = TRIM(s.phone)
WHERE s.stat_date = ?
AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
ORDER BY s.vin
`
const projectDailyMileageSQL = `
@@ -209,15 +606,25 @@ SELECT
s.daily_mileage_km,
s.latest_total_mileage_km
FROM vehicle_daily_mileage_source s
JOIN vehicle_data_source ds
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
WHERE s.vin = ?
AND s.stat_date = ?
AND s.protocol = ?
AND s.quality_status = '` + QualityOK + `'
AND s.daily_mileage_km BETWEEN 0 AND ?
AND ds.enabled = 1
ORDER BY ds.trust_priority,
AND s.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))))
AND (ds.id IS NULL OR ds.enabled = 1 OR (
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
))
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
WHEN 'PLATFORM' THEN 0
WHEN 'DIRECT' THEN 1
WHEN 'UNKNOWN' THEN 2
ELSE 3
END,
COALESCE(ds.trust_priority, 100000),
s.sample_count DESC,
s.latest_event_time DESC,
s.source_key ASC
@@ -231,22 +638,32 @@ ON DUPLICATE KEY UPDATE
const markSelectedSourceSQL = `
UPDATE vehicle_daily_mileage_source s
JOIN (
LEFT JOIN (
SELECT
s2.source_key,
s2.vin,
s2.stat_date,
s2.protocol
FROM vehicle_daily_mileage_source s2
JOIN vehicle_data_source ds
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s2.protocol AND ds.source_ip = s2.source_ip
WHERE s2.vin = ?
AND s2.stat_date = ?
AND s2.protocol = ?
AND s2.quality_status = '` + QualityOK + `'
AND s2.daily_mileage_km BETWEEN 0 AND ?
AND ds.enabled = 1
ORDER BY ds.trust_priority,
AND s2.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))))
AND (ds.id IS NULL OR ds.enabled = 1 OR (
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
))
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
WHEN 'PLATFORM' THEN 0
WHEN 'DIRECT' THEN 1
WHEN 'UNKNOWN' THEN 2
ELSE 3
END,
COALESCE(ds.trust_priority, 100000),
s2.sample_count DESC,
s2.latest_event_time DESC,
s2.source_key ASC
@@ -256,7 +673,7 @@ JOIN (
AND selected_source.vin = s.vin
AND selected_source.stat_date = s.stat_date
AND selected_source.protocol = s.protocol
SET s.is_selected = 1
SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END
WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ?
`
@@ -302,60 +719,30 @@ func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string
return sourceBaseline{
LatestTotalKM: latestTotal.Float64,
LatestEventTime: latestEvent.Time,
QualityReason: "historical_source_baseline",
QualityReason: QualityReasonHistorical,
}, latestTotal.Valid, nil
}
func lookupCurrentSourceBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (sourceBaseline, bool, error) {
if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(sourceKey) == "" {
return sourceBaseline{}, false, nil
}
rows, err := query.QueryContext(ctx, currentSourceBaselineSQL, vin, statDate, string(protocol), sourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{}, false, nil
}
var firstTotal sql.NullFloat64
var firstEvent sql.NullTime
if err := rows.Scan(&firstTotal, &firstEvent); err != nil {
return sourceBaseline{}, false, err
}
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{
LatestTotalKM: firstTotal.Float64,
LatestEventTime: firstEvent.Time,
QualityReason: "current_day_first_sample",
}, firstTotal.Valid, nil
// LookupLatestSourceBaselineBefore returns the nearest durable odometer for the
// same VIN, protocol and source before statDate. Missing calendar days are
// skipped automatically.
func LookupLatestSourceBaselineBefore(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (float64, time.Time, bool, error) {
baseline, found, err := lookupPreviousSourceBaseline(ctx, query, vin, statDate, protocol, sourceKey)
return baseline.LatestTotalKM, baseline.LatestEventTime, found, err
}
const previousSourceBaselineSQL = `
SELECT latest_total_mileage_km, latest_event_time
FROM vehicle_daily_mileage_source
WHERE vin = ?
AND stat_date < ?
AND stat_date < ?
AND protocol = ?
AND source_key = ?
AND quality_status = '` + QualityOK + `'
AND latest_total_mileage_km IS NOT NULL
AND latest_total_mileage_km > 0
AND latest_event_time IS NOT NULL
AND latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)
AND latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)
ORDER BY stat_date DESC, latest_event_time DESC
LIMIT 1
`
const currentSourceBaselineSQL = `
SELECT first_total_mileage_km, first_event_time
FROM vehicle_daily_mileage_source
WHERE vin = ?
AND stat_date = ?
AND protocol = ?
AND source_key = ?
AND quality_status = '` + QualityOK + `'
ORDER BY latest_event_time DESC
LIMIT 1
`
@@ -2,10 +2,13 @@ package stats
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
@@ -21,6 +24,85 @@ func TestSourceKeyUsesProtocolDeviceAndSourceIP(t *testing.T) {
}
}
func TestSourceKeyForDirectUsesStableDeviceIdentity(t *testing.T) {
first := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "115.231.168.135", "DIRECT")
second := SourceKeyForKind(envelope.ProtocolJT808, "13307765812", "", "39.144.3.22", "DIRECT")
if first != "JT808:13307765812@DIRECT" || second != first {
t.Fatalf("direct source keys = %q/%q, want stable phone key", first, second)
}
fallback := SourceKeyForKind(envelope.ProtocolJT808, "", "", "39.144.3.22", "DIRECT")
if fallback != "JT808:unknown@39.144.3.22" {
t.Fatalf("direct fallback source key = %q", fallback)
}
}
func TestSourceKeyForPlatformUsesStableSourceCode(t *testing.T) {
first := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.194.167", "PLATFORM", "guangan_beidou")
second := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
if first != "JT808:41456413943@PLATFORM:guangan_beidou" || second != first {
t.Fatalf("platform source keys = %q/%q, want stable source_code key", first, second)
}
withoutSourceCode := SourceKeyForSource(envelope.ProtocolJT808, "41456413943", "", "117.132.198.90", "PLATFORM", "")
if withoutSourceCode != "JT808:41456413943@117.132.198.90" {
t.Fatalf("platform fallback source key = %q", withoutSourceCode)
}
withoutDeviceIdentity := SourceKeyForSource(envelope.ProtocolJT808, "", "", "117.132.198.90", "PLATFORM", "guangan_beidou")
if withoutDeviceIdentity != "JT808:unknown@117.132.198.90" {
t.Fatalf("unknown platform source key = %q", withoutDeviceIdentity)
}
}
func TestSourceMileageSampleFromMetricUsesDirectSourceKey(t *testing.T) {
sample := MetricSample{
VIN: "LA9GG64L7PBAF4001",
StatDate: "2026-07-12",
Protocol: envelope.ProtocolJT808,
Phone: "13307765812",
TotalMileageKM: 4123.9,
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
}
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "115.231.168.135",
SourceEndpoint: "115.231.168.135:43625",
SourceKind: "DIRECT",
})
if candidate.SourceKey != "JT808:13307765812@DIRECT" {
t.Fatalf("source key = %q", candidate.SourceKey)
}
if candidate.SourceIP != "115.231.168.135" {
t.Fatalf("source ip = %q", candidate.SourceIP)
}
}
func TestSourceMileageSampleFromMetricUsesPlatformSourceCode(t *testing.T) {
sample := MetricSample{
VIN: "LNXNEGRR0SR321372",
StatDate: "2026-07-12",
Protocol: envelope.ProtocolJT808,
Phone: "41456413943",
TotalMileageKM: 46484.2,
EventTime: time.Date(2026, 7, 12, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
}
candidate := SourceMileageSampleFromMetric(sample, SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "117.132.194.167",
SourceEndpoint: "117.132.194.167:9806",
SourceCode: "guangan_beidou",
PlatformName: "广安北斗",
SourceKind: "PLATFORM",
})
if candidate.SourceKey != "JT808:41456413943@PLATFORM:guangan_beidou" {
t.Fatalf("source key = %q", candidate.SourceKey)
}
if candidate.PlatformName != "广安北斗" {
t.Fatalf("platform name = %q", candidate.PlatformName)
}
}
func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
exec := &recordingExec{}
sample := SourceMileageSample{
@@ -50,8 +132,13 @@ func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
for _, want := range []string{
"INSERT INTO vehicle_daily_mileage_source",
"ON DUPLICATE KEY UPDATE",
"daily_mileage_km = GREATEST(",
"quality_status = VALUES(quality_status)",
"daily_mileage_km = CASE",
"VALUES(latest_event_time) >= latest_event_time",
"VALUES(first_event_time) <= first_event_time",
"quality_status = CASE",
"THEN '" + QualityInvalidDelta + "'",
"quality_reason = CASE",
"THEN 'outside_daily_range'",
"platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name)",
} {
if !strings.Contains(sql, want) {
@@ -63,6 +150,114 @@ func TestUpsertSourceMileageWritesCandidateRow(t *testing.T) {
}
}
func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing.T) {
exec := &recordingExec{}
loc := time.FixedZone("Asia/Shanghai", 8*3600)
previous := time.Date(2026, 7, 12, 23, 59, 59, 999_000_000, loc)
current := time.Date(2026, 7, 13, 0, 0, 1, 999_000_000, loc)
sample := SourceMileageSample{
VIN: "LMRKH9AC7R1004098",
StatDate: "2026-07-13",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC7R1004098@PLATFORM:yutong",
SourceIP: "mqtt",
FirstTotalKM: 41249,
LatestTotalKM: 41250,
DailyKM: 1,
SampleCount: 1,
FirstEventTime: previous,
LatestEventTime: current,
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
if err := UpsertSourceMileage(context.Background(), exec, sample); err != nil {
t.Fatalf("UpsertSourceMileage() error = %v", err)
}
if len(exec.calls) != 1 {
t.Fatalf("exec calls = %d, want 1", len(exec.calls))
}
if got := exec.calls[0].args[13]; got != previous.Truncate(time.Second) {
t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second))
}
if got := exec.calls[0].args[14]; got != current.Truncate(time.Second) {
t.Fatalf("latest event arg = %v, want %v", got, current.Truncate(time.Second))
}
}
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
for _, want := range []string{
"first_total_mileage_km = CASE",
"VALUES(first_event_time) <= first_event_time",
"latest_total_mileage_km = CASE",
"VALUES(latest_event_time) >= latest_event_time",
"daily_mileage_km = CASE",
"VALUES(latest_total_mileage_km)",
"VALUES(first_total_mileage_km)",
} {
if !strings.Contains(upsertSourceMileageSQL, want) {
t.Fatalf("event-time boundary upsert SQL missing %q:\n%s", want, upsertSourceMileageSQL)
}
}
for _, forbidden := range []string{
"GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))",
"LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))",
"current_day_fallback_after_invalid_baseline",
} {
if strings.Contains(upsertSourceMileageSQL, forbidden) {
t.Fatalf("event-time boundary upsert must not contain %q:\n%s", forbidden, upsertSourceMileageSQL)
}
}
}
func TestUpsertSourceMileageRechecksMergedDailyRange(t *testing.T) {
if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" {
t.Fatalf("SQL mileage limits drifted from Go quality constants: selected=%s jitter=%s", maxSelectedDailyMileageKMSQL, maxNegativeMileageJitterKMSQL)
}
for _, want := range []string{
"quality_status = CASE",
">= -" + maxNegativeMileageJitterKMSQL,
"DATEDIFF(DATE(",
"* " + maxSelectedDailyMileageKMSQL,
"THEN '" + QualityInvalidDelta + "'",
"quality_reason = CASE",
"THEN 'outside_daily_range'",
"THEN '" + QualityReasonCurrentDayFirst + "'",
"ELSE '" + QualityReasonHistorical + "'",
} {
if !strings.Contains(upsertSourceMileageSQL, want) {
t.Fatalf("merged range guard missing %q:\n%s", want, upsertSourceMileageSQL)
}
}
}
func TestPreviousSourceBaselineUsesLatestEarlierCalendarDay(t *testing.T) {
if !strings.Contains(previousSourceBaselineSQL, "stat_date < ?") {
t.Fatalf("previous baseline should search earlier calendar days:\n%s", previousSourceBaselineSQL)
}
if !strings.Contains(previousSourceBaselineSQL, "ORDER BY stat_date DESC, latest_event_time DESC") {
t.Fatalf("previous baseline should prefer the nearest earlier sample:\n%s", previousSourceBaselineSQL)
}
if strings.Contains(previousSourceBaselineSQL, "quality_status =") {
t.Fatalf("previous day's last odometer must remain usable even when that day's delta has no baseline:\n%s", previousSourceBaselineSQL)
}
for _, want := range []string{
"latest_total_mileage_km > 0",
"latest_event_time >= CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME)",
"latest_event_time < DATE_ADD(CAST(CONCAT(stat_date, ' 00:00:00') AS DATETIME), INTERVAL 1 DAY)",
} {
if !strings.Contains(previousSourceBaselineSQL, want) {
t.Fatalf("previous baseline SQL missing %q:\n%s", want, previousSourceBaselineSQL)
}
}
}
func TestDailyMileageFromDayBoundaryUsesCurrentMinusPrevious(t *testing.T) {
got := DailyMileageFromDayBoundary(14989.0, 15369.0)
if got != 380.0 {
t.Fatalf("daily mileage = %v, want current latest minus previous last = 380", got)
}
}
func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
exec := &recordingExec{}
sample := SourceMileageSample{
@@ -82,30 +277,227 @@ func TestUpsertSourceMileageSkipsBlankSourceIP(t *testing.T) {
}
}
func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
exec := &recordingExec{}
err := NormalizePlatformSourceMileage(context.Background(), exec, "LNXNEGRR0SR321372", "2026-07-12", envelope.ProtocolJT808)
if err != nil {
t.Fatalf("NormalizePlatformSourceMileage() error = %v", err)
}
if len(exec.calls) != 2 {
t.Fatalf("exec calls = %d, want insert merge + delete legacy", len(exec.calls))
}
insertSQL := exec.calls[0].query
for _, want := range []string{
"INSERT INTO vehicle_daily_mileage_source",
"CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '@PLATFORM:', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))",
"LEFT JOIN vehicle_data_source ds",
"LEFT JOIN (",
"FROM vehicle_identifier",
"identifier_type = 'JT808_PHONE'",
"HAVING COUNT(DISTINCT TRIM(source_code)) = 1",
"ds.source_kind = 'PLATFORM'",
"MAX(TRIM(COALESCE(ds.platform_name, vi.platform_name)))",
"s.source_key <> CONCAT",
"GROUP BY s.vin, s.stat_date, s.protocol, stable_source_key",
"ON DUPLICATE KEY UPDATE",
"sample_count = sample_count + VALUES(sample_count)",
"s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')",
"WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
"THEN '" + QualityInvalidDelta + "'",
"ELSE '" + QualityOK + "'",
"THEN 'negative_jitter_clamped'",
"THEN '" + QualityReasonHistorical + "'",
"ELSE '" + QualityReasonCurrentDayFirst + "'",
upsertSourceMergedMissingPreviousBaselineSQL,
} {
if !strings.Contains(insertSQL, want) {
t.Fatalf("normalize insert SQL missing %q:\n%s", want, insertSQL)
}
}
deleteSQL := exec.calls[1].query
for _, want := range []string{
"DELETE s",
"FROM vehicle_daily_mileage_source s",
"LEFT JOIN vehicle_data_source ds",
"FROM vehicle_identifier",
"ds.source_kind = 'PLATFORM'",
"s.source_key <> CONCAT",
} {
if !strings.Contains(deleteSQL, want) {
t.Fatalf("normalize delete SQL missing %q:\n%s", want, deleteSQL)
}
}
for i, call := range exec.calls {
if len(call.args) != 3 || call.args[0] != "LNXNEGRR0SR321372" || call.args[1] != "2026-07-12" || call.args[2] != "JT808" {
t.Fatalf("call %d args = %#v", i, call.args)
}
}
}
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
statDate := "2026-07-12"
protocol := envelope.ProtocolJT808
rows := sqlmock.NewRows([]string{"vin"}).
AddRow("LA9HE60A0PBAF4002").
AddRow("LA9HE60A1PBAF4008")
mock.ExpectQuery(selectPlatformSourceMileageLegacyVINSQL).
WithArgs(statDate, string(protocol)).
WillReturnRows(rows)
for _, vin := range []string{"LA9HE60A0PBAF4002", "LA9HE60A1PBAF4008"} {
mock.ExpectExec(normalizePlatformSourceMileageInsertSQL).
WithArgs(vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(normalizePlatformSourceMileageDeleteSQL).
WithArgs(vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
mock.ExpectCommit()
}
normalized, err := NormalizePlatformSourceMileageForDate(context.Background(), db, statDate, protocol)
if err != nil {
t.Fatalf("NormalizePlatformSourceMileageForDate() error = %v", err)
}
if normalized != 2 {
t.Fatalf("normalized = %d, want 2", normalized)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations were not met: %v", err)
}
}
func TestShouldNormalizePlatformSourceMileage(t *testing.T) {
if !ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: "JT808:41456413943@PLATFORM:guangan_beidou"}) {
t.Fatal("platform source key should trigger legacy key normalization")
}
for _, sourceKey := range []string{"JT808:41456413943@DIRECT", "JT808:41456413943@117.132.194.167", ""} {
if ShouldNormalizePlatformSourceMileage(SourceMileageSample{SourceKey: sourceKey}) {
t.Fatalf("source key %q should not trigger platform normalization", sourceKey)
}
}
}
func TestApplyMileageQualityRulesClampsSmallNegativeJitter(t *testing.T) {
sample := SourceMileageSample{
DailyKM: -0.1,
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
ApplyMileageQualityRules(&sample)
if sample.DailyKM != 0 {
t.Fatalf("daily km = %v, want 0", sample.DailyKM)
}
if sample.QualityStatus != QualityOK || sample.QualityReason != "negative_jitter_clamped" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
}
func TestApplyMileageQualityRulesRejectsLargeNegativeDelta(t *testing.T) {
sample := SourceMileageSample{
DailyKM: -55,
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
ApplyMileageQualityRules(&sample)
if sample.DailyKM != -55 {
t.Fatalf("daily km = %v, want original invalid delta", sample.DailyKM)
}
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
}
func TestApplyMileageQualityRulesAcceptsPlausibleMultiDayFallbackDelta(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
sample := SourceMileageSample{
DailyKM: 7833.5,
FirstEventTime: time.Date(2026, 7, 4, 11, 7, 58, 0, loc),
LatestEventTime: time.Date(2026, 7, 12, 2, 52, 47, 0, loc),
QualityStatus: QualityOK,
QualityReason: "historical_source_baseline",
}
ApplyMileageQualityRules(&sample)
if sample.QualityStatus != QualityOK || sample.QualityReason != "historical_source_baseline" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
if sample.DailyKM != 7833.5 {
t.Fatalf("daily km = %v", sample.DailyKM)
}
if got := MileageQualityWindowDays(sample.FirstEventTime, sample.LatestEventTime); got != 8 {
t.Fatalf("window days = %d, want 8", got)
}
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 8*maxSelectedDailyMileageKM {
t.Fatalf("quality limit = %d, want 8-day limit %d", got, 8*maxSelectedDailyMileageKM)
}
}
func TestApplyMileageQualityRulesAcceptsContinuousHighUtilizationDay(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
sample := SourceMileageSample{
DailyKM: 1895.7,
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
QualityStatus: QualityOK,
QualityReason: QualityReasonHistorical,
}
ApplyMileageQualityRules(&sample)
if sample.QualityStatus != QualityOK || sample.QualityReason != QualityReasonHistorical {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
if got := MileageQualityLimitKM(sample.FirstEventTime, sample.LatestEventTime); got != 2500 {
t.Fatalf("quality limit = %d, want 2500", got)
}
}
func TestApplyMileageQualityRulesRejectsImplausibleSingleDayJump(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
sample := SourceMileageSample{
DailyKM: 4000,
FirstEventTime: time.Date(2026, 7, 12, 23, 59, 37, 0, loc),
LatestEventTime: time.Date(2026, 7, 13, 20, 32, 42, 0, loc),
QualityStatus: QualityOK,
QualityReason: QualityReasonHistorical,
}
ApplyMileageQualityRules(&sample)
if sample.QualityStatus != QualityInvalidDelta || sample.QualityReason != "outside_daily_range" {
t.Fatalf("quality = %s/%s", sample.QualityStatus, sample.QualityReason)
}
}
func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
exec := &recordingExec{}
err := ProjectDailyMileage(context.Background(), exec, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808)
if err != nil {
t.Fatalf("ProjectDailyMileage() error = %v", err)
}
if len(exec.calls) != 4 {
t.Fatalf("exec calls = %d, want 4", len(exec.calls))
}
updateSources := exec.calls[0].query
projectFinal := exec.calls[1].query
markSelected := exec.calls[2].query
cleanupFinal := exec.calls[3].query
if !strings.Contains(updateSources, "UPDATE vehicle_daily_mileage_source") || !strings.Contains(updateSources, "is_selected = 0") {
t.Fatalf("first query should clear selected candidates: %s", updateSources)
if len(exec.calls) != 3 {
t.Fatalf("exec calls = %d, want 3", len(exec.calls))
}
projectFinal := exec.calls[0].query
markSelected := exec.calls[1].query
cleanupFinal := exec.calls[2].query
for _, want := range []string{
"INSERT INTO vehicle_daily_mileage",
"FROM vehicle_daily_mileage_source s",
"JOIN vehicle_data_source ds",
"LEFT JOIN vehicle_data_source ds",
"ds.id",
"ORDER BY ds.trust_priority",
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
"WHEN 'PLATFORM' THEN 0",
"WHEN 'DIRECT' THEN 1",
"WHEN 'UNKNOWN' THEN 2",
"ds.trust_priority",
"s.quality_status = '" + QualityOK + "'",
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
"s.daily_mileage_km BETWEEN 0 AND",
"DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))",
} {
if !strings.Contains(projectFinal, want) {
t.Fatalf("project query missing %q: %s", want, projectFinal)
@@ -123,8 +515,10 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
t.Fatalf("project query should not use final-table field %q: %s", forbidden, projectFinal)
}
}
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") || !strings.Contains(markSelected, "SET s.is_selected = 1") {
t.Fatalf("mark query should flag the elected source: %s", markSelected)
if !strings.Contains(markSelected, "UPDATE vehicle_daily_mileage_source s") ||
!strings.Contains(markSelected, "LEFT JOIN (") ||
!strings.Contains(markSelected, "SET s.is_selected = CASE WHEN selected_source.source_key IS NULL THEN 0 ELSE 1 END") {
t.Fatalf("mark query should reconcile the elected source: %s", markSelected)
}
if strings.Contains(markSelected, "JOIN vehicle_daily_mileage m") {
t.Fatalf("mark query should not rejoin final mileage for candidate selection: %s", markSelected)
@@ -135,18 +529,33 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"s2.source_key",
"s2.quality_status = '" + QualityOK + "'",
"s2.daily_mileage_km BETWEEN 0 AND",
"ds.enabled = 1",
"ORDER BY ds.trust_priority",
"DATEDIFF(DATE(s2.latest_event_time), DATE(s2.first_event_time))",
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
"WHEN 'PLATFORM' THEN 0",
"WHEN 'DIRECT' THEN 1",
"WHEN 'UNKNOWN' THEN 2",
"ds.trust_priority",
"LIMIT 1",
} {
if !strings.Contains(markSelected, want) {
t.Fatalf("mark query missing %q: %s", want, markSelected)
}
}
if len(exec.calls[2].args) != 7 {
t.Fatalf("mark query args = %d, want 7", len(exec.calls[2].args))
if len(exec.calls[1].args) != 7 {
t.Fatalf("mark query args = %d, want 7", len(exec.calls[1].args))
}
if got := exec.calls[2].args[3]; got != maxSelectedDailyMileageKM {
for _, forbidden := range []string{
"WHEN 'UNKNOWN' THEN 1",
"WHEN 'DIRECT' THEN 2",
} {
if strings.Contains(projectFinal, forbidden) || strings.Contains(markSelected, forbidden) {
t.Fatalf("source selection should prefer classified DIRECT before UNKNOWN, found %q", forbidden)
}
}
if got := exec.calls[1].args[3]; got != maxSelectedDailyMileageKM {
t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM)
}
for _, want := range []string{
@@ -159,7 +568,70 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
t.Fatalf("cleanup query missing %q: %s", want, cleanupFinal)
}
}
if len(exec.calls[3].args) != 6 {
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[3].args))
if len(exec.calls[2].args) != 6 {
t.Fatalf("cleanup query args = %d, want 6", len(exec.calls[2].args))
}
}
func TestProjectDailyMileageCommitsTransactionForSQLDB(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
vin := "LA9GG64L7PBAF4001"
statDate := "2026-07-08"
protocol := envelope.ProtocolJT808
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
mock.ExpectCommit()
if err := ProjectDailyMileage(context.Background(), db, vin, statDate, protocol); err != nil {
t.Fatalf("ProjectDailyMileage() error = %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations were not met: %v", err)
}
}
func TestProjectDailyMileageRollsBackTransactionOnFailure(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
vin := "LA9GG64L7PBAF4001"
statDate := "2026-07-08"
protocol := envelope.ProtocolJT808
wantErr := errors.New("mark selected failed")
mock.ExpectBegin()
mock.ExpectExec(projectDailyMileageSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(markSelectedSourceSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
WillReturnError(wantErr)
mock.ExpectRollback()
err = ProjectDailyMileage(context.Background(), db, vin, statDate, protocol)
if !errors.Is(err, wantErr) {
t.Fatalf("ProjectDailyMileage() error = %v, want %v", err, wantErr)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations were not met: %v", err)
}
}
func expectProjectDailyMileageSQL(mock sqlmock.Sqlmock, vin string, statDate string, protocol envelope.Protocol) {
mock.ExpectBegin()
mock.ExpectExec(projectDailyMileageSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(markSelectedSourceSQL).
WithArgs(vin, statDate, string(protocol), maxSelectedDailyMileageKM, vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(cleanupProjectedDailyMileageSQL).
WithArgs(vin, statDate, string(protocol), vin, statDate, string(protocol)).
WillReturnResult(sqlmock.NewResult(0, 1))
}
@@ -11,10 +11,12 @@ import (
func TestNormalizeSourceIPDropsPort(t *testing.T) {
tests := map[string]string{
"115.231.168.135:20215": "115.231.168.135",
"115.231.168.135": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"": "",
"115.231.168.135:20215": "115.231.168.135",
"115.231.168.135": "115.231.168.135",
" 115.159.85.149:28316 ": "115.159.85.149",
"mqtt://yutong/ytforward/shln/3": "mqtt",
"MQTT://YUTONG/topic": "mqtt",
"": "",
}
for input, want := range tests {
if got := NormalizeSourceIP(input); got != want {
@@ -43,6 +45,60 @@ func TestNewSourceIdentityRequiresSourceIP(t *testing.T) {
}
}
func TestShouldManageDataSourceRequiresPlatformEvidence(t *testing.T) {
if ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135"}) {
t.Fatal("unclassified source should not be managed")
}
if !ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceCode: "g7s"}) {
t.Fatal("source_code should make source manageable")
}
if ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceKind: "DIRECT"}) {
t.Fatal("direct source without platform evidence should not be auto-managed by source IP")
}
if !ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceKind: "PLATFORM"}) {
t.Fatal("explicit platform source_kind should make source manageable")
}
if ShouldManageDataSource(SourceIdentity{Protocol: envelope.ProtocolJT808, SourceCode: "g7s"}) {
t.Fatal("empty source ip should not be managed")
}
}
func TestSourceKindForDataSourceWriteInfersPlatformEvidence(t *testing.T) {
tests := []struct {
name string
identity SourceIdentity
want string
}{
{
name: "empty evidence",
identity: SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135"},
want: "UNKNOWN",
},
{
name: "source code",
identity: SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceCode: "g7s"},
want: "PLATFORM",
},
{
name: "platform name",
identity: SourceIdentity{Protocol: envelope.ProtocolGB32960, SourceIP: "8.134.95.166", PlatformName: "Hyundai"},
want: "PLATFORM",
},
{
name: "explicit direct wins",
identity: SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: "115.231.168.135", SourceCode: "direct-import", SourceKind: "DIRECT"},
want: "DIRECT",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sourceKindForDataSourceWrite(tt.identity); got != tt.want {
t.Fatalf("sourceKindForDataSourceWrite() = %q, want %q", got, tt.want)
}
})
}
}
func TestUpsertDataSourcePreservesManualFields(t *testing.T) {
exec := &recordingExec{}
identity := SourceIdentity{
@@ -60,7 +116,7 @@ func TestUpsertDataSourcePreservesManualFields(t *testing.T) {
for _, want := range []string{
"INSERT INTO vehicle_data_source",
"latest_source_endpoint = VALUES(latest_source_endpoint)",
"latest_seen_at = VALUES(latest_seen_at)",
"latest_seen_at = GREATEST",
} {
if !strings.Contains(sql, want) {
t.Fatalf("source upsert missing %q: %s", want, sql)
@@ -77,3 +133,103 @@ func TestUpsertDataSourcePreservesManualFields(t *testing.T) {
}
}
}
func TestUpsertDataSourceWritesPlatformKindWhenEvidenceExists(t *testing.T) {
exec := &recordingExec{}
identity := SourceIdentity{
Protocol: envelope.ProtocolGB32960,
SourceIP: "8.134.95.166",
SourceEndpoint: "8.134.95.166:32960",
SourceCode: "Hyundai",
PlatformName: "现代 HTWO",
}
if err := UpsertDataSource(context.Background(), exec, identity, time.Date(2026, 7, 12, 18, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("UpsertDataSource() error = %v", err)
}
if len(exec.calls) != 1 {
t.Fatalf("exec calls = %d", len(exec.calls))
}
if got, want := exec.calls[0].args[5], "PLATFORM"; got != want {
t.Fatalf("source_kind arg = %#v, want %q", got, want)
}
}
func TestUpsertDataSourceCanReenableAutoRetiredSourceWithEvidence(t *testing.T) {
exec := &recordingExec{}
identity := SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "115.231.168.135",
SourceEndpoint: "115.231.168.135:20215",
SourceCode: "g7s",
PlatformName: "G7s",
}
if err := UpsertDataSource(context.Background(), exec, identity, time.Date(2026, 7, 12, 18, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("UpsertDataSource() error = %v", err)
}
sql := exec.calls[0].query
for _, want := range []string{
"vehicle_data_source.enabled = 0",
"vehicle_data_source.remark LIKE 'auto-retired:%'",
"vehicle_data_source.remark = 'auto-reenabled: source evidence restored'",
"THEN 'auto-reenabled: source evidence restored'",
"THEN 1",
} {
if !strings.Contains(sql, want) {
t.Fatalf("source upsert should re-enable auto-retired source with evidence; missing %q:\n%s", want, sql)
}
}
if strings.Contains(sql, "enabled = VALUES(enabled)") {
t.Fatalf("source upsert should not blindly copy enabled from values:\n%s", sql)
}
if strings.Index(sql, "enabled = CASE") < 0 || strings.Index(sql, "remark = CASE") < 0 || strings.Index(sql, "enabled = CASE") > strings.Index(sql, "remark = CASE") {
t.Fatalf("source upsert must restore enabled before updating remark because MySQL evaluates assignments in order:\n%s", sql)
}
}
func TestDataSourceSchemaIncludesStableSourceCode(t *testing.T) {
for _, want := range []string{
"source_code VARCHAR(64) NULL",
"source_kind VARCHAR(32) NOT NULL DEFAULT 'UNKNOWN'",
"KEY idx_protocol_source_code (protocol, source_code)",
"KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)",
} {
if !strings.Contains(DataSourceTableSQL, want) {
t.Fatalf("data source schema missing %q:\n%s", want, DataSourceTableSQL)
}
}
for _, want := range []string{
"ALTER TABLE vehicle_data_source ADD COLUMN source_code",
"ALTER TABLE vehicle_data_source ADD COLUMN source_kind",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_code",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen",
} {
if !containsStatement(DailyMileageAlterSQL, want) {
t.Fatalf("data source alter SQL missing %q: %#v", want, DailyMileageAlterSQL)
}
}
}
func TestDailyMileageSourceSchemaIncludesQueryIndexes(t *testing.T) {
for _, want := range []string{
"KEY idx_protocol_date_selected (protocol, stat_date, is_selected, vin)",
"KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin)",
"KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin)",
"KEY idx_source_ip_date (protocol, source_ip, stat_date)",
} {
if !strings.Contains(DailyMileageSourceTableSQL, want) {
t.Fatalf("daily mileage source schema missing %q:\n%s", want, DailyMileageSourceTableSQL)
}
if !containsStatement(DailyMileageAlterSQL, "ALTER TABLE vehicle_daily_mileage_source ADD "+want) {
t.Fatalf("daily mileage source alter SQL missing %q: %#v", want, DailyMileageAlterSQL)
}
}
}
func containsStatement(statements []string, fragment string) bool {
for _, statement := range statements {
if strings.Contains(statement, fragment) {
return true
}
}
return false
}
@@ -0,0 +1,170 @@
package telemetry
import (
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type LocationProjection struct {
Latitude float64
Longitude float64
SpeedKMH *float64
SOCPercent *float64
AltitudeM *float64
DirectionDeg *float64
AlarmFlag *int64
StatusFlag *int64
}
type locationFieldMapping struct {
Latitude []string
Longitude []string
SpeedKMH []string
SOCPercent []string
AltitudeM []string
DirectionDeg []string
AlarmFlag []string
StatusFlag []string
}
// HasRealtimeFields separates protocol telemetry from headers, identity
// annotations, and transport metadata before realtime/stat projections.
func HasRealtimeFields(protocol envelope.Protocol, fields map[string]any) bool {
switch protocol {
case envelope.ProtocolGB32960:
for field := range fields {
if !strings.HasPrefix(field, "gb32960.") {
continue
}
if strings.HasPrefix(field, "gb32960.header.") ||
strings.HasPrefix(field, "gb32960.platform.") ||
strings.HasPrefix(field, "gb32960.identity.") ||
strings.HasPrefix(field, "gb32960.device_time.") {
continue
}
return true
}
return false
case envelope.ProtocolJT808:
return hasPrefix(fields, "jt808.location.")
case envelope.ProtocolYutongMQTT:
return hasPrefix(fields, "yutong_mqtt.data.") ||
hasPrefix(fields, "yutong_mqtt.root.data.")
default:
return false
}
}
// LocationProjectionForProtocol is the single protocol-field contract used by
// storage projections. It intentionally ignores bare standardized field names.
func LocationProjectionForProtocol(protocol envelope.Protocol, fields map[string]any) (LocationProjection, bool) {
mapping := locationMapping(protocol)
latitude, hasLatitude := firstNumber(fields, mapping.Latitude)
longitude, hasLongitude := firstNumber(fields, mapping.Longitude)
if !hasLatitude || !hasLongitude {
return LocationProjection{}, false
}
return LocationProjection{
Latitude: latitude,
Longitude: longitude,
SpeedKMH: numberPointer(fields, mapping.SpeedKMH),
SOCPercent: numberPointer(fields, mapping.SOCPercent),
AltitudeM: numberPointer(fields, mapping.AltitudeM),
DirectionDeg: numberPointer(fields, mapping.DirectionDeg),
AlarmFlag: integerPointer(fields, mapping.AlarmFlag),
StatusFlag: integerPointer(fields, mapping.StatusFlag),
}, true
}
func locationMapping(protocol envelope.Protocol) locationFieldMapping {
switch protocol {
case envelope.ProtocolGB32960:
return locationFieldMapping{
Latitude: []string{"gb32960.position.latitude"},
Longitude: []string{"gb32960.position.longitude"},
SpeedKMH: []string{"gb32960.vehicle.speed_kmh"},
SOCPercent: []string{"gb32960.vehicle.soc_percent"},
}
case envelope.ProtocolJT808:
return locationFieldMapping{
Latitude: []string{"jt808.location.latitude"},
Longitude: []string{"jt808.location.longitude"},
SpeedKMH: []string{"jt808.location.speed_kmh"},
AltitudeM: []string{"jt808.location.altitude_m"},
DirectionDeg: []string{"jt808.location.direction_deg"},
AlarmFlag: []string{"jt808.location.alarm_flag"},
StatusFlag: []string{"jt808.location.status_flag"},
}
case envelope.ProtocolYutongMQTT:
return locationFieldMapping{
Latitude: []string{
"yutong_mqtt.data.latitude",
"yutong_mqtt.root.data.latitude",
},
Longitude: []string{
"yutong_mqtt.data.longitude",
"yutong_mqtt.root.data.longitude",
},
SpeedKMH: []string{
"yutong_mqtt.data.meter_speed",
"yutong_mqtt.root.data.meter_speed",
"yutong_mqtt.data.speed_kmh",
"yutong_mqtt.root.data.speed_kmh",
"yutong_mqtt.data.speed",
"yutong_mqtt.root.data.speed",
},
SOCPercent: []string{
"yutong_mqtt.data.battery_capacity_soc",
"yutong_mqtt.root.data.battery_capacity_soc",
"yutong_mqtt.data.soc_percent",
"yutong_mqtt.root.data.soc_percent",
},
DirectionDeg: []string{
"yutong_mqtt.data.gpsdirection",
"yutong_mqtt.root.data.gpsdirection",
"yutong_mqtt.data.direction_deg",
"yutong_mqtt.root.data.direction_deg",
"yutong_mqtt.data.direction",
"yutong_mqtt.root.data.direction",
},
}
default:
return locationFieldMapping{}
}
}
func firstNumber(fields map[string]any, keys []string) (float64, bool) {
for _, key := range keys {
if value, ok := Number(fields, key); ok {
return value, true
}
}
return 0, false
}
func numberPointer(fields map[string]any, keys []string) *float64 {
value, ok := firstNumber(fields, keys)
if !ok {
return nil
}
return &value
}
func integerPointer(fields map[string]any, keys []string) *int64 {
value, ok := firstNumber(fields, keys)
if !ok {
return nil
}
integer := int64(value)
return &integer
}
func hasPrefix(fields map[string]any, prefix string) bool {
for field := range fields {
if strings.HasPrefix(field, prefix) {
return true
}
}
return false
}
@@ -0,0 +1,131 @@
package telemetry
import (
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestLocationProjectionForProtocolUsesProtocolFields(t *testing.T) {
tests := []struct {
name string
protocol envelope.Protocol
fields map[string]any
assert func(*testing.T, LocationProjection)
}{
{
name: "gb32960",
protocol: envelope.ProtocolGB32960,
fields: map[string]any{
"gb32960.position.latitude": "30.56",
"gb32960.position.longitude": "121.0",
"gb32960.vehicle.speed_kmh": "54.3",
"gb32960.vehicle.soc_percent": "85",
"gb32960.vehicle.total_mileage": "123",
},
assert: func(t *testing.T, location LocationProjection) {
assertFloatPointer(t, location.SpeedKMH, 54.3)
assertFloatPointer(t, location.SOCPercent, 85)
},
},
{
name: "jt808",
protocol: envelope.ProtocolJT808,
fields: map[string]any{
"jt808.location.latitude": "30.590151",
"jt808.location.longitude": "121.069881",
"jt808.location.speed_kmh": "23",
"jt808.location.altitude_m": "5",
"jt808.location.direction_deg": "79",
"jt808.location.alarm_flag": "2",
"jt808.location.status_flag": "786435",
},
assert: func(t *testing.T, location LocationProjection) {
assertFloatPointer(t, location.SpeedKMH, 23)
assertFloatPointer(t, location.AltitudeM, 5)
assertFloatPointer(t, location.DirectionDeg, 79)
assertIntPointer(t, location.AlarmFlag, 2)
assertIntPointer(t, location.StatusFlag, 786435)
},
},
{
name: "yutong root fallback",
protocol: envelope.ProtocolYutongMQTT,
fields: map[string]any{
"yutong_mqtt.root.data.latitude": "30.590921",
"yutong_mqtt.root.data.longitude": "121.075044",
"yutong_mqtt.root.data.meter_speed": "27",
"yutong_mqtt.root.data.battery_capacity_soc": "78.4",
"yutong_mqtt.root.data.gpsdirection": "88",
},
assert: func(t *testing.T, location LocationProjection) {
assertFloatPointer(t, location.SpeedKMH, 27)
assertFloatPointer(t, location.SOCPercent, 78.4)
assertFloatPointer(t, location.DirectionDeg, 88)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
location, ok := LocationProjectionForProtocol(tt.protocol, tt.fields)
if !ok {
t.Fatal("LocationProjectionForProtocol() did not find coordinates")
}
if location.Latitude == 0 || location.Longitude == 0 {
t.Fatalf("coordinates = %v,%v", location.Latitude, location.Longitude)
}
tt.assert(t, location)
})
}
}
func TestLocationProjectionForProtocolRejectsBareStandardizedFields(t *testing.T) {
for _, protocol := range []envelope.Protocol{
envelope.ProtocolGB32960,
envelope.ProtocolJT808,
envelope.ProtocolYutongMQTT,
} {
if location, ok := LocationProjectionForProtocol(protocol, map[string]any{
"latitude": 30.5,
"longitude": 121.0,
"speed_kmh": 20,
}); ok {
t.Fatalf("protocol %s unexpectedly accepted bare fields: %#v", protocol, location)
}
}
}
func TestHasRealtimeFieldsExcludesMetadataOnlyPayloads(t *testing.T) {
tests := []struct {
protocol envelope.Protocol
fields map[string]any
want bool
}{
{protocol: envelope.ProtocolGB32960, fields: map[string]any{"gb32960.header.command": "0x02"}, want: false},
{protocol: envelope.ProtocolGB32960, fields: map[string]any{"gb32960.gd_fc_stack.stack_count": "1"}, want: true},
{protocol: envelope.ProtocolJT808, fields: map[string]any{"jt808.registration.plate": "粤A00001"}, want: false},
{protocol: envelope.ProtocolJT808, fields: map[string]any{"jt808.location.speed_kmh": "20"}, want: true},
{protocol: envelope.ProtocolYutongMQTT, fields: map[string]any{"yutong_mqtt.metadata.topic": "/ytforward/shln/3"}, want: false},
{protocol: envelope.ProtocolYutongMQTT, fields: map[string]any{"yutong_mqtt.data.meter_speed": "20"}, want: true},
}
for _, tt := range tests {
if got := HasRealtimeFields(tt.protocol, tt.fields); got != tt.want {
t.Fatalf("HasRealtimeFields(%s, %#v) = %v, want %v", tt.protocol, tt.fields, got, tt.want)
}
}
}
func assertFloatPointer(t *testing.T, value *float64, want float64) {
t.Helper()
if value == nil || *value != want {
t.Fatalf("float pointer = %v, want %v", value, want)
}
}
func assertIntPointer(t *testing.T, value *int64, want int64) {
t.Helper()
if value == nil || *value != want {
t.Fatalf("int pointer = %v, want %v", value, want)
}
}
@@ -0,0 +1,89 @@
package telemetry
import (
"encoding/json"
"strconv"
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
type MileageFieldMapping struct {
Key string
Scale float64
}
// MileageFieldMappings is the single protocol-to-unit contract used by every
// projection that exposes or calculates total mileage.
func MileageFieldMappings(protocol envelope.Protocol) []MileageFieldMapping {
switch protocol {
case envelope.ProtocolGB32960:
return []MileageFieldMapping{{Key: "gb32960.vehicle.total_mileage_km", Scale: 1}}
case envelope.ProtocolJT808:
return []MileageFieldMapping{{Key: "jt808.location.total_mileage_km", Scale: 1}}
case envelope.ProtocolYutongMQTT:
return []MileageFieldMapping{
{Key: "yutong_mqtt.data.total_mileage_km", Scale: 1},
{Key: "yutong_mqtt.root.data.total_mileage_km", Scale: 1},
{Key: "yutong_mqtt.data.total_mileage", Scale: 0.001},
{Key: "yutong_mqtt.root.data.total_mileage", Scale: 0.001},
}
default:
return nil
}
}
func TotalMileageKM(protocol envelope.Protocol, fields map[string]any) (float64, bool) {
for _, mapping := range MileageFieldMappings(protocol) {
value, ok := Number(fields, mapping.Key)
if !ok {
continue
}
return value * mapping.Scale, true
}
return 0, false
}
func Number(fields map[string]any, key string) (float64, bool) {
if len(fields) == 0 {
return 0, false
}
value, ok := fields[key]
if !ok || value == nil {
return 0, false
}
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int8:
return float64(typed), true
case int16:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case uint:
return float64(typed), true
case uint8:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case uint64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
}
}
@@ -1,5 +1,13 @@
package topics
import (
"fmt"
"sort"
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
RawGB32960 = "vehicle.raw.go.gb32960.v1"
RawJT808 = "vehicle.raw.go.jt808.v1"
@@ -9,3 +17,196 @@ const (
FieldsYutongMQTT = "vehicle.fields.go.yutong-mqtt.v1"
Unified = "vehicle.event.go.unified.v1"
)
const (
RawPrefix = "vehicle.raw."
FieldsPrefix = "vehicle.fields."
)
func ProtocolForKnownRawTopic(topic string) (string, bool) {
switch strings.TrimSpace(topic) {
case RawGB32960:
return "GB32960", true
case RawJT808:
return "JT808", true
case RawYutongMQTT:
return "YUTONG_MQTT", true
default:
return "", false
}
}
func ProtocolForKnownFieldsTopic(topic string) (string, bool) {
switch strings.TrimSpace(topic) {
case FieldsGB32960:
return "GB32960", true
case FieldsJT808:
return "JT808", true
case FieldsYutongMQTT:
return "YUTONG_MQTT", true
default:
return "", false
}
}
func ValidateKnownRawTopicProtocol(topic string, protocol string) error {
expectedProtocol, ok := ProtocolForKnownRawTopic(topic)
if !ok {
return nil
}
protocol = strings.TrimSpace(protocol)
if protocol == expectedProtocol {
return nil
}
if protocol == "" {
protocol = "UNKNOWN"
}
return fmt.Errorf("raw topic %q expects protocol %s, got %s", strings.TrimSpace(topic), expectedProtocol, protocol)
}
func ValidateKnownFieldsTopicProtocol(topic string, protocol string) error {
expectedProtocol, ok := ProtocolForKnownFieldsTopic(topic)
if !ok {
return nil
}
protocol = strings.TrimSpace(protocol)
if protocol == expectedProtocol {
return nil
}
if protocol == "" {
protocol = "UNKNOWN"
}
return fmt.Errorf("fields topic %q expects protocol %s, got %s", strings.TrimSpace(topic), expectedProtocol, protocol)
}
func ValidateRawEnvelope(topic string, env envelope.FrameEnvelope) (string, error) {
if err := ValidateKnownRawTopicProtocol(topic, string(env.Protocol)); err != nil {
return "protocol_topic_mismatch", err
}
if strings.HasPrefix(strings.TrimSpace(topic), RawPrefix) {
switch env.EventKind {
case "", envelope.EventKindRaw:
return "", nil
default:
return "event_kind_mismatch", fmt.Errorf("raw topic %q expects event kind %s, got %s", strings.TrimSpace(topic), envelope.EventKindRaw, env.EventKind)
}
}
return "", nil
}
func ValidateFieldsEnvelope(topic string, env envelope.FrameEnvelope) (string, error) {
if err := ValidateKnownFieldsTopicProtocol(topic, string(env.Protocol)); err != nil {
return "protocol_topic_mismatch", err
}
if strings.HasPrefix(strings.TrimSpace(topic), FieldsPrefix) {
if env.EventKind != envelope.EventKindFields {
return "event_kind_mismatch", fmt.Errorf("fields topic %q expects event kind %s, got %s", strings.TrimSpace(topic), envelope.EventKindFields, env.EventKind)
}
if strings.TrimSpace(env.FieldMapping) == "" {
return "missing_field_mapping", fmt.Errorf("fields topic %q expects non-empty field mapping", strings.TrimSpace(topic))
}
if len(env.Fields) == 0 {
return "missing_fields", fmt.Errorf("fields topic %q expects non-empty fields payload", strings.TrimSpace(topic))
}
if err := ValidateProtocolFieldNames(env.Protocol, env.Fields); err != nil {
return "invalid_field_name", err
}
}
return "", nil
}
func ValidateProtocolFieldNames(protocol envelope.Protocol, fields map[string]any) error {
prefix, ok := protocolFieldPrefix(protocol)
if !ok {
return nil
}
invalid := make([]string, 0)
for key := range fields {
trimmed := strings.TrimSpace(key)
if key == trimmed && len(trimmed) > len(prefix) && strings.HasPrefix(trimmed, prefix) {
continue
}
invalid = append(invalid, key)
}
if len(invalid) == 0 {
return nil
}
sort.Strings(invalid)
if len(invalid) > 3 {
invalid = invalid[:3]
}
return fmt.Errorf("fields protocol %s expects every field name under %q; invalid fields: %q", protocol, prefix, invalid)
}
func protocolFieldPrefix(protocol envelope.Protocol) (string, bool) {
switch protocol {
case envelope.ProtocolGB32960:
return "gb32960.", true
case envelope.ProtocolJT808:
return "jt808.", true
case envelope.ProtocolYutongMQTT:
return "yutong_mqtt.", true
default:
return "", false
}
}
func ValidateKnownRawFieldsProtocols(raw map[string]string, fields map[string]string, valueName string) error {
for protocol, value := range raw {
if err := ValidateKnownRawTopicProtocol(value, protocol); err != nil {
return fmt.Errorf("raw %s for %s must match protocol: %w", valueName, protocol, err)
}
}
for protocol, value := range fields {
if err := ValidateKnownFieldsTopicProtocol(value, protocol); err != nil {
return fmt.Errorf("fields %s for %s must match protocol: %w", valueName, protocol, err)
}
}
return nil
}
func ValidateKafkaRawFields(raw map[string]string, fields map[string]string) error {
for name, topic := range raw {
topic = strings.TrimSpace(topic)
if topic == "" {
return fmt.Errorf("raw kafka topic for %s is empty", name)
}
if !strings.HasPrefix(topic, RawPrefix) {
return fmt.Errorf("raw kafka topic for %s must start with %q, got %q", name, RawPrefix, topic)
}
}
for name, topic := range fields {
topic = strings.TrimSpace(topic)
if topic == "" {
return fmt.Errorf("fields kafka topic for %s is empty", name)
}
if !strings.HasPrefix(topic, FieldsPrefix) {
return fmt.Errorf("fields kafka topic for %s must start with %q, got %q", name, FieldsPrefix, topic)
}
}
if err := ValidateKnownRawFieldsProtocols(raw, fields, "kafka topic"); err != nil {
return err
}
return ValidateRawFieldsDisjoint(raw, fields, "kafka topic")
}
func ValidateRawFieldsDisjoint(raw map[string]string, fields map[string]string, valueName string) error {
rawByValue := map[string]string{}
for name, value := range raw {
value = strings.TrimSpace(value)
if value == "" {
continue
}
rawByValue[value] = name
}
for fieldsName, value := range fields {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if rawName, ok := rawByValue[value]; ok {
return fmt.Errorf("raw and fields %s must be different: raw %s and fields %s both use %q", valueName, rawName, fieldsName, value)
}
}
return nil
}
@@ -0,0 +1,249 @@
package topics
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestValidateKafkaRawFieldsRejectsWrongFamilyPrefix(t *testing.T) {
err := ValidateKafkaRawFields(
map[string]string{"JT808": FieldsJT808},
map[string]string{"JT808": FieldsJT808},
)
if err == nil {
t.Fatal("ValidateKafkaRawFields() error = nil, want raw prefix rejection")
}
if !strings.Contains(err.Error(), "raw kafka topic") {
t.Fatalf("error = %q, want raw kafka topic hint", err)
}
err = ValidateKafkaRawFields(
map[string]string{"JT808": RawJT808},
map[string]string{"JT808": RawJT808},
)
if err == nil {
t.Fatal("ValidateKafkaRawFields() error = nil, want fields prefix rejection")
}
if !strings.Contains(err.Error(), "fields kafka topic") {
t.Fatalf("error = %q, want fields kafka topic hint", err)
}
}
func TestValidateRawFieldsDisjointRejectsSharedValue(t *testing.T) {
err := ValidateRawFieldsDisjoint(
map[string]string{"raw-jt808": "vehicle.same.jt808"},
map[string]string{"fields-jt808": "vehicle.same.jt808"},
"nats subject",
)
if err == nil {
t.Fatal("ValidateRawFieldsDisjoint() error = nil, want duplicate rejection")
}
if !strings.Contains(err.Error(), "must be different") {
t.Fatalf("error = %q, want disjoint hint", err)
}
}
func TestValidateKnownFieldsTopicProtocolRejectsMismatchedProtocol(t *testing.T) {
err := ValidateKnownFieldsTopicProtocol(FieldsGB32960, "JT808")
if err == nil {
t.Fatal("ValidateKnownFieldsTopicProtocol() error = nil, want mismatch rejection")
}
if !strings.Contains(err.Error(), "expects protocol GB32960") {
t.Fatalf("error = %q, want expected protocol hint", err)
}
}
func TestValidateKnownRawTopicProtocolRejectsMismatchedProtocol(t *testing.T) {
err := ValidateKnownRawTopicProtocol(RawJT808, "GB32960")
if err == nil {
t.Fatal("ValidateKnownRawTopicProtocol() error = nil, want mismatch rejection")
}
if !strings.Contains(err.Error(), "expects protocol JT808") {
t.Fatalf("error = %q, want expected protocol hint", err)
}
}
func TestValidateKnownTopicProtocolAllowsMatchingAndCustomTopics(t *testing.T) {
if err := ValidateKnownRawTopicProtocol(RawGB32960, "GB32960"); err != nil {
t.Fatalf("ValidateKnownRawTopicProtocol() matching topic error = %v", err)
}
if err := ValidateKnownRawTopicProtocol("vehicle.raw.custom.g7", "JT808"); err != nil {
t.Fatalf("ValidateKnownRawTopicProtocol() custom topic error = %v", err)
}
if err := ValidateKnownFieldsTopicProtocol(FieldsJT808, "JT808"); err != nil {
t.Fatalf("ValidateKnownFieldsTopicProtocol() matching topic error = %v", err)
}
if err := ValidateKnownFieldsTopicProtocol("vehicle.fields.custom.g7", "JT808"); err != nil {
t.Fatalf("ValidateKnownFieldsTopicProtocol() custom topic error = %v", err)
}
}
func TestValidateRawEnvelopeRejectsExplicitNonRawEventKind(t *testing.T) {
status, err := ValidateRawEnvelope(RawJT808, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
})
if err == nil {
t.Fatal("ValidateRawEnvelope() error = nil, want event kind mismatch")
}
if status != "event_kind_mismatch" {
t.Fatalf("status = %q, want event_kind_mismatch", status)
}
if !strings.Contains(err.Error(), "expects event kind RAW") {
t.Fatalf("error = %q, want RAW hint", err)
}
}
func TestValidateRawEnvelopeAllowsHistoricalEmptyAndRawEventKind(t *testing.T) {
for _, kind := range []envelope.EventKind{"", envelope.EventKindRaw} {
if status, err := ValidateRawEnvelope(RawGB32960, envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, EventKind: kind}); err != nil {
t.Fatalf("ValidateRawEnvelope(%q) status=%q error=%v", kind, status, err)
}
}
}
func TestValidateFieldsEnvelopeRejectsExplicitNonFieldsEventKind(t *testing.T) {
status, err := ValidateFieldsEnvelope(FieldsYutongMQTT, envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
EventKind: envelope.EventKindRaw,
})
if err == nil {
t.Fatal("ValidateFieldsEnvelope() error = nil, want event kind mismatch")
}
if status != "event_kind_mismatch" {
t.Fatalf("status = %q, want event_kind_mismatch", status)
}
if !strings.Contains(err.Error(), "expects event kind FIELDS") {
t.Fatalf("error = %q, want FIELDS hint", err)
}
}
func TestValidateFieldsEnvelopeRequiresFieldsContract(t *testing.T) {
status, err := ValidateFieldsEnvelope(FieldsJT808, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
Fields: map[string]any{"jt808.location.total_mileage_km": 10241.2},
})
if err == nil {
t.Fatal("ValidateFieldsEnvelope() error = nil, want missing field mapping")
}
if status != "missing_field_mapping" {
t.Fatalf("status = %q, want missing_field_mapping", status)
}
status, err = ValidateFieldsEnvelope(FieldsJT808, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
FieldMapping: "2026-07-03.v1",
})
if err == nil {
t.Fatal("ValidateFieldsEnvelope() error = nil, want missing fields")
}
if status != "missing_fields" {
t.Fatalf("status = %q, want missing_fields", status)
}
status, err = ValidateFieldsEnvelope(FieldsJT808, envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
FieldMapping: "2026-07-03.v1",
Fields: map[string]any{"jt808.location.total_mileage_km": 10241.2},
})
if err != nil || status != "" {
t.Fatalf("ValidateFieldsEnvelope() status=%q error=%v, want ok", status, err)
}
}
func TestValidateFieldsEnvelopeRejectsNonProtocolFieldNames(t *testing.T) {
for _, tt := range []struct {
name string
topic string
protocol envelope.Protocol
fields map[string]any
}{
{
name: "normalized core field",
topic: FieldsJT808,
protocol: envelope.ProtocolJT808,
fields: map[string]any{"total_mileage_km": 10241.2},
},
{
name: "other protocol namespace",
topic: FieldsGB32960,
protocol: envelope.ProtocolGB32960,
fields: map[string]any{"jt808.location.latitude": 30.1},
},
{
name: "empty suffix",
topic: FieldsYutongMQTT,
protocol: envelope.ProtocolYutongMQTT,
fields: map[string]any{"yutong_mqtt.": 1},
},
} {
t.Run(tt.name, func(t *testing.T) {
status, err := ValidateFieldsEnvelope(tt.topic, envelope.FrameEnvelope{
Protocol: tt.protocol,
EventKind: envelope.EventKindFields,
FieldMapping: "2026-07-03.v1",
Fields: tt.fields,
})
if err == nil {
t.Fatal("ValidateFieldsEnvelope() error = nil, want invalid field rejection")
}
if status != "invalid_field_name" {
t.Fatalf("status = %q, want invalid_field_name", status)
}
if !strings.Contains(err.Error(), "expects every field name") {
t.Fatalf("error = %q, want protocol namespace hint", err)
}
})
}
}
func TestValidateFieldsEnvelopeAcceptsProtocolNamespaces(t *testing.T) {
for _, tt := range []struct {
topic string
protocol envelope.Protocol
field string
}{
{topic: FieldsGB32960, protocol: envelope.ProtocolGB32960, field: "gb32960.vehicle.total_mileage_km"},
{topic: FieldsJT808, protocol: envelope.ProtocolJT808, field: "jt808.location.total_mileage_km"},
{topic: FieldsYutongMQTT, protocol: envelope.ProtocolYutongMQTT, field: "yutong_mqtt.root.data.total_mileage"},
} {
status, err := ValidateFieldsEnvelope(tt.topic, envelope.FrameEnvelope{
Protocol: tt.protocol,
EventKind: envelope.EventKindFields,
FieldMapping: "2026-07-03.v1",
Fields: map[string]any{tt.field: 1},
})
if err != nil || status != "" {
t.Fatalf("ValidateFieldsEnvelope(%s) status=%q error=%v", tt.protocol, status, err)
}
}
}
func TestValidateKafkaRawFieldsRejectsKnownProtocolMismatch(t *testing.T) {
err := ValidateKafkaRawFields(
map[string]string{"JT808": RawGB32960},
map[string]string{"JT808": FieldsJT808},
)
if err == nil {
t.Fatal("ValidateKafkaRawFields() error = nil, want known raw protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
err = ValidateKafkaRawFields(
map[string]string{"JT808": RawJT808},
map[string]string{"JT808": FieldsGB32960},
)
if err == nil {
t.Fatal("ValidateKafkaRawFields() error = nil, want known fields protocol mismatch")
}
if !strings.Contains(err.Error(), "must match protocol") {
t.Fatalf("error = %q, want protocol mismatch hint", err)
}
}