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 f41079de..80986844 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -216,6 +216,91 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V return page(items, query), nil } +func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { + items := make([]VehicleServiceOverview, 0, len(query.Keywords)) + for _, keyword := range normalizedKeywords(query.Keywords) { + vehicles := m.vehiclesForKeyword(keyword, query.Protocol) + resolution := buildVehicleIdentityResolution(keyword, vehicles) + identity := resolveVehicleIdentity(keyword, vehicles) + resolvedVIN := "" + if identity != nil && strings.TrimSpace(identity.VIN) != "" { + resolvedVIN = identity.VIN + } else if resolution.Resolved { + resolvedVIN = resolution.VIN + } + if resolvedVIN == "" { + items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})) + continue + } + summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol) + sourceStatus := vehicleSourceStatus(vehicles, nil, nil, nil, nil) + items = append(items, *buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})) + } + return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil +} + +func (m *MockStore) vehiclesForKeyword(keyword string, protocol string) []VehicleRow { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + protocol = strings.TrimSpace(protocol) + out := make([]VehicleRow, 0) + for _, vehicle := range m.vehicles { + if protocol != "" && vehicle.Protocol != protocol { + continue + } + haystack := strings.ToLower(vehicle.VIN + vehicle.Plate + vehicle.Phone + vehicle.OEM) + if keyword == "" || strings.Contains(haystack, keyword) { + out = append(out, vehicle) + } + } + return out +} + +func (m *MockStore) realtimeSummaryForVIN(vin string, protocol string) *VehicleRealtimeRow { + protocol = strings.TrimSpace(protocol) + var summary *VehicleRealtimeRow + for _, location := range m.locations { + if location.VIN != vin { + continue + } + if protocol != "" && location.Protocol != protocol { + continue + } + if summary == nil { + vehicle := m.vehicleByVIN(location.VIN) + summary = &VehicleRealtimeRow{ + VIN: location.VIN, + Plate: firstNonEmpty(location.Plate, vehicle.Plate), + Phone: vehicle.Phone, + OEM: vehicle.OEM, + BindingStatus: "bound", + LastSeen: location.LastSeen, + } + } + if !containsString(summary.Protocols, location.Protocol) { + summary.Protocols = append(summary.Protocols, location.Protocol) + summary.SourceCount = len(summary.Protocols) + } + if location.LastSeen >= summary.LastSeen { + summary.PrimaryProtocol = location.Protocol + summary.Longitude = location.Longitude + summary.Latitude = location.Latitude + summary.SpeedKmh = location.SpeedKmh + summary.SOCPercent = location.SOCPercent + summary.TotalMileageKm = location.TotalMileageKm + summary.LastSeen = location.LastSeen + } + if location.LastSeen >= "2026-07-03 20:11:00" { + summary.Online = true + summary.OnlineSourceCount++ + } + } + if summary != nil { + sort.Strings(summary.Protocols) + summary.ServiceStatus = buildRealtimeServiceStatus(*summary) + } + return summary +} + func (m *MockStore) vehicleByVIN(vin string) VehicleRow { for _, vehicle := range m.vehicles { if vehicle.VIN == vin { 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 953cfcf4..fd375767 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -228,6 +228,85 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) return Page[VehicleRealtimeRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } +func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { + keywords := normalizedKeywords(query.Keywords) + if len(keywords) == 0 { + return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil + } + placeholders := repeatPlaceholders(len(keywords)) + protocolJoin := "" + args := make([]any, 0, len(keywords)*5+1) + for i := 0; i < 3; i++ { + for _, keyword := range keywords { + args = append(args, keyword) + } + } + for i := 0; i < 2; i++ { + for _, keyword := range keywords { + args = append(args, keyword) + } + } + if protocol := strings.TrimSpace(query.Protocol); protocol != "" { + protocolJoin = " AND s.protocol = ? " + args = append(args, protocol) + } + sqlText := `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` + + `COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` + + `COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` + + `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, ` + + `COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(s.protocol ORDER BY s.updated_at DESC, s.protocol ASC), ',', 1), '') AS primary_protocol ` + + `FROM (` + + `SELECT b.vin, b.plate, b.phone, b.oem FROM vehicle_identity_binding b ` + + `WHERE b.vin IN (` + placeholders + `) OR b.plate IN (` + placeholders + `) OR b.phone IN (` + placeholders + `) ` + + `UNION ` + + `SELECT s.vin, COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem ` + + `FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` + + `WHERE s.vin IN (` + placeholders + `) OR s.plate IN (` + placeholders + `) ` + + `GROUP BY s.vin, b.plate, b.phone, b.oem` + + `) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin + + `WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin` + rows, err := s.db.QueryContext(ctx, sqlText, args...) + if err != nil { + return Page[VehicleServiceOverview]{}, err + } + defer rows.Close() + byKey := map[string]*VehicleServiceOverview{} + for rows.Next() { + var overview VehicleServiceOverview + var protocols string + if err := rows.Scan(&overview.VIN, &overview.Plate, &overview.Phone, &overview.OEM, &protocols, &overview.SourceCount, &overview.OnlineSourceCount, &overview.LastSeen, &overview.PrimaryProtocol); err != nil { + return Page[VehicleServiceOverview]{}, err + } + overview.Protocols = splitCSV(protocols) + if overview.PrimaryProtocol == "" && len(overview.Protocols) > 0 { + overview.PrimaryProtocol = overview.Protocols[0] + } + overview.RealtimeCount = overview.SourceCount + overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount) + overview.ServiceStatus = buildVehicleServiceStatus(strings.TrimSpace(overview.VIN) != "", statusesForOverview(&overview)) + for _, key := range []string{overview.VIN, overview.Plate, overview.Phone} { + if trimmed := strings.ToLower(strings.TrimSpace(key)); trimmed != "" { + byKey[trimmed] = &overview + } + } + } + if err := rows.Err(); err != nil { + return Page[VehicleServiceOverview]{}, err + } + items := make([]VehicleServiceOverview, 0, len(keywords)) + for _, keyword := range keywords { + if overview := byKey[strings.ToLower(strings.TrimSpace(keyword))]; overview != nil { + items = append(items, *overview) + continue + } + resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}} + items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})) + } + return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil +} + func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) { built := buildRealtimeLocationSQL(query) total := 0 @@ -685,3 +764,10 @@ func firstNonEmpty(values ...string) string { } return "" } + +func repeatPlaceholders(count int) string { + if count <= 0 { + return "" + } + return strings.TrimRight(strings.Repeat("?,", count), ",") +} diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 135e6279..9c66bceb 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -24,6 +24,10 @@ type Store interface { OpsHealth(context.Context) (OpsHealth, error) } +type VehicleOverviewBatchStore interface { + VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) +} + type RawFrameQuery struct { Protocol string `json:"protocol"` VIN string `json:"vin"` @@ -133,30 +137,32 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr } func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { - keywords := normalizedKeywords(query.Keywords) - total := len(keywords) - if query.Offset < 0 { - query.Offset = 0 + keywords, total, limit, offset := overviewBatchPage(query) + if offset >= total { + return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: limit, Offset: offset}, nil } - if query.Limit <= 0 || query.Limit > 200 { - query.Limit = 200 + query.Keywords = keywords + query.Limit = limit + query.Offset = offset + if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok { + page, err := batchStore.VehicleServiceOverviews(ctx, query) + if err != nil { + return Page[VehicleServiceOverview]{}, err + } + page.Total = total + page.Limit = limit + page.Offset = offset + return page, nil } - if query.Offset >= total { - return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil - } - end := query.Offset + query.Limit - if end > total { - end = total - } - items := make([]VehicleServiceOverview, 0, end-query.Offset) - for _, keyword := range keywords[query.Offset:end] { + items := make([]VehicleServiceOverview, 0, len(keywords)) + for _, keyword := range keywords { overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol) if err != nil { return Page[VehicleServiceOverview]{}, err } items = append(items, overview) } - return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil + return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { @@ -566,6 +572,27 @@ func normalizedKeywords(values []string) []string { return out } +func overviewBatchPage(query VehicleOverviewBatchQuery) ([]string, int, int, int) { + keywords := normalizedKeywords(query.Keywords) + total := len(keywords) + limit := query.Limit + offset := query.Offset + if offset < 0 { + offset = 0 + } + if limit <= 0 || limit > 200 { + limit = 200 + } + if offset >= total { + return []string{}, total, limit, offset + } + end := offset + limit + if end > total { + end = total + } + return keywords[offset:end], total, limit, offset +} + func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus { statusByProtocol := map[string]*VehicleSourceStatus{} ensure := func(protocol string) *VehicleSourceStatus { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go new file mode 100644 index 00000000..1b1e263f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -0,0 +1,45 @@ +package platform + +import ( + "context" + "net/url" + "testing" +) + +type countingStore struct { + *MockStore + vehiclesCalls int + vehicleRealtimeCalls int +} + +func newCountingStore() *countingStore { + return &countingStore{MockStore: NewMockStore()} +} + +func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { + s.vehiclesCalls++ + return s.MockStore.Vehicles(ctx, query) +} + +func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { + s.vehicleRealtimeCalls++ + return s.MockStore.VehicleRealtime(ctx, query) +} + +func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) { + store := newCountingStore() + service := NewService(store) + page, err := service.VehicleServiceOverviews(context.Background(), VehicleOverviewBatchQuery{ + Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, + Limit: 200, + }) + if err != nil { + t.Fatalf("VehicleServiceOverviews returned error: %v", err) + } + if page.Total != 2 || len(page.Items) != 2 { + t.Fatalf("expected two overview rows, got %+v", page) + } + if store.vehiclesCalls > 1 || store.vehicleRealtimeCalls > 1 { + t.Fatalf("batch overview should avoid per-keyword store calls, vehicles=%d realtime=%d", store.vehiclesCalls, store.vehicleRealtimeCalls) + } +}