feat(platform): expose multi-source vehicle evidence
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user