feat(platform): add vehicle service API

This commit is contained in:
lingniu
2026-07-04 01:43:26 +08:00
parent 5000d5cd1c
commit 8e800f795c
5 changed files with 37 additions and 9 deletions

View File

@@ -29,6 +29,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles) h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve) h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage) h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail)
h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail) h.mux.HandleFunc("GET /api/vehicles/detail", h.handleVehicleDetail)
h.mux.HandleFunc("GET /api/realtime/vehicles", h.handleVehicleRealtime) h.mux.HandleFunc("GET /api/realtime/vehicles", h.handleVehicleRealtime)
h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations) h.mux.HandleFunc("GET /api/realtime/locations", h.handleRealtimeLocations)

View File

@@ -64,6 +64,30 @@ func TestHandlerVehicleDetail(t *testing.T) {
} }
} }
func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword=粤AG18312&protocol=JT808", 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 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.Resolution == nil || body.Data.Resolution.VIN != "LB9A32A24R0LS1426" {
t.Fatalf("vehicle service should resolve canonical vehicle identity, got %+v body=%s", body.Data.Resolution, rec.Body.String())
}
for _, row := range body.Data.Realtime {
if row.Protocol != "JT808" {
t.Fatalf("vehicle service should keep source filter, got realtime row %+v body=%s", row, rec.Body.String())
}
}
}
func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) { func TestHandlerVehicleDetailResolvesPlateToVIN(t *testing.T) {
handler := NewHandler(NewService(NewMockStore())) handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -74,7 +74,7 @@ export const api = {
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`), vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`), vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`), vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicles/detail?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
vehicleRealtime: (params = new URLSearchParams()) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`), vehicleRealtime: (params = new URLSearchParams()) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`),
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`), realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`), historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),

View File

@@ -249,7 +249,7 @@ test('switches vehicle detail to a source from the coverage cards', async () =>
window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path.includes('/api/vehicles/detail')) { if (path.includes('/api/vehicle-service')) {
return { return {
ok: true, ok: true,
json: async () => ({ json: async () => ({
@@ -320,14 +320,14 @@ test('switches vehicle detail to a source from the coverage cards', async () =>
await waitFor(() => { await waitFor(() => {
expect(window.location.hash).toBe('#/detail?keyword=VIN001&protocol=JT808'); expect(window.location.hash).toBe('#/detail?keyword=VIN001&protocol=JT808');
}); });
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/detail?keyword=VIN001&protocol=JT808'), undefined); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
}); });
test('opens history with the currently selected vehicle detail source', async () => { test('opens history with the currently selected vehicle detail source', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path.includes('/api/vehicles/detail')) { if (path.includes('/api/vehicle-service')) {
return { return {
ok: true, ok: true,
json: async () => ({ json: async () => ({
@@ -446,9 +446,9 @@ test('opens vehicle service from an empty history query with the current filters
test('shows selected source scope on vehicle detail', async () => { test('shows selected source scope on vehicle detail', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001&protocol=JT808'); window.history.replaceState(null, '', '/#/detail?keyword=VIN001&protocol=JT808');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path.includes('/api/vehicles/detail')) { if (path.includes('/api/vehicle-service')) {
return { return {
ok: true, ok: true,
json: async () => ({ json: async () => ({
@@ -516,4 +516,5 @@ test('shows selected source scope on vehicle detail', async () => {
expect(await screen.findByText('当前查看范围')).toBeInTheDocument(); expect(await screen.findByText('当前查看范围')).toBeInTheDocument();
expect(screen.getByText('单一来源JT808')).toBeInTheDocument(); expect(screen.getByText('单一来源JT808')).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
}); });

View File

@@ -35,18 +35,20 @@ The platform is vehicle-first. Query APIs should accept `keyword` as the user-fa
The BFF resolves `keyword` through vehicle identity data before querying realtime, history, RAW, or mileage data. `vin` remains accepted as a compatibility alias on data query APIs, but new UI and integrations should send `keyword`. The BFF resolves `keyword` through vehicle identity data before querying realtime, history, RAW, or mileage data. `vin` remains accepted as a compatibility alias on data query APIs, but new UI and integrations should send `keyword`.
If a keyword cannot be resolved to a VIN, data APIs must not fabricate a VIN. They should return empty result pages for vehicle data, while `/api/vehicles/detail` exposes `lookupResolved=false` and quality issues for follow-up binding work. If a keyword cannot be resolved to a VIN, data APIs must not fabricate a VIN. They should return empty result pages for vehicle data, while `/api/vehicle-service` exposes `lookupResolved=false` and quality issues for follow-up binding work.
## Core Query APIs ## Core Query APIs
### Vehicle Detail ### Vehicle Service
```http ```http
GET /api/vehicles/detail?keyword=AG18312 GET /api/vehicle-service?keyword=AG18312
``` ```
Returns one vehicle service view with identity, realtime summary, source coverage, history preview, RAW preview, mileage preview, and quality issues. Returns one vehicle service view with identity, realtime summary, source coverage, history preview, RAW preview, mileage preview, and quality issues.
`/api/vehicles/detail` remains available as a compatibility alias. New UI and integrations should use `/api/vehicle-service` to make the vehicle-first boundary explicit.
### Realtime Vehicles ### Realtime Vehicles
```http ```http