From f996fa0df9f66f6102af0d24737d386f4ca8ffc0 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 03:05:27 +0800 Subject: [PATCH] feat(platform): add batch vehicle overview api --- .../apps/api/internal/platform/handler.go | 12 +++++ .../api/internal/platform/handler_test.go | 30 +++++++++++ .../apps/api/internal/platform/service.go | 51 +++++++++++++++++++ .../apps/web/src/api/client.test.ts | 36 +++++++++++++ .../apps/web/src/api/client.ts | 12 +++++ 5 files changed, 141 insertions(+) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 18dbc849..643a4031 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -31,6 +31,7 @@ func (h *Handler) routes() { 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("POST /api/vehicle-service/overviews", h.handleVehicleServiceOverviews) 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) @@ -92,6 +93,17 @@ func (h *Handler) handleVehicleServiceOverview(w http.ResponseWriter, r *http.Re h.write(w, r, data, err) } +func (h *Handler) handleVehicleServiceOverviews(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var query VehicleOverviewBatchQuery + if err := json.NewDecoder(r.Body).Decode(&query); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r)) + return + } + data, err := h.service.VehicleServiceOverviews(r.Context(), query) + 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 1c11b159..b6b205d2 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -1,6 +1,7 @@ package platform import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -232,6 +233,35 @@ func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) { } } +func TestHandlerVehicleServiceOverviewsEndpoint(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", bytes.NewBufferString(`{"keywords":["粤AG18312","LMRKH9AC2R1004087"]}`)) + req.Header.Set("Content-Type", "application/json") + 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[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.Total != 2 || len(body.Data.Items) != 2 { + t.Fatalf("batch overview should return two items, got %+v body=%s", body.Data, rec.Body.String()) + } + if body.Data.Items[0].VIN != "LB9A32A24R0LS1426" || body.Data.Items[1].VIN != "LMRKH9AC2R1004087" { + t.Fatalf("batch overview should preserve request order, got %+v", body.Data.Items) + } + if body.Data.Items[0].ServiceStatus == nil || body.Data.Items[1].ServiceStatus == nil { + t.Fatalf("batch overview should include canonical status for every vehicle, got %+v", body.Data.Items) + } + if strings.Contains(rec.Body.String(), `"raw"`) || strings.Contains(rec.Body.String(), `"history"`) { + t.Fatalf("batch overview 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 7a3e19c7..135e6279 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -36,6 +36,13 @@ type RawFrameQuery struct { Offset int `json:"offset"` } +type VehicleOverviewBatchQuery struct { + Keywords []string `json:"keywords"` + Protocol string `json:"protocol"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + type Service struct { store Store runtime RuntimeInfo @@ -125,6 +132,33 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr return *overview, nil } +func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { + keywords := normalizedKeywords(query.Keywords) + total := len(keywords) + if query.Offset < 0 { + query.Offset = 0 + } + if query.Limit <= 0 || query.Limit > 200 { + query.Limit = 200 + } + if query.Offset >= total { + return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil + } + end := query.Offset + query.Limit + if end > total { + end = total + } + items := make([]VehicleServiceOverview, 0, end-query.Offset) + for _, keyword := range keywords[query.Offset:end] { + overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol) + if err != nil { + return Page[VehicleServiceOverview]{}, err + } + items = append(items, overview) + } + return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil +} + func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { keyword := strings.TrimSpace(vin) protocol = strings.TrimSpace(protocol) @@ -515,6 +549,23 @@ func cloneValues(values url.Values) url.Values { return cloned } +func normalizedKeywords(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus { statusByProtocol := map[string]*VehicleSourceStatus{} ensure := func(protocol string) *VehicleSourceStatus { 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 a3112bf9..3171f846 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -105,6 +105,42 @@ test('vehicleServiceOverview sends keyword to the lightweight overview endpoint' expect(result.serviceStatus?.status).toBe('degraded'); }); +test('vehicleServiceOverviews posts keywords to the batch overview endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + items: [ + { vin: 'VIN001', plate: '粤AG18312', protocols: ['JT808'], coverageStatus: 'online', sourceCount: 1, onlineSourceCount: 1 }, + { vin: 'VIN002', plate: '豫A88888', protocols: ['YUTONG_MQTT'], coverageStatus: 'online', sourceCount: 1, onlineSourceCount: 1 } + ], + total: 2, + limit: 200, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.vehicleServiceOverviews({ + keywords: ['粤AG18312', 'LMRKH9AC2R1004087'], + limit: 200, + offset: 0 + }); + + expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overviews', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + keywords: ['粤AG18312', 'LMRKH9AC2R1004087'], + limit: 200, + offset: 0 + }) + }); + expect(result.total).toBe(2); +}); + 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 35b183ef..1904f6a7 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -30,6 +30,13 @@ export type RawFrameQuery = { offset?: number; }; +export type VehicleOverviewBatchQuery = { + keywords: string[]; + protocol?: string; + limit?: number; + offset?: number; +}; + type ApiErrorEnvelope = { error?: { code?: string; @@ -77,6 +84,11 @@ export const api = { 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()}`), + vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request>('/api/vehicle-service/overviews', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(query) + }), 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()}`),