feat(platform): add vehicle service summary

This commit is contained in:
lingniu
2026-07-04 03:23:21 +08:00
parent a3b86c7617
commit 2872e8c94d
6 changed files with 180 additions and 0 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 != "" {

View File

@@ -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"`

View File

@@ -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,
&degraded,
&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

View File

@@ -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 {