package history import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" ) type Queryer interface { QueryContext(context.Context, string, ...any) (*sql.Rows, error) } type RawFrameQuery struct { Protocol string VehicleKey string VIN string Phone string DeviceID string MessageID string DateFrom string DateTo string Limit int Offset int } type RawFrameRow struct { TS string `json:"ts"` FrameID string `json:"frame_id"` EventID string `json:"event_id"` MessageID int64 `json:"message_id"` MessageIDHex string `json:"message_id_hex"` EventTime string `json:"event_time"` ReceivedAt string `json:"received_at"` RawSizeBytes int64 `json:"raw_size_bytes"` RawHex string `json:"raw_hex,omitempty"` RawText string `json:"raw_text,omitempty"` ParsedJSON string `json:"parsed_json,omitempty"` FieldsJSON string `json:"fields_json,omitempty"` ParseStatus string `json:"parse_status"` ParseError string `json:"parse_error,omitempty"` SourceEndpoint string `json:"source_endpoint"` Protocol string `json:"protocol"` VehicleKey string `json:"vehicle_key"` VIN string `json:"vin"` Phone string `json:"phone,omitempty"` DeviceID string `json:"device_id,omitempty"` } type LocationQuery struct { Protocol string VehicleKey string VIN string Phone string DeviceID string DateFrom string DateTo string Limit int Offset int } type LocationRow struct { TS string `json:"ts"` EventID string `json:"event_id"` FrameID string `json:"frame_id"` ReceivedAt string `json:"received_at"` Longitude float64 `json:"longitude"` Latitude float64 `json:"latitude"` AltitudeM *float64 `json:"altitude_m,omitempty"` SpeedKMH *float64 `json:"speed_kmh,omitempty"` DirectionDeg *int64 `json:"direction_deg,omitempty"` AlarmFlag *int64 `json:"alarm_flag,omitempty"` StatusFlag *int64 `json:"status_flag,omitempty"` TotalMileageKM *float64 `json:"total_mileage_km,omitempty"` Protocol string `json:"protocol"` VehicleKey string `json:"vehicle_key"` VIN string `json:"vin"` Phone string `json:"phone,omitempty"` DeviceID string `json:"device_id,omitempty"` } type MileagePointQuery struct { Protocol string VehicleKey string VIN string Phone string DeviceID string DateFrom string DateTo string Limit int Offset int } type MileagePointRow struct { TS string `json:"ts"` EventID string `json:"event_id"` FrameID string `json:"frame_id"` ReceivedAt string `json:"received_at"` TotalMileageKM float64 `json:"total_mileage_km"` SpeedKMH *float64 `json:"speed_kmh,omitempty"` Longitude *float64 `json:"longitude,omitempty"` Latitude *float64 `json:"latitude,omitempty"` Protocol string `json:"protocol"` VehicleKey string `json:"vehicle_key"` VIN string `json:"vin"` Phone string `json:"phone,omitempty"` DeviceID string `json:"device_id,omitempty"` } type RawFrameRepository struct { db Queryer database string } func NewRawFrameRepository(db Queryer, database string) *RawFrameRepository { if db == nil { panic("raw frame query db must not be nil") } database = strings.TrimSpace(database) if database != "" && !safeIdentifier(database) { database = "" } return &RawFrameRepository{db: db, database: database} } type LocationRepository struct { db Queryer database string } type MileagePointRepository struct { db Queryer database string } func NewLocationRepository(db Queryer, database string) *LocationRepository { if db == nil { panic("location query db must not be nil") } database = strings.TrimSpace(database) if database != "" && !safeIdentifier(database) { database = "" } return &LocationRepository{db: db, database: database} } func NewMileagePointRepository(db Queryer, database string) *MileagePointRepository { if db == nil { panic("mileage point query db must not be nil") } database = strings.TrimSpace(database) if database != "" && !safeIdentifier(database) { database = "" } return &MileagePointRepository{db: db, database: database} } func (r *RawFrameRepository) Query(ctx context.Context, query RawFrameQuery) ([]RawFrameRow, error) { query = normalizeRawFrameQuery(query) sqlText, args := buildRawFrameSQL(r.tableName(), query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() var out []RawFrameRow for rows.Next() { var row RawFrameRow var ts scanDateTime var eventTime scanDateTime var receivedAt scanDateTime if err := rows.Scan( &ts, &row.FrameID, &row.EventID, &row.MessageID, &eventTime, &receivedAt, &row.RawSizeBytes, &row.RawHex, &row.RawText, &row.ParsedJSON, &row.FieldsJSON, &row.ParseStatus, &row.ParseError, &row.SourceEndpoint, &row.Protocol, &row.VehicleKey, &row.VIN, &row.Phone, &row.DeviceID, ); err != nil { return nil, err } row.TS = ts.String row.EventTime = eventTime.String row.ReceivedAt = receivedAt.String row.MessageIDHex = fmt.Sprintf("0x%04X", row.MessageID) out = append(out, row) } return out, rows.Err() } func (r *RawFrameRepository) Count(ctx context.Context, query RawFrameQuery) (int64, error) { query = normalizeRawFrameQuery(query) sqlText, args := buildRawFrameCountSQL(r.tableName(), query) return countRows(ctx, r.db, sqlText, args...) } func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([]LocationRow, error) { query = normalizeLocationQuery(query) sqlText, args := buildLocationSQL(r.tableName(), query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() var out []LocationRow for rows.Next() { var row LocationRow var ts scanDateTime var receivedAt scanDateTime var altitude sql.NullFloat64 var speed sql.NullFloat64 var direction sql.NullInt64 var alarm sql.NullInt64 var status sql.NullInt64 var mileage sql.NullFloat64 if err := rows.Scan( &ts, &row.EventID, &row.FrameID, &receivedAt, &row.Longitude, &row.Latitude, &altitude, &speed, &direction, &alarm, &status, &mileage, &row.Protocol, &row.VehicleKey, &row.VIN, &row.Phone, &row.DeviceID, ); err != nil { return nil, err } row.TS = ts.String row.ReceivedAt = receivedAt.String row.AltitudeM = nullableFloat(altitude) row.SpeedKMH = nullableFloat(speed) row.DirectionDeg = nullableInt(direction) row.AlarmFlag = nullableInt(alarm) row.StatusFlag = nullableInt(status) row.TotalMileageKM = nullableFloat(mileage) out = append(out, row) } return out, rows.Err() } func (r *LocationRepository) Count(ctx context.Context, query LocationQuery) (int64, error) { query = normalizeLocationQuery(query) sqlText, args := buildLocationCountSQL(r.tableName(), query) return countRows(ctx, r.db, sqlText, args...) } func (r *MileagePointRepository) Query(ctx context.Context, query MileagePointQuery) ([]MileagePointRow, error) { query = normalizeMileagePointQuery(query) sqlText, args := buildMileagePointSQL(r.tableName(), query) rows, err := r.db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err } defer rows.Close() var out []MileagePointRow for rows.Next() { var row MileagePointRow var ts scanDateTime var receivedAt scanDateTime var speed sql.NullFloat64 var longitude sql.NullFloat64 var latitude sql.NullFloat64 if err := rows.Scan( &ts, &row.EventID, &row.FrameID, &receivedAt, &row.TotalMileageKM, &speed, &longitude, &latitude, &row.Protocol, &row.VehicleKey, &row.VIN, &row.Phone, &row.DeviceID, ); err != nil { return nil, err } row.TS = ts.String row.ReceivedAt = receivedAt.String row.SpeedKMH = nullableFloat(speed) row.Longitude = nullableFloat(longitude) row.Latitude = nullableFloat(latitude) out = append(out, row) } return out, rows.Err() } func (r *MileagePointRepository) Count(ctx context.Context, query MileagePointQuery) (int64, error) { query = normalizeMileagePointQuery(query) sqlText, args := buildMileagePointCountSQL(r.tableName(), query) return countRows(ctx, r.db, sqlText, args...) } func countRows(ctx context.Context, db Queryer, 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 (r *RawFrameRepository) tableName() string { if r.database == "" { return "raw_frames" } return r.database + ".raw_frames" } func (r *LocationRepository) tableName() string { if r.database == "" { return "vehicle_locations" } return r.database + ".vehicle_locations" } func (r *MileagePointRepository) tableName() string { if r.database == "" { return "vehicle_mileage_points" } return r.database + ".vehicle_mileage_points" } func normalizeRawFrameQuery(query RawFrameQuery) RawFrameQuery { query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol)) query.VehicleKey = strings.TrimSpace(query.VehicleKey) query.VIN = strings.TrimSpace(query.VIN) query.Phone = strings.TrimSpace(query.Phone) query.DeviceID = strings.TrimSpace(query.DeviceID) query.MessageID = strings.TrimSpace(query.MessageID) query.DateFrom = strings.TrimSpace(query.DateFrom) query.DateTo = strings.TrimSpace(query.DateTo) if query.Limit <= 0 { query.Limit = 20 } return query } func normalizeLocationQuery(query LocationQuery) LocationQuery { query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol)) query.VehicleKey = strings.TrimSpace(query.VehicleKey) query.VIN = strings.TrimSpace(query.VIN) query.Phone = strings.TrimSpace(query.Phone) query.DeviceID = strings.TrimSpace(query.DeviceID) query.DateFrom = strings.TrimSpace(query.DateFrom) query.DateTo = strings.TrimSpace(query.DateTo) if query.Limit <= 0 { query.Limit = 20 } return query } func normalizeMileagePointQuery(query MileagePointQuery) MileagePointQuery { query.Protocol = strings.ToUpper(strings.TrimSpace(query.Protocol)) query.VehicleKey = strings.TrimSpace(query.VehicleKey) query.VIN = strings.TrimSpace(query.VIN) query.Phone = strings.TrimSpace(query.Phone) query.DeviceID = strings.TrimSpace(query.DeviceID) query.DateFrom = strings.TrimSpace(query.DateFrom) query.DateTo = strings.TrimSpace(query.DateTo) if query.Limit <= 0 { query.Limit = 20 } return query } func buildRawFrameSQL(table string, query RawFrameQuery) (string, []any) { where := rawFrameWhere(query) sqlText := `SELECT ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes, raw_hex, raw_text, parsed_json, fields_json, parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone, device_id FROM ` + table if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY ts DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset) return sqlText, nil } func buildRawFrameCountSQL(table string, query RawFrameQuery) (string, []any) { sqlText := `SELECT COUNT(*) FROM ` + table if where := rawFrameWhere(query); len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } return sqlText, nil } func buildLocationSQL(table string, query LocationQuery) (string, []any) { where := locationWhere(query) sqlText := `SELECT ts, event_id, frame_id, received_at, longitude, latitude, altitude_m, speed_kmh, direction_deg, alarm_flag, status_flag, total_mileage_km, protocol, vehicle_key, vin, phone, device_id FROM ` + table if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY ts DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset) return sqlText, nil } func buildLocationCountSQL(table string, query LocationQuery) (string, []any) { sqlText := `SELECT COUNT(*) FROM ` + table if where := locationWhere(query); len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } return sqlText, nil } func buildMileagePointSQL(table string, query MileagePointQuery) (string, []any) { where := mileagePointWhere(query) sqlText := `SELECT ts, event_id, frame_id, received_at, total_mileage_km, speed_kmh, longitude, latitude, protocol, vehicle_key, vin, phone, device_id FROM ` + table if len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } sqlText += " ORDER BY ts DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset) return sqlText, nil } func buildMileagePointCountSQL(table string, query MileagePointQuery) (string, []any) { sqlText := `SELECT COUNT(*) FROM ` + table if where := mileagePointWhere(query); len(where) > 0 { sqlText += " WHERE " + strings.Join(where, " AND ") } return sqlText, nil } func rawFrameWhere(query RawFrameQuery) []string { var where []string add := func(clause string) { where = append(where, clause) } if query.Protocol != "" { add("protocol = '" + quote(query.Protocol) + "'") } if query.VehicleKey != "" { add("vehicle_key = '" + quote(query.VehicleKey) + "'") } if query.VIN != "" { add("vin = '" + quote(query.VIN) + "'") } if query.Phone != "" { add("phone = '" + quote(query.Phone) + "'") } if query.DeviceID != "" { add("device_id = '" + quote(query.DeviceID) + "'") } if query.MessageID != "" { if parsed, ok := parseMessageID(query.MessageID); ok { add("message_id = " + strconv.FormatInt(parsed, 10)) } } if query.DateFrom != "" { add("ts >= '" + quote(query.DateFrom) + "'") } if query.DateTo != "" { add("ts <= '" + quote(query.DateTo) + "'") } return where } func locationWhere(query LocationQuery) []string { var where []string add := func(clause string) { where = append(where, clause) } if query.Protocol != "" { add("protocol = '" + quote(query.Protocol) + "'") } if query.VehicleKey != "" { add("vehicle_key = '" + quote(query.VehicleKey) + "'") } if query.VIN != "" { add("vin = '" + quote(query.VIN) + "'") } if query.Phone != "" { add("phone = '" + quote(query.Phone) + "'") } if query.DeviceID != "" { add("device_id = '" + quote(query.DeviceID) + "'") } if query.DateFrom != "" { add("ts >= '" + quote(query.DateFrom) + "'") } if query.DateTo != "" { add("ts <= '" + quote(query.DateTo) + "'") } return where } func mileagePointWhere(query MileagePointQuery) []string { var where []string add := func(clause string) { where = append(where, clause) } if query.Protocol != "" { add("protocol = '" + quote(query.Protocol) + "'") } if query.VehicleKey != "" { add("vehicle_key = '" + quote(query.VehicleKey) + "'") } if query.VIN != "" { add("vin = '" + quote(query.VIN) + "'") } if query.Phone != "" { add("phone = '" + quote(query.Phone) + "'") } if query.DeviceID != "" { add("device_id = '" + quote(query.DeviceID) + "'") } if query.DateFrom != "" { add("ts >= '" + quote(query.DateFrom) + "'") } if query.DateTo != "" { add("ts <= '" + quote(query.DateTo) + "'") } return where } type RawFrameHandler struct { repository *RawFrameRepository } type LocationHandler struct { repository *LocationRepository } type MileagePointHandler struct { repository *MileagePointRepository } func NewRawFrameHandler(repository *RawFrameRepository) *RawFrameHandler { if repository == nil { panic("raw frame repository must not be nil") } return &RawFrameHandler{repository: repository} } func NewLocationHandler(repository *LocationRepository) *LocationHandler { if repository == nil { panic("location repository must not be nil") } return &LocationHandler{repository: repository} } func NewMileagePointHandler(repository *MileagePointRepository) *MileagePointHandler { if repository == nil { panic("mileage point repository must not be nil") } return &MileagePointHandler{repository: repository} } func (h *RawFrameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/history/raw-frames" { writeHistoryError(w, http.StatusNotFound, "route not found") return } query, err := parseRawFrameQuery(r) if err != nil { writeHistoryError(w, http.StatusBadRequest, err.Error()) return } total, err := h.repository.Count(r.Context(), query) if err != nil { writeHistoryError(w, http.StatusInternalServerError, err.Error()) return } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeHistoryError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": rows, "total": total, "limit": query.Limit, "offset": query.Offset, }) } func (h *LocationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/history/locations" { writeHistoryError(w, http.StatusNotFound, "route not found") return } query, err := parseLocationQuery(r) if err != nil { writeHistoryError(w, http.StatusBadRequest, err.Error()) return } total, err := h.repository.Count(r.Context(), query) if err != nil { writeHistoryError(w, http.StatusInternalServerError, err.Error()) return } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeHistoryError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": rows, "total": total, "limit": query.Limit, "offset": query.Offset, }) } func (h *MileagePointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeHistoryError(w, http.StatusMethodNotAllowed, "method not allowed") return } if strings.Trim(r.URL.Path, "/") != "api/history/mileage-points" { writeHistoryError(w, http.StatusNotFound, "route not found") return } query, err := parseMileagePointQuery(r) if err != nil { writeHistoryError(w, http.StatusBadRequest, err.Error()) return } total, err := h.repository.Count(r.Context(), query) if err != nil { writeHistoryError(w, http.StatusInternalServerError, err.Error()) return } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeHistoryError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": rows, "total": total, "limit": query.Limit, "offset": query.Offset, }) } func parseRawFrameQuery(r *http.Request) (RawFrameQuery, error) { values := r.URL.Query() limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit") if err != nil { return RawFrameQuery{}, err } offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset") if err != nil { return RawFrameQuery{}, err } query := RawFrameQuery{ Protocol: values.Get("protocol"), VehicleKey: values.Get("vehicleKey"), VIN: values.Get("vin"), Phone: values.Get("phone"), DeviceID: values.Get("deviceId"), MessageID: values.Get("messageId"), DateFrom: values.Get("dateFrom"), DateTo: values.Get("dateTo"), Limit: limit, Offset: offset, } if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) { return RawFrameQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss") } if strings.TrimSpace(query.MessageID) != "" { if _, ok := parseMessageID(query.MessageID); !ok { return RawFrameQuery{}, errors.New("messageId must be decimal or hex like 0x0200") } } return normalizeRawFrameQuery(query), nil } func parseMileagePointQuery(r *http.Request) (MileagePointQuery, error) { values := r.URL.Query() limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit") if err != nil { return MileagePointQuery{}, err } offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset") if err != nil { return MileagePointQuery{}, err } query := MileagePointQuery{ Protocol: values.Get("protocol"), VehicleKey: values.Get("vehicleKey"), VIN: values.Get("vin"), Phone: values.Get("phone"), DeviceID: values.Get("deviceId"), DateFrom: values.Get("dateFrom"), DateTo: values.Get("dateTo"), Limit: limit, Offset: offset, } if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) { return MileagePointQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss") } return normalizeMileagePointQuery(query), nil } func parseLocationQuery(r *http.Request) (LocationQuery, error) { values := r.URL.Query() limit, err := parseBoundedInt(values.Get("limit"), 20, 1, 500, "limit") if err != nil { return LocationQuery{}, err } offset, err := parseBoundedInt(values.Get("offset"), 0, 0, 1_000_000, "offset") if err != nil { return LocationQuery{}, err } query := LocationQuery{ Protocol: values.Get("protocol"), VehicleKey: values.Get("vehicleKey"), VIN: values.Get("vin"), Phone: values.Get("phone"), DeviceID: values.Get("deviceId"), DateFrom: values.Get("dateFrom"), DateTo: values.Get("dateTo"), Limit: limit, Offset: offset, } if !validDateTime(query.DateFrom) || !validDateTime(query.DateTo) { return LocationQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD or YYYY-MM-DD HH:mm:ss") } return normalizeLocationQuery(query), nil } func parseBoundedInt(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 parseMessageID(value string) (int64, bool) { value = strings.TrimSpace(strings.ToLower(value)) if value == "" { return 0, false } if strings.HasPrefix(value, "0x") { parsed, err := strconv.ParseInt(strings.TrimPrefix(value, "0x"), 16, 32) return parsed, err == nil } parsed, err := strconv.ParseInt(value, 10, 32) return parsed, err == nil } func validDateTime(value string) bool { value = strings.TrimSpace(value) if value == "" { return true } for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339} { if _, err := time.Parse(layout, value); err == nil { return true } } return false } 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 } type scanDateTime struct { String string } func (s *scanDateTime) Scan(value any) error { s.String = formatSQLTime(value, "2006-01-02 15:04:05") return nil } func formatSQLTime(value any, layout string) string { switch typed := value.(type) { case nil: return "" case time.Time: return typed.Format(layout) case []byte: return strings.TrimSpace(string(typed)) case string: return strings.TrimSpace(typed) default: return strings.TrimSpace(fmt.Sprint(typed)) } } 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 } func writeHistoryError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]any{"error": message}) }