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 804f295f..465ab42e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -88,6 +88,31 @@ func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) { } } +func TestHandlerVehicleServiceIncludesVehicleLevelStatus(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 VehicleDetail `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 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.OnlineSourceCount != 1 || body.Data.ServiceStatus.SourceCount != 2 { + t.Fatalf("vehicle service should expose source counts, got %+v body=%s", body.Data.ServiceStatus, rec.Body.String()) + } +} + func TestHandlerVehicleDetailResolvesPlateToVIN(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 022aa7d3..ee20f9f9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -73,6 +73,7 @@ type VehicleDetail struct { Resolution *VehicleIdentityResolution `json:"resolution,omitempty"` Identity *VehicleRow `json:"identity,omitempty"` RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"` + ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` Sources []string `json:"sources"` SourceStatus []VehicleSourceStatus `json:"sourceStatus"` Realtime []RealtimeLocationRow `json:"realtime"` @@ -82,6 +83,15 @@ type VehicleDetail struct { Quality Page[QualityIssueRow] `json:"quality"` } +type VehicleServiceStatus struct { + Status string `json:"status"` + Severity string `json:"severity"` + Title string `json:"title"` + Detail string `json:"detail"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` +} + type VehicleSourceStatus struct { Protocol string `json:"protocol"` Online bool `json:"online"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 04a7c375..2193f1cd 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -4,6 +4,7 @@ import ( "context" "net/url" "sort" + "strconv" "strings" ) @@ -118,6 +119,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string LookupKey: keyword, LookupResolved: false, Resolution: &resolution, + ServiceStatus: buildVehicleServiceStatus(false, nil), Sources: []string{}, SourceStatus: []VehicleSourceStatus{}, Realtime: []RealtimeLocationRow{}, @@ -176,6 +178,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string Resolution: &resolution, Identity: identity, RealtimeSummary: summary, + ServiceStatus: buildVehicleServiceStatus(true, sourceStatus), Sources: sourceNames(sourceStatus), SourceStatus: sourceStatus, Realtime: realtime.Items, @@ -419,6 +422,66 @@ func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, return out } +func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *VehicleServiceStatus { + if !resolved { + return &VehicleServiceStatus{ + Status: "identity_required", + Severity: "warning", + Title: "身份未绑定", + Detail: "车辆关键词暂未解析到 VIN,需先维护身份绑定后才能形成完整车辆服务。", + } + } + sourceCount := len(statuses) + onlineSourceCount := 0 + for _, status := range statuses { + if status.Online { + onlineSourceCount++ + } + } + if sourceCount == 0 { + return &VehicleServiceStatus{ + Status: "no_data", + Severity: "warning", + Title: "暂无数据来源", + Detail: "车辆已解析,但暂未查询到 32960、808 或 MQTT 数据来源。", + SourceCount: sourceCount, + OnlineSourceCount: onlineSourceCount, + } + } + if onlineSourceCount == 0 { + return &VehicleServiceStatus{ + Status: "offline", + Severity: "error", + Title: "车辆离线", + Detail: "所有已知数据来源均未在线,需要检查平台转发、终端上报或链路状态。", + SourceCount: sourceCount, + OnlineSourceCount: onlineSourceCount, + } + } + if onlineSourceCount < sourceCount { + return &VehicleServiceStatus{ + Status: "degraded", + Severity: "warning", + Title: "部分来源离线", + Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "车辆服务可用但需要关注离线来源。"), + SourceCount: sourceCount, + OnlineSourceCount: onlineSourceCount, + } + } + return &VehicleServiceStatus{ + Status: "healthy", + Severity: "ok", + Title: "服务正常", + Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "全部已知来源在线。"), + SourceCount: sourceCount, + OnlineSourceCount: onlineSourceCount, + } +} + +func sourceCoverageDetail(sourceCount int, onlineSourceCount int, suffix string) string { + return strconv.Itoa(onlineSourceCount) + "/" + strconv.Itoa(sourceCount) + " 个来源在线," + suffix +} + func latestString(left string, right string) string { left = strings.TrimSpace(left) right = strings.TrimSpace(right) diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 23385b4d..e16b794e 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -70,6 +70,7 @@ export interface VehicleDetail { resolution?: VehicleIdentityResolution; identity?: VehicleRow; realtimeSummary?: VehicleRealtimeRow; + serviceStatus?: VehicleServiceStatus; sources: string[]; sourceStatus: VehicleSourceStatus[]; realtime: RealtimeLocationRow[]; @@ -79,6 +80,15 @@ export interface VehicleDetail { quality: Page; } +export interface VehicleServiceStatus { + status: string; + severity: string; + title: string; + detail: string; + sourceCount: number; + onlineSourceCount: number; +} + export interface VehicleSourceStatus { protocol: string; online: boolean; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index a5897a4e..be227519 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -87,6 +87,7 @@ export function VehicleDetail({ const qualityCount = detail?.quality?.total ?? 0; const online = resolution?.online || summary?.online || identity?.online || false; const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-'; + const serviceStatus = detail?.serviceStatus; const activeProtocol = query.protocol?.trim() ?? ''; const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合'; const formKey = `${query.keyword}-${query.protocol ?? ''}`; @@ -145,6 +146,16 @@ export function VehicleDetail({ + {serviceStatus ? ( +
+ 车辆服务状态 + + {serviceStatus.title} + + {serviceStatus.detail} +
+ ) : null} +
{ { protocol: 'GB32960', online: false, lastSeen: '2026-07-03 20:11:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }, { protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true } ], + serviceStatus: { + status: 'degraded', + severity: 'warning', + title: '部分来源离线', + detail: '1/2 个来源在线,车辆服务可用但需要关注离线来源。', + sourceCount: 2, + onlineSourceCount: 1 + }, realtime: [], history: { items: [], total: 0, limit: 20, offset: 0 }, raw: { items: [], total: 0, limit: 10, offset: 0 }, @@ -516,5 +524,7 @@ test('shows selected source scope on vehicle detail', async () => { expect(await screen.findByText('当前查看范围')).toBeInTheDocument(); expect(screen.getByText('单一来源:JT808')).toBeInTheDocument(); + expect(screen.getByText('车辆服务状态')).toBeInTheDocument(); + expect(screen.getByText('部分来源离线')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined); });