From 8e800f795cba030fcbea6b582d2aceb4f40680ec Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 01:43:26 +0800 Subject: [PATCH] feat(platform): add vehicle service API --- .../apps/api/internal/platform/handler.go | 1 + .../api/internal/platform/handler_test.go | 24 +++++++++++++++++++ .../apps/web/src/api/client.ts | 2 +- .../apps/web/src/test/App.test.tsx | 11 +++++---- vehicle-data-platform/docs/api-contract.md | 8 ++++--- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index d195cf6f..a2b27f50 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -29,6 +29,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles) h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve) h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage) + h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail) h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail) h.mux.HandleFunc("GET /api/realtime/vehicles", h.handleVehicleRealtime) h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations) 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 bbb460b7..804f295f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -64,6 +64,30 @@ func TestHandlerVehicleDetail(t *testing.T) { } } +func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword=粤AG18312&protocol=JT808", 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 VehicleDetail `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.Resolution == nil || body.Data.Resolution.VIN != "LB9A32A24R0LS1426" { + t.Fatalf("vehicle service should resolve canonical vehicle identity, got %+v body=%s", body.Data.Resolution, rec.Body.String()) + } + for _, row := range body.Data.Realtime { + if row.Protocol != "JT808" { + t.Fatalf("vehicle service should keep source filter, got realtime row %+v body=%s", row, rec.Body.String()) + } + } +} + func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index d92e4f09..fb0238eb 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -74,7 +74,7 @@ export const api = { vehicles: (params = new URLSearchParams()) => request>(`/api/vehicles?${params.toString()}`), vehicleResolve: (params = new URLSearchParams()) => request(`/api/vehicles/resolve?${params.toString()}`), vehicleCoverage: (params = new URLSearchParams()) => request>(`/api/vehicles/coverage?${params.toString()}`), - vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicles/detail?${params.toString()}`), + vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicle-service?${params.toString()}`), vehicleRealtime: (params = new URLSearchParams()) => request>(`/api/realtime/vehicles?${params.toString()}`), realtimeLocations: (params = new URLSearchParams()) => request>(`/api/realtime/locations?${params.toString()}`), historyLocations: (params = new URLSearchParams()) => request>(`/api/history/locations?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 27fcf576..e4448c5f 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -249,7 +249,7 @@ test('switches vehicle detail to a source from the coverage cards', async () => window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); - if (path.includes('/api/vehicles/detail')) { + if (path.includes('/api/vehicle-service')) { return { ok: true, json: async () => ({ @@ -320,14 +320,14 @@ test('switches vehicle detail to a source from the coverage cards', async () => await waitFor(() => { expect(window.location.hash).toBe('#/detail?keyword=VIN001&protocol=JT808'); }); - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/detail?keyword=VIN001&protocol=JT808'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined); }); test('opens history with the currently selected vehicle detail source', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); - if (path.includes('/api/vehicles/detail')) { + if (path.includes('/api/vehicle-service')) { return { ok: true, json: async () => ({ @@ -446,9 +446,9 @@ test('opens vehicle service from an empty history query with the current filters test('shows selected source scope on vehicle detail', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN001&protocol=JT808'); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); - if (path.includes('/api/vehicles/detail')) { + if (path.includes('/api/vehicle-service')) { return { ok: true, json: async () => ({ @@ -516,4 +516,5 @@ test('shows selected source scope on vehicle detail', async () => { expect(await screen.findByText('当前查看范围')).toBeInTheDocument(); expect(screen.getByText('单一来源:JT808')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined); }); diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 9bc1d606..317410b3 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -35,18 +35,20 @@ The platform is vehicle-first. Query APIs should accept `keyword` as the user-fa 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. +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/vehicle-service` exposes `lookupResolved=false` and quality issues for follow-up binding work. ## Core Query APIs -### Vehicle Detail +### Vehicle Service ```http -GET /api/vehicles/detail?keyword=粤AG18312 +GET /api/vehicle-service?keyword=粤AG18312 ``` Returns one vehicle service view with identity, realtime summary, source coverage, history preview, RAW preview, mileage preview, and quality issues. +`/api/vehicles/detail` remains available as a compatibility alias. New UI and integrations should use `/api/vehicle-service` to make the vehicle-first boundary explicit. + ### Realtime Vehicles ```http