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 fd375767..c60aad2f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -233,46 +233,14 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh 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...) + built := buildVehicleServiceOverviewBatchSQL(query) + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) if err != nil { return Page[VehicleServiceOverview]{}, err } defer rows.Close() byKey := map[string]*VehicleServiceOverview{} + overviews := make([]VehicleServiceOverview, 0, len(keywords)) for rows.Next() { var overview VehicleServiceOverview var protocols string @@ -286,9 +254,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh overview.RealtimeCount = overview.SourceCount overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount) overview.ServiceStatus = buildVehicleServiceStatus(strings.TrimSpace(overview.VIN) != "", statusesForOverview(&overview)) + overviews = append(overviews, overview) + current := &overviews[len(overviews)-1] for _, key := range []string{overview.VIN, overview.Plate, overview.Phone} { if trimmed := strings.ToLower(strings.TrimSpace(key)); trimmed != "" { - byKey[trimmed] = &overview + byKey[trimmed] = current } } } @@ -301,12 +271,81 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh items = append(items, *overview) continue } + matched := false + for index := range overviews { + if vehicleServiceOverviewMatchesKeyword(overviews[index], keyword) { + items = append(items, overviews[index]) + matched = true + break + } + } + if matched { + 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 buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQuery { + keywords := normalizedKeywords(query.Keywords) + identityWhere, args := fuzzyKeywordWhere("b", []string{"vin", "plate", "phone", "oem"}, keywords) + realtimeWhere, realtimeArgs := fuzzyKeywordWhere("s", []string{"vin", "plate"}, keywords) + args = append(args, realtimeArgs...) + protocolJoin := "" + if protocol := strings.TrimSpace(query.Protocol); protocol != "" { + protocolJoin = " AND s.protocol = ? " + args = append(args, protocol) + } + return SQLQuery{ + Text: `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 ` + identityWhere + ` ` + + `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 ` + realtimeWhere + ` ` + + `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`, + Args: args, + } +} + +func fuzzyKeywordWhere(alias string, fields []string, keywords []string) (string, []any) { + clauses := make([]string, 0, len(keywords)) + args := make([]any, 0, len(keywords)*len(fields)) + for _, keyword := range keywords { + fieldClauses := make([]string, 0, len(fields)) + for _, field := range fields { + fieldClauses = append(fieldClauses, alias+"."+field+" LIKE ?") + args = append(args, "%"+keyword+"%") + } + clauses = append(clauses, "("+strings.Join(fieldClauses, " OR ")+")") + } + return strings.Join(clauses, " OR "), args +} + +func vehicleServiceOverviewMatchesKeyword(overview VehicleServiceOverview, keyword string) bool { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return false + } + for _, value := range []string{overview.VIN, overview.Plate, overview.Phone, overview.OEM} { + if strings.Contains(strings.ToLower(strings.TrimSpace(value)), keyword) { + return true + } + } + return false +} + func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) { built := buildRealtimeLocationSQL(query) total := 0 diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go new file mode 100644 index 00000000..356ac38e --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -0,0 +1,42 @@ +package platform + +import ( + "strings" + "testing" +) + +func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.T) { + built := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{ + Keywords: []string{"AG183", "R0LS1426"}, + Protocol: "JT808", + }) + if !strings.Contains(built.Text, "LIKE ?") { + t.Fatalf("batch overview SQL should support fuzzy keyword matching, got %s", built.Text) + } + if !strings.Contains(built.Text, "b.oem LIKE ?") { + t.Fatalf("batch overview SQL should include OEM in vehicle identity search, got %s", built.Text) + } + if len(built.Args) == 0 || built.Args[0] != "%AG183%" { + t.Fatalf("batch overview SQL should bind fuzzy keyword args first, got %#v", built.Args) + } + if got := built.Args[len(built.Args)-1]; got != "JT808" { + t.Fatalf("protocol should remain an exact filter, got %#v args=%#v", got, built.Args) + } +} + +func TestVehicleServiceOverviewMatchesPartialKeyword(t *testing.T) { + overview := VehicleServiceOverview{ + VIN: "LB9A32A24R0LS1426", + Plate: "粤AG18312", + Phone: "13307795425", + OEM: "G7s", + } + for _, keyword := range []string{"R0LS1426", "AG183", "077954", "g7"} { + if !vehicleServiceOverviewMatchesKeyword(overview, keyword) { + t.Fatalf("overview %+v should match partial keyword %q", overview, keyword) + } + } + if vehicleServiceOverviewMatchesKeyword(overview, "不存在") { + t.Fatalf("overview %+v should not match unrelated keyword", overview) + } +}