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