From 703a7b112f1f33672d718c7c5c9fddb1609b3f32 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 01:06:24 +0800 Subject: [PATCH] feat(platform): resolve vehicle identity before navigation --- .../apps/api/internal/platform/handler.go | 12 ++++ .../api/internal/platform/handler_test.go | 41 ++++++++++++ .../apps/api/internal/platform/model.go | 12 ++++ .../apps/api/internal/platform/service.go | 65 +++++++++++++++++++ vehicle-data-platform/apps/web/src/App.tsx | 19 ++++-- .../apps/web/src/api/client.test.ts | 26 ++++++++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 12 ++++ .../apps/web/src/layout/AppShell.tsx | 8 ++- 9 files changed, 189 insertions(+), 8 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 7966a0f9..d195cf6f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -27,6 +27,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) routes() { h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary) h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles) + h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve) h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage) h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail) h.mux.HandleFunc("GET /api/realtime/vehicles", h.handleVehicleRealtime) @@ -51,6 +52,17 @@ func (h *Handler) handleVehicles(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleVehicleResolve(w http.ResponseWriter, r *http.Request) { + keyword := firstNonEmpty(r.URL.Query().Get("keyword"), r.URL.Query().Get("vin")) + keyword = strings.TrimSpace(keyword) + if keyword == "" { + httpx.WriteError(w, http.StatusBadRequest, "VEHICLE_KEY_REQUIRED", "车辆关键词不能为空", "", traceID(r)) + return + } + data, err := h.service.ResolveVehicleIdentity(r.Context(), keyword, r.URL.Query().Get("protocol")) + h.write(w, r, data, err) +} + func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request) { data, err := h.service.VehicleCoverage(r.Context(), r.URL.Query()) h.write(w, r, data, err) 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 8ccbf783..e946ab1b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -117,6 +117,47 @@ func TestHandlerVehicleDetailKeepsUnresolvedLookupOutOfVIN(t *testing.T) { } } +func TestHandlerVehicleResolveReturnsCanonicalIdentity(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/resolve?keyword=粤AG18312", 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 VehicleIdentityResolution `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.Resolved || body.Data.VIN != "LB9A32A24R0LS1426" || body.Data.Plate != "粤AG18312" { + t.Fatalf("resolve should return canonical identity, got %+v body=%s", body.Data, rec.Body.String()) + } + if len(body.Data.Protocols) < 2 { + t.Fatalf("resolve should include available source protocols, got %+v", body.Data.Protocols) + } +} + +func TestHandlerVehicleResolveKeepsUnresolvedKeyword(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/vehicles/resolve?keyword=64646848247", 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 VehicleIdentityResolution `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.Resolved || body.Data.VIN != "" || body.Data.LookupKey != "64646848247" { + t.Fatalf("unresolved keyword should not fabricate identity, got %+v body=%s", body.Data, 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 c6d7339c..f0d3fafe 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -54,6 +54,18 @@ type VehicleCoverageRow struct { BindingStatus string `json:"bindingStatus"` } +type VehicleIdentityResolution struct { + LookupKey string `json:"lookupKey"` + Resolved bool `json:"resolved"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen"` +} + type VehicleDetail struct { VIN string `json:"vin"` LookupKey string `json:"lookupKey"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 7749e427..040a3d0c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -56,6 +56,58 @@ func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleR return s.store.Vehicles(ctx, query) } +func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, protocol string) (VehicleIdentityResolution, error) { + keyword = strings.TrimSpace(keyword) + result := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}} + if keyword == "" { + return result, nil + } + vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"20"}} + if protocol = strings.TrimSpace(protocol); protocol != "" { + vehicleQuery.Set("protocol", protocol) + } + vehicles, err := s.store.Vehicles(ctx, vehicleQuery) + if err != nil { + return VehicleIdentityResolution{}, err + } + identity := resolveVehicleIdentity(keyword, vehicles.Items) + if identity == nil || strings.TrimSpace(identity.VIN) == "" { + if isLikelyVIN(keyword) { + result.Resolved = true + result.VIN = keyword + } + return result, nil + } + result.Resolved = true + result.VIN = identity.VIN + result.Plate = identity.Plate + result.Phone = identity.Phone + result.OEM = identity.OEM + for _, vehicle := range vehicles.Items { + if !strings.EqualFold(vehicle.VIN, identity.VIN) { + continue + } + if vehicle.Online { + result.Online = true + } + if vehicle.LastSeen > result.LastSeen { + result.LastSeen = vehicle.LastSeen + } + if result.Plate == "" { + result.Plate = vehicle.Plate + } + if result.Phone == "" { + result.Phone = vehicle.Phone + } + if result.OEM == "" { + result.OEM = vehicle.OEM + } + result.Protocols = appendIfMissing(result.Protocols, vehicle.Protocol) + } + sort.Strings(result.Protocols) + return result, nil +} + func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) { return s.store.VehicleCoverage(ctx, query) } @@ -419,3 +471,16 @@ func sourceNames(statuses []VehicleSourceStatus) []string { } return out } + +func appendIfMissing(values []string, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return values + } + for _, current := range values { + if current == value { + return values + } + } + return append(values, value) +} diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index 3b0cc77f..372c5ed0 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -37,13 +37,22 @@ export default function App() { return () => window.clearInterval(timer); }, [refreshOpsHealth]); - const openVehicle = (vin: string) => { - const nextVin = vin.trim(); - if (!nextVin) { + const openVehicle = async (keyword: string) => { + const lookupKey = keyword.trim(); + if (!lookupKey) { return; } - setActiveVin(nextVin); - setActivePage('detail'); + try { + const resolved = await api.vehicleResolve(new URLSearchParams({ keyword: lookupKey })); + const nextKey = resolved.resolved && resolved.vin ? resolved.vin : lookupKey; + setActiveVin(nextKey); + setActivePage('detail'); + if (!resolved.resolved) { + Toast.warning('未匹配到车辆身份,已打开问题排查视图'); + } + } catch (error) { + Toast.error(error instanceof Error ? error.message : '车辆查询失败'); + } }; const openHistoryForVehicle = (vin: string) => { diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index f6f59847..ed30a0ae 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -38,6 +38,32 @@ test('rawFramesQuery posts structured JSON instead of URL query strings', async }); }); +test('vehicleResolve sends keyword to the identity resolution endpoint', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + lookupKey: '粤AG18312', + resolved: true, + vin: 'LB9A32A24R0LS1426', + plate: '粤AG18312', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808'], + online: true, + lastSeen: '2026-07-03 20:12:10' + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.vehicleResolve(new URLSearchParams({ keyword: '粤AG18312' })); + + expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/resolve?keyword=%E7%B2%A4AG18312', undefined); + expect(result.vin).toBe('LB9A32A24R0LS1426'); +}); + test('api errors include backend message, detail, and trace id', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 0bd9b80d..d92e4f09 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -13,6 +13,7 @@ import type { VehicleRealtimeRow, VehicleCoverageRow, VehicleDetail, + VehicleIdentityResolution, VehicleRow } from './types'; @@ -71,6 +72,7 @@ function withTraceID(message: string, traceID?: string) { export const api = { dashboardSummary: () => request('/api/dashboard/summary'), vehicles: (params = new URLSearchParams()) => request>(`/api/vehicles?${params.toString()}`), + vehicleResolve: (params = new URLSearchParams()) => request(`/api/vehicles/resolve?${params.toString()}`), vehicleCoverage: (params = new URLSearchParams()) => request>(`/api/vehicles/coverage?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicles/detail?${params.toString()}`), vehicleRealtime: (params = new URLSearchParams()) => request>(`/api/realtime/vehicles?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index e45bd1a7..329ba042 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -51,6 +51,18 @@ export interface VehicleCoverageRow { bindingStatus: string; } +export interface VehicleIdentityResolution { + lookupKey: string; + resolved: boolean; + vin: string; + plate: string; + phone: string; + oem: string; + protocols: string[]; + online: boolean; + lastSeen: string; +} + export interface VehicleDetail { vin: string; lookupKey: string; diff --git a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx index 25c18639..9519f6c8 100644 --- a/vehicle-data-platform/apps/web/src/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/layout/AppShell.tsx @@ -41,10 +41,11 @@ export function AppShell({ activePage: PageKey; linkIssueCount: number | null; onChange: (page: PageKey) => void; - onVehicleSearch: (keyword: string) => void; + onVehicleSearch: (keyword: string) => void | Promise; children: ReactNode; }) { const [keyword, setKeyword] = useState(''); + const [searching, setSearching] = useState(false); const search = () => { const value = keyword.trim(); @@ -52,7 +53,8 @@ export function AppShell({ Toast.warning('请输入 VIN / 车牌 / 手机号'); return; } - onVehicleSearch(value); + setSearching(true); + Promise.resolve(onVehicleSearch(value)).finally(() => setSearching(false)); }; return ( @@ -81,7 +83,7 @@ export function AppShell({ onEnterPress={search} style={{ width: 320 }} /> - + 生产环境