From 17c4591040410596e1e3b06c0ce0a47c289b0771 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 16:35:04 +0800 Subject: [PATCH] feat(platform): expose multi-source vehicle evidence --- .../apps/api/internal/platform/handler.go | 6 + .../api/internal/platform/handler_test.go | 47 +++ .../apps/api/internal/platform/mock_store.go | 27 ++ .../apps/api/internal/platform/model.go | 63 +++ .../apps/api/internal/platform/service.go | 209 ++++++++++ .../api/internal/platform/service_test.go | 25 ++ .../platform/source_evidence_store.go | 361 ++++++++++++++++++ .../apps/web/src/api/client.ts | 7 + .../apps/web/src/api/types.ts | 61 +++ .../apps/web/src/v2/pages/MonitorPage.tsx | 9 +- .../web/src/v2/pages/VehiclePage.test.tsx | 12 +- .../apps/web/src/v2/pages/VehiclePage.tsx | 7 +- .../VehicleSourceEvidencePanel.test.tsx | 58 +++ .../v2/shared/VehicleSourceEvidencePanel.tsx | 128 +++++++ .../apps/web/src/v2/styles/v2.css | 76 ++++ .../deploy/install-web-release.sh | 4 +- .../deploy/install-web-release.test.sh | 2 +- .../deploy/prune-release-history.py | 10 +- .../deploy/prune-release-history.test.sh | 4 +- vehicle-data-platform/docs/deployment.md | 4 + .../vehicle-data-platform-meeting-todo.md | 20 +- 21 files changed, 1129 insertions(+), 11 deletions(-) create mode 100644 vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.tsx diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index cc48ea4d..898c130b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -59,6 +59,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/profile", h.handleVehicleProfile) h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile) h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry) + h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/source-evidence", h.handleVehicleSourceEvidence) h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles) h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback) h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog) @@ -104,6 +105,11 @@ func (h *Handler) handleLatestTelemetry(w http.ResponseWriter, r *http.Request) h.write(w, r, data, err) } +func (h *Handler) handleVehicleSourceEvidence(w http.ResponseWriter, r *http.Request) { + data, err := h.service.VehicleSourceEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date")) + h.write(w, r, data, err) +} + func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) { var input VehicleProfileInput if !decodeJSONBody(w, r, &input) { 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 c04ad339..1e547b12 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -373,6 +373,53 @@ func TestHandlerVehicleDetail(t *testing.T) { } } +func TestHandlerVehicleSourceEvidence(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/vehicles/LB9A32A24R0LS1426/source-evidence?date=2026-07-16", 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 VehicleSourceEvidence `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 != "LB9A32A24R0LS1426" || body.Data.MileageDate != "2026-07-16" { + t.Fatalf("unexpected source evidence identity: %+v", body.Data) + } + if len(body.Data.LocationSources) != 3 || len(body.Data.MileageSources) != 2 { + t.Fatalf("source evidence should expose all mock candidates: %+v", body.Data) + } + recommended := 0 + for _, source := range body.Data.LocationSources { + if source.Recommended { + recommended++ + } + if strings.Contains(source.TerminalLabel, "13307795425") { + t.Fatalf("source evidence must not expose a raw terminal identifier: %+v", source) + } + } + if recommended != 1 || body.Data.RecommendedLocationProtocol != "JT808" { + t.Fatalf("source evidence should align with realtime election: %+v", body.Data) + } + if body.Data.Comparison.LocationMaxDistanceM <= 0 || body.Data.Comparison.TotalMileageDeltaKm <= 0 { + t.Fatalf("source evidence should calculate cross-source deltas: %+v", body.Data.Comparison) + } +} + +func TestHandlerVehicleSourceEvidenceRejectsInvalidDate(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/vehicles/LB9A32A24R0LS1426/source-evidence?date=16-07-2026", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "INVALID_DATE") { + t.Fatalf("unexpected invalid date response: status=%d body=%s", rec.Code, rec.Body.String()) + } +} + func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 99d8e9bd..be9ce5ab 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -58,6 +58,33 @@ func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfil return profile, ok, nil } +func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) { + if vin != "LB9A32A24R0LS1426" { + return VehicleSourceEvidence{ + VIN: vin, MileageDate: date, + LocationSources: []VehicleLocationSourceEvidence{}, + MileageSources: []VehicleMileageSourceEvidence{}, + }, nil + } + lonGB, latGB, speedGB, mileageGB, socGB := 113.2644, 23.1291, 41.8, 48798.9, 76.2 + lonJT1, latJT1, speedJT1, mileageJT1 := 113.2646, 23.1293, 42.5, 48797.6 + lonJT2, latJT2, speedJT2, mileageJT2 := 113.2675, 23.1315, 40.2, 48794.1 + firstGB, latestGB, dailyGB := 48782.3, 48798.9, 16.6 + firstJT, latestJT, dailyJT := 48781.7, 48797.6, 15.9 + return VehicleSourceEvidence{ + VIN: vin, Plate: "粤AG18312", MileageDate: date, + LocationSources: []VehicleLocationSourceEvidence{ + {Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, Online: true, QualityStatus: "OK", Longitude: &lonGB, Latitude: &latGB, SpeedKmh: &speedGB, TotalMileageKm: &mileageGB, SOCPercent: &socGB, EventTime: "2026-07-16 16:20:08", ReceivedAt: "2026-07-16 16:20:09", sourceKey: "GB32960:canonical"}, + {Protocol: "JT808", SourceLabel: "G7", TerminalLabel: "终端 133****5425", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 20, Online: true, QualityStatus: "OK", Longitude: &lonJT1, Latitude: &latJT1, SpeedKmh: &speedJT1, TotalMileageKm: &mileageJT1, EventTime: "2026-07-16 16:20:06", ReceivedAt: "2026-07-16 16:20:08", sourceKey: "jt808:g7"}, + {Protocol: "JT808", SourceLabel: "北斗平台", TerminalLabel: "终端 139****1208", SourceKind: "PLATFORM", SelectedWithinProtocol: false, Enabled: true, Priority: 30, Online: true, QualityStatus: "OK", Longitude: &lonJT2, Latitude: &latJT2, SpeedKmh: &speedJT2, TotalMileageKm: &mileageJT2, EventTime: "2026-07-16 16:19:58", ReceivedAt: "2026-07-16 16:20:02", sourceKey: "jt808:beidou"}, + }, + MileageSources: []VehicleMileageSourceEvidence{ + {Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, QualityStatus: "OK", FirstTotalMileageKm: &firstGB, LatestTotalMileageKm: &latestGB, DailyMileageKm: &dailyGB, SampleCount: 1080, FirstEventTime: "2026-07-16 00:00:10", LatestEventTime: "2026-07-16 16:20:08", sourceKey: "gb:factory"}, + {Protocol: "JT808", SourceLabel: "G7", TerminalLabel: "终端 133****5425", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 20, QualityStatus: "OK", FirstTotalMileageKm: &firstJT, LatestTotalMileageKm: &latestJT, DailyMileageKm: &dailyJT, SampleCount: 4320, FirstEventTime: "2026-07-16 00:00:03", LatestEventTime: "2026-07-16 16:20:06", sourceKey: "jt808:g7"}, + }, + }, nil +} + func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) { m.profileMu.Lock() defer m.profileMu.Unlock() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 635b14b6..947b1084 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -903,6 +903,69 @@ type VehicleSourceConsistency struct { Detail string `json:"detail"` } +type VehicleSourceEvidence struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + MileageDate string `json:"mileageDate"` + RecommendedLocationProtocol string `json:"recommendedLocationProtocol"` + RecommendedLocationLabel string `json:"recommendedLocationLabel"` + LocationConflict bool `json:"locationConflict"` + ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"` + LocationSources []VehicleLocationSourceEvidence `json:"locationSources"` + MileageSources []VehicleMileageSourceEvidence `json:"mileageSources"` + Comparison VehicleSourceEvidenceComparison `json:"comparison"` + AsOf string `json:"asOf"` +} + +type VehicleLocationSourceEvidence struct { + Protocol string `json:"protocol"` + SourceLabel string `json:"sourceLabel"` + TerminalLabel string `json:"terminalLabel"` + SourceKind string `json:"sourceKind"` + SelectedWithinProtocol bool `json:"selectedWithinProtocol"` + Recommended bool `json:"recommended"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + Online bool `json:"online"` + QualityStatus string `json:"qualityStatus"` + QualityReason string `json:"qualityReason"` + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + SpeedKmh *float64 `json:"speedKmh,omitempty"` + TotalMileageKm *float64 `json:"totalMileageKm,omitempty"` + SOCPercent *float64 `json:"socPercent,omitempty"` + EventTime string `json:"eventTime"` + ReceivedAt string `json:"receivedAt"` + sourceKey string +} + +type VehicleMileageSourceEvidence struct { + Protocol string `json:"protocol"` + SourceLabel string `json:"sourceLabel"` + TerminalLabel string `json:"terminalLabel"` + SourceKind string `json:"sourceKind"` + SelectedWithinProtocol bool `json:"selectedWithinProtocol"` + Recommended bool `json:"recommended"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + QualityStatus string `json:"qualityStatus"` + QualityReason string `json:"qualityReason"` + FirstTotalMileageKm *float64 `json:"firstTotalMileageKm,omitempty"` + LatestTotalMileageKm *float64 `json:"latestTotalMileageKm,omitempty"` + DailyMileageKm *float64 `json:"dailyMileageKm,omitempty"` + SampleCount int64 `json:"sampleCount"` + FirstEventTime string `json:"firstEventTime"` + LatestEventTime string `json:"latestEventTime"` + sourceKey string +} + +type VehicleSourceEvidenceComparison struct { + LocationMaxDistanceM float64 `json:"locationMaxDistanceM"` + TotalMileageDeltaKm float64 `json:"totalMileageDeltaKm"` + DailyMileageDeltaKm float64 `json:"dailyMileageDeltaKm"` + ReportTimeDeltaSeconds float64 `json:"reportTimeDeltaSeconds"` +} + type VehicleServiceOverview struct { VIN string `json:"vin"` Plate string `json:"plate"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index fc01c40e..566fec34 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -49,6 +49,10 @@ type VehicleProfileStore interface { SyncVehicleProfiles(context.Context, VehicleProfileSyncRequest) (VehicleProfileSyncResult, error) } +type VehicleSourceEvidenceStore interface { + VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error) +} + type RawFrameQuery struct { Protocol string `json:"protocol"` VIN string `json:"vin"` @@ -1008,6 +1012,211 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string }, nil } +func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) { + vin = strings.TrimSpace(vin) + if vin == "" { + return VehicleSourceEvidence{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"} + } + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return VehicleSourceEvidence{}, err + } + date = strings.TrimSpace(date) + if date == "" { + date = time.Now().Format("2006-01-02") + } else if _, err := time.Parse("2006-01-02", date); err != nil { + return VehicleSourceEvidence{}, clientError{Code: "INVALID_DATE", Message: "里程日期格式应为 YYYY-MM-DD"} + } + if err := authorizeVehicleDailyEvidenceDate(ctx, vin, date); err != nil { + return VehicleSourceEvidence{}, err + } + store, ok := s.store.(VehicleSourceEvidenceStore) + if !ok { + return VehicleSourceEvidence{}, errors.New("vehicle source evidence store is not configured") + } + evidence, err := store.VehicleSourceEvidence(ctx, vin, date) + if err != nil { + return VehicleSourceEvidence{}, err + } + evidence.VIN = vin + evidence.MileageDate = date + if evidence.LocationSources == nil { + evidence.LocationSources = []VehicleLocationSourceEvidence{} + } + if evidence.MileageSources == nil { + evidence.MileageSources = []VehicleMileageSourceEvidence{} + } + + realtime, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {vin}, "limit": {"1"}}) + if err != nil { + return VehicleSourceEvidence{}, err + } + var summary *VehicleRealtimeRow + if len(realtime.Items) > 0 { + summary = &realtime.Items[0] + if evidence.Plate == "" { + evidence.Plate = summary.Plate + } + evidence.RecommendedLocationProtocol = summary.PrimaryProtocol + evidence.LocationConflict = summary.LocationConflict + evidence.ConflictDistanceM = summary.ConflictDistanceM + for index := range evidence.LocationSources { + source := &evidence.LocationSources[index] + source.Recommended = source.Protocol == summary.PrimaryProtocol && + (source.sourceKey == summary.LocationSource || + (source.sourceKey == source.Protocol+":canonical" && summary.LocationSource == source.Protocol+":canonical")) + if source.Recommended { + evidence.RecommendedLocationLabel = source.SourceLabel + } + } + } + if evidence.RecommendedLocationLabel == "" { + for index := range evidence.LocationSources { + source := &evidence.LocationSources[index] + if source.SelectedWithinProtocol && (summary == nil || source.Protocol == summary.PrimaryProtocol) { + source.Recommended = true + evidence.RecommendedLocationProtocol = source.Protocol + evidence.RecommendedLocationLabel = source.SourceLabel + break + } + } + } + markRecommendedMileageSource(evidence.MileageSources, evidence.RecommendedLocationProtocol) + evidence.Comparison = compareVehicleSourceEvidence(evidence.LocationSources, evidence.MileageSources) + evidence.AsOf = time.Now().Format(time.RFC3339) + return evidence, nil +} + +func authorizeVehicleDailyEvidenceDate(ctx context.Context, vin string, date string) error { + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return nil + } + grant, ok := principal.VehicleGrant(vin) + if !ok || grant.ValidFrom.IsZero() { + return clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"} + } + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + requestedDay, err := time.ParseInLocation("2006-01-02", date, shanghai) + if err != nil { + return clientError{Code: "INVALID_DATE", Message: "里程日期格式应为 YYYY-MM-DD"} + } + validFrom := grant.ValidFrom.In(shanghai) + firstFullDay := time.Date(validFrom.Year(), validFrom.Month(), validFrom.Day(), 0, 0, 0, 0, shanghai) + if !validFrom.Equal(firstFullDay) { + firstFullDay = firstFullDay.AddDate(0, 0, 1) + } + if requestedDay.Before(firstFullDay) { + return clientError{Code: "HISTORY_BEFORE_AUTHORIZATION", Message: "所选里程日期早于该车辆首个完整授权日"} + } + if grant.ValidTo != nil && !grant.ValidTo.IsZero() { + requestedEnd := requestedDay.AddDate(0, 0, 1) + if requestedEnd.After(grant.ValidTo.In(shanghai)) { + return clientError{Code: "HISTORY_AFTER_AUTHORIZATION", Message: "所选里程日期超出该车辆完整授权范围"} + } + } + return nil +} + +func markRecommendedMileageSource(sources []VehicleMileageSourceEvidence, preferredProtocol string) { + for index := range sources { + sources[index].Recommended = false + } + for _, protocol := range append([]string{preferredProtocol}, canonicalVehicleProtocols...) { + if protocol == "" { + continue + } + for index := range sources { + if sources[index].Protocol == protocol && sources[index].SelectedWithinProtocol { + sources[index].Recommended = true + return + } + } + } +} + +func compareVehicleSourceEvidence(locations []VehicleLocationSourceEvidence, mileages []VehicleMileageSourceEvidence) VehicleSourceEvidenceComparison { + var comparison VehicleSourceEvidenceComparison + for left := 0; left < len(locations); left++ { + if locations[left].Longitude == nil || locations[left].Latitude == nil { + continue + } + for right := left + 1; right < len(locations); right++ { + if locations[right].Longitude == nil || locations[right].Latitude == nil { + continue + } + distanceM := haversineKm(*locations[left].Latitude, *locations[left].Longitude, *locations[right].Latitude, *locations[right].Longitude) * 1000 + if distanceM > comparison.LocationMaxDistanceM { + comparison.LocationMaxDistanceM = distanceM + } + } + } + var minTotal, maxTotal float64 + var hasTotal bool + var minDaily, maxDaily float64 + var hasDaily bool + var minTime, maxTime time.Time + for _, source := range locations { + if source.TotalMileageKm != nil { + value := *source.TotalMileageKm + if !hasTotal || value < minTotal { + minTotal = value + } + if !hasTotal || value > maxTotal { + maxTotal = value + } + hasTotal = true + } + if parsed, ok := parseVehicleServiceTime(firstNonEmpty(source.EventTime, source.ReceivedAt)); ok { + if minTime.IsZero() || parsed.Before(minTime) { + minTime = parsed + } + if maxTime.IsZero() || parsed.After(maxTime) { + maxTime = parsed + } + } + } + for _, source := range mileages { + if source.LatestTotalMileageKm != nil { + value := *source.LatestTotalMileageKm + if !hasTotal || value < minTotal { + minTotal = value + } + if !hasTotal || value > maxTotal { + maxTotal = value + } + hasTotal = true + } + if source.DailyMileageKm != nil { + value := *source.DailyMileageKm + if !hasDaily || value < minDaily { + minDaily = value + } + if !hasDaily || value > maxDaily { + maxDaily = value + } + hasDaily = true + } + if parsed, ok := parseVehicleServiceTime(source.LatestEventTime); ok { + if minTime.IsZero() || parsed.Before(minTime) { + minTime = parsed + } + if maxTime.IsZero() || parsed.After(maxTime) { + maxTime = parsed + } + } + } + if hasTotal { + comparison.TotalMileageDeltaKm = maxTotal - minTotal + } + if hasDaily { + comparison.DailyMileageDeltaKm = maxDaily - minDaily + } + if !minTime.IsZero() && !maxTime.IsZero() { + comparison.ReportTimeDeltaSeconds = maxTime.Sub(minTime).Seconds() + } + return comparison +} + func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []RealtimeLocationRow, protocol string) *VehicleSourceConsistency { consistency := &VehicleSourceConsistency{ SourceCount: len(statuses), diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index dbd51d58..be31fa24 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -186,6 +186,31 @@ func TestCustomerMileageFailsClosedWithoutGrantTime(t *testing.T) { } } +func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) { + service := NewService(newCountingStore()) + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, shanghai) + validTo := time.Date(2026, 7, 15, 10, 0, 0, 0, shanghai) + ctx := WithPrincipal(context.Background(), Principal{ + Name: "客户甲", Role: "customer", UserType: "customer", + VehicleVINs: []string{"LB9A32A24R0LS1426"}, + VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom, ValidTo: &validTo}}, + }) + if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-10"); err == nil { + t.Fatal("partial authorization start day should not expose daily source evidence") + } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" { + t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err) + } + if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-11"); err != nil { + t.Fatalf("first complete authorization day should be allowed: %v", err) + } + if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-15"); err == nil { + t.Fatal("partial authorization end day should not expose daily source evidence") + } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_AFTER_AUTHORIZATION" { + t.Fatalf("expected HISTORY_AFTER_AUTHORIZATION, err=%v", err) + } +} + func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ return s.MockStore.VehicleServiceOverviews(ctx, query) diff --git a/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go b/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go new file mode 100644 index 00000000..6eebbe70 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go @@ -0,0 +1,361 @@ +package platform + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" +) + +type canonicalLocationEvidence struct { + row VehicleLocationSourceEvidence +} + +func (s *ProductionStore) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) { + evidence := VehicleSourceEvidence{ + VIN: vin, + MileageDate: date, + LocationSources: []VehicleLocationSourceEvidence{}, + MileageSources: []VehicleMileageSourceEvidence{}, + } + _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(NULLIF(plate, '')), '') FROM vehicle_identity_binding WHERE vin = ?`, vin).Scan(&evidence.Plate) + + canonical, err := s.canonicalLocationEvidence(ctx, vin) + if err != nil { + return VehicleSourceEvidence{}, err + } + candidates, err := s.locationSourceEvidence(ctx, vin, canonical) + if err != nil { + return VehicleSourceEvidence{}, err + } + seen := make(map[string]struct{}, len(candidates)) + for _, source := range candidates { + seen[source.Protocol+"\x00"+source.sourceKey] = struct{}{} + evidence.LocationSources = append(evidence.LocationSources, source) + } + for _, source := range canonical { + key := source.row.Protocol + "\x00" + source.row.sourceKey + if _, exists := seen[key]; exists { + continue + } + evidence.LocationSources = append(evidence.LocationSources, source.row) + } + sortLocationEvidence(evidence.LocationSources) + + mileageSources, err := s.mileageSourceEvidence(ctx, vin, date) + if err != nil { + return VehicleSourceEvidence{}, err + } + canonicalMileage, err := s.canonicalMileageEvidence(ctx, vin, date) + if err != nil { + return VehicleSourceEvidence{}, err + } + selectedProtocols := map[string]bool{} + for _, source := range mileageSources { + if source.SelectedWithinProtocol { + selectedProtocols[source.Protocol] = true + } + evidence.MileageSources = append(evidence.MileageSources, source) + } + for _, source := range canonicalMileage { + if selectedProtocols[source.Protocol] { + continue + } + evidence.MileageSources = append(evidence.MileageSources, source) + } + sortMileageEvidence(evidence.MileageSources) + return evidence, nil +} + +func (s *ProductionStore) canonicalLocationEvidence(ctx context.Context, vin string) ([]canonicalLocationEvidence, error) { + rows, err := s.db.QueryContext(ctx, `SELECT protocol, COALESCE(NULLIF(source_key, ''), CONCAT(protocol, ':canonical')), +COALESCE(NULLIF(plate, ''), ''), DATE_FORMAT(event_time, '%Y-%m-%d %H:%i:%s'), +latitude, longitude, speed_kmh, total_mileage_km, soc_percent, +DATE_FORMAT(received_at, '%Y-%m-%d %H:%i:%s'), +CASE WHEN received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END +FROM vehicle_realtime_location +WHERE vin = ? +ORDER BY protocol`, vin) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]canonicalLocationEvidence, 0, 3) + for rows.Next() { + var row VehicleLocationSourceEvidence + var plate string + var eventTime, receivedAt sql.NullString + var latitude, longitude, speed, mileage, soc sql.NullFloat64 + var online int + if err := rows.Scan( + &row.Protocol, &row.sourceKey, &plate, &eventTime, + &latitude, &longitude, &speed, &mileage, &soc, &receivedAt, &online, + ); err != nil { + return nil, err + } + row.SourceLabel = row.Protocol + row.SourceKind = "CANONICAL" + row.SelectedWithinProtocol = true + row.Enabled = true + row.Online = online == 1 + row.QualityStatus = "OK" + row.Longitude = nullFloatPointer(longitude) + row.Latitude = nullFloatPointer(latitude) + row.SpeedKmh = nullFloatPointer(speed) + row.TotalMileageKm = nullFloatPointer(mileage) + row.SOCPercent = nullFloatPointer(soc) + row.EventTime = nullString(eventTime) + row.ReceivedAt = nullString(receivedAt) + out = append(out, canonicalLocationEvidence{row: row}) + } + return out, rows.Err() +} + +func (s *ProductionStore) locationSourceEvidence(ctx context.Context, vin string, canonical []canonicalLocationEvidence) ([]VehicleLocationSourceEvidence, error) { + selected := make(map[string]string, len(canonical)) + for _, source := range canonical { + selected[source.row.Protocol] = source.row.sourceKey + } + rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key, s.source_code, s.source_kind, s.phone, s.device_id, +DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s'), s.latitude, s.longitude, s.speed_kmh, +s.total_mileage_km, s.soc_percent, DATE_FORMAT(s.received_at, '%Y-%m-%d %H:%i:%s'), +s.quality_status, s.quality_reason, +COALESCE(p.enabled, 1), +COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END), +CASE WHEN s.received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END +FROM vehicle_realtime_location_source s +LEFT JOIN vehicle_location_source_policy p + ON p.vin = s.vin AND p.protocol = s.protocol AND p.source_key = s.source_key +WHERE s.vin = ? +ORDER BY s.protocol, COALESCE(p.enabled, 1) DESC, +COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END), +s.received_at DESC +LIMIT 50`, vin) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]VehicleLocationSourceEvidence, 0) + for rows.Next() { + var row VehicleLocationSourceEvidence + var sourceCode, phone, deviceID string + var eventTime, receivedAt, qualityReason sql.NullString + var latitude, longitude, speed, mileage, soc sql.NullFloat64 + var enabled, online int + if err := rows.Scan( + &row.Protocol, &row.sourceKey, &sourceCode, &row.SourceKind, &phone, &deviceID, + &eventTime, &latitude, &longitude, &speed, &mileage, &soc, &receivedAt, + &row.QualityStatus, &qualityReason, &enabled, &row.Priority, &online, + ); err != nil { + return nil, err + } + row.TerminalLabel = terminalEvidenceLabel(phone, deviceID) + row.SourceLabel = readableSourceLabel(row.Protocol, sourceCode, row.SourceKind, row.TerminalLabel) + row.SelectedWithinProtocol = selected[row.Protocol] == row.sourceKey + row.Enabled = enabled == 1 + row.Online = online == 1 + row.QualityReason = nullString(qualityReason) + row.Longitude = nullFloatPointer(longitude) + row.Latitude = nullFloatPointer(latitude) + row.SpeedKmh = nullFloatPointer(speed) + row.TotalMileageKm = nullFloatPointer(mileage) + row.SOCPercent = nullFloatPointer(soc) + row.EventTime = nullString(eventTime) + row.ReceivedAt = nullString(receivedAt) + out = append(out, row) + } + return out, rows.Err() +} + +func (s *ProductionStore) mileageSourceEvidence(ctx context.Context, vin string, date string) ([]VehicleMileageSourceEvidence, error) { + rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key, +COALESCE(NULLIF(TRIM(s.platform_name), ''), NULLIF(TRIM(ds.platform_name), ''), NULLIF(TRIM(ds.source_code), ''), ''), +COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), +COALESCE(s.phone, ''), COALESCE(s.device_id, ''), +s.first_total_mileage_km, s.latest_total_mileage_km, s.daily_mileage_km, s.sample_count, +DATE_FORMAT(s.first_event_time, '%Y-%m-%d %H:%i:%s'), +DATE_FORMAT(s.latest_event_time, '%Y-%m-%d %H:%i:%s'), +s.quality_status, s.quality_reason, s.is_selected, +COALESCE(ds.enabled, 1), COALESCE(ds.trust_priority, 100) +FROM vehicle_daily_mileage_source s +LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip +WHERE s.vin = ? AND s.stat_date = ? +ORDER BY s.protocol, s.is_selected DESC, COALESCE(ds.enabled, 1) DESC, +COALESCE(ds.trust_priority, 100), s.sample_count DESC, s.latest_event_time DESC +LIMIT 100`, vin, date) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]VehicleMileageSourceEvidence, 0) + for rows.Next() { + var row VehicleMileageSourceEvidence + var provider, phone, deviceID string + var firstTotal, latestTotal, daily sql.NullFloat64 + var firstTime, latestTime, qualityReason sql.NullString + var selected, enabled int + if err := rows.Scan( + &row.Protocol, &row.sourceKey, &provider, &row.SourceKind, &phone, &deviceID, + &firstTotal, &latestTotal, &daily, &row.SampleCount, &firstTime, &latestTime, + &row.QualityStatus, &qualityReason, &selected, &enabled, &row.Priority, + ); err != nil { + return nil, err + } + row.TerminalLabel = terminalEvidenceLabel(phone, deviceID) + row.SourceLabel = readableSourceLabel(row.Protocol, provider, row.SourceKind, row.TerminalLabel) + row.SelectedWithinProtocol = selected == 1 + row.Enabled = enabled == 1 + row.QualityReason = nullString(qualityReason) + row.FirstTotalMileageKm = nullFloatPointer(firstTotal) + row.LatestTotalMileageKm = nullFloatPointer(latestTotal) + row.DailyMileageKm = nullFloatPointer(daily) + row.FirstEventTime = nullString(firstTime) + row.LatestEventTime = nullString(latestTime) + out = append(out, row) + } + return out, rows.Err() +} + +func (s *ProductionStore) canonicalMileageEvidence(ctx context.Context, vin string, date string) ([]VehicleMileageSourceEvidence, error) { + rows, err := s.db.QueryContext(ctx, `SELECT m.protocol, +COALESCE(NULLIF(TRIM(ds.platform_name), ''), NULLIF(TRIM(ds.source_code), ''), ''), +COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'CANONICAL'), +m.latest_total_mileage_km, m.daily_mileage_km, +DATE_FORMAT(m.updated_at, '%Y-%m-%d %H:%i:%s'), +COALESCE(ds.enabled, 1), COALESCE(ds.trust_priority, 100) +FROM vehicle_daily_mileage m +LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id +WHERE m.vin = ? AND m.stat_date = ? +ORDER BY m.protocol`, vin, date) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]VehicleMileageSourceEvidence, 0, 3) + for rows.Next() { + var row VehicleMileageSourceEvidence + var provider string + var latestTotal, daily sql.NullFloat64 + var latestTime sql.NullString + var enabled int + if err := rows.Scan( + &row.Protocol, &provider, &row.SourceKind, &latestTotal, &daily, + &latestTime, &enabled, &row.Priority, + ); err != nil { + return nil, err + } + row.sourceKey = row.Protocol + ":canonical" + row.SourceLabel = readableSourceLabel(row.Protocol, provider, row.SourceKind, "") + row.SelectedWithinProtocol = true + row.Enabled = enabled == 1 + row.QualityStatus = "OK" + row.LatestTotalMileageKm = nullFloatPointer(latestTotal) + row.DailyMileageKm = nullFloatPointer(daily) + row.LatestEventTime = nullString(latestTime) + out = append(out, row) + } + return out, rows.Err() +} + +func readableSourceLabel(protocol string, provider string, sourceKind string, terminal string) string { + if provider = strings.TrimSpace(provider); provider != "" { + return provider + } + if terminal != "" { + return protocol + " · " + terminal + } + if sourceKind = strings.TrimSpace(sourceKind); sourceKind != "" && sourceKind != "UNKNOWN" && sourceKind != "CANONICAL" { + return protocol + " · " + sourceKind + } + return protocol +} + +func terminalEvidenceLabel(phone string, deviceID string) string { + parts := make([]string, 0, 2) + if phone = maskEvidenceIdentifier(phone); phone != "" { + parts = append(parts, "终端 "+phone) + } + if deviceID = maskEvidenceIdentifier(deviceID); deviceID != "" && (len(parts) == 0 || !strings.Contains(parts[0], deviceID)) { + parts = append(parts, "设备 "+deviceID) + } + return strings.Join(parts, " / ") +} + +func maskEvidenceIdentifier(value string) string { + value = strings.TrimSpace(value) + switch length := len([]rune(value)); { + case length == 0: + return "" + case length <= 4: + return "****" + case length <= 7: + runes := []rune(value) + return string(runes[:2]) + "***" + string(runes[length-2:]) + default: + runes := []rune(value) + return string(runes[:3]) + "****" + string(runes[length-4:]) + } +} + +func nullFloatPointer(value sql.NullFloat64) *float64 { + if !value.Valid { + return nil + } + result := value.Float64 + return &result +} + +func nullString(value sql.NullString) string { + if !value.Valid { + return "" + } + return value.String +} + +func protocolEvidenceRank(protocol string) int { + for index, candidate := range canonicalVehicleProtocols { + if protocol == candidate { + return index + } + } + return len(canonicalVehicleProtocols) +} + +func sortLocationEvidence(sources []VehicleLocationSourceEvidence) { + sort.SliceStable(sources, func(left, right int) bool { + lhs, rhs := sources[left], sources[right] + if protocolEvidenceRank(lhs.Protocol) != protocolEvidenceRank(rhs.Protocol) { + return protocolEvidenceRank(lhs.Protocol) < protocolEvidenceRank(rhs.Protocol) + } + if lhs.SelectedWithinProtocol != rhs.SelectedWithinProtocol { + return lhs.SelectedWithinProtocol + } + if lhs.Enabled != rhs.Enabled { + return lhs.Enabled + } + if lhs.Priority != rhs.Priority { + return lhs.Priority < rhs.Priority + } + return fmt.Sprintf("%s\x00%s", lhs.SourceLabel, lhs.TerminalLabel) < fmt.Sprintf("%s\x00%s", rhs.SourceLabel, rhs.TerminalLabel) + }) +} + +func sortMileageEvidence(sources []VehicleMileageSourceEvidence) { + sort.SliceStable(sources, func(left, right int) bool { + lhs, rhs := sources[left], sources[right] + if protocolEvidenceRank(lhs.Protocol) != protocolEvidenceRank(rhs.Protocol) { + return protocolEvidenceRank(lhs.Protocol) < protocolEvidenceRank(rhs.Protocol) + } + if lhs.SelectedWithinProtocol != rhs.SelectedWithinProtocol { + return lhs.SelectedWithinProtocol + } + if lhs.Enabled != rhs.Enabled { + return lhs.Enabled + } + if lhs.Priority != rhs.Priority { + return lhs.Priority < rhs.Priority + } + return fmt.Sprintf("%s\x00%s", lhs.SourceLabel, lhs.TerminalLabel) < fmt.Sprintf("%s\x00%s", rhs.SourceLabel, rhs.TerminalLabel) + }) +} diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index af24af63..7b5a007f 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -56,6 +56,7 @@ import type { VehicleIdentityResolution, VehicleServiceOverview, VehicleServiceSummary, + VehicleSourceEvidence, VehicleRow } from './types'; import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session'; @@ -242,6 +243,12 @@ export const api = { vehicleDetail: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/vehicle-service?${params.toString()}`, withSignal(undefined, signal)), vehicleProfile: (vin: string) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`), latestTelemetry: (vin: string, signal?: AbortSignal) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`, withSignal(undefined, signal)), + vehicleSourceEvidence: (vin: string, date?: string, signal?: AbortSignal) => { + const params = new URLSearchParams(); + if (date) params.set('date', date); + const suffix = params.toString() ? `?${params.toString()}` : ''; + return request(`/api/v2/vehicles/${encodeURIComponent(vin)}/source-evidence${suffix}`, withSignal(undefined, signal)); + }, updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index a26d0fd6..eea09bc9 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -502,6 +502,67 @@ export interface VehicleSourceConsistency { detail: string; } +export interface VehicleSourceEvidence { + vin: string; + plate: string; + mileageDate: string; + recommendedLocationProtocol: string; + recommendedLocationLabel: string; + locationConflict: boolean; + conflictDistanceM?: number; + locationSources: VehicleLocationSourceEvidence[]; + mileageSources: VehicleMileageSourceEvidence[]; + comparison: VehicleSourceEvidenceComparison; + asOf: string; +} + +export interface VehicleLocationSourceEvidence { + protocol: string; + sourceLabel: string; + terminalLabel: string; + sourceKind: string; + selectedWithinProtocol: boolean; + recommended: boolean; + enabled: boolean; + priority: number; + online: boolean; + qualityStatus: string; + qualityReason: string; + longitude?: number; + latitude?: number; + speedKmh?: number; + totalMileageKm?: number; + socPercent?: number; + eventTime: string; + receivedAt: string; +} + +export interface VehicleMileageSourceEvidence { + protocol: string; + sourceLabel: string; + terminalLabel: string; + sourceKind: string; + selectedWithinProtocol: boolean; + recommended: boolean; + enabled: boolean; + priority: number; + qualityStatus: string; + qualityReason: string; + firstTotalMileageKm?: number; + latestTotalMileageKm?: number; + dailyMileageKm?: number; + sampleCount: number; + firstEventTime: string; + latestEventTime: string; +} + +export interface VehicleSourceEvidenceComparison { + locationMaxDistanceM: number; + totalMileageDeltaKm: number; + dailyMileageDeltaKm: number; + reportTimeDeltaSeconds: number; +} + export interface VehicleServiceOverview { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index 965fac57..9238cea2 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -6,6 +6,7 @@ import { api } from '../../api/client'; import type { Page, VehicleRealtimeRow } from '../../api/types'; import { FleetMap } from '../map/FleetMap'; import { EmptyState, InlineError } from '../shared/AsyncState'; +import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel'; import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor'; import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData'; import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; @@ -184,6 +185,7 @@ function VehicleDetailCard({ onCollapse: () => void; onClear: () => void; }) { + const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false); const card = useMonitorVehicleCard(vehicle.vin, vehicle, true); const detail = card.detail.data; const activeAlerts = card.activeAlerts.data; @@ -222,8 +224,8 @@ function VehicleDetailCard({
速度{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}
SOC{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}{hasRealtimeSOC(vehicle) ? '%' : ''}
-
总里程{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}{hasRealtimeMileage(vehicle) ? 'km' : ''}
-
今日里程{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}{dailyMileage == null ? '' : 'km'}
+
总里程
+
今日里程
状态{statusLabel(status)}
当前告警{formatNumber(activeAlerts?.total ?? 0)}
@@ -233,11 +235,12 @@ function VehicleDetailCard({
时间
{vehicle.lastSeen || '暂无'}
新鲜度
{relativeFreshness(vehicle.lastSeen)}
-
坐标
{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}
+
坐标
位置
{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}
告警状态
{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}
+ ); } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx index 7ebae573..717e28c8 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, render, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { api } from '../../api/client'; @@ -52,6 +52,13 @@ test('polls lightweight single-vehicle realtime data and passes its report inter vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 }); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); + const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue({ + vin: initialRealtime.vin, plate: initialRealtime.plate, mileageDate: '2026-07-16', + recommendedLocationProtocol: 'JT808', recommendedLocationLabel: 'JT808', locationConflict: false, + locationSources: [], mileageSources: [], + comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, + asOf: '2026-07-16T10:00:00+08:00' + }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(} />); @@ -66,4 +73,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([ expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 }) ])); + expect(sourceEvidence).not.toHaveBeenCalled(); + fireEvent.click(screen.getByTitle('展开全部位置来源')); + await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1)); }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index 67485845..1e298723 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -15,6 +15,7 @@ import { formatZhNumber } from '../domain/formatters'; import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync'; import { FleetMap } from '../map/FleetMap'; import { InlineError, PageLoading } from '../shared/AsyncState'; +import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel'; function fmt(value?: string) { return value?.trim() || '—'; } function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; } @@ -173,6 +174,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) { const { session } = usePlatformSession(); + const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false); const realtime = liveRealtime ?? detail.realtimeSummary; const identity = detail.identity; const mapVehicles = realtime ? [realtime] : []; @@ -185,11 +187,12 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
-
undefined} />
{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}
+
undefined} />
实时指标{timeOnly(realtime?.lastSeen)}
-
速度{metric(realtime?.speedKmh)}km/h
SOC{metric(realtime?.socPercent)}%
总里程{metric(realtime?.totalMileageKm)}km
当日里程{metric(lastMileage?.dailyMileageKm)}km
在线来源{realtime?.onlineSourceCount ?? 0}
数据源总数{detail.sourceStatus.length}
+
速度{metric(realtime?.speedKmh)}km/h
SOC{metric(realtime?.socPercent)}%
总里程
当日里程
在线来源{realtime?.onlineSourceCount ?? 0}
数据源总数{detail.sourceStatus.length}
+
diff --git a/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx new file mode 100644 index 00000000..e842fe3b --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx @@ -0,0 +1,58 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { api } from '../../api/client'; +import type { VehicleSourceEvidence } from '../../api/types'; +import { VehicleSourceEvidencePanel } from './VehicleSourceEvidencePanel'; + +const evidence: VehicleSourceEvidence = { + vin: 'VIN-001', + plate: '粤A12345', + mileageDate: '2026-07-16', + recommendedLocationProtocol: 'JT808', + recommendedLocationLabel: 'G7', + locationConflict: true, + conflictDistanceM: 328, + locationSources: [ + { + protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM', + selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true, + qualityStatus: 'OK', qualityReason: '', longitude: 113.26, latitude: 23.13, speedKmh: 20, + totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01' + }, + { + protocol: 'JT808', sourceLabel: '北斗平台', terminalLabel: '终端 139****1208', sourceKind: 'PLATFORM', + selectedWithinProtocol: false, recommended: false, enabled: true, priority: 30, online: true, + qualityStatus: 'OK', qualityReason: '', longitude: 113.27, latitude: 23.14, speedKmh: 18, + totalMileageKm: 998, eventTime: '2026-07-16 09:59:55', receivedAt: '2026-07-16 09:59:57' + } + ], + mileageSources: [{ + protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM', + selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, qualityStatus: 'OK', + qualityReason: '', firstTotalMileageKm: 990, latestTotalMileageKm: 1000, dailyMileageKm: 10, + sampleCount: 100, firstEventTime: '2026-07-16 00:00:00', latestEventTime: '2026-07-16 10:00:00' + }], + comparison: { locationMaxDistanceM: 328, totalMileageDeltaKm: 2, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 6 }, + asOf: '2026-07-16T10:00:02+08:00' +}; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test('loads all source evidence only after the user expands it', async () => { + const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue(evidence); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render(); + + expect(sourceEvidence).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '查看全部来源' })); + await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1)); + expect(await screen.findByText('北斗平台')).toBeInTheDocument(); + expect(screen.getAllByText('当前推荐').length).toBeGreaterThan(0); + expect(screen.queryByText('13307795425')).not.toBeInTheDocument(); + client.clear(); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.tsx b/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.tsx new file mode 100644 index 00000000..16a09997 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.tsx @@ -0,0 +1,128 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { api } from '../../api/client'; +import type { VehicleLocationSourceEvidence, VehicleMileageSourceEvidence } from '../../api/types'; +import { QUERY_MEMORY } from '../queryPolicy'; + +function today() { + const now = new Date(); + const offset = now.getTimezoneOffset() * 60_000; + return new Date(now.getTime() - offset).toISOString().slice(0, 10); +} + +function number(value?: number, digits = 1) { + return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +} + +function coordinate(source: VehicleLocationSourceEvidence) { + if (source.longitude == null || source.latitude == null) return '—'; + return `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`; +} + +function evidenceTone(source: Pick) { + if (!source.enabled) return 'disabled'; + if (source.qualityStatus !== 'OK') return 'warning'; + if (source.recommended) return 'recommended'; + return source.online ? 'ready' : 'offline'; +} + +function sourceBadges(source: Pick) { + return <> + {source.recommended ? 当前推荐 : null} + {!source.recommended && source.selectedWithinProtocol ? 协议内选中 : null} + {!source.enabled ? 已禁用 : null} + {source.enabled && !source.online ? 离线 : null} + {source.qualityStatus !== 'OK' ? {source.qualityStatus} : null} + ; +} + +function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) { + return
+
{source.sourceLabel || source.protocol}{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}
+
+
坐标
{coordinate(source)}
+
速度
{source.speedKmh == null ? '—' : `${number(source.speedKmh)} km/h`}
+
总里程
{source.totalMileageKm == null ? '—' : `${number(source.totalMileageKm)} km`}
+
SOC
{source.socPercent == null ? '—' : `${number(source.socPercent)}%`}
+
设备时间
{source.eventTime || '—'}
+
接收时间
{source.receivedAt || '—'}
+
+ {source.qualityReason ?

质量说明:{source.qualityReason}

: null} +
; +} + +function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) { + const comparable = { ...source, online: true }; + return
+
{source.sourceLabel || source.protocol}{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}
+
+
当日里程
{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}
+
最新总里程
{source.latestTotalMileageKm == null ? '—' : `${number(source.latestTotalMileageKm)} km`}
+
起始总里程
{source.firstTotalMileageKm == null ? '—' : `${number(source.firstTotalMileageKm)} km`}
+
有效样本
{source.sampleCount.toLocaleString('zh-CN')}
+
首条时间
{source.firstEventTime || '—'}
+
末条时间
{source.latestEventTime || '—'}
+
+ {source.qualityReason ?

选举说明:{source.qualityReason}

: null} +
; +} + +export function VehicleSourceEvidencePanel({ + vin, + compact = false, + open, + onOpenChange +}: { + vin: string; + compact?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const expanded = open ?? internalOpen; + const setExpanded = (value: boolean) => { + if (open == null) setInternalOpen(value); + onOpenChange?.(value); + }; + const [date, setDate] = useState(today); + const query = useQuery({ + queryKey: ['vehicle-source-evidence', vin, date], + queryFn: ({ signal }) => api.vehicleSourceEvidence(vin, date, signal), + enabled: expanded && Boolean(vin), + staleTime: 15_000, + gcTime: QUERY_MEMORY.summaryGcTime, + refetchOnWindowFocus: false + }); + const sourceCount = (query.data?.locationSources.length ?? 0) + (query.data?.mileageSources.length ?? 0); + const description = useMemo(() => { + if (!query.data) return '按需读取,不影响车辆列表和地图刷新性能'; + if (sourceCount <= 2) return '当前车辆来源较少,仅展示实际存在的证据'; + return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`; + }, [query.data, sourceCount]); + + return
+
+
位置与里程来源{description}
+ +
+ {expanded ?
+
+ + 终端标识已脱敏;推荐结果来自后台选举,展开不会改变原始证据。 +
+ {query.isPending ?
正在读取全部来源证据…
: null} + {query.isError ?
{query.error instanceof Error ? query.error.message : '来源证据读取失败'}
: null} + {query.data ? <> +
+
推荐位置来源{query.data.recommendedLocationLabel || query.data.recommendedLocationProtocol || '—'}
+
最大位置差{number(query.data.comparison.locationMaxDistanceM)}m
+
总里程差{number(query.data.comparison.totalMileageDeltaKm)}km
+
上报时间差{number(query.data.comparison.reportTimeDeltaSeconds, 0)}s
+
+ {query.data.locationSources.length ?
当前位置来源{query.data.locationConflict ? `后台检测到位置冲突${query.data.conflictDistanceM == null ? '' : ` · ${number(query.data.conflictDistanceM)} m`}` : '推荐来源与备用来源并列展示'}
{query.data.locationSources.map((source, index) => )}
: null} + {query.data.mileageSources.length ?
{query.data.mileageDate} 里程来源日里程差 {number(query.data.comparison.dailyMileageDeltaKm)} km
{query.data.mileageSources.map((source, index) => )}
: null} + {!query.data.locationSources.length && !query.data.mileageSources.length ?
该车辆当前没有可展示的来源证据
: null} + : null} +
: null} +
; +} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 58f91c2e..f434b5d3 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -282,6 +282,64 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-metric-grid small { display: block; color: var(--v2-muted); font-size: 8px; } .v2-metric-grid strong { display: block; margin-top: 4px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } .v2-metric-grid em { margin-left: 2px; color: var(--v2-muted); font-size: 7px; font-style: normal; font-weight: 500; } +.v2-metric-source-link, .v2-live-source-link { display: block; width: 100%; margin-top: 4px; overflow: hidden; border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 700; } +.v2-metric-source-link:hover, .v2-live-source-link:hover, .v2-detail-source-link:hover, .v2-map-source-link:hover { color: var(--v2-blue); } +.v2-detail-source-link { max-width: 100%; overflow: hidden; border: 0; background: transparent; padding: 0; color: #3d4a5e; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font: inherit; } + +.v2-source-evidence { min-width: 0; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; box-shadow: 0 4px 18px rgba(23,43,77,.045); } +.v2-record-grid > .v2-source-evidence { grid-column: 1 / -1; } +.v2-source-evidence-trigger { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; } +.v2-source-evidence-trigger > div { min-width: 0; } +.v2-source-evidence-trigger strong { display: block; color: #25344a; font-size: 12px; } +.v2-source-evidence-trigger span { display: block; margin-top: 3px; overflow: hidden; color: #79869a; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; } +.v2-source-evidence-trigger button { flex: 0 0 auto; height: 30px; border: 1px solid #bfd4f6; border-radius: 7px; background: #f3f7ff; padding: 0 11px; color: var(--v2-blue); cursor: pointer; font-size: 9px; font-weight: 700; } +.v2-source-evidence-body { border-top: 1px solid var(--v2-border); padding: 12px 14px 14px; } +.v2-source-evidence-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 10px; } +.v2-source-evidence-toolbar label { display: flex; align-items: center; gap: 8px; color: #59677b; font-size: 9px; font-weight: 700; } +.v2-source-evidence-toolbar input { height: 30px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 8px; color: #344257; font-size: 9px; } +.v2-source-evidence-toolbar small { color: #8b96a8; font-size: 8px; } +.v2-source-evidence-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); overflow: hidden; border: 1px solid #dfe6ef; border-radius: 8px; background: #f8fafc; } +.v2-source-evidence-summary > div { min-width: 0; padding: 10px 12px; } +.v2-source-evidence-summary > div + div { border-left: 1px solid #e2e8f0; } +.v2-source-evidence-summary small { display: block; color: #7b8799; font-size: 8px; } +.v2-source-evidence-summary strong { display: block; margin-top: 4px; overflow: hidden; color: #26364c; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.v2-source-evidence-summary em { margin-left: 3px; color: #8793a5; font-size: 8px; font-style: normal; font-weight: 500; } +.v2-source-evidence-group { margin-top: 12px; padding: 0; border: 0; } +.v2-source-evidence-group > header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; } +.v2-source-evidence-group > header strong { color: #314157; font-size: 11px; } +.v2-source-evidence-group > header span { color: #8995a7; font-size: 8px; } +.v2-source-evidence-group > div { display: grid; grid-template-columns: repeat(auto-fit,minmax(245px,1fr)); gap: 8px; } +.v2-source-evidence-card { min-width: 0; overflow: hidden; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; } +.v2-source-evidence-card.is-recommended { border-color: #8eb8f5; box-shadow: inset 3px 0 #3b82f6; } +.v2-source-evidence-card.is-warning { border-color: #f0c16f; box-shadow: inset 3px 0 #eaa126; } +.v2-source-evidence-card.is-disabled, .v2-source-evidence-card.is-offline { background: #fafbfc; opacity: .82; } +.v2-source-evidence-card > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid #edf1f5; padding: 8px 10px; } +.v2-source-evidence-card > header > div { min-width: 0; } +.v2-source-evidence-card > header strong { display: block; overflow: hidden; color: #2c3b50; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.v2-source-evidence-card > header span { display: block; margin-top: 3px; overflow: hidden; color: #7e8a9c; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.v2-source-evidence-card > header aside { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 4px; } +.v2-source-evidence-card > header b { border-radius: 10px; background: #edf2f7; padding: 2px 6px; color: #64748b; font-size: 7px; } +.v2-source-evidence-card > header b.is-recommended { background: #e8f1ff; color: #1769d2; } +.v2-source-evidence-card > header b.is-disabled, .v2-source-evidence-card > header b.is-offline { background: #eef0f3; color: #7b8491; } +.v2-source-evidence-card > header b.is-warning { background: #fff4dc; color: #a46500; } +.v2-source-evidence-card dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; padding: 5px 10px 8px; } +.v2-source-evidence-card dl > div { min-width: 0; padding: 5px 3px; } +.v2-source-evidence-card dt { color: #8b96a7; font-size: 7px; } +.v2-source-evidence-card dd { margin: 3px 0 0; overflow: hidden; color: #445268; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.v2-source-evidence-card > p { margin: 0 10px 9px; border-radius: 5px; background: #fff7e8; padding: 6px 8px; color: #9a650b; font-size: 7px; line-height: 1.5; } +.v2-source-evidence-state { display: flex; min-height: 80px; align-items: center; justify-content: center; gap: 8px; color: #718096; font-size: 9px; } +.v2-source-evidence-state i { width: 16px; height: 16px; border: 2px solid #cbd8ea; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; } +.v2-source-evidence-state.is-error { color: #b42318; } +.v2-source-evidence-state button { height: 28px; border: 1px solid #edb5b0; border-radius: 6px; background: #fff; padding: 0 9px; color: #a61b13; cursor: pointer; font-size: 8px; } +.v2-source-evidence.is-compact { margin-top: 10px; border-right: 0; border-left: 0; border-radius: 0; box-shadow: none; } +.v2-source-evidence.is-compact .v2-source-evidence-trigger { padding: 9px 0; } +.v2-source-evidence.is-compact .v2-source-evidence-body { padding: 10px 0 2px; } +.v2-source-evidence.is-compact .v2-source-evidence-toolbar { align-items: flex-start; flex-direction: column; gap: 7px; } +.v2-source-evidence.is-compact .v2-source-evidence-summary { grid-template-columns: repeat(2,minmax(0,1fr)); } +.v2-source-evidence.is-compact .v2-source-evidence-summary > div:nth-child(3) { border-left: 0; } +.v2-source-evidence.is-compact .v2-source-evidence-summary > div:nth-child(n+3) { border-top: 1px solid #e2e8f0; } +.v2-source-evidence.is-compact .v2-source-evidence-group > div { grid-template-columns: 1fr; } +.v2-vehicle-detail .v2-source-evidence-group { padding: 0; border-top: 0; } .v2-event-strip { display: flex; min-height: 48px; align-items: center; gap: 22px; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; padding: 0 16px; box-shadow: 0 3px 12px rgba(21,32,51,.035); color: #6d7a8d; font-size: 10px; } .v2-event-strip strong { color: var(--v2-text); font-size: 11px; } @@ -381,6 +439,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-single-map-card .v2-map-legend { display: none; } .v2-single-map-card footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 14px; color: #64748b; font-size: 9px; } .v2-single-map-card footer span { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-map-source-link { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; border: 0; background: transparent; padding: 0; color: #64748b; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font: inherit; } .v2-single-map-card footer time { white-space: nowrap; font-variant-numeric: tabular-nums; } .v2-archive-card { grid-column: 2; grid-row: 1; } .v2-record-list { margin: 0; padding: 8px 14px 4px; } @@ -400,6 +459,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-live-grid > div:nth-child(n+4) { border-top: 1px solid var(--v2-border); } .v2-live-grid small { display: block; color: var(--v2-muted); font-size: 9px; } .v2-live-grid strong { display: block; margin-top: 5px; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } +.v2-live-source-link { margin-top: 5px; font-size: 17px; font-variant-numeric: tabular-nums; } .v2-live-grid em { margin-left: 3px; color: var(--v2-muted); font-size: 8px; font-style: normal; font-weight: 500; } .v2-telemetry-card { grid-column: 1; grid-row: 2 / span 2; } .v2-telemetry-card nav { display: flex; height: 42px; align-items: stretch; border-bottom: 1px solid var(--v2-border); padding: 0 8px; overflow-x: auto; } @@ -2041,4 +2101,20 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-mileage-table .is-total { width: 104px; min-width: 104px; padding-right: 10px; } .v2-mileage-table .is-daily { font-size: 10px; } .v2-mileage-table .is-period { font-size: 11px; } + .v2-source-evidence-trigger { min-height: 58px; padding: 10px 11px; } + .v2-source-evidence-trigger strong { font-size: 13px; } + .v2-source-evidence-trigger span { max-width: 220px; font-size: 10px; } + .v2-source-evidence-trigger button { height: 34px; font-size: 10px; } + .v2-source-evidence-body { padding: 10px; } + .v2-source-evidence-toolbar { align-items: flex-start; flex-direction: column; gap: 7px; } + .v2-source-evidence-toolbar label { font-size: 10px; } + .v2-source-evidence-toolbar input { height: 34px; font-size: 11px; } + .v2-source-evidence-summary { grid-template-columns: repeat(2,minmax(0,1fr)); } + .v2-source-evidence-summary > div:nth-child(3) { border-left: 0; } + .v2-source-evidence-summary > div:nth-child(n+3) { border-top: 1px solid #e2e8f0; } + .v2-source-evidence-summary strong { font-size: 15px; } + .v2-source-evidence-group > div { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin; } + .v2-source-evidence-card { min-width: min(82vw,300px); flex: 0 0 min(82vw,300px); scroll-snap-align: start; } + .v2-source-evidence-card > header strong { font-size: 11px; } + .v2-source-evidence-card dd { font-size: 10px; } } diff --git a/vehicle-data-platform/deploy/install-web-release.sh b/vehicle-data-platform/deploy/install-web-release.sh index 059dcf69..4e110c8b 100755 --- a/vehicle-data-platform/deploy/install-web-release.sh +++ b/vehicle-data-platform/deploy/install-web-release.sh @@ -68,7 +68,9 @@ trap rollback ERR test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; } mkdir -p "$next/web" cp "$old/platform-api" "$next/platform-api" -cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service" +if test -f "$old/lingniu-vehicle-platform.service"; then + cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service" +fi cp -a "$old/deploy" "$next/deploy" cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh" cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py" diff --git a/vehicle-data-platform/deploy/install-web-release.test.sh b/vehicle-data-platform/deploy/install-web-release.test.sh index ea8a5688..626e981f 100755 --- a/vehicle-data-platform/deploy/install-web-release.test.sh +++ b/vehicle-data-platform/deploy/install-web-release.test.sh @@ -10,7 +10,6 @@ old="$root/releases/old-release" mkdir -p "$old/web/assets" "$old/deploy" "$root/env" "$fixture/bin" printf '#!/usr/bin/env bash\nexit 0\n' > "$old/platform-api" chmod +x "$old/platform-api" -printf '[Service]\n' > "$old/lingniu-vehicle-platform.service" cp "$SCRIPT_DIR/verify-web-release.sh" "$old/deploy/verify-web-release.sh" printf 'old.js\n' > "$old/web/.release-assets" printf 'old\n' > "$old/web/assets/old.js" @@ -38,6 +37,7 @@ grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env" test -f "$root/current/web/assets/new.js" test -f "$root/current/web/assets/old.js" test -f "$root/current/web/.compatibility-manifests/1.assets" +test ! -e "$root/current/lingniu-vehicle-platform.service" test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3 grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out" diff --git a/vehicle-data-platform/deploy/prune-release-history.py b/vehicle-data-platform/deploy/prune-release-history.py index 770035f0..056746a6 100755 --- a/vehicle-data-platform/deploy/prune-release-history.py +++ b/vehicle-data-platform/deploy/prune-release-history.py @@ -29,6 +29,12 @@ def main(): raise SystemExit('protected release is outside the release root: {}'.format(path)) protected.add(path) + metadata_removed = 0 + for path in releases.iterdir(): + if path.is_file() and path.name.startswith('._'): + path.unlink() + metadata_removed += 1 + candidates = [path.resolve() for path in releases.iterdir() if path.is_dir() and not path.is_symlink()] candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True) keep = set(protected) @@ -46,7 +52,9 @@ def main(): shutil.rmtree(str(path)) removed += 1 - print('release_prune=ok before={} kept={} removed={}'.format(len(candidates), len(keep), removed)) + print('release_prune=ok before={} kept={} removed={} metadata_removed={}'.format( + len(candidates), len(keep), removed, metadata_removed + )) if __name__ == '__main__': diff --git a/vehicle-data-platform/deploy/prune-release-history.test.sh b/vehicle-data-platform/deploy/prune-release-history.test.sh index e38a00a9..88c8be5d 100755 --- a/vehicle-data-platform/deploy/prune-release-history.test.sh +++ b/vehicle-data-platform/deploy/prune-release-history.test.sh @@ -10,9 +10,11 @@ for index in 1 2 3 4 5 6; do mkdir "$fixture/releases/r$index" touch -t "20260716010${index}" "$fixture/releases/r$index" done +printf 'metadata\n' > "$fixture/releases/._r1" output=$(python3 "$SCRIPT_DIR/prune-release-history.py" "$fixture" 3 "$fixture/releases/r1") -test "$output" = 'release_prune=ok before=6 kept=3 removed=3' +test "$output" = 'release_prune=ok before=6 kept=3 removed=3 metadata_removed=1' +test ! -e "$fixture/releases/._r1" test -d "$fixture/releases/r1" test -d "$fixture/releases/r6" test -d "$fixture/releases/r5" diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index fb8ec8fb..44f6215f 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -205,6 +205,8 @@ curl -fsS -H "$AUTH_HEADER" -H 'Content-Type: application/json' \ -d '{"keyword":"LNXNEGRR7SR318212","status":"active","limit":20,"offset":0}' \ http://127.0.0.1:20300/api/v2/alerts/events curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/telemetry/latest +curl -fsS -H "$AUTH_HEADER" \ + 'http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/source-evidence?date=2026-07-16' curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/vehicles?limit=5' curl -fsS -H "$AUTH_HEADER" \ 'http://127.0.0.1:20300/api/v2/history/series?keyword=LNXNEGRR7SR318212&dateFrom=2026-07-14T00%3A00&dateTo=2026-07-14T06%3A00&targetPoints=240' @@ -237,6 +239,8 @@ Track smoke must also select the final playback event and verify synchronized SO Global-monitor smoke must verify `zoom=5` returns clusters, a city `bounds` at `zoom=13` returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`. +Source-evidence smoke must use a vehicle with at least two protocols and a vehicle with more than one JT808 terminal. Verify that the response returns every actual location/mileage candidate, exactly one current recommendation, protocol-internal selection, availability/quality state and cross-source differences. Raw `source_key`, source IP, endpoint, phone and device identifiers must not appear; terminal labels must remain masked. The browser must not call this endpoint before the operator expands coordinates or mileage. Production baseline for `沪A35898F` on 2026-07-16 returned two JT808 terminals with one recommendation; 20 sequential requests had a 26.6ms median and 29.1ms P95. + Latest-telemetry smoke must use an actively reporting VIN and verify that populated categories exactly sum to `values.length`, catalog-mapped values retain both the unified `key` and exact `sourceField`, every value has frame/time/protocol evidence, and the quality reason agrees with freshness and parser status. `scannedFrames` must remain at or below 15: the implementation resolves identity once, queries the three supported protocol tables concurrently with five rows each, and never runs a pagination count. The browser must display source, compact device time and quality without deriving labels, units or categories from RAW keys. The bounded synthetic gate is `go test ./internal/platform -run 'TestBuildLatestTelemetryResponse|TestLatestTelemetryQuality|TestLatestTelemetryBoundsProtocolReads' -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkLatestTelemetryHundredFrames -benchmem`. Alert evaluation is intentionally not exposed as an HTTP route because it mutates candidate/event state. Production evaluation is owned by the evaluator unit. Confirm its `rules/vehicles/candidates_advanced/duplicate_observations/late_observations/stale_evidence_skipped/opened/recovered` log line and verify the resulting event through the read APIs. A release gate with an active scoped rule must survive at least one persisted candidate reload and one persisted last-trigger reload; MySQL `DATETIME(3)` values are scanned directly as times so millisecond values are never forced through an integer `UNIX_TIMESTAMP` conversion. diff --git a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md index b7ae1c95..7933a891 100644 --- a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md +++ b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md @@ -4,6 +4,13 @@ 唯一会议依据:`张兰发起的视频会议_0716.txt` +来源校验: + +- 原文件:`/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/张兰发起的视频会议_0716.txt` +- 文件修改时间:`2026-07-16 15:16:16 +0800` +- 文件大小:43,820 字节,共 1,259 行 +- SHA-256:`57f34ac130ec5a96d9d7929df4977acad50b5aeeae869f864c77bf99017063c3` + 当前项目:`vehicle-data-platform` ## 1. 范围与执行原则 @@ -121,7 +128,18 @@ ### P0-03 位置与里程的多来源展开 -状态:`进行中` +状态:`已完成` + +已上线结果(release `source-evidence-scope-20260716163402`): + +- 新增按车辆、日期读取全部位置与里程来源的受 Scope 保护接口;只有用户主动展开后才请求,不参与列表、地图初始化或定时车队刷新。 +- 全局监控和单车详情均可点击坐标、总里程、当日里程,或使用“查看全部来源”按钮展开。 +- 同一协议多终端独立展示来源平台、脱敏终端、协议内选中、当前推荐、启停、在线和质量状态。 +- 位置卡片展示坐标、速度、总里程、SOC、设备时间和接收时间;里程卡片展示首末总里程、日里程、样本数和首末时间。 +- 自动计算最大位置差、总里程差、当日里程差和上报时间差;展开只解释后台选举,不改变原始证据。 +- 生产真实车辆 `沪A35898F` 验证可同时展示赛格和 G7s 两个 JT808 终端,并仅推荐其中一个;20 次接口请求中位约 26.6ms、P95 约 29.1ms。 +- 终端手机号/设备号在服务端脱敏,响应不返回原始 `source_key`、来源 IP 或端点。 +- 客户查询历史日期时继续执行车辆授权时间边界:授权首日/结束日不是完整自然日时不返回该日的来源里程证据。 目标: