feat(platform): expose multi-source vehicle evidence

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

View File

@@ -59,6 +59,7 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/profile", h.handleVehicleProfile)
h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry)
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/source-evidence", h.handleVehicleSourceEvidence)
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
@@ -104,6 +105,11 @@ func (h *Handler) handleLatestTelemetry(w http.ResponseWriter, r *http.Request)
h.write(w, r, data, err)
}
func (h *Handler) handleVehicleSourceEvidence(w http.ResponseWriter, r *http.Request) {
data, err := h.service.VehicleSourceEvidence(r.Context(), r.PathValue("vin"), r.URL.Query().Get("date"))
h.write(w, r, data, err)
}
func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) {
var input VehicleProfileInput
if !decodeJSONBody(w, r, &input) {

View File

@@ -373,6 +373,53 @@ func TestHandlerVehicleDetail(t *testing.T) {
}
}
func TestHandlerVehicleSourceEvidence(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/vehicles/LB9A32A24R0LS1426/source-evidence?date=2026-07-16", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data VehicleSourceEvidence `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
if body.Data.VIN != "LB9A32A24R0LS1426" || body.Data.MileageDate != "2026-07-16" {
t.Fatalf("unexpected source evidence identity: %+v", body.Data)
}
if len(body.Data.LocationSources) != 3 || len(body.Data.MileageSources) != 2 {
t.Fatalf("source evidence should expose all mock candidates: %+v", body.Data)
}
recommended := 0
for _, source := range body.Data.LocationSources {
if source.Recommended {
recommended++
}
if strings.Contains(source.TerminalLabel, "13307795425") {
t.Fatalf("source evidence must not expose a raw terminal identifier: %+v", source)
}
}
if recommended != 1 || body.Data.RecommendedLocationProtocol != "JT808" {
t.Fatalf("source evidence should align with realtime election: %+v", body.Data)
}
if body.Data.Comparison.LocationMaxDistanceM <= 0 || body.Data.Comparison.TotalMileageDeltaKm <= 0 {
t.Fatalf("source evidence should calculate cross-source deltas: %+v", body.Data.Comparison)
}
}
func TestHandlerVehicleSourceEvidenceRejectsInvalidDate(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/vehicles/LB9A32A24R0LS1426/source-evidence?date=16-07-2026", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "INVALID_DATE") {
t.Fatalf("unexpected invalid date response: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()

View File

@@ -58,6 +58,33 @@ func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfil
return profile, ok, nil
}
func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) {
if vin != "LB9A32A24R0LS1426" {
return VehicleSourceEvidence{
VIN: vin, MileageDate: date,
LocationSources: []VehicleLocationSourceEvidence{},
MileageSources: []VehicleMileageSourceEvidence{},
}, nil
}
lonGB, latGB, speedGB, mileageGB, socGB := 113.2644, 23.1291, 41.8, 48798.9, 76.2
lonJT1, latJT1, speedJT1, mileageJT1 := 113.2646, 23.1293, 42.5, 48797.6
lonJT2, latJT2, speedJT2, mileageJT2 := 113.2675, 23.1315, 40.2, 48794.1
firstGB, latestGB, dailyGB := 48782.3, 48798.9, 16.6
firstJT, latestJT, dailyJT := 48781.7, 48797.6, 15.9
return VehicleSourceEvidence{
VIN: vin, Plate: "粤AG18312", MileageDate: date,
LocationSources: []VehicleLocationSourceEvidence{
{Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, Online: true, QualityStatus: "OK", Longitude: &lonGB, Latitude: &latGB, SpeedKmh: &speedGB, TotalMileageKm: &mileageGB, SOCPercent: &socGB, EventTime: "2026-07-16 16:20:08", ReceivedAt: "2026-07-16 16:20:09", sourceKey: "GB32960:canonical"},
{Protocol: "JT808", SourceLabel: "G7", TerminalLabel: "终端 133****5425", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 20, Online: true, QualityStatus: "OK", Longitude: &lonJT1, Latitude: &latJT1, SpeedKmh: &speedJT1, TotalMileageKm: &mileageJT1, EventTime: "2026-07-16 16:20:06", ReceivedAt: "2026-07-16 16:20:08", sourceKey: "jt808:g7"},
{Protocol: "JT808", SourceLabel: "北斗平台", TerminalLabel: "终端 139****1208", SourceKind: "PLATFORM", SelectedWithinProtocol: false, Enabled: true, Priority: 30, Online: true, QualityStatus: "OK", Longitude: &lonJT2, Latitude: &latJT2, SpeedKmh: &speedJT2, TotalMileageKm: &mileageJT2, EventTime: "2026-07-16 16:19:58", ReceivedAt: "2026-07-16 16:20:02", sourceKey: "jt808:beidou"},
},
MileageSources: []VehicleMileageSourceEvidence{
{Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, QualityStatus: "OK", FirstTotalMileageKm: &firstGB, LatestTotalMileageKm: &latestGB, DailyMileageKm: &dailyGB, SampleCount: 1080, FirstEventTime: "2026-07-16 00:00:10", LatestEventTime: "2026-07-16 16:20:08", sourceKey: "gb:factory"},
{Protocol: "JT808", SourceLabel: "G7", TerminalLabel: "终端 133****5425", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 20, QualityStatus: "OK", FirstTotalMileageKm: &firstJT, LatestTotalMileageKm: &latestJT, DailyMileageKm: &dailyJT, SampleCount: 4320, FirstEventTime: "2026-07-16 00:00:03", LatestEventTime: "2026-07-16 16:20:06", sourceKey: "jt808:g7"},
},
}, nil
}
func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
m.profileMu.Lock()
defer m.profileMu.Unlock()

View File

@@ -903,6 +903,69 @@ type VehicleSourceConsistency struct {
Detail string `json:"detail"`
}
type VehicleSourceEvidence struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
MileageDate string `json:"mileageDate"`
RecommendedLocationProtocol string `json:"recommendedLocationProtocol"`
RecommendedLocationLabel string `json:"recommendedLocationLabel"`
LocationConflict bool `json:"locationConflict"`
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
LocationSources []VehicleLocationSourceEvidence `json:"locationSources"`
MileageSources []VehicleMileageSourceEvidence `json:"mileageSources"`
Comparison VehicleSourceEvidenceComparison `json:"comparison"`
AsOf string `json:"asOf"`
}
type VehicleLocationSourceEvidence struct {
Protocol string `json:"protocol"`
SourceLabel string `json:"sourceLabel"`
TerminalLabel string `json:"terminalLabel"`
SourceKind string `json:"sourceKind"`
SelectedWithinProtocol bool `json:"selectedWithinProtocol"`
Recommended bool `json:"recommended"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
Online bool `json:"online"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
Longitude *float64 `json:"longitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
SpeedKmh *float64 `json:"speedKmh,omitempty"`
TotalMileageKm *float64 `json:"totalMileageKm,omitempty"`
SOCPercent *float64 `json:"socPercent,omitempty"`
EventTime string `json:"eventTime"`
ReceivedAt string `json:"receivedAt"`
sourceKey string
}
type VehicleMileageSourceEvidence struct {
Protocol string `json:"protocol"`
SourceLabel string `json:"sourceLabel"`
TerminalLabel string `json:"terminalLabel"`
SourceKind string `json:"sourceKind"`
SelectedWithinProtocol bool `json:"selectedWithinProtocol"`
Recommended bool `json:"recommended"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
QualityStatus string `json:"qualityStatus"`
QualityReason string `json:"qualityReason"`
FirstTotalMileageKm *float64 `json:"firstTotalMileageKm,omitempty"`
LatestTotalMileageKm *float64 `json:"latestTotalMileageKm,omitempty"`
DailyMileageKm *float64 `json:"dailyMileageKm,omitempty"`
SampleCount int64 `json:"sampleCount"`
FirstEventTime string `json:"firstEventTime"`
LatestEventTime string `json:"latestEventTime"`
sourceKey string
}
type VehicleSourceEvidenceComparison struct {
LocationMaxDistanceM float64 `json:"locationMaxDistanceM"`
TotalMileageDeltaKm float64 `json:"totalMileageDeltaKm"`
DailyMileageDeltaKm float64 `json:"dailyMileageDeltaKm"`
ReportTimeDeltaSeconds float64 `json:"reportTimeDeltaSeconds"`
}
type VehicleServiceOverview struct {
VIN string `json:"vin"`
Plate string `json:"plate"`

View File

@@ -49,6 +49,10 @@ type VehicleProfileStore interface {
SyncVehicleProfiles(context.Context, VehicleProfileSyncRequest) (VehicleProfileSyncResult, error)
}
type VehicleSourceEvidenceStore interface {
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
}
type RawFrameQuery struct {
Protocol string `json:"protocol"`
VIN string `json:"vin"`
@@ -1008,6 +1012,211 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
}, nil
}
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
vin = strings.TrimSpace(vin)
if vin == "" {
return VehicleSourceEvidence{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"}
}
if err := authorizeVehicleVIN(ctx, vin); err != nil {
return VehicleSourceEvidence{}, err
}
date = strings.TrimSpace(date)
if date == "" {
date = time.Now().Format("2006-01-02")
} else if _, err := time.Parse("2006-01-02", date); err != nil {
return VehicleSourceEvidence{}, clientError{Code: "INVALID_DATE", Message: "里程日期格式应为 YYYY-MM-DD"}
}
if err := authorizeVehicleDailyEvidenceDate(ctx, vin, date); err != nil {
return VehicleSourceEvidence{}, err
}
store, ok := s.store.(VehicleSourceEvidenceStore)
if !ok {
return VehicleSourceEvidence{}, errors.New("vehicle source evidence store is not configured")
}
evidence, err := store.VehicleSourceEvidence(ctx, vin, date)
if err != nil {
return VehicleSourceEvidence{}, err
}
evidence.VIN = vin
evidence.MileageDate = date
if evidence.LocationSources == nil {
evidence.LocationSources = []VehicleLocationSourceEvidence{}
}
if evidence.MileageSources == nil {
evidence.MileageSources = []VehicleMileageSourceEvidence{}
}
realtime, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {vin}, "limit": {"1"}})
if err != nil {
return VehicleSourceEvidence{}, err
}
var summary *VehicleRealtimeRow
if len(realtime.Items) > 0 {
summary = &realtime.Items[0]
if evidence.Plate == "" {
evidence.Plate = summary.Plate
}
evidence.RecommendedLocationProtocol = summary.PrimaryProtocol
evidence.LocationConflict = summary.LocationConflict
evidence.ConflictDistanceM = summary.ConflictDistanceM
for index := range evidence.LocationSources {
source := &evidence.LocationSources[index]
source.Recommended = source.Protocol == summary.PrimaryProtocol &&
(source.sourceKey == summary.LocationSource ||
(source.sourceKey == source.Protocol+":canonical" && summary.LocationSource == source.Protocol+":canonical"))
if source.Recommended {
evidence.RecommendedLocationLabel = source.SourceLabel
}
}
}
if evidence.RecommendedLocationLabel == "" {
for index := range evidence.LocationSources {
source := &evidence.LocationSources[index]
if source.SelectedWithinProtocol && (summary == nil || source.Protocol == summary.PrimaryProtocol) {
source.Recommended = true
evidence.RecommendedLocationProtocol = source.Protocol
evidence.RecommendedLocationLabel = source.SourceLabel
break
}
}
}
markRecommendedMileageSource(evidence.MileageSources, evidence.RecommendedLocationProtocol)
evidence.Comparison = compareVehicleSourceEvidence(evidence.LocationSources, evidence.MileageSources)
evidence.AsOf = time.Now().Format(time.RFC3339)
return evidence, nil
}
func authorizeVehicleDailyEvidenceDate(ctx context.Context, vin string, date string) error {
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return nil
}
grant, ok := principal.VehicleGrant(vin)
if !ok || grant.ValidFrom.IsZero() {
return clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"}
}
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
requestedDay, err := time.ParseInLocation("2006-01-02", date, shanghai)
if err != nil {
return clientError{Code: "INVALID_DATE", Message: "里程日期格式应为 YYYY-MM-DD"}
}
validFrom := grant.ValidFrom.In(shanghai)
firstFullDay := time.Date(validFrom.Year(), validFrom.Month(), validFrom.Day(), 0, 0, 0, 0, shanghai)
if !validFrom.Equal(firstFullDay) {
firstFullDay = firstFullDay.AddDate(0, 0, 1)
}
if requestedDay.Before(firstFullDay) {
return clientError{Code: "HISTORY_BEFORE_AUTHORIZATION", Message: "所选里程日期早于该车辆首个完整授权日"}
}
if grant.ValidTo != nil && !grant.ValidTo.IsZero() {
requestedEnd := requestedDay.AddDate(0, 0, 1)
if requestedEnd.After(grant.ValidTo.In(shanghai)) {
return clientError{Code: "HISTORY_AFTER_AUTHORIZATION", Message: "所选里程日期超出该车辆完整授权范围"}
}
}
return nil
}
func markRecommendedMileageSource(sources []VehicleMileageSourceEvidence, preferredProtocol string) {
for index := range sources {
sources[index].Recommended = false
}
for _, protocol := range append([]string{preferredProtocol}, canonicalVehicleProtocols...) {
if protocol == "" {
continue
}
for index := range sources {
if sources[index].Protocol == protocol && sources[index].SelectedWithinProtocol {
sources[index].Recommended = true
return
}
}
}
}
func compareVehicleSourceEvidence(locations []VehicleLocationSourceEvidence, mileages []VehicleMileageSourceEvidence) VehicleSourceEvidenceComparison {
var comparison VehicleSourceEvidenceComparison
for left := 0; left < len(locations); left++ {
if locations[left].Longitude == nil || locations[left].Latitude == nil {
continue
}
for right := left + 1; right < len(locations); right++ {
if locations[right].Longitude == nil || locations[right].Latitude == nil {
continue
}
distanceM := haversineKm(*locations[left].Latitude, *locations[left].Longitude, *locations[right].Latitude, *locations[right].Longitude) * 1000
if distanceM > comparison.LocationMaxDistanceM {
comparison.LocationMaxDistanceM = distanceM
}
}
}
var minTotal, maxTotal float64
var hasTotal bool
var minDaily, maxDaily float64
var hasDaily bool
var minTime, maxTime time.Time
for _, source := range locations {
if source.TotalMileageKm != nil {
value := *source.TotalMileageKm
if !hasTotal || value < minTotal {
minTotal = value
}
if !hasTotal || value > maxTotal {
maxTotal = value
}
hasTotal = true
}
if parsed, ok := parseVehicleServiceTime(firstNonEmpty(source.EventTime, source.ReceivedAt)); ok {
if minTime.IsZero() || parsed.Before(minTime) {
minTime = parsed
}
if maxTime.IsZero() || parsed.After(maxTime) {
maxTime = parsed
}
}
}
for _, source := range mileages {
if source.LatestTotalMileageKm != nil {
value := *source.LatestTotalMileageKm
if !hasTotal || value < minTotal {
minTotal = value
}
if !hasTotal || value > maxTotal {
maxTotal = value
}
hasTotal = true
}
if source.DailyMileageKm != nil {
value := *source.DailyMileageKm
if !hasDaily || value < minDaily {
minDaily = value
}
if !hasDaily || value > maxDaily {
maxDaily = value
}
hasDaily = true
}
if parsed, ok := parseVehicleServiceTime(source.LatestEventTime); ok {
if minTime.IsZero() || parsed.Before(minTime) {
minTime = parsed
}
if maxTime.IsZero() || parsed.After(maxTime) {
maxTime = parsed
}
}
}
if hasTotal {
comparison.TotalMileageDeltaKm = maxTotal - minTotal
}
if hasDaily {
comparison.DailyMileageDeltaKm = maxDaily - minDaily
}
if !minTime.IsZero() && !maxTime.IsZero() {
comparison.ReportTimeDeltaSeconds = maxTime.Sub(minTime).Seconds()
}
return comparison
}
func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []RealtimeLocationRow, protocol string) *VehicleSourceConsistency {
consistency := &VehicleSourceConsistency{
SourceCount: len(statuses),

View File

@@ -186,6 +186,31 @@ func TestCustomerMileageFailsClosedWithoutGrantTime(t *testing.T) {
}
}
func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) {
service := NewService(newCountingStore())
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, shanghai)
validTo := time.Date(2026, 7, 15, 10, 0, 0, 0, shanghai)
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom, ValidTo: &validTo}},
})
if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-10"); err == nil {
t.Fatal("partial authorization start day should not expose daily source evidence")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" {
t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err)
}
if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-11"); err != nil {
t.Fatalf("first complete authorization day should be allowed: %v", err)
}
if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-15"); err == nil {
t.Fatal("partial authorization end day should not expose daily source evidence")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_AFTER_AUTHORIZATION" {
t.Fatalf("expected HISTORY_AFTER_AUTHORIZATION, err=%v", err)
}
}
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++
return s.MockStore.VehicleServiceOverviews(ctx, query)

View File

@@ -0,0 +1,361 @@
package platform
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
)
type canonicalLocationEvidence struct {
row VehicleLocationSourceEvidence
}
func (s *ProductionStore) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
evidence := VehicleSourceEvidence{
VIN: vin,
MileageDate: date,
LocationSources: []VehicleLocationSourceEvidence{},
MileageSources: []VehicleMileageSourceEvidence{},
}
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(NULLIF(plate, '')), '') FROM vehicle_identity_binding WHERE vin = ?`, vin).Scan(&evidence.Plate)
canonical, err := s.canonicalLocationEvidence(ctx, vin)
if err != nil {
return VehicleSourceEvidence{}, err
}
candidates, err := s.locationSourceEvidence(ctx, vin, canonical)
if err != nil {
return VehicleSourceEvidence{}, err
}
seen := make(map[string]struct{}, len(candidates))
for _, source := range candidates {
seen[source.Protocol+"\x00"+source.sourceKey] = struct{}{}
evidence.LocationSources = append(evidence.LocationSources, source)
}
for _, source := range canonical {
key := source.row.Protocol + "\x00" + source.row.sourceKey
if _, exists := seen[key]; exists {
continue
}
evidence.LocationSources = append(evidence.LocationSources, source.row)
}
sortLocationEvidence(evidence.LocationSources)
mileageSources, err := s.mileageSourceEvidence(ctx, vin, date)
if err != nil {
return VehicleSourceEvidence{}, err
}
canonicalMileage, err := s.canonicalMileageEvidence(ctx, vin, date)
if err != nil {
return VehicleSourceEvidence{}, err
}
selectedProtocols := map[string]bool{}
for _, source := range mileageSources {
if source.SelectedWithinProtocol {
selectedProtocols[source.Protocol] = true
}
evidence.MileageSources = append(evidence.MileageSources, source)
}
for _, source := range canonicalMileage {
if selectedProtocols[source.Protocol] {
continue
}
evidence.MileageSources = append(evidence.MileageSources, source)
}
sortMileageEvidence(evidence.MileageSources)
return evidence, nil
}
func (s *ProductionStore) canonicalLocationEvidence(ctx context.Context, vin string) ([]canonicalLocationEvidence, error) {
rows, err := s.db.QueryContext(ctx, `SELECT protocol, COALESCE(NULLIF(source_key, ''), CONCAT(protocol, ':canonical')),
COALESCE(NULLIF(plate, ''), ''), DATE_FORMAT(event_time, '%Y-%m-%d %H:%i:%s'),
latitude, longitude, speed_kmh, total_mileage_km, soc_percent,
DATE_FORMAT(received_at, '%Y-%m-%d %H:%i:%s'),
CASE WHEN received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END
FROM vehicle_realtime_location
WHERE vin = ?
ORDER BY protocol`, vin)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]canonicalLocationEvidence, 0, 3)
for rows.Next() {
var row VehicleLocationSourceEvidence
var plate string
var eventTime, receivedAt sql.NullString
var latitude, longitude, speed, mileage, soc sql.NullFloat64
var online int
if err := rows.Scan(
&row.Protocol, &row.sourceKey, &plate, &eventTime,
&latitude, &longitude, &speed, &mileage, &soc, &receivedAt, &online,
); err != nil {
return nil, err
}
row.SourceLabel = row.Protocol
row.SourceKind = "CANONICAL"
row.SelectedWithinProtocol = true
row.Enabled = true
row.Online = online == 1
row.QualityStatus = "OK"
row.Longitude = nullFloatPointer(longitude)
row.Latitude = nullFloatPointer(latitude)
row.SpeedKmh = nullFloatPointer(speed)
row.TotalMileageKm = nullFloatPointer(mileage)
row.SOCPercent = nullFloatPointer(soc)
row.EventTime = nullString(eventTime)
row.ReceivedAt = nullString(receivedAt)
out = append(out, canonicalLocationEvidence{row: row})
}
return out, rows.Err()
}
func (s *ProductionStore) locationSourceEvidence(ctx context.Context, vin string, canonical []canonicalLocationEvidence) ([]VehicleLocationSourceEvidence, error) {
selected := make(map[string]string, len(canonical))
for _, source := range canonical {
selected[source.row.Protocol] = source.row.sourceKey
}
rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key, s.source_code, s.source_kind, s.phone, s.device_id,
DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s'), s.latitude, s.longitude, s.speed_kmh,
s.total_mileage_km, s.soc_percent, DATE_FORMAT(s.received_at, '%Y-%m-%d %H:%i:%s'),
s.quality_status, s.quality_reason,
COALESCE(p.enabled, 1),
COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END),
CASE WHEN s.received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END
FROM vehicle_realtime_location_source s
LEFT JOIN vehicle_location_source_policy p
ON p.vin = s.vin AND p.protocol = s.protocol AND p.source_key = s.source_key
WHERE s.vin = ?
ORDER BY s.protocol, COALESCE(p.enabled, 1) DESC,
COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END),
s.received_at DESC
LIMIT 50`, vin)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]VehicleLocationSourceEvidence, 0)
for rows.Next() {
var row VehicleLocationSourceEvidence
var sourceCode, phone, deviceID string
var eventTime, receivedAt, qualityReason sql.NullString
var latitude, longitude, speed, mileage, soc sql.NullFloat64
var enabled, online int
if err := rows.Scan(
&row.Protocol, &row.sourceKey, &sourceCode, &row.SourceKind, &phone, &deviceID,
&eventTime, &latitude, &longitude, &speed, &mileage, &soc, &receivedAt,
&row.QualityStatus, &qualityReason, &enabled, &row.Priority, &online,
); err != nil {
return nil, err
}
row.TerminalLabel = terminalEvidenceLabel(phone, deviceID)
row.SourceLabel = readableSourceLabel(row.Protocol, sourceCode, row.SourceKind, row.TerminalLabel)
row.SelectedWithinProtocol = selected[row.Protocol] == row.sourceKey
row.Enabled = enabled == 1
row.Online = online == 1
row.QualityReason = nullString(qualityReason)
row.Longitude = nullFloatPointer(longitude)
row.Latitude = nullFloatPointer(latitude)
row.SpeedKmh = nullFloatPointer(speed)
row.TotalMileageKm = nullFloatPointer(mileage)
row.SOCPercent = nullFloatPointer(soc)
row.EventTime = nullString(eventTime)
row.ReceivedAt = nullString(receivedAt)
out = append(out, row)
}
return out, rows.Err()
}
func (s *ProductionStore) mileageSourceEvidence(ctx context.Context, vin string, date string) ([]VehicleMileageSourceEvidence, error) {
rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key,
COALESCE(NULLIF(TRIM(s.platform_name), ''), NULLIF(TRIM(ds.platform_name), ''), NULLIF(TRIM(ds.source_code), ''), ''),
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'),
COALESCE(s.phone, ''), COALESCE(s.device_id, ''),
s.first_total_mileage_km, s.latest_total_mileage_km, s.daily_mileage_km, s.sample_count,
DATE_FORMAT(s.first_event_time, '%Y-%m-%d %H:%i:%s'),
DATE_FORMAT(s.latest_event_time, '%Y-%m-%d %H:%i:%s'),
s.quality_status, s.quality_reason, s.is_selected,
COALESCE(ds.enabled, 1), COALESCE(ds.trust_priority, 100)
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip
WHERE s.vin = ? AND s.stat_date = ?
ORDER BY s.protocol, s.is_selected DESC, COALESCE(ds.enabled, 1) DESC,
COALESCE(ds.trust_priority, 100), s.sample_count DESC, s.latest_event_time DESC
LIMIT 100`, vin, date)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]VehicleMileageSourceEvidence, 0)
for rows.Next() {
var row VehicleMileageSourceEvidence
var provider, phone, deviceID string
var firstTotal, latestTotal, daily sql.NullFloat64
var firstTime, latestTime, qualityReason sql.NullString
var selected, enabled int
if err := rows.Scan(
&row.Protocol, &row.sourceKey, &provider, &row.SourceKind, &phone, &deviceID,
&firstTotal, &latestTotal, &daily, &row.SampleCount, &firstTime, &latestTime,
&row.QualityStatus, &qualityReason, &selected, &enabled, &row.Priority,
); err != nil {
return nil, err
}
row.TerminalLabel = terminalEvidenceLabel(phone, deviceID)
row.SourceLabel = readableSourceLabel(row.Protocol, provider, row.SourceKind, row.TerminalLabel)
row.SelectedWithinProtocol = selected == 1
row.Enabled = enabled == 1
row.QualityReason = nullString(qualityReason)
row.FirstTotalMileageKm = nullFloatPointer(firstTotal)
row.LatestTotalMileageKm = nullFloatPointer(latestTotal)
row.DailyMileageKm = nullFloatPointer(daily)
row.FirstEventTime = nullString(firstTime)
row.LatestEventTime = nullString(latestTime)
out = append(out, row)
}
return out, rows.Err()
}
func (s *ProductionStore) canonicalMileageEvidence(ctx context.Context, vin string, date string) ([]VehicleMileageSourceEvidence, error) {
rows, err := s.db.QueryContext(ctx, `SELECT m.protocol,
COALESCE(NULLIF(TRIM(ds.platform_name), ''), NULLIF(TRIM(ds.source_code), ''), ''),
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'CANONICAL'),
m.latest_total_mileage_km, m.daily_mileage_km,
DATE_FORMAT(m.updated_at, '%Y-%m-%d %H:%i:%s'),
COALESCE(ds.enabled, 1), COALESCE(ds.trust_priority, 100)
FROM vehicle_daily_mileage m
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.vin = ? AND m.stat_date = ?
ORDER BY m.protocol`, vin, date)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]VehicleMileageSourceEvidence, 0, 3)
for rows.Next() {
var row VehicleMileageSourceEvidence
var provider string
var latestTotal, daily sql.NullFloat64
var latestTime sql.NullString
var enabled int
if err := rows.Scan(
&row.Protocol, &provider, &row.SourceKind, &latestTotal, &daily,
&latestTime, &enabled, &row.Priority,
); err != nil {
return nil, err
}
row.sourceKey = row.Protocol + ":canonical"
row.SourceLabel = readableSourceLabel(row.Protocol, provider, row.SourceKind, "")
row.SelectedWithinProtocol = true
row.Enabled = enabled == 1
row.QualityStatus = "OK"
row.LatestTotalMileageKm = nullFloatPointer(latestTotal)
row.DailyMileageKm = nullFloatPointer(daily)
row.LatestEventTime = nullString(latestTime)
out = append(out, row)
}
return out, rows.Err()
}
func readableSourceLabel(protocol string, provider string, sourceKind string, terminal string) string {
if provider = strings.TrimSpace(provider); provider != "" {
return provider
}
if terminal != "" {
return protocol + " · " + terminal
}
if sourceKind = strings.TrimSpace(sourceKind); sourceKind != "" && sourceKind != "UNKNOWN" && sourceKind != "CANONICAL" {
return protocol + " · " + sourceKind
}
return protocol
}
func terminalEvidenceLabel(phone string, deviceID string) string {
parts := make([]string, 0, 2)
if phone = maskEvidenceIdentifier(phone); phone != "" {
parts = append(parts, "终端 "+phone)
}
if deviceID = maskEvidenceIdentifier(deviceID); deviceID != "" && (len(parts) == 0 || !strings.Contains(parts[0], deviceID)) {
parts = append(parts, "设备 "+deviceID)
}
return strings.Join(parts, " / ")
}
func maskEvidenceIdentifier(value string) string {
value = strings.TrimSpace(value)
switch length := len([]rune(value)); {
case length == 0:
return ""
case length <= 4:
return "****"
case length <= 7:
runes := []rune(value)
return string(runes[:2]) + "***" + string(runes[length-2:])
default:
runes := []rune(value)
return string(runes[:3]) + "****" + string(runes[length-4:])
}
}
func nullFloatPointer(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
result := value.Float64
return &result
}
func nullString(value sql.NullString) string {
if !value.Valid {
return ""
}
return value.String
}
func protocolEvidenceRank(protocol string) int {
for index, candidate := range canonicalVehicleProtocols {
if protocol == candidate {
return index
}
}
return len(canonicalVehicleProtocols)
}
func sortLocationEvidence(sources []VehicleLocationSourceEvidence) {
sort.SliceStable(sources, func(left, right int) bool {
lhs, rhs := sources[left], sources[right]
if protocolEvidenceRank(lhs.Protocol) != protocolEvidenceRank(rhs.Protocol) {
return protocolEvidenceRank(lhs.Protocol) < protocolEvidenceRank(rhs.Protocol)
}
if lhs.SelectedWithinProtocol != rhs.SelectedWithinProtocol {
return lhs.SelectedWithinProtocol
}
if lhs.Enabled != rhs.Enabled {
return lhs.Enabled
}
if lhs.Priority != rhs.Priority {
return lhs.Priority < rhs.Priority
}
return fmt.Sprintf("%s\x00%s", lhs.SourceLabel, lhs.TerminalLabel) < fmt.Sprintf("%s\x00%s", rhs.SourceLabel, rhs.TerminalLabel)
})
}
func sortMileageEvidence(sources []VehicleMileageSourceEvidence) {
sort.SliceStable(sources, func(left, right int) bool {
lhs, rhs := sources[left], sources[right]
if protocolEvidenceRank(lhs.Protocol) != protocolEvidenceRank(rhs.Protocol) {
return protocolEvidenceRank(lhs.Protocol) < protocolEvidenceRank(rhs.Protocol)
}
if lhs.SelectedWithinProtocol != rhs.SelectedWithinProtocol {
return lhs.SelectedWithinProtocol
}
if lhs.Enabled != rhs.Enabled {
return lhs.Enabled
}
if lhs.Priority != rhs.Priority {
return lhs.Priority < rhs.Priority
}
return fmt.Sprintf("%s\x00%s", lhs.SourceLabel, lhs.TerminalLabel) < fmt.Sprintf("%s\x00%s", rhs.SourceLabel, rhs.TerminalLabel)
})
}