Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime/snapshot_writer.go
2026-07-02 16:36:52 +08:00

421 lines
12 KiB
Go

package realtime
import (
"context"
"database/sql"
"encoding/json"
"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
}
_, err := w.exec.ExecContext(ctx, realtimeLocationTableSQL)
return err
}
func (w *SnapshotWriter) Update(ctx context.Context, env envelope.FrameEnvelope) error {
vehicleKey := strings.TrimSpace(env.VehicleKey())
if vehicleKey == "" || strings.HasSuffix(vehicleKey, ":unknown") {
return nil
}
if !hasRealtimePayload(env) {
return nil
}
plate, err := w.plateForEnvelope(ctx, env)
if err != nil {
return err
}
fieldsJSON, err := marshalObject(env.Fields)
if err != nil {
return err
}
parsedJSON, err := marshalObject(env.Parsed)
if err != nil {
return err
}
eventTime := nullableTime(env.EventTimeMS)
receivedAt := nullableTime(env.ReceivedAtMS)
if _, err = w.exec.ExecContext(ctx, upsertRealtimeSnapshotSQL,
string(env.Protocol),
vehicleKey,
strings.TrimSpace(env.VIN),
strings.TrimSpace(env.Phone),
strings.TrimSpace(env.DeviceID),
plate,
strings.TrimSpace(env.MessageID),
env.Sequence,
strings.TrimSpace(env.SourceEndpoint),
eventTime,
parsedJSON,
fieldsJSON,
receivedAt,
env.StableEventID(),
); err != nil {
return err
}
location, ok := realtimeLocationFromEnvelope(env, vehicleKey, fieldsJSON, plate)
if !ok {
return nil
}
_, err = w.exec.ExecContext(ctx, upsertRealtimeLocationSQL,
location.Protocol,
location.VehicleKey,
location.VIN,
location.Phone,
location.DeviceID,
location.Plate,
location.MessageID,
location.Sequence,
location.SourceEndpoint,
location.EventTime,
location.Latitude,
location.Longitude,
location.SpeedKMH,
location.TotalMileageKM,
location.SOCPercent,
location.AltitudeM,
location.DirectionDeg,
location.AlarmFlag,
location.StatusFlag,
location.FieldsJSON,
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
VehicleKey string
VIN string
Phone string
DeviceID string
Plate string
MessageID string
Sequence uint16
SourceEndpoint string
EventTime any
Latitude float64
Longitude float64
SpeedKMH any
TotalMileageKM any
SOCPercent any
AltitudeM any
DirectionDeg any
AlarmFlag any
StatusFlag any
FieldsJSON string
ReceivedAt any
EventID string
}
func realtimeLocationFromEnvelope(env envelope.FrameEnvelope, vehicleKey string, fieldsJSON 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),
VehicleKey: vehicleKey,
VIN: strings.TrimSpace(env.VIN),
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
Plate: plate,
MessageID: strings.TrimSpace(env.MessageID),
Sequence: env.Sequence,
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
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"),
FieldsJSON: fieldsJSON,
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 marshalObject(value map[string]any) (string, error) {
if value == nil {
value = map[string]any{}
}
payload, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(payload), nil
}
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,
vehicle_key VARCHAR(96) NOT NULL,
vin VARCHAR(32) NOT NULL DEFAULT '',
phone VARCHAR(32) NOT NULL DEFAULT '',
device_id VARCHAR(96) NOT NULL DEFAULT '',
plate VARCHAR(32) NOT NULL DEFAULT '',
message_id VARCHAR(32) NOT NULL DEFAULT '',
sequence_id INT NOT NULL DEFAULT 0,
source_endpoint VARCHAR(128) NOT NULL DEFAULT '',
event_time DATETIME(3) NULL,
parsed_json JSON NOT NULL,
fields_json JSON NOT 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_vehicle (protocol, vehicle_key),
KEY idx_vehicle_key (vehicle_key),
KEY idx_vin (vin),
KEY idx_protocol_updated (protocol, updated_at)
)`
const upsertRealtimeSnapshotSQL = `
INSERT INTO vehicle_realtime_snapshot
(protocol, vehicle_key, vin, phone, device_id, plate, message_id, sequence_id,
source_endpoint, event_time, parsed_json, fields_json, received_at, event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?)
ON DUPLICATE KEY UPDATE
vin = IF(VALUES(vin) <> '', VALUES(vin), vin),
phone = IF(VALUES(phone) <> '', VALUES(phone), phone),
device_id = IF(VALUES(device_id) <> '', VALUES(device_id), device_id),
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
message_id = VALUES(message_id),
sequence_id = VALUES(sequence_id),
source_endpoint = VALUES(source_endpoint),
event_time = VALUES(event_time),
parsed_json = VALUES(parsed_json),
fields_json = VALUES(fields_json),
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,
vehicle_key VARCHAR(96) NOT NULL,
vin VARCHAR(32) NOT NULL DEFAULT '',
phone VARCHAR(32) NOT NULL DEFAULT '',
device_id VARCHAR(96) NOT NULL DEFAULT '',
plate VARCHAR(32) NOT NULL DEFAULT '',
message_id VARCHAR(32) NOT NULL DEFAULT '',
sequence_id INT NOT NULL DEFAULT 0,
source_endpoint VARCHAR(128) 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,
fields_json JSON NOT 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_vehicle (protocol, vehicle_key),
KEY idx_vehicle_key (vehicle_key),
KEY idx_vin (vin),
KEY idx_protocol_updated (protocol, updated_at),
KEY idx_location (longitude, latitude)
)`
const upsertRealtimeLocationSQL = `
INSERT INTO vehicle_realtime_location
(protocol, vehicle_key, vin, phone, device_id, plate, message_id, sequence_id,
source_endpoint, event_time, latitude, longitude, speed_kmh, total_mileage_km,
soc_percent, altitude_m, direction_deg, alarm_flag, status_flag, fields_json,
received_at, event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?)
ON DUPLICATE KEY UPDATE
vin = IF(VALUES(vin) <> '', VALUES(vin), vin),
phone = IF(VALUES(phone) <> '', VALUES(phone), phone),
device_id = IF(VALUES(device_id) <> '', VALUES(device_id), device_id),
plate = IF(VALUES(plate) <> '', VALUES(plate), plate),
message_id = VALUES(message_id),
sequence_id = VALUES(sequence_id),
source_endpoint = VALUES(source_endpoint),
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),
fields_json = VALUES(fields_json),
received_at = VALUES(received_at),
event_id = VALUES(event_id),
updated_at = CURRENT_TIMESTAMP
`