fix(platform): preserve fuzzy vehicle overview search

This commit is contained in:
lingniu
2026-07-04 03:19:29 +08:00
parent 5d49675582
commit a3b86c7617
2 changed files with 117 additions and 36 deletions

View File

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

View File

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