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

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