diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index a2b27f50..18dbc849 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -30,6 +30,7 @@ func (h *Handler) routes() { 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/vehicle-service/overview", h.handleVehicleServiceOverview) 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) @@ -80,6 +81,17 @@ func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleVehicleServiceOverview(w http.ResponseWriter, r *http.Request) { + keyword := firstNonEmpty(r.URL.Query().Get("keyword"), r.URL.Query().Get("vin")) + keyword = strings.TrimSpace(keyword) + if keyword == "" { + httpx.WriteError(w, http.StatusBadRequest, "VEHICLE_KEY_REQUIRED", "车辆关键词不能为空", "", traceID(r)) + return + } + data, err := h.service.VehicleServiceOverview(r.Context(), keyword, r.URL.Query().Get("protocol")) + h.write(w, r, data, err) +} + func (h *Handler) handleVehicleRealtime(w http.ResponseWriter, r *http.Request) { data, err := h.service.VehicleRealtime(r.Context(), r.URL.Query()) h.write(w, r, data, err) 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 c518f00c..36b37a09 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -204,6 +204,31 @@ func TestHandlerVehicleServiceIncludesUnifiedOverview(t *testing.T) { } } +func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service/overview?keyword=粤AG18312", 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 VehicleServiceOverview `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.VIN != "LB9A32A24R0LS1426" || body.Data.Plate != "粤AG18312" { + t.Fatalf("overview endpoint should resolve canonical vehicle identity, got %+v", body.Data) + } + if body.Data.SourceCount != 2 || body.Data.OnlineSourceCount != 1 || body.Data.CoverageStatus != "partial" { + t.Fatalf("overview endpoint should summarize vehicle service coverage, got %+v", body.Data) + } + if strings.Contains(rec.Body.String(), `"raw"`) || strings.Contains(rec.Body.String(), `"history"`) { + t.Fatalf("overview endpoint should not return heavy detail pages: %s", rec.Body.String()) + } +} + func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index e36c9aa2..62c7daf9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -85,6 +85,46 @@ func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[V return s.store.VehicleRealtime(ctx, resolvedQuery) } +func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) { + keyword = strings.TrimSpace(keyword) + protocol = strings.TrimSpace(protocol) + vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"20"}} + if protocol != "" { + vehicleQuery.Set("protocol", protocol) + } + vehicles, err := s.store.Vehicles(ctx, vehicleQuery) + if err != nil { + return VehicleServiceOverview{}, err + } + resolution := buildVehicleIdentityResolution(keyword, vehicles.Items) + identity := resolveVehicleIdentity(keyword, vehicles.Items) + resolvedVIN := "" + if identity != nil && strings.TrimSpace(identity.VIN) != "" { + resolvedVIN = identity.VIN + } else if resolution.Resolved { + resolvedVIN = resolution.VIN + } + if resolvedVIN == "" { + return *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}), nil + } + + realtimeQuery := url.Values{"vin": {resolvedVIN}, "limit": {"1"}} + if protocol != "" { + realtimeQuery.Set("protocol", protocol) + } + realtimeSummary, err := s.store.VehicleRealtime(ctx, realtimeQuery) + if err != nil { + return VehicleServiceOverview{}, err + } + var summary *VehicleRealtimeRow + if len(realtimeSummary.Items) > 0 { + summary = &realtimeSummary.Items[0] + } + sourceStatus := vehicleSourceStatus(vehicles.Items, nil, nil, nil, nil) + overview := buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}) + return *overview, nil +} + func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { keyword := strings.TrimSpace(vin) protocol = strings.TrimSpace(protocol) diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index ed30a0ae..70e190de 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -64,6 +64,38 @@ test('vehicleResolve sends keyword to the identity resolution endpoint', async ( expect(result.vin).toBe('LB9A32A24R0LS1426'); }); +test('vehicleServiceOverview sends keyword to the lightweight overview endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + vin: 'LB9A32A24R0LS1426', + plate: '粤AG18312', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808'], + primaryProtocol: 'JT808', + coverageStatus: 'partial', + sourceCount: 2, + onlineSourceCount: 1, + lastSeen: '2026-07-03 20:12:10', + realtimeCount: 2, + historyCount: 0, + rawCount: 0, + mileageCount: 0, + qualityIssueCount: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.vehicleServiceOverview(new URLSearchParams({ keyword: '粤AG18312' })); + + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312', undefined); + expect(result.coverageStatus).toBe('partial'); +}); + test('api errors include backend message, detail, and trace id', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index fb0238eb..35b183ef 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -14,6 +14,7 @@ import type { VehicleCoverageRow, VehicleDetail, VehicleIdentityResolution, + VehicleServiceOverview, VehicleRow } from './types'; @@ -75,6 +76,7 @@ export const api = { 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/vehicle-service?${params.toString()}`), + vehicleServiceOverview: (params = new URLSearchParams()) => request(`/api/vehicle-service/overview?${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()}`),