From a60f87c9e7a9d1824423e828d75eb6392b0de9b2 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 06:00:06 +0800 Subject: [PATCH] feat(platform): expose missing source diagnosis --- .../api/internal/platform/handler_test.go | 31 +++++++++++-- .../apps/api/internal/platform/model.go | 21 ++++----- .../platform/production_store_test.go | 3 ++ .../apps/api/internal/platform/service.go | 44 ++++++++++++++++++- .../apps/web/src/api/types.ts | 1 + .../apps/web/src/pages/VehicleDetail.tsx | 7 +++ .../apps/web/src/test/App.test.tsx | 10 +++-- vehicle-data-platform/docs/api-contract.md | 7 +-- 8 files changed, 102 insertions(+), 22 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 3be9c876..8d788199 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -253,8 +253,8 @@ func TestHandlerVehicleServiceIncludesVehicleLevelStatus(t *testing.T) { if body.Data.ServiceStatus == nil { t.Fatalf("vehicle service should include serviceStatus: %s", rec.Body.String()) } - if body.Data.ServiceStatus.Status != "degraded" || body.Data.ServiceStatus.Title != "部分来源离线" { - t.Fatalf("vehicle service should summarize partial source health, got %+v body=%s", body.Data.ServiceStatus, rec.Body.String()) + if body.Data.ServiceStatus.Status != "degraded" || body.Data.ServiceStatus.Title != "来源不完整" { + t.Fatalf("vehicle service should prioritize missing source evidence, got %+v body=%s", body.Data.ServiceStatus, rec.Body.String()) } if body.Data.ServiceStatus.OnlineSourceCount != 1 || body.Data.ServiceStatus.SourceCount != 3 { t.Fatalf("vehicle service should expose source counts, got %+v body=%s", body.Data.ServiceStatus, rec.Body.String()) @@ -320,8 +320,8 @@ func TestHandlerVehicleServiceIncludesSourceConsistency(t *testing.T) { if consistency.Status == "" || consistency.Severity == "" || consistency.Title == "" || consistency.Detail == "" { t.Fatalf("sourceConsistency should expose actionable diagnosis, got %+v", consistency) } - if consistency.Status != "degraded" || consistency.Title != "部分来源离线" { - t.Fatalf("sourceConsistency should diagnose partial source health, got %+v", consistency) + if consistency.Status != "degraded" || consistency.Title != "来源不完整" { + t.Fatalf("sourceConsistency should diagnose missing source evidence before offline evidence, got %+v", consistency) } } @@ -354,6 +354,29 @@ func TestHandlerVehicleServiceExposesMissingSourceSlots(t *testing.T) { } } +func TestHandlerVehicleServiceConsistencyExposesMissingProtocols(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword=粤AG18312", 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 struct { + SourceConsistency struct { + MissingProtocols []string `json:"missingProtocols"` + } `json:"sourceConsistency"` + } `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 !containsString(body.Data.SourceConsistency.MissingProtocols, "YUTONG_MQTT") { + t.Fatalf("sourceConsistency should expose missing source evidence, got %+v body=%s", body.Data.SourceConsistency.MissingProtocols, rec.Body.String()) + } +} + func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 07831c67..a009f8fe 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -106,16 +106,17 @@ type VehicleDetail struct { } type VehicleSourceConsistency struct { - SourceCount int `json:"sourceCount"` - OnlineSourceCount int `json:"onlineSourceCount"` - LocatedSourceCount int `json:"locatedSourceCount"` - MileageDeltaKm float64 `json:"mileageDeltaKm"` - SourceTimeDeltaSeconds float64 `json:"sourceTimeDeltaSeconds"` - Scope string `json:"scope"` - Status string `json:"status"` - Severity string `json:"severity"` - Title string `json:"title"` - Detail string `json:"detail"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` + LocatedSourceCount int `json:"locatedSourceCount"` + MissingProtocols []string `json:"missingProtocols"` + MileageDeltaKm float64 `json:"mileageDeltaKm"` + SourceTimeDeltaSeconds float64 `json:"sourceTimeDeltaSeconds"` + Scope string `json:"scope"` + Status string `json:"status"` + Severity string `json:"severity"` + Title string `json:"title"` + Detail string `json:"detail"` } type VehicleServiceOverview struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go index 856d0b42..bd888023 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -57,6 +57,9 @@ func TestVehicleServiceOverviewDerivedFieldsIncludeSourceConsistency(t *testing. if overview.SourceConsistency.SourceCount != 2 || overview.SourceConsistency.OnlineSourceCount != 1 { t.Fatalf("source consistency should mirror overview source coverage, got %+v", overview.SourceConsistency) } + if !containsString(overview.SourceConsistency.MissingProtocols, "YUTONG_MQTT") { + t.Fatalf("source consistency should expose missing canonical source evidence, got %+v", overview.SourceConsistency) + } if overview.ServiceStatus == nil || overview.ServiceStatus.Status != "degraded" { t.Fatalf("overview should keep canonical service status, got %+v", overview.ServiceStatus) } diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 90b192b3..c86febd2 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -333,6 +333,9 @@ func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []Re if status.Online { consistency.OnlineSourceCount++ } + if vehicleSourceEvidenceMissing(status) { + consistency.MissingProtocols = append(consistency.MissingProtocols, status.Protocol) + } } var minMileage, maxMileage float64 hasMileage := false @@ -393,6 +396,16 @@ func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []Re return consistency } +func vehicleSourceEvidenceMissing(status VehicleSourceStatus) bool { + return strings.TrimSpace(status.Protocol) != "" && + !status.Online && + strings.TrimSpace(status.LastSeen) == "" && + !status.HasRealtime && + !status.HasHistory && + !status.HasRaw && + !status.HasMileage +} + func applyVehicleSourceConsistencyDiagnosis(consistency *VehicleSourceConsistency) { if consistency == nil { return @@ -413,6 +426,11 @@ func applyVehicleSourceConsistencyDiagnosis(consistency *VehicleSourceConsistenc consistency.Severity = "error" consistency.Title = "全部来源离线" consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "无法判断实时一致性。") + case len(consistency.MissingProtocols) > 0: + consistency.Status = "degraded" + consistency.Severity = "warning" + consistency.Title = "来源不完整" + consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "车辆服务可用但缺少 "+strings.Join(consistency.MissingProtocols, "、")+" 来源。") case consistency.OnlineSourceCount < consistency.SourceCount: consistency.Status = "degraded" consistency.Severity = "warning" @@ -520,6 +538,8 @@ func buildVehicleServiceOverview(vin string, lookupKey string, resolution *Vehic resolved = resolution.Resolved } applyVehicleServiceOverviewDerivedFields(overview, resolved) + overview.SourceConsistency.MissingProtocols = missingProtocolsFromStatuses(statuses) + applyVehicleSourceConsistencyDiagnosis(overview.SourceConsistency) return overview } @@ -532,6 +552,7 @@ func applyVehicleServiceOverviewDerivedFields(overview *VehicleServiceOverview, overview.SourceConsistency = &VehicleSourceConsistency{ SourceCount: overview.SourceCount, OnlineSourceCount: overview.OnlineSourceCount, + MissingProtocols: missingCanonicalProtocols(overview.Protocols), Scope: "all_sources", } applyVehicleSourceConsistencyDiagnosis(overview.SourceConsistency) @@ -899,7 +920,8 @@ func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *V onlineSourceCount++ } } - if sourceCount == 0 { + missingProtocols := missingProtocolsFromStatuses(statuses) + if sourceCount == 0 || sourceCount == len(missingProtocols) { return &VehicleServiceStatus{ Status: "no_data", Severity: "warning", @@ -919,6 +941,16 @@ func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *V OnlineSourceCount: onlineSourceCount, } } + if len(missingProtocols) > 0 { + return &VehicleServiceStatus{ + Status: "degraded", + Severity: "warning", + Title: "来源不完整", + Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "车辆服务可用但缺少 "+strings.Join(missingProtocols, "、")+" 来源。"), + SourceCount: sourceCount, + OnlineSourceCount: onlineSourceCount, + } + } if onlineSourceCount < sourceCount { return &VehicleServiceStatus{ Status: "degraded", @@ -939,6 +971,16 @@ func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *V } } +func missingProtocolsFromStatuses(statuses []VehicleSourceStatus) []string { + missing := make([]string, 0, len(statuses)) + for _, status := range statuses { + if vehicleSourceEvidenceMissing(status) { + missing = append(missing, status.Protocol) + } + } + return missing +} + func buildVehicleCoverageServiceStatus(row VehicleCoverageRow) *VehicleServiceStatus { if row.BindingStatus != "bound" { return &VehicleServiceStatus{ diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index c838a78c..9fd7580e 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -106,6 +106,7 @@ export interface VehicleSourceConsistency { sourceCount: number; onlineSourceCount: number; locatedSourceCount: number; + missingProtocols: string[]; mileageDeltaKm: number; sourceTimeDeltaSeconds: number; scope: string; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 62ea2155..a1dce28f 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -156,6 +156,12 @@ export function VehicleDetail({ const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds; const consistencyTitle = consistency?.title ?? '-'; const consistencyDetail = consistency?.detail ?? '-'; + const missingProtocols = consistency?.missingProtocols ?? []; + const missingProtocolText = missingProtocols.length > 0 ? ( + + {missingProtocols.map((item) => {item})} + + ) : '-'; const evidenceSourceCount = overview?.sourceCount ?? consistencySourceCount; const evidenceOnlineSourceCount = overview?.onlineSourceCount ?? consistencyOnlineSourceCount; const evidenceLocatedSourceCount = consistencyLocatedSourceCount; @@ -393,6 +399,7 @@ export function VehicleDetail({ { key: '一致性结论', value: consistencyTitle }, { key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' }, { key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' }, + { key: '缺失来源', value: missingProtocolText }, { key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' }, { key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') }, { key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) }, 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 e729122f..7e519067 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2607,13 +2607,14 @@ test('shows cross-source consistency for one vehicle service', async () => { sourceCount: 3, onlineSourceCount: 2, locatedSourceCount: 3, + missingProtocols: ['YUTONG_MQTT'], mileageDeltaKm: 0.4, sourceTimeDeltaSeconds: 35, scope: 'all_sources', status: 'degraded', severity: 'warning', - title: '部分来源离线', - detail: '2/3 个来源在线,车辆服务可用但需要关注离线来源。' + title: '来源不完整', + detail: '2/3 个来源在线,车辆服务可用但缺少 YUTONG_MQTT 来源。' }, sources: ['GB32960', 'JT808', 'YUTONG_MQTT'], sourceStatus: [ @@ -2651,10 +2652,11 @@ test('shows cross-source consistency for one vehicle service', async () => { render(); expect(await screen.findByText('跨来源一致性')).toBeInTheDocument(); - expect(screen.getAllByText('部分来源离线').length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText('2/3 个来源在线,车辆服务可用但需要关注离线来源。').length).toBeGreaterThan(0); + expect(screen.getAllByText('来源不完整').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('2/3 个来源在线,车辆服务可用但缺少 YUTONG_MQTT 来源。').length).toBeGreaterThan(0); expect(screen.getByText('3 个来源')).toBeInTheDocument(); expect(screen.getByText('2/3 在线')).toBeInTheDocument(); + expect(screen.getByText('缺失来源')).toBeInTheDocument(); expect(screen.getAllByText('3 个来源有位置').length).toBeGreaterThan(0); expect(screen.getAllByText('0.4 km').length).toBeGreaterThan(0); expect(screen.getAllByText('35 秒').length).toBeGreaterThan(0); diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 741d740c..ce8cdf81 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -66,17 +66,18 @@ Returns one vehicle service view with identity, realtime summary, source coverag "sourceCount": 3, "onlineSourceCount": 2, "locatedSourceCount": 2, + "missingProtocols": ["YUTONG_MQTT"], "mileageDeltaKm": 0.8, "sourceTimeDeltaSeconds": 42, "status": "degraded", "severity": "warning", - "title": "部分来源离线", - "detail": "2/3 个来源在线,车辆服务可用但需要关注离线来源。" + "title": "来源不完整", + "detail": "2/3 个来源在线,车辆服务可用但缺少 YUTONG_MQTT 来源。" } } ``` -`status` values are `consistent`, `degraded`, `offline`, `single_source`, `no_source`, `mileage_divergent`, and `time_divergent`. `severity` values are `ok`, `warning`, and `error`. `/api/vehicle-service/overview` and `/api/vehicle-service/overviews` also return `sourceConsistency`; overview responses may only include lightweight counts and diagnosis, while detail responses include location, mileage, and source-time deltas when realtime source rows are available. +`missingProtocols` lists canonical source evidence that is completely absent for the vehicle service, so callers can distinguish a missing source from an offline-but-known source. `status` values are `consistent`, `degraded`, `offline`, `single_source`, `no_source`, `mileage_divergent`, and `time_divergent`. `severity` values are `ok`, `warning`, and `error`. `/api/vehicle-service/overview` and `/api/vehicle-service/overviews` also return `sourceConsistency`; overview responses may only include lightweight counts and diagnosis, while detail responses include location, mileage, and source-time deltas when realtime source rows are available. ### Realtime Vehicles