package realtime import ( "context" "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) type mysqlQueryer interface { QueryContext(context.Context, string, ...any) (*sql.Rows, error) } type RealtimeTableQuery struct { Protocol string VIN string Plate string Domain string Field string IncludeTotal bool Limit int Offset int } type SnapshotRow struct { Protocol string `json:"protocol"` VIN string `json:"vin"` Plate string `json:"plate"` PlatformName string `json:"platform_name,omitempty"` Peer string `json:"peer,omitempty"` ParsedJSON string `json:"parsed_json,omitempty"` EventTime string `json:"event_time,omitempty"` ReceivedAt string `json:"received_at,omitempty"` EventID string `json:"event_id"` UpdatedAt string `json:"updated_at"` } type LocationRow struct { Protocol string `json:"protocol"` VIN string `json:"vin"` Plate string `json:"plate"` EventTime string `json:"event_time,omitempty"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` SpeedKMH *float64 `json:"speed_kmh,omitempty"` TotalMileageKM *float64 `json:"total_mileage_km,omitempty"` SOCPercent *float64 `json:"soc_percent,omitempty"` AltitudeM *float64 `json:"altitude_m,omitempty"` DirectionDeg *float64 `json:"direction_deg,omitempty"` AlarmFlag *int64 `json:"alarm_flag,omitempty"` StatusFlag *int64 `json:"status_flag,omitempty"` ReceivedAt string `json:"received_at,omitempty"` EventID string `json:"event_id"` UpdatedAt string `json:"updated_at"` } type KVRow struct { Protocol string `json:"protocol"` VIN string `json:"vin"` Domain string `json:"domain"` Field string `json:"field"` Value string `json:"value"` ValueType string `json:"value_type"` EventTime string `json:"event_time,omitempty"` ReceivedAt string `json:"received_at,omitempty"` EventID string `json:"event_id"` UpdatedAt string `json:"updated_at"` } type SnapshotQueryRepository struct { db mysqlQueryer } func NewSnapshotQueryRepository(db mysqlQueryer) *SnapshotQueryRepository { if db == nil { panic("snapshot query db must not be nil") } return &SnapshotQueryRepository{db: db} } func (r *SnapshotQueryRepository) Count(ctx context.Context, query RealtimeTableQuery) (int64, error) { sqlText, args := buildRealtimeCountSQL("vehicle_realtime_snapshot", normalizeRealtimeTableQuery(query)) return queryCount(ctx, r.db, sqlText, args) } func (r *SnapshotQueryRepository) Query(ctx context.Context, query RealtimeTableQuery) ([]SnapshotRow, error) { query = normalizeRealtimeTableQuery(query) sqlText, args := buildRealtimeSelectSQL( "vehicle_realtime_snapshot", "protocol, vin, plate, platform_name, peer, parsed_json, event_time, received_at, event_id, updated_at", query, ) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() out := make([]SnapshotRow, 0) for rows.Next() { var row SnapshotRow var eventTime, receivedAt, updatedAt scanSQLDateTime var parsedJSON sql.NullString if err := rows.Scan(&row.Protocol, &row.VIN, &row.Plate, &row.PlatformName, &row.Peer, &parsedJSON, &eventTime, &receivedAt, &row.EventID, &updatedAt); err != nil { return nil, err } if parsedJSON.Valid { row.ParsedJSON = parsedJSON.String } row.EventTime = eventTime.String row.ReceivedAt = receivedAt.String row.UpdatedAt = updatedAt.String out = append(out, row) } return out, rows.Err() } type LocationQueryRepository struct { db mysqlQueryer } type KVQueryRepository struct { db mysqlQueryer } func NewLocationQueryRepository(db mysqlQueryer) *LocationQueryRepository { if db == nil { panic("location query db must not be nil") } return &LocationQueryRepository{db: db} } func NewKVQueryRepository(db mysqlQueryer) *KVQueryRepository { if db == nil { panic("kv query db must not be nil") } return &KVQueryRepository{db: db} } func (r *LocationQueryRepository) Count(ctx context.Context, query RealtimeTableQuery) (int64, error) { sqlText, args := buildRealtimeCountSQL("vehicle_realtime_location", normalizeRealtimeTableQuery(query)) return queryCount(ctx, r.db, sqlText, args) } func (r *LocationQueryRepository) Query(ctx context.Context, query RealtimeTableQuery) ([]LocationRow, error) { 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", query, ) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() out := make([]LocationRow, 0) for rows.Next() { var row LocationRow var eventTime, receivedAt, updatedAt scanSQLDateTime var speed, mileage, soc, altitude, direction sql.NullFloat64 var alarm, status sql.NullInt64 if err := rows.Scan( &row.Protocol, &row.VIN, &row.Plate, &eventTime, &row.Latitude, &row.Longitude, &speed, &mileage, &soc, &altitude, &direction, &alarm, &status, &receivedAt, &row.EventID, &updatedAt, ); err != nil { return nil, err } row.EventTime = eventTime.String row.SpeedKMH = nullableFloat(speed) row.TotalMileageKM = nullableFloat(mileage) row.SOCPercent = nullableFloat(soc) row.AltitudeM = nullableFloat(altitude) row.DirectionDeg = nullableFloat(direction) row.AlarmFlag = nullableInt(alarm) row.StatusFlag = nullableInt(status) row.ReceivedAt = receivedAt.String row.UpdatedAt = updatedAt.String out = append(out, row) } return out, rows.Err() } func (r *KVQueryRepository) Count(ctx context.Context, query RealtimeTableQuery) (int64, error) { where, args := buildRealtimeKVWhere(normalizeRealtimeTableQuery(query)) sqlText := "SELECT COUNT(*) FROM vehicle_realtime_kv" if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } return queryCount(ctx, r.db, sqlText, args) } func (r *KVQueryRepository) Query(ctx context.Context, query RealtimeTableQuery) ([]KVRow, error) { query = normalizeRealtimeTableQuery(query) where, args := buildRealtimeKVWhere(query) sqlText := "SELECT protocol, vin, domain_name, field_name, field_value, value_type, event_time, received_at, event_id, updated_at FROM vehicle_realtime_kv" if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" args = append(args, query.Limit, query.Offset) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() out := make([]KVRow, 0) for rows.Next() { var row KVRow var eventTime, receivedAt, updatedAt scanSQLDateTime var value sql.NullString if err := rows.Scan(&row.Protocol, &row.VIN, &row.Domain, &row.Field, &value, &row.ValueType, &eventTime, &receivedAt, &row.EventID, &updatedAt); err != nil { return nil, err } if value.Valid { row.Value = value.String } row.EventTime = eventTime.String row.ReceivedAt = receivedAt.String row.UpdatedAt = updatedAt.String out = append(out, row) } return out, rows.Err() } type SnapshotQueryHandler struct { repository *SnapshotQueryRepository } func NewSnapshotQueryHandler(repository *SnapshotQueryRepository) *SnapshotQueryHandler { if repository == nil { panic("snapshot query repository must not be nil") } return &SnapshotQueryHandler{repository: repository} } func (h *SnapshotQueryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/realtime/snapshots" { writeError(w, http.StatusNotFound, "route not found") return } query, err := parseRealtimeTableQuery(r) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var total int64 if query.IncludeTotal { total, err = h.repository.Count(r.Context(), query) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !query.IncludeTotal { total = int64(len(rows)) } writePage(w, rows, total, query) } type LocationQueryHandler struct { repository *LocationQueryRepository } type KVQueryHandler struct { repository *KVQueryRepository } func NewLocationQueryHandler(repository *LocationQueryRepository) *LocationQueryHandler { if repository == nil { panic("location query repository must not be nil") } return &LocationQueryHandler{repository: repository} } func NewKVQueryHandler(repository *KVQueryRepository) *KVQueryHandler { if repository == nil { panic("kv query repository must not be nil") } return &KVQueryHandler{repository: repository} } func (h *LocationQueryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/realtime/locations" { writeError(w, http.StatusNotFound, "route not found") return } query, err := parseRealtimeTableQuery(r) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var total int64 if query.IncludeTotal { total, err = h.repository.Count(r.Context(), query) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !query.IncludeTotal { total = int64(len(rows)) } writePage(w, rows, total, query) } func (h *KVQueryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/realtime/kv" { writeError(w, http.StatusNotFound, "route not found") return } query, err := parseRealtimeTableQuery(r) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var total int64 if query.IncludeTotal { total, err = h.repository.Count(r.Context(), query) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !query.IncludeTotal { total = int64(len(rows)) } writePage(w, rows, total, query) } func parseRealtimeTableQuery(r *http.Request) (RealtimeTableQuery, error) { values := r.URL.Query() limit, err := parseRealtimeBoundedInt(values.Get("limit"), 50, 1, 1000, "limit") if err != nil { return RealtimeTableQuery{}, err } offset, err := parseRealtimeBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset") if err != nil { return RealtimeTableQuery{}, err } return normalizeRealtimeTableQuery(RealtimeTableQuery{ Protocol: values.Get("protocol"), VIN: values.Get("vin"), Plate: values.Get("plate"), Domain: values.Get("domain"), Field: values.Get("field"), IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"), Limit: limit, Offset: offset, }), nil } func normalizeRealtimeTableQuery(query RealtimeTableQuery) RealtimeTableQuery { query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol)) query.VIN = strings.TrimSpace(query.VIN) query.Plate = strings.TrimSpace(query.Plate) query.Domain = strings.TrimSpace(query.Domain) query.Field = strings.TrimSpace(query.Field) if query.Limit <= 0 { query.Limit = 50 } return query } func buildRealtimeKVWhere(query RealtimeTableQuery) ([]string, []any) { var where []string var args []any if query.Protocol != "" { where = append(where, "protocol = ?") args = append(args, query.Protocol) } if query.VIN != "" { where = append(where, "vin = ?") args = append(args, query.VIN) } if query.Domain != "" { where = append(where, "domain_name = ?") args = append(args, query.Domain) } if query.Field != "" { where = append(where, "field_name = ?") args = append(args, query.Field) } return where, args } func buildRealtimeCountSQL(table string, query RealtimeTableQuery) (string, []any) { where, args := buildRealtimeWhere(query) sqlText := "SELECT COUNT(*) FROM " + table if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } return sqlText, args } func buildRealtimeSelectSQL(table string, columns string, query RealtimeTableQuery) (string, []any) { where, args := buildRealtimeWhere(query) sqlText := "SELECT " + columns + " FROM " + table if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" args = append(args, query.Limit, query.Offset) return sqlText, args } func buildRealtimeWhere(query RealtimeTableQuery) ([]string, []any) { var where []string var args []any if query.Protocol != "" { where = append(where, "protocol = ?") args = append(args, query.Protocol) } if query.VIN != "" { where = append(where, "vin = ?") args = append(args, query.VIN) } if query.Plate != "" { where = append(where, "plate = ?") args = append(args, query.Plate) } return where, args } func queryCount(ctx context.Context, db mysqlQueryer, sqlText string, args []any) (int64, error) { rows, err := db.QueryContext(ctx, sqlText, args...) if err != nil { return 0, err } defer rows.Close() var total int64 if rows.Next() { if err := rows.Scan(&total); err != nil { return 0, err } } return total, rows.Err() } func parseRealtimeBoundedInt(raw string, fallback int, min int, max int, name string) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { return fallback, nil } value, err := strconv.Atoi(raw) if err != nil || value < min || value > max { return 0, fmt.Errorf("%s must be between %d and %d", name, min, max) } return value, nil } func writePage(w http.ResponseWriter, items any, total int64, query RealtimeTableQuery) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": items, "total": total, "limit": query.Limit, "offset": query.Offset, }) } func nullableFloat(value sql.NullFloat64) *float64 { if !value.Valid { return nil } return &value.Float64 } func nullableInt(value sql.NullInt64) *int64 { if !value.Valid { return nil } return &value.Int64 } type scanSQLDateTime struct { String string } func (s *scanSQLDateTime) Scan(value any) error { switch typed := value.(type) { case nil: s.String = "" case time.Time: s.String = typed.Format("2006-01-02 15:04:05.000") case []byte: s.String = strings.TrimSpace(string(typed)) case string: s.String = strings.TrimSpace(typed) default: s.String = strings.TrimSpace(fmt.Sprint(typed)) } return nil }