Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime/snapshot_writer.go

335 lines
8.8 KiB
Go

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 (
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 '',
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)
)`
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 (
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 '',
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_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
`