From 0bfd1191cb15033868f2c892df0c7486c46b2258 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 2 Jul 2026 18:41:23 +0800 Subject: [PATCH] feat: add realtime snapshot and location APIs --- go/vehicle-gateway/cmd/realtime-api/main.go | 11 +- .../internal/realtime/mysql_query.go | 386 ++++++++++++++++++ .../internal/realtime/mysql_query_test.go | 101 +++++ .../internal/realtime/openapi.go | 175 ++++++++ .../internal/realtime/openapi_test.go | 25 ++ 5 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 go/vehicle-gateway/internal/realtime/mysql_query.go create mode 100644 go/vehicle-gateway/internal/realtime/mysql_query_test.go create mode 100644 go/vehicle-gateway/internal/realtime/openapi.go create mode 100644 go/vehicle-gateway/internal/realtime/openapi_test.go diff --git a/go/vehicle-gateway/cmd/realtime-api/main.go b/go/vehicle-gateway/cmd/realtime-api/main.go index e387e11e..baf8ad14 100644 --- a/go/vehicle-gateway/cmd/realtime-api/main.go +++ b/go/vehicle-gateway/cmd/realtime-api/main.go @@ -46,6 +46,8 @@ func main() { }) mux := http.NewServeMux() + mux.Handle("/openapi.json", realtime.NewOpenAPIHandler()) + mux.Handle("/swagger-ui/", realtime.NewSwaggerUIHandler()) mux.Handle("/api/realtime/vehicles/", realtime.NewHandler(repository)) closeStats := func() {} var updater realtimeUpdater = repository @@ -73,13 +75,18 @@ func main() { logger.Info("realtime mysql snapshot enabled", "table", "vehicle_realtime_snapshot", "plate_binding_table", bindingTable) } mux.Handle("/api/stats/daily-metrics", stats.NewMetricHandler(stats.NewMetricRepository(db))) + mux.Handle("/api/realtime/snapshots", realtime.NewSnapshotQueryHandler(realtime.NewSnapshotQueryRepository(db))) + mux.Handle("/api/realtime/locations", realtime.NewLocationQueryHandler(realtime.NewLocationQueryRepository(db))) logger.Info("stats mysql query enabled") } else { - mux.HandleFunc("/api/stats/daily-metrics", func(w http.ResponseWriter, _ *http.Request) { + mysqlUnavailable := func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]any{"error": "MYSQL_DSN is not configured"}) - }) + } + mux.HandleFunc("/api/stats/daily-metrics", mysqlUnavailable) + mux.HandleFunc("/api/realtime/snapshots", mysqlUnavailable) + mux.HandleFunc("/api/realtime/locations", mysqlUnavailable) logger.Warn("MYSQL_DSN is empty; stats query api disabled") } defer closeStats() diff --git a/go/vehicle-gateway/internal/realtime/mysql_query.go b/go/vehicle-gateway/internal/realtime/mysql_query.go new file mode 100644 index 00000000..43f833f8 --- /dev/null +++ b/go/vehicle-gateway/internal/realtime/mysql_query.go @@ -0,0 +1,386 @@ +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 + Limit int + Offset int +} + +type SnapshotRow struct { + Protocol string `json:"protocol"` + VIN string `json:"vin"` + Plate string `json:"plate"` + EventTime string `json:"event_time,omitempty"` + ReceivedAt string `json:"received_at,omitempty"` + EventID string `json:"event_id"` + CreatedAt string `json:"created_at"` + 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"` + CreatedAt string `json:"created_at"` + 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, event_time, received_at, event_id, created_at, 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, createdAt, updatedAt scanSQLDateTime + if err := rows.Scan(&row.Protocol, &row.VIN, &row.Plate, &eventTime, &receivedAt, &row.EventID, &createdAt, &updatedAt); err != nil { + return nil, err + } + row.EventTime = eventTime.String + row.ReceivedAt = receivedAt.String + row.CreatedAt = createdAt.String + row.UpdatedAt = updatedAt.String + out = append(out, row) + } + return out, rows.Err() +} + +type LocationQueryRepository struct { + db mysqlQueryer +} + +func NewLocationQueryRepository(db mysqlQueryer) *LocationQueryRepository { + if db == nil { + panic("location query db must not be nil") + } + return &LocationQueryRepository{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, created_at, 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, createdAt, 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, + &createdAt, + &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.CreatedAt = createdAt.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 + } + 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 + } + writePage(w, rows, total, query) +} + +type LocationQueryHandler struct { + repository *LocationQueryRepository +} + +func NewLocationQueryHandler(repository *LocationQueryRepository) *LocationQueryHandler { + if repository == nil { + panic("location query repository must not be nil") + } + return &LocationQueryHandler{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 + } + 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 + } + 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"), + 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) + if query.Limit <= 0 { + query.Limit = 50 + } + return query +} + +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 +} diff --git a/go/vehicle-gateway/internal/realtime/mysql_query_test.go b/go/vehicle-gateway/internal/realtime/mysql_query_test.go new file mode 100644 index 00000000..24690f70 --- /dev/null +++ b/go/vehicle-gateway/internal/realtime/mysql_query_test.go @@ -0,0 +1,101 @@ +package realtime + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestSnapshotQueryHandlerReturnsRealtimeSnapshots(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer db.Close() + + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_realtime_snapshot"). + WithArgs("GB32960", "LB9A32A21R0LS1707"). + WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) + mock.ExpectQuery("SELECT protocol, vin, plate, event_time, received_at, event_id, created_at, updated_at FROM vehicle_realtime_snapshot"). + WithArgs("GB32960", "LB9A32A21R0LS1707", 20, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "protocol", "vin", "plate", "event_time", "received_at", "event_id", "created_at", "updated_at", + }).AddRow( + "GB32960", "LB9A32A21R0LS1707", "浙A12345", "2026-07-02 16:01:02.123", "2026-07-02 16:01:03.456", "evt-1", "2026-07-02 16:01:03", "2026-07-02 16:01:04", + )) + + handler := NewSnapshotQueryHandler(NewSnapshotQueryRepository(db)) + request := httptest.NewRequest(http.MethodGet, "/api/realtime/snapshots?protocol=gb32960&vin=LB9A32A21R0LS1707&limit=20", 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{`"protocol":"GB32960"`, `"vin":"LB9A32A21R0LS1707"`, `"plate":"浙A12345"`, `"total":1`, `"limit":20`} { + 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 TestLocationQueryHandlerReturnsRealtimeLocations(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer db.Close() + + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_realtime_location"). + WithArgs("JT808", "粤B98765"). + WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) + mock.ExpectQuery("SELECT 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, created_at, updated_at FROM vehicle_realtime_location"). + WithArgs("JT808", "粤B98765", 10, 10). + WillReturnRows(sqlmock.NewRows([]string{ + "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", "created_at", "updated_at", + }).AddRow( + "JT808", "LKLG7C4E3NA774736", "粤B98765", "2026-07-02 16:11:02.000", 30.123456, 120.654321, 54.3, 48798.9, + nil, 19.0, 88.0, int64(0), int64(3), "2026-07-02 16:11:03.000", "evt-2", "2026-07-02 16:11:03", "2026-07-02 16:11:04", + )) + + handler := NewLocationQueryHandler(NewLocationQueryRepository(db)) + request := httptest.NewRequest(http.MethodGet, "/api/realtime/locations?protocol=jt808&plate=粤B98765&limit=10&offset=10", 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{`"protocol":"JT808"`, `"latitude":30.123456`, `"longitude":120.654321`, `"total_mileage_km":48798.9`, `"offset":10`} { + 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 TestRealtimeQueryHandlerRejectsInvalidPagination(t *testing.T) { + handler := NewSnapshotQueryHandler(NewSnapshotQueryRepository(&sql.DB{})) + request := httptest.NewRequest(http.MethodGet, "/api/realtime/snapshots?limit=1001", nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } +} diff --git a/go/vehicle-gateway/internal/realtime/openapi.go b/go/vehicle-gateway/internal/realtime/openapi.go new file mode 100644 index 00000000..2fe9b0ae --- /dev/null +++ b/go/vehicle-gateway/internal/realtime/openapi.go @@ -0,0 +1,175 @@ +package realtime + +import ( + "encoding/json" + "net/http" + "strings" +) + +func NewOpenAPIHandler() http.Handler { + return http.HandlerFunc(func(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, "/") != "openapi.json" { + writeError(w, http.StatusNotFound, "route not found") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(realtimeOpenAPISpec()) + }) +} + +func NewSwaggerUIHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(swaggerHTML)) + }) +} + +func realtimeOpenAPISpec() map[string]any { + return map[string]any{ + "openapi": "3.0.3", + "info": map[string]any{ + "title": "Lingniu Vehicle Realtime API", + "version": "1.0.0", + "description": "MySQL realtime query APIs for vehicle_realtime_snapshot and vehicle_realtime_location.", + }, + "paths": map[string]any{ + "/api/realtime/snapshots": map[string]any{ + "get": map[string]any{ + "summary": "查询车辆实时快照", + "description": "从 vehicle_realtime_snapshot 查询各协议最新在线快照,支持协议、VIN、车牌过滤和分页。", + "tags": []string{"Realtime Snapshot API"}, + "x-table": "vehicle_realtime_snapshot", + "parameters": commonRealtimeParameters(), + "responses": map[string]any{ + "200": map[string]any{ + "description": "Snapshot page", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{"$ref": "#/components/schemas/SnapshotPage"}, + }, + }, + }, + }, + }, + }, + "/api/realtime/locations": map[string]any{ + "get": map[string]any{ + "summary": "查询车辆实时位置", + "description": "从 vehicle_realtime_location 查询各协议最新位置,支持协议、VIN、车牌过滤和分页。", + "tags": []string{"Realtime Location API"}, + "x-table": "vehicle_realtime_location", + "parameters": commonRealtimeParameters(), + "responses": map[string]any{ + "200": map[string]any{ + "description": "Location page", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": map[string]any{"$ref": "#/components/schemas/LocationPage"}, + }, + }, + }, + }, + }, + }, + }, + "components": map[string]any{ + "schemas": map[string]any{ + "SnapshotPage": pageSchema("#/components/schemas/SnapshotRow"), + "LocationPage": pageSchema("#/components/schemas/LocationRow"), + "SnapshotRow": map[string]any{ + "type": "object", + "properties": map[string]any{ + "protocol": stringSchema("GB32960"), + "vin": stringSchema("LB9A32A21R0LS1707"), + "plate": stringSchema("浙A12345"), + "event_time": stringSchema("2026-07-02 16:01:02.123"), + "received_at": stringSchema("2026-07-02 16:01:03.456"), + "event_id": stringSchema("event id"), + "created_at": stringSchema("2026-07-02 16:01:03"), + "updated_at": stringSchema("2026-07-02 16:01:04"), + }, + }, + "LocationRow": map[string]any{ + "type": "object", + "properties": map[string]any{ + "protocol": stringSchema("JT808"), + "vin": stringSchema("LKLG7C4E3NA774736"), + "plate": stringSchema("粤B98765"), + "event_time": stringSchema("2026-07-02 16:11:02.000"), + "latitude": numberSchema(30.123456), + "longitude": numberSchema(120.654321), + "speed_kmh": numberSchema(54.3), + "total_mileage_km": numberSchema(48798.9), + "soc_percent": numberSchema(81.5), + "altitude_m": numberSchema(19.0), + "direction_deg": numberSchema(88.0), + "alarm_flag": integerSchema(0), + "status_flag": integerSchema(3), + "received_at": stringSchema("2026-07-02 16:11:03.000"), + "event_id": stringSchema("event id"), + "created_at": stringSchema("2026-07-02 16:11:03"), + "updated_at": stringSchema("2026-07-02 16:11:04"), + }, + }, + }, + }, + } +} + +func commonRealtimeParameters() []map[string]any { + return []map[string]any{ + {"name": "protocol", "in": "query", "schema": map[string]any{"type": "string", "enum": []string{"GB32960", "JT808", "YUTONG_MQTT"}}, "required": false}, + {"name": "vin", "in": "query", "schema": map[string]any{"type": "string"}, "required": false}, + {"name": "plate", "in": "query", "schema": map[string]any{"type": "string"}, "required": false}, + {"name": "limit", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 1, "maximum": 1000, "default": 50}, "required": false}, + {"name": "offset", "in": "query", "schema": map[string]any{"type": "integer", "minimum": 0, "maximum": 1000000, "default": 0}, "required": false}, + } +} + +func pageSchema(itemRef string) map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "items": map[string]any{"type": "array", "items": map[string]any{"$ref": itemRef}}, + "total": integerSchema(1), + "limit": integerSchema(50), + "offset": integerSchema(0), + }, + } +} + +func stringSchema(example string) map[string]any { + return map[string]any{"type": "string", "example": example} +} + +func numberSchema(example float64) map[string]any { + return map[string]any{"type": "number", "example": example} +} + +func integerSchema(example int64) map[string]any { + return map[string]any{"type": "integer", "example": example} +} + +const swaggerHTML = ` + + + + Lingniu Vehicle Realtime API + + + +
+ + + +` diff --git a/go/vehicle-gateway/internal/realtime/openapi_test.go b/go/vehicle-gateway/internal/realtime/openapi_test.go new file mode 100644 index 00000000..ca7b62aa --- /dev/null +++ b/go/vehicle-gateway/internal/realtime/openapi_test.go @@ -0,0 +1,25 @@ +package realtime + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOpenAPIHandlerDocumentsRealtimeSnapshotAndLocation(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/openapi.json", nil) + response := httptest.NewRecorder() + + NewOpenAPIHandler().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{`"/api/realtime/snapshots"`, `"/api/realtime/locations"`, `"vehicle_realtime_snapshot"`, `"vehicle_realtime_location"`} { + if !strings.Contains(body, want) { + t.Fatalf("openapi missing %s: %s", want, body) + } + } +}