feat(platform): expose multi-source vehicle evidence

This commit is contained in:
lingniu
2026-07-16 16:35:04 +08:00
parent 196cfa018f
commit 17c4591040
21 changed files with 1129 additions and 11 deletions

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"`

View File

@@ -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),

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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<VehicleDetail>(`/api/vehicle-service?${params.toString()}`, withSignal(undefined, signal)),
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
latestTelemetry: (vin: string, signal?: AbortSignal) => request<LatestTelemetryResponse>(`/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<VehicleSourceEvidence>(`/api/v2/vehicles/${encodeURIComponent(vin)}/source-evidence${suffix}`, withSignal(undefined, signal));
},
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),

View File

@@ -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;

View File

@@ -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({
<div className="v2-metric-grid">
<div><small></small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
<div><small></small><strong>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></strong></div>
<div><small></small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
<div><small></small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></button></div>
<div><small></small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></button></div>
<div><small></small><strong>{statusLabel(status)}</strong></div>
<div><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
</div>
@@ -233,11 +235,12 @@ function VehicleDetailCard({
<dl className="v2-detail-list">
<div><dt></dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
<div><dt></dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</dd></div>
<div><dt></dt><dd><button type="button" className="v2-detail-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</button></dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
</aside>
);
}

View File

@@ -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(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
@@ -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));
});

View File

@@ -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
</section>
<div className="v2-record-grid">
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><span><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</span><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
<section className="v2-record-card v2-live-card"><header><strong></strong><span><IconClock />{timeOnly(realtime?.lastSeen)}</span></header><div className="v2-live-grid">
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><strong>{metric(realtime?.totalMileageKm)}<em>km</em></strong></div><div><small></small><strong>{metric(lastMileage?.dailyMileageKm)}<em>km</em></strong></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{metric(realtime?.totalMileageKm)}<em>km</em></button></div><div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{metric(lastMileage?.dailyMileageKm)}<em>km</em></button></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
</div></section>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<Events detail={detail} />
</div>

View File

@@ -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(<QueryClientProvider client={client}><VehicleSourceEvidencePanel vin="VIN-001" /></QueryClientProvider>);
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();
});

View File

@@ -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<VehicleLocationSourceEvidence, 'recommended' | 'enabled' | 'online' | 'qualityStatus'>) {
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<VehicleLocationSourceEvidence, 'recommended' | 'selectedWithinProtocol' | 'enabled' | 'online' | 'qualityStatus'>) {
return <>
{source.recommended ? <b className="is-recommended"></b> : null}
{!source.recommended && source.selectedWithinProtocol ? <b></b> : null}
{!source.enabled ? <b className="is-disabled"></b> : null}
{source.enabled && !source.online ? <b className="is-offline">线</b> : null}
{source.qualityStatus !== 'OK' ? <b className="is-warning">{source.qualityStatus}</b> : null}
</>;
}
function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) {
return <article className={`v2-source-evidence-card is-${evidenceTone(source)}`}>
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(source)}</aside></header>
<dl>
<div><dt></dt><dd>{coordinate(source)}</dd></div>
<div><dt></dt><dd>{source.speedKmh == null ? '—' : `${number(source.speedKmh)} km/h`}</dd></div>
<div><dt></dt><dd>{source.totalMileageKm == null ? '—' : `${number(source.totalMileageKm)} km`}</dd></div>
<div><dt>SOC</dt><dd>{source.socPercent == null ? '—' : `${number(source.socPercent)}%`}</dd></div>
<div><dt></dt><dd>{source.eventTime || '—'}</dd></div>
<div><dt></dt><dd>{source.receivedAt || '—'}</dd></div>
</dl>
{source.qualityReason ? <p>{source.qualityReason}</p> : null}
</article>;
}
function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) {
const comparable = { ...source, online: true };
return <article className={`v2-source-evidence-card is-${evidenceTone(comparable)}`}>
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(comparable)}</aside></header>
<dl>
<div><dt></dt><dd>{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.latestTotalMileageKm == null ? '—' : `${number(source.latestTotalMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.firstTotalMileageKm == null ? '—' : `${number(source.firstTotalMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.sampleCount.toLocaleString('zh-CN')}</dd></div>
<div><dt></dt><dd>{source.firstEventTime || '—'}</dd></div>
<div><dt></dt><dd>{source.latestEventTime || '—'}</dd></div>
</dl>
{source.qualityReason ? <p>{source.qualityReason}</p> : null}
</article>;
}
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 <section className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`}>
<header className="v2-source-evidence-trigger">
<div><strong></strong><span>{description}</span></div>
<button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</button>
</header>
{expanded ? <div className="v2-source-evidence-body">
<div className="v2-source-evidence-toolbar">
<label><span></span><input type="date" value={date} max={today()} onChange={(event) => setDate(event.target.value)} /></label>
<small></small>
</div>
{query.isPending ? <div className="v2-source-evidence-state"><i /></div> : null}
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><button type="button" onClick={() => void query.refetch()}></button></div> : null}
{query.data ? <>
<div className="v2-source-evidence-summary">
<div><small></small><strong>{query.data.recommendedLocationLabel || query.data.recommendedLocationProtocol || '—'}</strong></div>
<div><small></small><strong>{number(query.data.comparison.locationMaxDistanceM)}<em>m</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.totalMileageDeltaKm)}<em>km</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.reportTimeDeltaSeconds, 0)}<em>s</em></strong></div>
</div>
{query.data.locationSources.length ? <section className="v2-source-evidence-group"><header><strong></strong><span>{query.data.locationConflict ? `后台检测到位置冲突${query.data.conflictDistanceM == null ? '' : ` · ${number(query.data.conflictDistanceM)} m`}` : '推荐来源与备用来源并列展示'}</span></header><div>{query.data.locationSources.map((source, index) => <LocationSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{query.data.mileageSources.length ? <section className="v2-source-evidence-group"><header><strong>{query.data.mileageDate} </strong><span> {number(query.data.comparison.dailyMileageDeltaKm)} km</span></header><div>{query.data.mileageSources.map((source, index) => <MileageSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{!query.data.locationSources.length && !query.data.mileageSources.length ? <div className="v2-source-evidence-state"></div> : null}
</> : null}
</div> : null}
</section>;
}

View File

@@ -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; }
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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__':

View File

@@ -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"

View File

@@ -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.

View File

@@ -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 或端点。
- 客户查询历史日期时继续执行车辆授权时间边界:授权首日/结束日不是完整自然日时不返回该日的来源里程证据。
目标: