From 999d58da1d84ec352578a9e36d35485720d8f681 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 05:28:29 +0800 Subject: [PATCH] feat(platform): filter vehicles by missing source --- .../api/internal/platform/handler_test.go | 16 ++++ .../apps/api/internal/platform/mock_store.go | 5 ++ .../api/internal/platform/mysql_queries.go | 8 ++ .../internal/platform/query_builders_test.go | 17 ++++ .../apps/web/src/pages/Vehicles.tsx | 6 ++ .../apps/web/src/test/App.test.tsx | 79 +++++++++++++++++++ 6 files changed, 131 insertions(+) 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 1fd3d1f8..dc797f7d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -110,6 +110,22 @@ func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) { } } +func TestHandlerVehicleCoverageFiltersMissingProtocol(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10&missingProtocol=YUTONG_MQTT", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") { + t.Fatalf("missing MQTT coverage should include vehicle without MQTT source: %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "LMRKH9AC2R1004087") { + t.Fatalf("missing MQTT coverage should exclude vehicle with MQTT source: %s", rec.Body.String()) + } +} + func TestHandlerVehicleCoverageSummary(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 a7ef66cf..060cb9e9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -679,6 +679,11 @@ func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool { return false } } + if missingProtocol := strings.TrimSpace(query.Get("missingProtocol")); missingProtocol != "" { + if containsString(row.Protocols, missingProtocol) { + return false + } + } return keepServiceStatus(row.ServiceStatus, query.Get("serviceStatus")) } 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 f229a614..9756462c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -84,6 +84,10 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { case "multi": having = append(having, "COUNT(DISTINCT s.protocol) > 1") } + if missingProtocol := strings.TrimSpace(query.Get("missingProtocol")); missingProtocol != "" { + having = append(having, "COUNT(DISTINCT CASE WHEN s.protocol = ? THEN s.protocol END) = 0") + args = append(args, missingProtocol) + } 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") @@ -167,6 +171,10 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { case "multi": having = append(having, "COUNT(DISTINCT s.protocol) > 1") } + if missingProtocol := strings.TrimSpace(query.Get("missingProtocol")); missingProtocol != "" { + having = append(having, "COUNT(DISTINCT CASE WHEN s.protocol = ? THEN s.protocol END) = 0") + args = append(args, missingProtocol) + } 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") 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 b9f548c4..69f9b11f 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 @@ -100,6 +100,23 @@ func TestBuildVehicleCoverageSQLIncludesNoDataVehicles(t *testing.T) { } } +func TestBuildVehicleCoverageSQLFiltersMissingProtocol(t *testing.T) { + query := url.Values{"missingProtocol": {"YUTONG_MQTT"}, "limit": {"8"}} + built := buildVehicleCoverageSQL(query) + for _, want := range []string{ + "HAVING", + "COUNT(DISTINCT CASE WHEN s.protocol = ? THEN s.protocol END) = 0", + "vehicle_coverage_count", + } { + if !strings.Contains(built.Text+built.CountText, want) { + t.Fatalf("missing protocol SQL missing %q: %s / %s", want, built.Text, built.CountText) + } + } + if len(built.Args) < 3 || built.Args[0] != "YUTONG_MQTT" || built.CountArgs[0] != "YUTONG_MQTT" { + t.Fatalf("missing protocol args should be shared by data and count queries, args=%#v count=%#v", built.Args, built.CountArgs) + } +} + func TestBuildVehicleCoverageSummarySQL(t *testing.T) { query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}} built := buildVehicleCoverageSummarySQL(query) diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index a10c1de9..50d92882 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -74,6 +74,7 @@ export function Vehicles({ if (values?.keyword) params.set('keyword', values.keyword); if (values?.protocol) params.set('protocol', values.protocol); if (values?.coverage) params.set('coverage', values.coverage); + if (values?.missingProtocol) params.set('missingProtocol', values.missingProtocol); if (values?.online) params.set('online', values.online); if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus); if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus); @@ -151,6 +152,11 @@ export function Vehicles({ 单源 多源 + + 缺 GB32960 + 缺 JT808 + 缺 YUTONG_MQTT + 服务正常 部分来源离线 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 d1d037eb..f30de436 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -337,6 +337,85 @@ test('filters vehicle list from result summary actions', async () => { expect(window.location.hash).toBe('#/vehicles?online=online'); }); +test('filters vehicle list by missing source evidence', async () => { + window.history.replaceState(null, '', '/#/vehicles'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicles/coverage/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1, + onlineVehicles: 0, + singleSourceVehicles: 0, + multiSourceVehicles: 1, + noDataVehicles: 0, + unboundVehicles: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicles/coverage')) { + return { + ok: true, + json: async () => ({ + data: { + items: [{ + vin: 'VIN001', + plate: '粤AG18312', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808'], + sourceCount: 2, + onlineSourceCount: 0, + online: false, + lastSeen: '2026-07-03 20:12:10', + bindingStatus: 'bound' + }], + total: 1, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('缺失来源')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('missing-protocol-filter')); + fireEvent.click(await screen.findByText('缺 YUTONG_MQTT')); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined); + }); +}); + test('copies shareable vehicle service filter link', async () => { window.history.replaceState(null, '', '/#/vehicles?keyword=%E7%B2%A4A&coverage=multi&serviceStatus=degraded'); const writeText = vi.fn(() => Promise.resolve());