fix: tighten source-aware mileage projection

This commit is contained in:
lingniu
2026-07-08 15:41:59 +08:00
parent 800ca1a8b1
commit e0ae57828e
7 changed files with 500 additions and 17 deletions

View File

@@ -19,9 +19,11 @@ type Execer interface {
type Writer struct {
exec Execer
query Queryer
loc *time.Location
mu sync.Mutex
lastTotalMileage map[string]float64
baselineCache map[string]sourceBaselineCacheEntry
}
type MetricSample struct {
@@ -43,7 +45,16 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
return &Writer{exec: exec, loc: loc, lastTotalMileage: map[string]float64{}}
writer := &Writer{
exec: exec,
loc: loc,
lastTotalMileage: map[string]float64{},
baselineCache: map[string]sourceBaselineCacheEntry{},
}
if query, ok := exec.(Queryer); ok {
writer.query = query
}
return writer
}
func (w *Writer) EnsureSchema(ctx context.Context) error {
@@ -79,6 +90,9 @@ func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
return err
}
candidate := SourceMileageSampleFromMetric(sample, identity)
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
return err
}
if err := UpsertSourceMileage(ctx, w.exec, candidate); err != nil {
return err
}
@@ -92,8 +106,7 @@ func (w *Writer) Append(ctx context.Context, env envelope.FrameEnvelope) error {
}
func (w *Writer) seenSameMileage(sample MetricSample) bool {
prefix := fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
key := prefix + sample.StatDate
key := mileageCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
if last, ok := w.lastTotalMileage[key]; ok && last == sample.TotalMileageKM {
@@ -103,8 +116,8 @@ func (w *Writer) seenSameMileage(sample MetricSample) bool {
}
func (w *Writer) markMileageWritten(sample MetricSample) {
prefix := fmt.Sprintf("%s|%s|", sample.VIN, sample.Protocol)
key := prefix + sample.StatDate
prefix := mileageCachePrefix(sample)
key := mileageCacheKey(sample)
w.mu.Lock()
defer w.mu.Unlock()
for existing := range w.lastTotalMileage {
@@ -112,9 +125,77 @@ func (w *Writer) markMileageWritten(sample MetricSample) {
delete(w.lastTotalMileage, existing)
}
}
for existing := range w.baselineCache {
if strings.HasPrefix(existing, prefix) && existing != key {
delete(w.baselineCache, existing)
}
}
w.lastTotalMileage[key] = sample.TotalMileageKM
}
func (w *Writer) applyRealtimeBaseline(ctx context.Context, candidate *SourceMileageSample) error {
if candidate == nil {
return nil
}
baseline, found, err := w.previousBaseline(ctx, *candidate)
if err != nil {
return err
}
if !found {
candidate.QualityStatus = QualityNoPreviousBaseline
candidate.QualityReason = "missing_previous_source"
return nil
}
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.FirstEventTime = baseline.LatestEventTime
candidate.DailyKM = candidate.LatestTotalKM - baseline.LatestTotalKM
candidate.QualityStatus = QualityOK
candidate.QualityReason = "same_source_previous_day"
if candidate.DailyKM < 0 || candidate.DailyKM > maxSelectedDailyMileageKM {
candidate.QualityStatus = QualityInvalidDelta
candidate.QualityReason = "outside_daily_range"
}
return nil
}
func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSample) (sourceBaseline, bool, error) {
cacheKey := mileageCacheKey(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
})
w.mu.Lock()
if cached, ok := w.baselineCache[cacheKey]; ok {
w.mu.Unlock()
return cached.baseline, cached.found, nil
}
w.mu.Unlock()
baseline, found, err := lookupPreviousSourceBaseline(ctx, w.query, candidate.VIN, candidate.StatDate, candidate.Protocol, candidate.SourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
w.mu.Lock()
w.baselineCache[cacheKey] = sourceBaselineCacheEntry{baseline: baseline, found: found}
w.mu.Unlock()
return baseline, found, nil
}
func mileageCachePrefix(sample MetricSample) string {
return fmt.Sprintf("%s|%s|%s|", sample.VIN, sample.Protocol, sample.SourceKey)
}
func mileageCacheKey(sample MetricSample) string {
return mileageCachePrefix(sample) + sample.StatDate
}
type sourceBaselineCacheEntry struct {
baseline sourceBaseline
found bool
}
func SamplesFromEnvelope(env envelope.FrameEnvelope, loc *time.Location) ([]MetricSample, error) {
vin := strings.TrimSpace(env.VIN)
if vin == "" {