feat(platform): expose multi-source vehicle evidence
This commit is contained in:
@@ -59,6 +59,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/profile", h.handleVehicleProfile)
|
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("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}/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("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
|
||||||
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
|
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
|
||||||
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
|
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)
|
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) {
|
func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
var input VehicleProfileInput
|
var input VehicleProfileInput
|
||||||
if !decodeJSONBody(w, r, &input) {
|
if !decodeJSONBody(w, r, &input) {
|
||||||
|
|||||||
@@ -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) {
|
func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -58,6 +58,33 @@ func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfil
|
|||||||
return profile, ok, nil
|
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) {
|
func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
|
||||||
m.profileMu.Lock()
|
m.profileMu.Lock()
|
||||||
defer m.profileMu.Unlock()
|
defer m.profileMu.Unlock()
|
||||||
|
|||||||
@@ -903,6 +903,69 @@ type VehicleSourceConsistency struct {
|
|||||||
Detail string `json:"detail"`
|
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 {
|
type VehicleServiceOverview struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Plate string `json:"plate"`
|
Plate string `json:"plate"`
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ type VehicleProfileStore interface {
|
|||||||
SyncVehicleProfiles(context.Context, VehicleProfileSyncRequest) (VehicleProfileSyncResult, error)
|
SyncVehicleProfiles(context.Context, VehicleProfileSyncRequest) (VehicleProfileSyncResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VehicleSourceEvidenceStore interface {
|
||||||
|
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
|
||||||
|
}
|
||||||
|
|
||||||
type RawFrameQuery struct {
|
type RawFrameQuery struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
@@ -1008,6 +1012,211 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
}, nil
|
}, 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 {
|
func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []RealtimeLocationRow, protocol string) *VehicleSourceConsistency {
|
||||||
consistency := &VehicleSourceConsistency{
|
consistency := &VehicleSourceConsistency{
|
||||||
SourceCount: len(statuses),
|
SourceCount: len(statuses),
|
||||||
|
|||||||
@@ -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) {
|
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||||
s.overviewBatchCalls++
|
s.overviewBatchCalls++
|
||||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import type {
|
|||||||
VehicleIdentityResolution,
|
VehicleIdentityResolution,
|
||||||
VehicleServiceOverview,
|
VehicleServiceOverview,
|
||||||
VehicleServiceSummary,
|
VehicleServiceSummary,
|
||||||
|
VehicleSourceEvidence,
|
||||||
VehicleRow
|
VehicleRow
|
||||||
} from './types';
|
} from './types';
|
||||||
import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session';
|
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)),
|
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`),
|
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)),
|
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`, {
|
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -502,6 +502,67 @@ export interface VehicleSourceConsistency {
|
|||||||
detail: string;
|
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 {
|
export interface VehicleServiceOverview {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '../../api/client';
|
|||||||
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
||||||
import { FleetMap } from '../map/FleetMap';
|
import { FleetMap } from '../map/FleetMap';
|
||||||
import { EmptyState, InlineError } from '../shared/AsyncState';
|
import { EmptyState, InlineError } from '../shared/AsyncState';
|
||||||
|
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||||
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
|
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 { 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';
|
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||||
@@ -184,6 +185,7 @@ function VehicleDetailCard({
|
|||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
|
||||||
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
|
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
|
||||||
const detail = card.detail.data;
|
const detail = card.detail.data;
|
||||||
const activeAlerts = card.activeAlerts.data;
|
const activeAlerts = card.activeAlerts.data;
|
||||||
@@ -222,8 +224,8 @@ function VehicleDetailCard({
|
|||||||
<div className="v2-metric-grid">
|
<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>速度</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>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><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><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)}>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></button></div>
|
||||||
<div><small>状态</small><strong>{statusLabel(status)}</strong></div>
|
<div><small>状态</small><strong>{statusLabel(status)}</strong></div>
|
||||||
<div><small>当前告警</small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em>条</em></strong></div>
|
<div><small>当前告警</small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em>条</em></strong></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,11 +235,12 @@ function VehicleDetailCard({
|
|||||||
<dl className="v2-detail-list">
|
<dl className="v2-detail-list">
|
||||||
<div><dt>时间</dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
|
<div><dt>时间</dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
|
||||||
<div><dt>新鲜度</dt><dd>{relativeFreshness(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>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
|
||||||
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
|
<div><dt>告警状态</dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
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 { afterEach, expect, test, vi } from 'vitest';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
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);
|
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
|
||||||
const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 });
|
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);
|
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 } } });
|
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>);
|
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([
|
await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([
|
||||||
expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 })
|
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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { formatZhNumber } from '../domain/formatters';
|
|||||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||||
import { FleetMap } from '../map/FleetMap';
|
import { FleetMap } from '../map/FleetMap';
|
||||||
import { InlineError, PageLoading } from '../shared/AsyncState';
|
import { InlineError, PageLoading } from '../shared/AsyncState';
|
||||||
|
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||||
|
|
||||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||||
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
|
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 }) {
|
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
|
||||||
const { session } = usePlatformSession();
|
const { session } = usePlatformSession();
|
||||||
|
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
|
||||||
const realtime = liveRealtime ?? detail.realtimeSummary;
|
const realtime = liveRealtime ?? detail.realtimeSummary;
|
||||||
const identity = detail.identity;
|
const identity = detail.identity;
|
||||||
const mapVehicles = realtime ? [realtime] : [];
|
const mapVehicles = realtime ? [realtime] : [];
|
||||||
@@ -185,11 +187,12 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="v2-record-grid">
|
<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} />
|
<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">
|
<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>
|
</div></section>
|
||||||
|
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
|
||||||
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
|
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
|
||||||
<Events detail={detail} />
|
<Events detail={detail} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 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 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-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 { 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; }
|
.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 .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 { 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-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-single-map-card footer time { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
.v2-archive-card { grid-column: 2; grid-row: 1; }
|
.v2-archive-card { grid-column: 2; grid-row: 1; }
|
||||||
.v2-record-list { margin: 0; padding: 8px 14px 4px; }
|
.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 > 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 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-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-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 { 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; }
|
.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-total { width: 104px; min-width: 104px; padding-right: 10px; }
|
||||||
.v2-mileage-table .is-daily { font-size: 10px; }
|
.v2-mileage-table .is-daily { font-size: 10px; }
|
||||||
.v2-mileage-table .is-period { font-size: 11px; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ trap rollback ERR
|
|||||||
test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; }
|
test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; }
|
||||||
mkdir -p "$next/web"
|
mkdir -p "$next/web"
|
||||||
cp "$old/platform-api" "$next/platform-api"
|
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 -a "$old/deploy" "$next/deploy"
|
||||||
cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh"
|
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"
|
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ old="$root/releases/old-release"
|
|||||||
mkdir -p "$old/web/assets" "$old/deploy" "$root/env" "$fixture/bin"
|
mkdir -p "$old/web/assets" "$old/deploy" "$root/env" "$fixture/bin"
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$old/platform-api"
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$old/platform-api"
|
||||||
chmod +x "$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"
|
cp "$SCRIPT_DIR/verify-web-release.sh" "$old/deploy/verify-web-release.sh"
|
||||||
printf 'old.js\n' > "$old/web/.release-assets"
|
printf 'old.js\n' > "$old/web/.release-assets"
|
||||||
printf 'old\n' > "$old/web/assets/old.js"
|
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/new.js"
|
||||||
test -f "$root/current/web/assets/old.js"
|
test -f "$root/current/web/assets/old.js"
|
||||||
test -f "$root/current/web/.compatibility-manifests/1.assets"
|
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
|
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"
|
grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out"
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ def main():
|
|||||||
raise SystemExit('protected release is outside the release root: {}'.format(path))
|
raise SystemExit('protected release is outside the release root: {}'.format(path))
|
||||||
protected.add(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 = [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)
|
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
||||||
keep = set(protected)
|
keep = set(protected)
|
||||||
@@ -46,7 +52,9 @@ def main():
|
|||||||
shutil.rmtree(str(path))
|
shutil.rmtree(str(path))
|
||||||
removed += 1
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ for index in 1 2 3 4 5 6; do
|
|||||||
mkdir "$fixture/releases/r$index"
|
mkdir "$fixture/releases/r$index"
|
||||||
touch -t "20260716010${index}" "$fixture/releases/r$index"
|
touch -t "20260716010${index}" "$fixture/releases/r$index"
|
||||||
done
|
done
|
||||||
|
printf 'metadata\n' > "$fixture/releases/._r1"
|
||||||
|
|
||||||
output=$(python3 "$SCRIPT_DIR/prune-release-history.py" "$fixture" 3 "$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/r1"
|
||||||
test -d "$fixture/releases/r6"
|
test -d "$fixture/releases/r6"
|
||||||
test -d "$fixture/releases/r5"
|
test -d "$fixture/releases/r5"
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ curl -fsS -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
|
|||||||
-d '{"keyword":"LNXNEGRR7SR318212","status":"active","limit":20,"offset":0}' \
|
-d '{"keyword":"LNXNEGRR7SR318212","status":"active","limit":20,"offset":0}' \
|
||||||
http://127.0.0.1:20300/api/v2/alerts/events
|
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/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/vehicles?limit=5'
|
||||||
curl -fsS -H "$AUTH_HEADER" \
|
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'
|
'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`.
|
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`.
|
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.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
唯一会议依据:`张兰发起的视频会议_0716.txt`
|
唯一会议依据:`张兰发起的视频会议_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`
|
当前项目:`vehicle-data-platform`
|
||||||
|
|
||||||
## 1. 范围与执行原则
|
## 1. 范围与执行原则
|
||||||
@@ -121,7 +128,18 @@
|
|||||||
|
|
||||||
### P0-03 位置与里程的多来源展开
|
### P0-03 位置与里程的多来源展开
|
||||||
|
|
||||||
状态:`进行中`
|
状态:`已完成`
|
||||||
|
|
||||||
|
已上线结果(release `source-evidence-scope-20260716163402`):
|
||||||
|
|
||||||
|
- 新增按车辆、日期读取全部位置与里程来源的受 Scope 保护接口;只有用户主动展开后才请求,不参与列表、地图初始化或定时车队刷新。
|
||||||
|
- 全局监控和单车详情均可点击坐标、总里程、当日里程,或使用“查看全部来源”按钮展开。
|
||||||
|
- 同一协议多终端独立展示来源平台、脱敏终端、协议内选中、当前推荐、启停、在线和质量状态。
|
||||||
|
- 位置卡片展示坐标、速度、总里程、SOC、设备时间和接收时间;里程卡片展示首末总里程、日里程、样本数和首末时间。
|
||||||
|
- 自动计算最大位置差、总里程差、当日里程差和上报时间差;展开只解释后台选举,不改变原始证据。
|
||||||
|
- 生产真实车辆 `沪A35898F` 验证可同时展示赛格和 G7s 两个 JT808 终端,并仅推荐其中一个;20 次接口请求中位约 26.6ms、P95 约 29.1ms。
|
||||||
|
- 终端手机号/设备号在服务端脱敏,响应不返回原始 `source_key`、来源 IP 或端点。
|
||||||
|
- 客户查询历史日期时继续执行车辆授权时间边界:授权首日/结束日不是完整自然日时不返回该日的来源里程证据。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user