feat: build vehicle data platform and production pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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、remark;source_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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user