diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 1fe4cd14..47613a3c 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/vehicles/coverage/summary", h.handleVehicleCoverageSummary) h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail) h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary) h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview) @@ -72,6 +73,11 @@ func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request) h.write(w, r, data, err) } +func (h *Handler) handleVehicleCoverageSummary(w http.ResponseWriter, r *http.Request) { + data, err := h.service.VehicleCoverageSummary(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} + func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) { data, err := h.service.VehicleServiceSummary(r.Context()) 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 5d7361e9..d8164be7 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -109,6 +109,28 @@ func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) { } } +func TestHandlerVehicleCoverageSummary(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage/summary?coverage=multi", 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 VehicleCoverageSummary `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.TotalVehicles != 1 || body.Data.MultiSourceVehicles != 1 { + t.Fatalf("coverage summary should honor filters, got %+v body=%s", body.Data, rec.Body.String()) + } + if body.Data.OnlineVehicles != 1 || body.Data.UnboundVehicles != 0 { + t.Fatalf("coverage summary should expose filtered online and binding totals, got %+v", body.Data) + } +} + func TestHandlerVehicleDetail(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() 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 29117e9c..514f5093 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -141,6 +141,32 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V return page(items, query), nil } +func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) { + allQuery := copyValues(query) + allQuery.Set("limit", "100000") + allQuery.Set("offset", "0") + coverage, err := m.VehicleCoverage(ctx, allQuery) + if err != nil { + return VehicleCoverageSummary{}, err + } + summary := VehicleCoverageSummary{TotalVehicles: coverage.Total} + for _, row := range coverage.Items { + if row.Online { + summary.OnlineVehicles++ + } + if row.SourceCount == 1 { + summary.SingleSourceVehicles++ + } + if row.SourceCount > 1 { + summary.MultiSourceVehicles++ + } + if row.BindingStatus != "bound" { + summary.UnboundVehicles++ + } + } + return summary, nil +} + func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) { coverage, err := m.VehicleCoverage(ctx, url.Values{"limit": {"10000"}}) if err != nil { @@ -658,6 +684,14 @@ func keepServiceStatus(status *VehicleServiceStatus, raw string) bool { } } +func copyValues(values url.Values) url.Values { + next := url.Values{} + for key, items := range values { + next[key] = append([]string(nil), items...) + } + return next +} + func parsePositive(raw string, fallback int) int { value, err := strconv.Atoi(raw) if err != nil || value < 0 { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 4720f0a7..e0f1bf76 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -63,6 +63,14 @@ type VehicleCoverageRow struct { ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` } +type VehicleCoverageSummary struct { + TotalVehicles int `json:"totalVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + SingleSourceVehicles int `json:"singleSourceVehicles"` + MultiSourceVehicles int `json:"multiSourceVehicles"` + UnboundVehicles int `json:"unboundVehicles"` +} + type VehicleIdentityResolution struct { LookupKey string `json:"lookupKey"` Resolved bool `json:"resolved"` diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index a1170722..07b48a24 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -142,6 +142,77 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { } } +func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { + args := []any{} + where := []string{"s.vin IS NOT NULL", "s.vin <> ''"} + having := []string{} + if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { + where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") + like := "%" + keyword + "%" + args = append(args, like, like, like, like, like, like) + } + if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { + where = append(where, "s.protocol = ?") + args = append(args, protocol) + } + switch strings.TrimSpace(query.Get("coverage")) { + case "single": + having = append(having, "COUNT(DISTINCT s.protocol) = 1") + case "multi": + having = append(having, "COUNT(DISTINCT s.protocol) > 1") + } + switch strings.TrimSpace(query.Get("online")) { + case "online": + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0") + case "offline": + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0") + } + switch strings.TrimSpace(query.Get("bindingStatus")) { + case "bound": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1") + case "unbound": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0") + } + switch strings.TrimSpace(query.Get("serviceStatus")) { + case "identity_required": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0") + case "offline": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1") + having = append(having, "COUNT(DISTINCT s.protocol) > 0") + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0") + case "degraded": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1") + having = append(having, "COUNT(DISTINCT s.protocol) > 0") + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0") + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol)") + case "healthy": + having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1") + having = append(having, "COUNT(DISTINCT s.protocol) > 0") + having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = COUNT(DISTINCT s.protocol)") + } + havingSQL := "" + if len(having) > 0 { + havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` ` + } + groupSQL := `SELECT s.vin, ` + + `COUNT(DISTINCT s.protocol) AS source_count, ` + + `COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` + + `MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound ` + + `FROM vehicle_realtime_snapshot s ` + + `LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` + + `WHERE ` + strings.Join(where, " AND ") + ` ` + + `GROUP BY s.vin ` + havingSQL + return SQLQuery{ + Text: `SELECT COUNT(*) AS total_vehicles, ` + + `COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, ` + + `COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, ` + + `COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, ` + + `COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles ` + + `FROM (` + groupSQL + `) vehicle_coverage_summary`, + Args: args, + } +} + func buildRealtimeLocationSQL(query url.Values) SQLQuery { limit := parsePositive(query.Get("limit"), 20) offset := parsePositive(query.Get("offset"), 0) diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 9bc0106b..ec37d233 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -256,6 +256,21 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) return Page[VehicleCoverageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } +func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) { + built := buildVehicleCoverageSummarySQL(query) + var summary VehicleCoverageSummary + if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan( + &summary.TotalVehicles, + &summary.OnlineVehicles, + &summary.SingleSourceVehicles, + &summary.MultiSourceVehicles, + &summary.UnboundVehicles, + ); err != nil { + return VehicleCoverageSummary{}, err + } + return summary, nil +} + func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { built := buildVehicleRealtimeSQL(query) total := 0 diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 9bc3944d..cfec833b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -83,6 +83,30 @@ func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) { } } +func TestBuildVehicleCoverageSummarySQL(t *testing.T) { + query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}} + built := buildVehicleCoverageSummarySQL(query) + for _, want := range []string{ + "vehicle_realtime_snapshot", + "vehicle_identity_binding", + "vehicle_coverage_summary", + "SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END)", + "SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END)", + "HAVING", + "COUNT(DISTINCT s.protocol) > 1", + } { + if !strings.Contains(built.Text, want) { + t.Fatalf("summary SQL missing %q: %s", want, built.Text) + } + } + if strings.Contains(built.Text, "LIMIT") || strings.Contains(built.Text, "OFFSET") { + t.Fatalf("summary SQL should not paginate: %s", built.Text) + } + if len(built.Args) != 6 || built.Args[0] != "%粤A%" { + t.Fatalf("args = %#v", built.Args) + } +} + func TestBuildRealtimeLocationSQL(t *testing.T) { query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}} built := buildRealtimeLocationSQL(query) diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 56443e8c..f88c03af 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -12,6 +12,7 @@ type Store interface { DashboardSummary(context.Context) (DashboardSummary, error) Vehicles(context.Context, url.Values) (Page[VehicleRow], error) VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error) + VehicleCoverageSummary(context.Context, url.Values) (VehicleCoverageSummary, error) VehicleServiceSummary(context.Context) (VehicleServiceSummary, error) VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error) RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error) @@ -89,6 +90,10 @@ func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[V return s.store.VehicleCoverage(ctx, query) } +func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) { + return s.store.VehicleCoverageSummary(ctx, query) +} + func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) { return s.store.VehicleServiceSummary(ctx) } diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 4b6f08ed..b8265472 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -12,6 +12,7 @@ import type { RealtimeLocationRow, VehicleRealtimeRow, VehicleCoverageRow, + VehicleCoverageSummary, VehicleDetail, VehicleIdentityResolution, VehicleServiceOverview, @@ -83,6 +84,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()}`), + vehicleCoverageSummary: (params = new URLSearchParams()) => request(`/api/vehicles/coverage/summary?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicle-service?${params.toString()}`), vehicleServiceSummary: () => request('/api/vehicle-service/summary'), vehicleServiceOverview: (params = new URLSearchParams()) => request(`/api/vehicle-service/overview?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 65d3284d..9b7a66d2 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -60,6 +60,14 @@ export interface VehicleCoverageRow { serviceStatus?: VehicleServiceStatus; } +export interface VehicleCoverageSummary { + totalVehicles: number; + onlineVehicles: number; + singleSourceVehicles: number; + multiSourceVehicles: number; + unboundVehicles: number; +} + export interface VehicleIdentityResolution { lookupKey: string; resolved: boolean; diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index 94153ed6..ec975853 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -1,7 +1,7 @@ import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui'; import { useEffect, useMemo, useState } from 'react'; import { api } from '../api/client'; -import type { VehicleCoverageRow } from '../api/types'; +import type { VehicleCoverageRow, VehicleCoverageSummary } from '../api/types'; import { DataEmpty } from '../components/DataEmpty'; import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; @@ -30,20 +30,18 @@ function vehicleServiceStatus(row: VehicleCoverageRow) { export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle: (vin: string) => void; initialFilters?: Record }) { const [rows, setRows] = useState([]); + const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const resultSummary = useMemo(() => { - const onlineRows = rows.filter((row) => row.online).length; - const multiSourceRows = rows.filter((row) => row.sourceCount > 1).length; - const unboundRows = rows.filter((row) => row.bindingStatus !== 'bound').length; return [ - { label: '过滤车辆', value: pagination.total.toLocaleString() }, - { label: '本页在线', value: onlineRows.toLocaleString() }, - { label: '本页多源', value: multiSourceRows.toLocaleString() }, - { label: '本页待绑定', value: unboundRows.toLocaleString() } + { label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString() }, + { label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString() }, + { label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString() }, + { label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString() } ]; - }, [pagination.total, rows]); + }, [pagination.total, summary]); const load = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoading(true); @@ -54,9 +52,13 @@ export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle if (values?.online) params.set('online', values.online); if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus); if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus); - api.vehicleCoverage(params) - .then((nextPage) => { + const summaryParams = new URLSearchParams(params); + summaryParams.delete('limit'); + summaryParams.delete('offset'); + Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)]) + .then(([nextPage, nextSummary]) => { setRows(nextPage.items); + setSummary(nextSummary); setPagination({ currentPage: page, pageSize, total: nextPage.total }); }) .catch((error: Error) => Toast.error(error.message)) 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 e64c717a..98247dc0 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -191,6 +191,22 @@ test('shows vehicle service result summary on vehicle list filters', async () => }) } as Response; } + if (path.includes('/api/vehicles/coverage/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 181, + onlineVehicles: 73, + singleSourceVehicles: 0, + multiSourceVehicles: 181, + unboundVehicles: 9 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } if (path.includes('/api/vehicles/coverage')) { return { ok: true, @@ -238,10 +254,11 @@ test('shows vehicle service result summary on vehicle list filters', async () => render(); expect(await screen.findByText('当前车辆结果')).toBeInTheDocument(); - expect(screen.getByText('181')).toBeInTheDocument(); + expect(screen.getAllByText('181').length).toBeGreaterThanOrEqual(2); expect(screen.getByText('过滤车辆')).toBeInTheDocument(); - expect(screen.getByText('本页在线')).toBeInTheDocument(); - expect(screen.getByText('本页多源')).toBeInTheDocument(); + expect(screen.getByText('73')).toBeInTheDocument(); + expect(screen.getByText('在线车辆')).toBeInTheDocument(); + expect(screen.getByText('待绑定')).toBeInTheDocument(); expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument(); });