diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index bd6c16e8..a2ae7d18 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -1,6 +1,7 @@ package platform import ( + "encoding/json" "net/http" "net/http/httptest" "strings" @@ -56,7 +57,7 @@ func TestHandlerVehicleDetail(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } - for _, want := range []string{"identity", "realtimeSummary", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus"} { + for _, want := range []string{"lookupKey", "lookupResolved", "identity", "realtimeSummary", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus"} { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("response missing %q: %s", want, rec.Body.String()) } @@ -89,6 +90,30 @@ func TestHandlerVehicleDetailAcceptsKeyword(t *testing.T) { } } +func TestHandlerVehicleDetailKeepsUnresolvedLookupOutOfVIN(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/detail?keyword=64646848247", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{`"lookupKey":"64646848247"`, `"lookupResolved":false`, `"vin":""`} { + if !strings.Contains(rec.Body.String(), want) { + t.Fatalf("response missing %q: %s", want, rec.Body.String()) + } + } + var body struct { + Data VehicleDetail `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.VIN == "64646848247" { + t.Fatalf("unresolved lookup should not be returned as top-level VIN: %s", rec.Body.String()) + } +} + func TestHandlerRealtimeLocations(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index f1dcdb0c..0106eef2 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -56,6 +56,8 @@ type VehicleCoverageRow struct { type VehicleDetail struct { VIN string `json:"vin"` + LookupKey string `json:"lookupKey"` + LookupResolved bool `json:"lookupResolved"` Identity *VehicleRow `json:"identity,omitempty"` RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"` Sources []string `json:"sources"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index cb2e5595..f556f66b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -67,23 +67,29 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string return VehicleDetail{}, err } identity := resolveVehicleIdentity(keyword, vehicles.Items) - resolvedVIN := keyword + resolvedVIN := "" if identity != nil && strings.TrimSpace(identity.VIN) != "" { resolvedVIN = identity.VIN + } else if isLikelyVIN(keyword) { + resolvedVIN = keyword + } + queryVIN := resolvedVIN + if queryVIN == "" { + queryVIN = keyword } - realtimeQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} - historyQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} - rawQuery := RawFrameQuery{VIN: resolvedVIN, IncludeFields: true, Limit: 10} - mileageQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} - qualityQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}} + realtimeQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}} + historyQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}} + rawQuery := RawFrameQuery{VIN: queryVIN, IncludeFields: true, Limit: 10} + mileageQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}} + qualityQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}} if protocol != "" { realtimeQuery.Set("protocol", protocol) historyQuery.Set("protocol", protocol) rawQuery.Protocol = protocol qualityQuery.Set("protocol", protocol) } - realtimeSummary, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {resolvedVIN}, "limit": {"1"}}) + realtimeSummary, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {queryVIN}, "limit": {"1"}}) if err != nil { return VehicleDetail{}, err } @@ -112,9 +118,11 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string return VehicleDetail{}, err } sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items) - sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus) + sourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, sourceStatus) return VehicleDetail{ VIN: resolvedVIN, + LookupKey: keyword, + LookupResolved: resolvedVIN != "", Identity: identity, RealtimeSummary: summary, Sources: sourceNames(sourceStatus), @@ -172,6 +180,23 @@ func resolveVehicleIdentity(keyword string, vehicles []VehicleRow) *VehicleRow { return nil } +func isLikelyVIN(value string) bool { + value = strings.ToUpper(strings.TrimSpace(value)) + if len(value) != 17 { + return false + } + for _, char := range value { + if char >= '0' && char <= '9' { + continue + } + if char >= 'A' && char <= 'Z' && char != 'I' && char != 'O' && char != 'Q' { + continue + } + return false + } + return true +} + func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) { return s.store.RealtimeLocations(ctx, query) } diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index d57ee48a..f256387b 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -53,6 +53,8 @@ export interface VehicleCoverageRow { export interface VehicleDetail { vin: string; + lookupKey: string; + lookupResolved: boolean; identity?: VehicleRow; realtimeSummary?: VehicleRealtimeRow; sources: string[]; diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index d274ede1..822c6690 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -74,8 +74,9 @@ export function VehicleDetail({ const summary = detail?.realtimeSummary; const latest = detail?.realtime[0]; const resolvedVIN = detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword; - const hasResolvedVIN = isLikelyVIN(resolvedVIN); + const hasResolvedVIN = detail?.lookupResolved ?? isLikelyVIN(resolvedVIN); const displayVIN = hasResolvedVIN ? resolvedVIN : '-'; + const displayLookupKey = detail?.lookupKey || query.keyword; const protocols = useMemo(() => detail?.sources ?? [], [detail?.sources]); const latestRaw = detail?.raw.items[0]; const qualityCount = detail?.quality.total ?? 0; @@ -112,7 +113,7 @@ export function VehicleDetail({ {hasResolvedVIN ? '已解析 VIN' : '未解析到 VIN'} }, { key: '车牌', value: summary?.plate || identity?.plate || latest?.plate || '-' },