diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 99b5a07b..2e086c8f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -236,3 +236,43 @@ func TestHandlerVehicleDataAPIsResolveVehicleKeyword(t *testing.T) { }) } } + +func TestHandlerRawFramesPostAcceptsKeyword(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/history/raw-frames/query", strings.NewReader(`{"keyword":"川AHTWO1","limit":1}`)) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + Items []RawFrameRow `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if len(body.Data.Items) != 1 || body.Data.Items[0].VIN != "LNXNEGRR7SR318212" { + t.Fatalf("raw POST should resolve keyword to VIN, got %+v body=%s", body.Data.Items, rec.Body.String()) + } +} + +func TestHandlerRawFramesKeepsUnresolvedKeywordEmpty(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/history/raw-frames?keyword=64646848247&limit=1", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data Page[RawFrameRow] `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if body.Data.Total != 0 || len(body.Data.Items) != 0 { + t.Fatalf("unresolved keyword should not fabricate raw rows: %+v body=%s", body.Data, rec.Body.String()) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index c7118aea..f5faff4c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -218,16 +218,21 @@ func (m *MockStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawF if !query.IncludeFields && len(query.Fields) == 0 { fields = nil } - protocol := defaultString(query.Protocol, "GB32960") - vin := defaultString(query.VIN, "LB9A32A24R0LS1426") rows := []RawFrameRow{ - {ID: "raw-20260703-001", VIN: vin, Plate: "粤AG18312", Protocol: protocol, FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParsedFields: fields}, + {ID: "raw-20260703-001", VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960", FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParsedFields: fields}, + {ID: "raw-20260703-002", VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", FrameType: "realtime", DeviceTime: "2026-07-03 20:12:06", ServerTime: "2026-07-03 20:12:06", RawSizeBytes: 430, ParsedFields: fields}, + } + if vin := strings.TrimSpace(query.VIN); vin != "" { + rows = keep(rows, func(row RawFrameRow) bool { return row.VIN == vin }) + } + if protocol := strings.TrimSpace(query.Protocol); protocol != "" { + rows = keep(rows, func(row RawFrameRow) bool { return row.Protocol == protocol }) } limit := query.Limit if limit <= 0 { limit = 100 } - return Page[RawFrameRow]{Items: rows, Total: len(rows), Limit: limit, Offset: query.Offset}, nil + return page(rows, url.Values{"limit": {strconv.Itoa(limit)}, "offset": {strconv.Itoa(query.Offset)}}), nil } func (m *MockStore) DailyMileage(_ context.Context, query url.Values) (Page[DailyMileageRow], error) { diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md new file mode 100644 index 00000000..9bc1d606 --- /dev/null +++ b/vehicle-data-platform/docs/api-contract.md @@ -0,0 +1,105 @@ +# Vehicle Data Platform API Contract + +## Response Envelope + +All platform APIs return the same envelope: + +```json +{ + "data": {}, + "traceId": "trace-20260704000000.000000", + "timestamp": 1783094400000 +} +``` + +Pagination uses: + +```json +{ + "items": [], + "total": 0, + "limit": 20, + "offset": 0 +} +``` + +## Vehicle Keyword + +The platform is vehicle-first. Query APIs should accept `keyword` as the user-facing vehicle selector. + +`keyword` may be: + +- VIN +- plate +- JT808 phone + +The BFF resolves `keyword` through vehicle identity data before querying realtime, history, RAW, or mileage data. `vin` remains accepted as a compatibility alias on data query APIs, but new UI and integrations should send `keyword`. + +If a keyword cannot be resolved to a VIN, data APIs must not fabricate a VIN. They should return empty result pages for vehicle data, while `/api/vehicles/detail` exposes `lookupResolved=false` and quality issues for follow-up binding work. + +## Core Query APIs + +### Vehicle Detail + +```http +GET /api/vehicles/detail?keyword=粤AG18312 +``` + +Returns one vehicle service view with identity, realtime summary, source coverage, history preview, RAW preview, mileage preview, and quality issues. + +### Realtime Vehicles + +```http +GET /api/realtime/vehicles?keyword=粤AG18312&protocol=JT808&online=online&limit=50&offset=0 +``` + +Returns VIN-level realtime rows. Protocol is a source filter, not a product boundary. + +### History Locations + +```http +GET /api/history/locations?keyword=粤AG18312&protocol=JT808&dateFrom=2026-07-03%2000:00:00&dateTo=2026-07-03%2023:59:59&limit=20&offset=0 +``` + +Returns historical location points from TDengine, enriched with plate where possible. + +### RAW Frames + +Short GET query: + +```http +GET /api/history/raw-frames?keyword=粤AG18312&protocol=GB32960&limit=20&offset=0&includeFields=true +``` + +Large field-filter query: + +```http +POST /api/history/raw-frames/query +Content-Type: application/json + +{ + "keyword": "粤AG18312", + "protocol": "GB32960", + "dateFrom": "2026-07-03 00:00:00", + "dateTo": "2026-07-03 23:59:59", + "fields": ["gb32960.vehicle.speed_kmh", "gb32960.vehicle.total_mileage_km"], + "includeFields": true, + "limit": 20, + "offset": 0 +} +``` + +Use POST when `fields` may be long. `parsedFields` is returned only when `includeFields=true` or specific `fields` are requested. + +### Mileage + +```http +GET /api/mileage/daily?keyword=粤AG18312&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03&limit=20&offset=0 +GET /api/mileage/summary?keyword=粤AG18312&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03 +``` + +Returns daily mileage rows and aggregate mileage summary. + +## Compatibility Rule + +Existing callers may still send `vin`. The BFF treats `vin` as a vehicle lookup value for compatibility, so `vin=粤AG18312` still resolves through identity binding. New code should use `keyword` to avoid implying the value is already a real VIN.