feat(platform): add vehicle service summary
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user