From 2872e8c94d1fa1b295fcd5b9bdbeb6e70b48b9e7 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 03:23:21 +0800 Subject: [PATCH] feat(platform): add vehicle service summary --- .../apps/api/internal/platform/handler.go | 6 ++ .../api/internal/platform/handler_test.go | 25 +++++++ .../apps/api/internal/platform/mock_store.go | 67 +++++++++++++++++++ .../apps/api/internal/platform/model.go | 12 ++++ .../api/internal/platform/production_store.go | 65 ++++++++++++++++++ .../apps/api/internal/platform/service.go | 5 ++ 6 files changed, 180 insertions(+) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 643a4031..1fe4cd14 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -30,6 +30,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve) h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage) h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail) + h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary) h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview) h.mux.HandleFunc("POST /api/vehicle-service/overviews", h.handleVehicleServiceOverviews) h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail) @@ -71,6 +72,11 @@ func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request) h.write(w, r, data, err) } +func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) { + data, err := h.service.VehicleServiceSummary(r.Context()) + h.write(w, r, data, err) +} + func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) { keyword := firstNonEmpty(r.URL.Query().Get("keyword"), r.URL.Query().Get("vin")) keyword = strings.TrimSpace(keyword) 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 b6b205d2..5d7361e9 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -233,6 +233,31 @@ func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) { } } +func TestHandlerVehicleServiceSummaryEndpoint(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service/summary", 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 VehicleServiceSummary `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.TotalVehicles < 4 || body.Data.OnlineVehicles < 1 { + t.Fatalf("summary should expose vehicle-level totals, got %+v", body.Data) + } + if body.Data.MultiSourceVehicles < 1 || body.Data.SingleSourceVehicles < 1 { + t.Fatalf("summary should expose source coverage distribution, got %+v", body.Data) + } + if len(body.Data.ServiceStatuses) == 0 || len(body.Data.Protocols) == 0 { + t.Fatalf("summary should expose service status and protocol distributions, got %+v", body.Data) + } +} + func TestHandlerVehicleServiceOverviewsEndpoint(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() 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 80986844..29117e9c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -141,6 +141,73 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V return page(items, query), nil } +func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) { + coverage, err := m.VehicleCoverage(ctx, url.Values{"limit": {"10000"}}) + if err != nil { + return VehicleServiceSummary{}, err + } + summary := VehicleServiceSummary{ + TotalVehicles: coverage.Total, + ServiceStatuses: []ServiceStatusStat{}, + Protocols: []ProtocolStat{}, + } + statusCounts := map[string]*ServiceStatusStat{} + protocolCounts := map[string]*ProtocolStat{} + for _, row := range coverage.Items { + if row.BindingStatus == "bound" { + summary.BoundVehicles++ + } + if row.Online { + summary.OnlineVehicles++ + } + switch { + case row.SourceCount <= 0: + summary.NoDataVehicles++ + case row.SourceCount == 1: + summary.SingleSourceVehicles++ + default: + summary.MultiSourceVehicles++ + } + status := "" + title := "" + if row.ServiceStatus != nil { + status = row.ServiceStatus.Status + title = row.ServiceStatus.Title + } + if status != "" { + current := statusCounts[status] + if current == nil { + current = &ServiceStatusStat{Status: status, Title: title} + statusCounts[status] = current + } + current.Count++ + if status == "identity_required" { + summary.IdentityRequiredVehicles++ + } + } + for _, protocol := range row.Protocols { + current := protocolCounts[protocol] + if current == nil { + current = &ProtocolStat{Protocol: protocol} + protocolCounts[protocol] = current + } + current.Total++ + if row.Online { + current.Online++ + } + } + } + for _, status := range statusCounts { + summary.ServiceStatuses = append(summary.ServiceStatuses, *status) + } + for _, protocol := range protocolCounts { + summary.Protocols = append(summary.Protocols, *protocol) + } + sort.Slice(summary.ServiceStatuses, func(i, j int) bool { return summary.ServiceStatuses[i].Status < summary.ServiceStatuses[j].Status }) + sort.Slice(summary.Protocols, func(i, j int) bool { return summary.Protocols[i].Protocol < summary.Protocols[j].Protocol }) + return summary, nil +} + func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { rows := m.locations if vin := strings.TrimSpace(query.Get("vin")); vin != "" { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 35b78fd4..4720f0a7 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -113,6 +113,18 @@ type VehicleServiceOverview struct { ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` } +type VehicleServiceSummary struct { + TotalVehicles int `json:"totalVehicles"` + BoundVehicles int `json:"boundVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + SingleSourceVehicles int `json:"singleSourceVehicles"` + MultiSourceVehicles int `json:"multiSourceVehicles"` + NoDataVehicles int `json:"noDataVehicles"` + IdentityRequiredVehicles int `json:"identityRequiredVehicles"` + ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"` + Protocols []ProtocolStat `json:"protocols"` +} + type VehicleServiceStatus struct { Status string `json:"status"` Severity string `json:"severity"` 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 c60aad2f..9bc0106b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -95,6 +95,71 @@ func (s *ProductionStore) serviceStatusStats(ctx context.Context) ([]ServiceStat return out, nil } +func (s *ProductionStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) { + rows, err := s.db.QueryContext(ctx, `SELECT +COUNT(*) AS total_vehicles, +COALESCE(SUM(CASE WHEN bound = 1 THEN 1 ELSE 0 END), 0) AS bound_vehicles, +COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, +COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, +COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, +COALESCE(SUM(CASE WHEN source_count = 0 THEN 1 ELSE 0 END), 0) AS no_data_vehicles, +COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS identity_required_vehicles, +COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count = source_count THEN 1 ELSE 0 END), 0) AS healthy_vehicles, +COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count > 0 AND online_source_count < source_count THEN 1 ELSE 0 END), 0) AS degraded_vehicles, +COALESCE(SUM(CASE WHEN bound = 1 AND source_count > 0 AND online_source_count = 0 THEN 1 ELSE 0 END), 0) AS offline_vehicles +FROM ( +SELECT v.vin, +MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound, +COUNT(DISTINCT s.protocol) AS source_count, +COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count +FROM ( +SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> '' +UNION +SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> '' +) v +LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin +LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin +GROUP BY v.vin +) vehicle_service_summary`) + if err != nil { + return VehicleServiceSummary{}, err + } + defer rows.Close() + var summary VehicleServiceSummary + var healthy, degraded, offline int + if rows.Next() { + if err := rows.Scan( + &summary.TotalVehicles, + &summary.BoundVehicles, + &summary.OnlineVehicles, + &summary.SingleSourceVehicles, + &summary.MultiSourceVehicles, + &summary.NoDataVehicles, + &summary.IdentityRequiredVehicles, + &healthy, + °raded, + &offline, + ); err != nil { + return VehicleServiceSummary{}, err + } + } + if err := rows.Err(); err != nil { + return VehicleServiceSummary{}, err + } + summary.ServiceStatuses = []ServiceStatusStat{ + {Status: "healthy", Title: "服务正常", Count: healthy}, + {Status: "degraded", Title: "部分来源离线", Count: degraded}, + {Status: "offline", Title: "车辆离线", Count: offline}, + {Status: "identity_required", Title: "身份未绑定", Count: summary.IdentityRequiredVehicles}, + } + protocols, err := s.protocolStats(ctx) + if err != nil { + return VehicleServiceSummary{}, err + } + summary.Protocols = protocols + return summary, nil +} + func (s *ProductionStore) frameToday(ctx context.Context, now time.Time) (int, error) { if s.tdengine == nil { return 0, nil diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 5c5ee132..56443e8c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -12,6 +12,7 @@ type Store interface { DashboardSummary(context.Context) (DashboardSummary, error) Vehicles(context.Context, url.Values) (Page[VehicleRow], error) VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error) + VehicleServiceSummary(context.Context) (VehicleServiceSummary, error) VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error) RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error) HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error) @@ -88,6 +89,10 @@ func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[V return s.store.VehicleCoverage(ctx, query) } +func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) { + return s.store.VehicleServiceSummary(ctx) +} + func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil {