package realtime import ( "context" "database/sql" "errors" "strings" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) type SnapshotExecer interface { ExecContext(context.Context, string, ...any) (sql.Result, error) } type PlateResolver interface { PlateByVIN(context.Context, string) (string, error) } type SnapshotWriter struct { exec SnapshotExecer plateResolver PlateResolver } func NewSnapshotWriter(exec SnapshotExecer) *SnapshotWriter { return NewSnapshotWriterWithPlateResolver(exec, nil) } func NewSnapshotWriterWithPlateResolver(exec SnapshotExecer, plateResolver PlateResolver) *SnapshotWriter { if exec == nil { panic("snapshot execer must not be nil") } return &SnapshotWriter{exec: exec, plateResolver: plateResolver} } func (w *SnapshotWriter) EnsureSchema(ctx context.Context) error { if _, err := w.exec.ExecContext(ctx, realtimeSnapshotTableSQL); err != nil { return err } if _, err := w.exec.ExecContext(ctx, realtimeLocationTableSQL); err != nil { return err } return nil } func (w *SnapshotWriter) Update(ctx context.Context, env envelope.FrameEnvelope) error { vin := strings.TrimSpace(env.VIN) if vin == "" { return nil } if !hasRealtimePayload(env) { return nil } plate, err := w.plateForEnvelope(ctx, env) if err != nil { return err } eventTime := nullableTime(env.EventTimeMS) receivedAt := nullableTime(env.ReceivedAtMS) if _, err = w.exec.ExecContext(ctx, upsertRealtimeSnapshotSQL, string(env.Protocol), vin, plate, eventTime, receivedAt, env.StableEventID(), ); err != nil { return err } location, ok := realtimeLocationFromEnvelope(env, vin, plate) if !ok { return nil } _, err = w.exec.ExecContext(ctx, upsertRealtimeLocationSQL, location.Protocol, location.VIN, location.Plate, location.EventTime, location.Latitude, location.Longitude, location.SpeedKMH, location.TotalMileageKM, location.SOCPercent, location.AltitudeM, location.DirectionDeg, location.AlarmFlag, location.StatusFlag, location.ReceivedAt, location.EventID, ) return err } func (w *SnapshotWriter) plateForEnvelope(ctx context.Context, env envelope.FrameEnvelope) (string, error) { if plate := strings.TrimSpace(env.Plate); plate != "" { return plate, nil } if w.plateResolver == nil { return "", nil } vin := strings.TrimSpace(env.VIN) if vin == "" { return "", nil } plate, err := w.plateResolver.PlateByVIN(ctx, vin) if err != nil { if errors.Is(err, sql.ErrNoRows) { return "", nil } return "", err } return strings.TrimSpace(plate), nil } type realtimeLocationRow struct { Protocol string VIN string Plate string EventTime any Latitude float64 Longitude float64 SpeedKMH any TotalMileageKM any SOCPercent any AltitudeM any DirectionDeg any AlarmFlag any StatusFlag any ReceivedAt any EventID string } 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 { return realtimeLocationRow{}, false } 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"), ReceivedAt: nullableTime(env.ReceivedAtMS), EventID: env.StableEventID(), }, true } func nullableNumberField(fields map[string]any, key string) any { value, ok := numberField(fields, key) if !ok { return nil } 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 } } type BindingPlateResolver struct { queryer Queryer table string } type Queryer interface { QueryRowContext(context.Context, string, ...any) *sql.Row } func NewBindingPlateResolver(queryer Queryer, table string) *BindingPlateResolver { if queryer == nil { panic("plate binding queryer must not be nil") } table = strings.TrimSpace(table) if table == "" || !safeIdentifier(table) { table = "vehicle_identity_binding" } return &BindingPlateResolver{queryer: queryer, table: table} } func (r *BindingPlateResolver) PlateByVIN(ctx context.Context, vin string) (string, error) { vin = strings.TrimSpace(vin) if vin == "" { return "", sql.ErrNoRows } query := "SELECT plate FROM " + r.table + " WHERE vin = ? AND plate IS NOT NULL AND plate <> '' ORDER BY updated_at DESC LIMIT 1" var plate string err := r.queryer.QueryRowContext(ctx, query, vin).Scan(&plate) if err != nil { return "", err } return strings.TrimSpace(plate), nil } func safeIdentifier(value string) bool { if value == "" { return false } for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { continue } return false } return true } func nullableTime(ms int64) any { if ms <= 0 { return nil } return time.UnixMilli(ms) } func hasRealtimePayload(env envelope.FrameEnvelope) bool { if len(env.Fields) > 0 { return true } if _, ok := env.Parsed["data_units"]; ok { return true } return false } const realtimeSnapshotTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, protocol VARCHAR(32) NOT NULL, vin VARCHAR(32) NOT NULL DEFAULT '', plate VARCHAR(32) NOT NULL DEFAULT '', event_time DATETIME(3) NULL, received_at DATETIME(3) NULL, event_id VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_realtime_snapshot_vin (protocol, vin), KEY idx_vin (vin), KEY idx_protocol_updated (protocol, updated_at) )` const upsertRealtimeSnapshotSQL = ` INSERT INTO vehicle_realtime_snapshot (protocol, vin, plate, event_time, received_at, event_id) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE plate = IF(VALUES(plate) <> '', VALUES(plate), plate), event_time = VALUES(event_time), received_at = VALUES(received_at), event_id = VALUES(event_id), updated_at = CURRENT_TIMESTAMP ` const realtimeLocationTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_realtime_location ( id BIGINT PRIMARY KEY AUTO_INCREMENT, protocol VARCHAR(32) NOT NULL, vin VARCHAR(32) NOT NULL DEFAULT '', plate VARCHAR(32) NOT NULL DEFAULT '', event_time DATETIME(3) NULL, latitude DECIMAL(12,6) NOT NULL, longitude DECIMAL(12,6) NOT NULL, speed_kmh DECIMAL(10,3) NULL, total_mileage_km DECIMAL(18,3) NULL, soc_percent DECIMAL(6,2) NULL, altitude_m DECIMAL(10,3) NULL, direction_deg DECIMAL(10,3) NULL, alarm_flag BIGINT NULL, status_flag BIGINT NULL, received_at DATETIME(3) NULL, event_id VARCHAR(64) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_realtime_location_vin (protocol, vin), KEY idx_vin (vin), KEY idx_protocol_updated (protocol, updated_at), KEY idx_location (longitude, latitude) )` const upsertRealtimeLocationSQL = ` INSERT INTO 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE plate = IF(VALUES(plate) <> '', VALUES(plate), plate), event_time = VALUES(event_time), latitude = VALUES(latitude), longitude = VALUES(longitude), speed_kmh = VALUES(speed_kmh), total_mileage_km = VALUES(total_mileage_km), soc_percent = VALUES(soc_percent), altitude_m = VALUES(altitude_m), direction_deg = VALUES(direction_deg), alarm_flag = VALUES(alarm_flag), status_flag = VALUES(status_flag), received_at = VALUES(received_at), event_id = VALUES(event_id), updated_at = CURRENT_TIMESTAMP `