From dd73fdbb5b7f16bcf4d307c4f3591162bf4fabe8 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 2 Jul 2026 00:29:49 +0800 Subject: [PATCH] feat: add vehicle mileage history query --- go/vehicle-gateway/cmd/realtime-api/main.go | 2 + go/vehicle-gateway/internal/history/query.go | 209 ++++++++++++++++++ .../internal/history/query_test.go | 60 +++++ 3 files changed, 271 insertions(+) diff --git a/go/vehicle-gateway/cmd/realtime-api/main.go b/go/vehicle-gateway/cmd/realtime-api/main.go index f804686e..273aca8f 100644 --- a/go/vehicle-gateway/cmd/realtime-api/main.go +++ b/go/vehicle-gateway/cmd/realtime-api/main.go @@ -93,6 +93,7 @@ func main() { database := env("TDENGINE_DATABASE", history.DefaultDatabase) mux.Handle("/api/history/raw-frames", history.NewRawFrameHandler(history.NewRawFrameRepository(db, database))) mux.Handle("/api/history/locations", history.NewLocationHandler(history.NewLocationRepository(db, database))) + mux.Handle("/api/history/mileage-points", history.NewMileagePointHandler(history.NewMileagePointRepository(db, database))) logger.Info("history query enabled", "driver", driver, "database", database) } else { historyUnavailable := func(w http.ResponseWriter, _ *http.Request) { @@ -102,6 +103,7 @@ func main() { } mux.HandleFunc("/api/history/raw-frames", historyUnavailable) mux.HandleFunc("/api/history/locations", historyUnavailable) + mux.HandleFunc("/api/history/mileage-points", historyUnavailable) logger.Warn("TDENGINE_DSN is empty; history query api disabled") } defer closeHistory() diff --git a/go/vehicle-gateway/internal/history/query.go b/go/vehicle-gateway/internal/history/query.go index 0b41eeca..c8098efb 100644 --- a/go/vehicle-gateway/internal/history/query.go +++ b/go/vehicle-gateway/internal/history/query.go @@ -84,6 +84,34 @@ type LocationRow struct { 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 @@ -105,6 +133,11 @@ type LocationRepository struct { 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") @@ -116,6 +149,17 @@ func NewLocationRepository(db Queryer, database string) *LocationRepository { 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) @@ -217,6 +261,50 @@ func (r *LocationRepository) Query(ctx context.Context, query LocationQuery) ([] return out, rows.Err() } +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 *RawFrameRepository) tableName() string { if r.database == "" { return "raw_frames" @@ -231,6 +319,13 @@ func (r *LocationRepository) tableName() string { 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) @@ -260,6 +355,20 @@ func normalizeLocationQuery(query LocationQuery) LocationQuery { 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) { var where []string add := func(clause string) { @@ -333,6 +442,40 @@ func buildLocationSQL(table string, query LocationQuery) (string, []any) { return sqlText, nil } +func buildMileagePointSQL(table string, query MileagePointQuery) (string, []any) { + 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) + "'") + } + 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 +} + type RawFrameHandler struct { repository *RawFrameRepository } @@ -341,6 +484,10 @@ 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") @@ -355,6 +502,13 @@ func NewLocationHandler(repository *LocationRepository) *LocationHandler { 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") @@ -411,6 +565,34 @@ func (h *LocationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }) } +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 + } + 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": len(rows), + "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") @@ -444,6 +626,33 @@ func parseRawFrameQuery(r *http.Request) (RawFrameQuery, error) { 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") diff --git a/go/vehicle-gateway/internal/history/query_test.go b/go/vehicle-gateway/internal/history/query_test.go index 09911b36..3f9e1d98 100644 --- a/go/vehicle-gateway/internal/history/query_test.go +++ b/go/vehicle-gateway/internal/history/query_test.go @@ -167,6 +167,42 @@ func TestLocationHandlerReturnsLocationsByVehicleKey(t *testing.T) { } } +func TestMileagePointHandlerReturnsMileageByVehicleKey(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer db.Close() + mock.ExpectQuery("vehicle_key = 'JT808:013307811350'"). + WillReturnRows(sqlmock.NewRows([]string{ + "ts", "event_id", "frame_id", "received_at", "total_mileage_km", "speed_kmh", "longitude", "latitude", + "protocol", "vehicle_key", "vin", "phone", "device_id", + }).AddRow( + "2026-07-02 00:18:22", "event-3", "go_frame", "2026-07-02 00:22:43", + 8792.8, 8.0, 121.07764, 30.585928, + "JT808", "JT808:013307811350", "", "013307811350", "", + )) + + handler := NewMileagePointHandler(NewMileagePointRepository(db, "lingniu_vehicle_ts")) + request := httptest.NewRequest(http.MethodGet, "/api/history/mileage-points?vehicleKey=JT808:013307811350&protocol=JT808&limit=1", nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } + body := response.Body.String() + for _, want := range []string{`"vehicle_key":"JT808:013307811350"`, `"total_mileage_km":8792.8`, `"speed_kmh":8`, `"total":1`} { + if !strings.Contains(body, want) { + t.Fatalf("response missing %s: %s", want, body) + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } +} + func TestRawFrameHandlerRejectsInvalidLimit(t *testing.T) { handler := NewRawFrameHandler(NewRawFrameRepository(&sql.DB{}, "")) request := httptest.NewRequest(http.MethodGet, "/api/history/raw-frames?limit=501", nil) @@ -192,6 +228,30 @@ func TestParseMessageIDSupportsDecimalAndHex(t *testing.T) { } } +func TestBuildMileagePointSQLUsesLiteralsForTDengine(t *testing.T) { + sqlText, args := buildMileagePointSQL("lingniu_vehicle_ts.vehicle_mileage_points", MileagePointQuery{ + Protocol: "JT808", + VehicleKey: "JT808:013307811350", + DateFrom: "2026-07-02 00:00:00", + DateTo: "2026-07-02 23:59:59", + Limit: 20, + Offset: 5, + }) + if len(args) != 0 { + t.Fatalf("expected no query args for TDengine, got %#v", args) + } + for _, want := range []string{ + "protocol = 'JT808'", + "vehicle_key = 'JT808:013307811350'", + "ts >= '2026-07-02 00:00:00'", + "LIMIT 20 OFFSET 5", + } { + if !strings.Contains(sqlText, want) { + t.Fatalf("sql missing %s: %s", want, sqlText) + } + } +} + func TestBuildLocationSQLUsesLiteralsForTDengine(t *testing.T) { sqlText, args := buildLocationSQL("lingniu_vehicle_ts.vehicle_locations", LocationQuery{ Protocol: "JT808",