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 1ed8da13..645c40c4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -50,6 +50,18 @@ func TestHandlerVehicleCoverage(t *testing.T) { t.Fatalf("response missing %q: %s", want, rec.Body.String()) } } + var body struct { + Data Page[VehicleCoverageRow] `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 len(body.Data.Items) == 0 || body.Data.Items[0].ServiceStatus == nil { + t.Fatalf("coverage row should include canonical vehicle service status: %s", rec.Body.String()) + } + if body.Data.Items[0].ServiceStatus.Status != "degraded" { + t.Fatalf("coverage row should expose degraded status, got %+v", body.Data.Items[0].ServiceStatus) + } } 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 aa095ad1..b3327d62 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -94,6 +94,7 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V } items := make([]VehicleCoverageRow, 0, len(byVIN)) for _, row := range byVIN { + row.ServiceStatus = buildVehicleCoverageServiceStatus(*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 f9f261f2..b278ef6b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -49,16 +49,17 @@ 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"` - SourceCount int `json:"sourceCount"` - OnlineSourceCount int `json:"onlineSourceCount"` - Online bool `json:"online"` - LastSeen string `json:"lastSeen"` - BindingStatus string `json:"bindingStatus"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + 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"` } type VehicleIdentityResolution 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 34e7f530..346f8478 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -163,6 +163,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values) } row.Protocols = splitCSV(protocols) row.Online = online == 1 + row.ServiceStatus = buildVehicleCoverageServiceStatus(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 2193f1cd..79457775 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -478,6 +478,57 @@ func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *V } } +func buildVehicleCoverageServiceStatus(row VehicleCoverageRow) *VehicleServiceStatus { + if row.BindingStatus != "bound" { + return &VehicleServiceStatus{ + Status: "identity_required", + Severity: "warning", + Title: "身份未绑定", + Detail: "车辆身份绑定不完整,需先维护 VIN、车牌或手机号映射。", + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + } + } + if row.SourceCount == 0 { + return &VehicleServiceStatus{ + Status: "no_data", + Severity: "warning", + Title: "暂无数据来源", + Detail: "车辆已绑定,但暂未查询到 32960、808 或 MQTT 数据来源。", + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + } + } + if row.OnlineSourceCount == 0 { + return &VehicleServiceStatus{ + Status: "offline", + Severity: "error", + Title: "车辆离线", + Detail: "所有已知数据来源均未在线,需要检查平台转发、终端上报或链路状态。", + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + } + } + if row.OnlineSourceCount < row.SourceCount { + return &VehicleServiceStatus{ + Status: "degraded", + Severity: "warning", + Title: "部分来源离线", + Detail: sourceCoverageDetail(row.SourceCount, row.OnlineSourceCount, "车辆服务可用但需要关注离线来源。"), + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + } + } + return &VehicleServiceStatus{ + Status: "healthy", + Severity: "ok", + Title: "服务正常", + Detail: sourceCoverageDetail(row.SourceCount, row.OnlineSourceCount, "全部已知来源在线。"), + SourceCount: row.SourceCount, + OnlineSourceCount: row.OnlineSourceCount, + } +} + func sourceCoverageDetail(sourceCount int, onlineSourceCount int, suffix string) string { return strconv.Itoa(onlineSourceCount) + "/" + strconv.Itoa(sourceCount) + " 个来源在线," + suffix } diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 0306fcbc..4c1c3e40 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -56,6 +56,7 @@ export interface VehicleCoverageRow { online: boolean; lastSeen: string; bindingStatus: string; + serviceStatus?: VehicleServiceStatus; } export interface VehicleIdentityResolution { diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx index 229fd281..b03f60cf 100644 --- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx @@ -7,6 +7,12 @@ import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; function vehicleServiceStatus(row: VehicleCoverageRow) { + if (row.serviceStatus) { + return { + label: row.serviceStatus.title, + color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const + }; + } if (row.bindingStatus !== 'bound') { return { label: '身份未绑定', color: 'orange' as const }; } 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 86ecc9cf..a3f01096 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -303,11 +303,19 @@ test('shows vehicle service status in vehicle list', async () => { phone: '13307795425', oem: 'G7s', protocols: ['GB32960', 'JT808'], - sourceCount: 2, + sourceCount: 1, onlineSourceCount: 1, online: true, lastSeen: '2026-07-03 20:12:10', - bindingStatus: 'bound' + bindingStatus: 'bound', + serviceStatus: { + status: 'degraded', + severity: 'warning', + title: '部分来源离线', + detail: '由车辆服务 API 统一判定', + sourceCount: 2, + onlineSourceCount: 1 + } }], total: 1, limit: 20, diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index 54b3d6dd..8716eef7 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -71,7 +71,7 @@ Returns VIN-level realtime rows. Protocol is a source filter, not a product boun 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`. +Returns VIN-level source coverage rows for the vehicle service list. Each row includes the canonical vehicle-level `serviceStatus` so frontend, exports, and external integrations share the same health definition. `serviceStatus` accepts `healthy`, `degraded`, `offline`, and `identity_required`. ### History Locations