From 14408d135db4a7ee998d7e874a7f4704529185a0 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 01:54:30 +0800 Subject: [PATCH] feat(platform): filter vehicles by service status --- .../api/internal/platform/handler_test.go | 16 ++++++++ .../apps/api/internal/platform/mock_store.go | 18 ++++++++- .../api/internal/platform/mysql_queries.go | 17 +++++++++ .../internal/platform/query_builders_test.go | 18 +++++++++ .../apps/web/src/pages/Vehicles.tsx | 7 ++++ .../apps/web/src/test/App.test.tsx | 38 +++++++++++++++++++ vehicle-data-platform/docs/api-contract.md | 8 ++++ 7 files changed, 120 insertions(+), 2 deletions(-) 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 465ab42e..fbeebf79 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -49,6 +49,22 @@ func TestHandlerVehicleCoverage(t *testing.T) { } } +func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10&serviceStatus=degraded", 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("degraded coverage should include partially online multi-source vehicle: %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "LMRKH9AC2R1004087") { + t.Fatalf("degraded coverage should exclude healthy single-source vehicle: %s", rec.Body.String()) + } +} + 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 f5faff4c..66c3fa3d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -427,9 +427,23 @@ func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool { } switch strings.TrimSpace(query.Get("bindingStatus")) { case "bound": - return row.BindingStatus == "bound" + if row.BindingStatus != "bound" { + return false + } case "unbound": - return row.BindingStatus == "unbound" + if row.BindingStatus != "unbound" { + return false + } + } + switch strings.TrimSpace(query.Get("serviceStatus")) { + case "identity_required": + return row.BindingStatus != "bound" + case "offline": + return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount == 0 + case "degraded": + return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount > 0 && row.OnlineSourceCount < row.SourceCount + case "healthy": + return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount == row.SourceCount default: return true } 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 77c58f7e..82764a70 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -79,6 +79,23 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { 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)") + } countArgs := append([]any(nil), args...) args = append(args, limit, offset) havingSQL := "" 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 0691a481..a48aa34e 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 @@ -48,6 +48,24 @@ func TestBuildVehicleCoverageSQL(t *testing.T) { } } +func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) { + query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}} + built := buildVehicleCoverageSQL(query) + for _, want := range []string{ + "HAVING", + "COUNT(DISTINCT s.protocol) > 0", + "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0", + "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol)", + } { + if !strings.Contains(built.Text, want) { + t.Fatalf("SQL missing degraded service status predicate %q: %s", want, built.Text) + } + } + if !strings.Contains(built.CountText, "vehicle_coverage_count") || !strings.Contains(built.CountText, "HAVING") { + t.Fatalf("count SQL should include service status HAVING: %s", built.CountText) + } +} + 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/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index 4659a463..229fd281 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -36,6 +36,7 @@ export function Vehicles({ onOpenVehicle }: { onOpenVehicle: (vin: string) => vo if (values?.coverage) params.set('coverage', values.coverage); 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) => { setRows(nextPage.items); @@ -98,6 +99,12 @@ export function Vehicles({ onOpenVehicle }: { onOpenVehicle: (vin: string) => vo 单源 多源 + + 服务正常 + 部分来源离线 + 车辆离线 + 身份未绑定 + 在线 离线 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 22e36cd5..abb563ac 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -294,6 +294,44 @@ test('shows vehicle service status in vehicle list', async () => { expect(screen.getByText('部分来源离线')).toBeInTheDocument(); }); +test('filters vehicle list by service status', async () => { + window.history.replaceState(null, '', '/#/vehicles'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/vehicles/coverage')) { + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: path.includes('/api/ops/health') + ? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } } + : { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + await screen.findByRole('heading', { name: '车辆服务' }); + fireEvent.click(screen.getByTestId('service-status-filter')); + fireEvent.click(await screen.findByText('部分来源离线')); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined); + }); +}); + 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) => { diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 317410b3..8970356a 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -57,6 +57,14 @@ GET /api/realtime/vehicles?keyword=粤AG18312&protocol=JT808&online=online&limit Returns VIN-level realtime rows. Protocol is a source filter, not a product boundary. +### Vehicle Coverage + +```http +GET /api/vehicles/coverage?keyword=粤AG18312&serviceStatus=degraded&limit=20&offset=0 +``` + +Returns VIN-level source coverage rows for the vehicle service list. `serviceStatus` accepts `healthy`, `degraded`, `offline`, and `identity_required`. + ### History Locations ```http