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 b3dedf88..f5acd1e7 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -94,8 +94,9 @@ func TestHandlerVehicleCoverage(t *testing.T) { } var rawBody struct { Data Page[struct { - VIN string `json:"vin"` - MissingProtocols []string `json:"missingProtocols"` + VIN string `json:"vin"` + MissingProtocols []string `json:"missingProtocols"` + SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency"` }] `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &rawBody); err != nil { @@ -104,6 +105,12 @@ func TestHandlerVehicleCoverage(t *testing.T) { if len(rawBody.Data.Items) == 0 || !containsString(rawBody.Data.Items[0].MissingProtocols, "YUTONG_MQTT") { t.Fatalf("coverage row should expose missing canonical protocols, got %+v body=%s", rawBody.Data.Items, rec.Body.String()) } + if len(rawBody.Data.Items) == 0 || rawBody.Data.Items[0].SourceConsistency == nil { + t.Fatalf("coverage row should expose vehicle-level source consistency: %s", rec.Body.String()) + } + if rawBody.Data.Items[0].SourceConsistency.Status != "degraded" || !containsString(rawBody.Data.Items[0].SourceConsistency.MissingProtocols, "YUTONG_MQTT") { + t.Fatalf("coverage source consistency should diagnose missing source evidence, got %+v body=%s", rawBody.Data.Items[0].SourceConsistency, rec.Body.String()) + } } func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) { 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 b6902c20..000b7826 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -134,6 +134,7 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V for _, row := range byVIN { row.MissingProtocols = missingCanonicalProtocols(row.Protocols) row.ServiceStatus = buildVehicleCoverageServiceStatus(*row) + row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row) if keepCoverageRow(*row, query) { items = append(items, *row) } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index fd2733b1..e9e3909c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -50,18 +50,19 @@ type VehicleRow struct { } type VehicleCoverageRow struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - Phone string `json:"phone"` - OEM string `json:"oem"` - Protocols []string `json:"protocols"` - MissingProtocols []string `json:"missingProtocols"` - SourceCount int `json:"sourceCount"` - OnlineSourceCount int `json:"onlineSourceCount"` - Online bool `json:"online"` - LastSeen string `json:"lastSeen"` - BindingStatus string `json:"bindingStatus"` - ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + MissingProtocols []string `json:"missingProtocols"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen"` + BindingStatus string `json:"bindingStatus"` + ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` + SourceConsistency *VehicleSourceConsistency `json:"sourceConsistency,omitempty"` } type VehicleCoverageSummary struct { 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 67c5c097..77a226aa 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -272,6 +272,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) row.MissingProtocols = missingCanonicalProtocols(row.Protocols) row.Online = online == 1 row.ServiceStatus = buildVehicleCoverageServiceStatus(row) + row.SourceConsistency = buildVehicleCoverageSourceConsistency(row) items = append(items, row) } if err := rows.Err(); err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index b5ba6afd..50bba5fd 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -1052,6 +1052,18 @@ func buildVehicleCoverageServiceStatus(row VehicleCoverageRow) *VehicleServiceSt } } +func buildVehicleCoverageSourceConsistency(row VehicleCoverageRow) *VehicleSourceConsistency { + consistency := &VehicleSourceConsistency{ + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + MissingProtocols: row.MissingProtocols, + Scope: "coverage", + LocatedSourceCount: 0, + } + applyVehicleSourceConsistencyDiagnosis(consistency) + return consistency +} + func buildRealtimeServiceStatus(row VehicleRealtimeRow) *VehicleServiceStatus { return buildVehicleCoverageServiceStatus(VehicleCoverageRow{ VIN: row.VIN, diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 17cf645d..72b38c6e 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -59,6 +59,7 @@ export interface VehicleCoverageRow { lastSeen: string; bindingStatus: string; serviceStatus?: VehicleServiceStatus; + sourceConsistency?: VehicleSourceConsistency; } export interface VehicleCoverageSummary { diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index b61e851c..52259c13 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -33,6 +33,15 @@ function sourceEvidenceText(row: VehicleCoverageRow) { return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`; } +function sourceConsistencyTag(row: VehicleCoverageRow) { + const consistency = row.sourceConsistency; + if (!consistency) { + return 未诊断; + } + const color = consistency.severity === 'ok' ? 'green' as const : consistency.severity === 'error' ? 'red' as const : 'orange' as const; + return {consistency.title || consistency.status}; +} + async function copyVehicleShareURL() { try { await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`); @@ -154,6 +163,11 @@ export function Vehicles({ ) }, + { + title: '来源一致性', + width: 140, + render: (_: unknown, row: VehicleCoverageRow) => sourceConsistencyTag(row) + }, { title: '证据覆盖', width: 120, render: (_: unknown, row: VehicleCoverageRow) => sourceEvidenceText(row) }, { title: '在线', width: 90, render: (_: unknown, row: VehicleCoverageRow) => }, { title: '最后在线', dataIndex: 'lastSeen', width: 170 }, 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 06f8042a..6aab28d0 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2760,6 +2760,87 @@ test('shows vehicle service status in vehicle list', async () => { expect(screen.getByText('部分来源离线')).toBeInTheDocument(); }); +test('shows source consistency diagnosis in vehicle service list', async () => { + window.history.replaceState(null, '', '/#/vehicles'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/vehicles/coverage/summary')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1, + onlineVehicles: 1, + singleSourceVehicles: 0, + multiSourceVehicles: 1, + noDataVehicles: 0, + unboundVehicles: 0, + missingSources: [] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/vehicles/coverage')) { + return { + ok: true, + json: async () => ({ + data: { + items: [{ + vin: 'VIN-CONSISTENCY-LIST', + plate: '粤A一致', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808'], + missingProtocols: ['YUTONG_MQTT'], + sourceCount: 2, + onlineSourceCount: 2, + online: true, + lastSeen: '2026-07-03 20:12:10', + bindingStatus: 'bound', + sourceConsistency: { + sourceCount: 2, + onlineSourceCount: 2, + locatedSourceCount: 0, + missingProtocols: ['YUTONG_MQTT'], + mileageDeltaKm: 0, + sourceTimeDeltaSeconds: 0, + scope: 'coverage', + status: 'degraded', + severity: 'warning', + title: '来源不完整', + detail: '2/2 个来源在线,车辆服务可用但缺少 YUTONG_MQTT 来源。' + } + }], + total: 1, + 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(); + + expect(await screen.findByText('VIN-CONSISTENCY-LIST')).toBeInTheDocument(); + expect(screen.getByText('来源一致性')).toBeInTheDocument(); + expect(screen.getByText('来源不完整')).toBeInTheDocument(); +}); + test('opens vehicle service from source-filtered vehicle list with source evidence', async () => { window.history.replaceState(null, '', '/#/vehicles?protocol=JT808'); 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 ce8cdf81..c17bfd09 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -97,6 +97,8 @@ Returns VIN-level source coverage rows for the vehicle service list. Each row in Coverage summary also exposes `noDataVehicles`, so UI can show vehicles that exist in identity binding but have no GB32960, JT808, or Yutong MQTT source evidence. `/api/vehicles/coverage?serviceStatus=no_data` returns those bound vehicles for follow-up source onboarding. +Coverage rows also include lightweight `sourceConsistency` so list views can show the same vehicle-level source diagnosis without issuing per-row detail requests. + ### History Locations ```http