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("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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user