feat(platform): add vehicle service API
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()}`),
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user