feat(platform): add batch vehicle overview api
This commit is contained in:
@@ -31,6 +31,7 @@ func (h *Handler) routes() {
|
|||||||
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/vehicle-service", h.handleVehicleDetail)
|
||||||
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
||||||
|
h.mux.HandleFunc("POST /api/vehicle-service/overviews", h.handleVehicleServiceOverviews)
|
||||||
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)
|
||||||
@@ -92,6 +93,17 @@ func (h *Handler) handleVehicleServiceOverview(w http.ResponseWriter, r *http.Re
|
|||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleVehicleServiceOverviews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
var query VehicleOverviewBatchQuery
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&query); err != nil {
|
||||||
|
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.VehicleServiceOverviews(r.Context(), query)
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleVehicleRealtime(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleVehicleRealtime(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.service.VehicleRealtime(r.Context(), r.URL.Query())
|
data, err := h.service.VehicleRealtime(r.Context(), r.URL.Query())
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -232,6 +233,35 @@ func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlerVehicleServiceOverviewsEndpoint(t *testing.T) {
|
||||||
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", bytes.NewBufferString(`{"keywords":["粤AG18312","LMRKH9AC2R1004087"]}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data Page[VehicleServiceOverview] `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.Total != 2 || len(body.Data.Items) != 2 {
|
||||||
|
t.Fatalf("batch overview should return two items, got %+v body=%s", body.Data, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Data.Items[0].VIN != "LB9A32A24R0LS1426" || body.Data.Items[1].VIN != "LMRKH9AC2R1004087" {
|
||||||
|
t.Fatalf("batch overview should preserve request order, got %+v", body.Data.Items)
|
||||||
|
}
|
||||||
|
if body.Data.Items[0].ServiceStatus == nil || body.Data.Items[1].ServiceStatus == nil {
|
||||||
|
t.Fatalf("batch overview should include canonical status for every vehicle, got %+v", body.Data.Items)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), `"raw"`) || strings.Contains(rec.Body.String(), `"history"`) {
|
||||||
|
t.Fatalf("batch overview should not return heavy detail pages: %s", 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()
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ type RawFrameQuery struct {
|
|||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VehicleOverviewBatchQuery struct {
|
||||||
|
Keywords []string `json:"keywords"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store Store
|
store Store
|
||||||
runtime RuntimeInfo
|
runtime RuntimeInfo
|
||||||
@@ -125,6 +132,33 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
|
|||||||
return *overview, nil
|
return *overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||||
|
keywords := normalizedKeywords(query.Keywords)
|
||||||
|
total := len(keywords)
|
||||||
|
if query.Offset < 0 {
|
||||||
|
query.Offset = 0
|
||||||
|
}
|
||||||
|
if query.Limit <= 0 || query.Limit > 200 {
|
||||||
|
query.Limit = 200
|
||||||
|
}
|
||||||
|
if query.Offset >= total {
|
||||||
|
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||||
|
}
|
||||||
|
end := query.Offset + query.Limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
items := make([]VehicleServiceOverview, 0, end-query.Offset)
|
||||||
|
for _, keyword := range keywords[query.Offset:end] {
|
||||||
|
overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
return Page[VehicleServiceOverview]{}, err
|
||||||
|
}
|
||||||
|
items = append(items, overview)
|
||||||
|
}
|
||||||
|
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||||
keyword := strings.TrimSpace(vin)
|
keyword := strings.TrimSpace(vin)
|
||||||
protocol = strings.TrimSpace(protocol)
|
protocol = strings.TrimSpace(protocol)
|
||||||
@@ -515,6 +549,23 @@ func cloneValues(values url.Values) url.Values {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizedKeywords(values []string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
||||||
statusByProtocol := map[string]*VehicleSourceStatus{}
|
statusByProtocol := map[string]*VehicleSourceStatus{}
|
||||||
ensure := func(protocol string) *VehicleSourceStatus {
|
ensure := func(protocol string) *VehicleSourceStatus {
|
||||||
|
|||||||
@@ -105,6 +105,42 @@ test('vehicleServiceOverview sends keyword to the lightweight overview endpoint'
|
|||||||
expect(result.serviceStatus?.status).toBe('degraded');
|
expect(result.serviceStatus?.status).toBe('degraded');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('vehicleServiceOverviews posts keywords to the batch overview endpoint', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ vin: 'VIN001', plate: '粤AG18312', protocols: ['JT808'], coverageStatus: 'online', sourceCount: 1, onlineSourceCount: 1 },
|
||||||
|
{ vin: 'VIN002', plate: '豫A88888', protocols: ['YUTONG_MQTT'], coverageStatus: 'online', sourceCount: 1, onlineSourceCount: 1 }
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
limit: 200,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.vehicleServiceOverviews({
|
||||||
|
keywords: ['粤AG18312', 'LMRKH9AC2R1004087'],
|
||||||
|
limit: 200,
|
||||||
|
offset: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/overviews', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
keywords: ['粤AG18312', 'LMRKH9AC2R1004087'],
|
||||||
|
limit: 200,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
});
|
||||||
|
expect(result.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
test('api errors include backend message, detail, and trace id', async () => {
|
test('api errors include backend message, detail, and trace id', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export type RawFrameQuery = {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type VehicleOverviewBatchQuery = {
|
||||||
|
keywords: string[];
|
||||||
|
protocol?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiErrorEnvelope = {
|
type ApiErrorEnvelope = {
|
||||||
error?: {
|
error?: {
|
||||||
code?: string;
|
code?: string;
|
||||||
@@ -77,6 +84,11 @@ export const api = {
|
|||||||
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/vehicle-service?${params.toString()}`),
|
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||||
|
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(query)
|
||||||
|
}),
|
||||||
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()}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user