Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/source_mileage.go
2026-07-08 15:41:59 +08:00

339 lines
9.8 KiB
Go

package stats
import (
"context"
"database/sql"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
QualityOK = "OK"
QualityNoPreviousBaseline = "NO_PREVIOUS_BASELINE"
QualityInvalidDelta = "INVALID_DELTA"
maxSelectedDailyMileageKM = 1000
)
type SourceMileageSample struct {
VIN string
StatDate string
Protocol envelope.Protocol
SourceKey string
SourceIP string
SourceEndpoint string
Phone string
DeviceID string
PlatformName string
FirstTotalKM float64
LatestTotalKM float64
DailyKM float64
SampleCount int64
FirstEventTime time.Time
LatestEventTime time.Time
QualityStatus string
QualityReason string
}
func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string {
identity := strings.TrimSpace(phone)
if identity == "" {
identity = strings.TrimSpace(deviceID)
}
if identity == "" {
identity = "unknown"
}
return string(protocol) + ":" + identity + "@" + strings.TrimSpace(sourceIP)
}
func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample {
eventTime := sample.EventTime
return SourceMileageSample{
VIN: sample.VIN,
StatDate: sample.StatDate,
Protocol: sample.Protocol,
SourceKey: SourceKey(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP),
SourceIP: identity.SourceIP,
SourceEndpoint: identity.SourceEndpoint,
Phone: sample.Phone,
DeviceID: sample.DeviceID,
FirstTotalKM: sample.TotalMileageKM,
LatestTotalKM: sample.TotalMileageKM,
DailyKM: 0,
SampleCount: 1,
FirstEventTime: eventTime,
LatestEventTime: eventTime,
QualityStatus: QualityOK,
QualityReason: "realtime_sample",
}
}
func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageSample) error {
if exec == nil {
panic("stats execer must not be nil")
}
if sample.VIN == "" || sample.SourceKey == "" || strings.TrimSpace(sample.SourceIP) == "" {
return nil
}
if sample.QualityStatus == "" {
sample.QualityStatus = QualityOK
}
_, err := exec.ExecContext(ctx, upsertSourceMileageSQL,
sample.VIN,
sample.StatDate,
string(sample.Protocol),
sample.SourceKey,
sample.SourceIP,
sample.SourceEndpoint,
sample.Phone,
sample.DeviceID,
sample.PlatformName,
sample.FirstTotalKM,
sample.LatestTotalKM,
sample.DailyKM,
sample.SampleCount,
sample.FirstEventTime,
sample.LatestEventTime,
sample.QualityStatus,
sample.QualityReason,
)
return err
}
func ProjectDailyMileage(ctx context.Context, exec Execer, vin string, statDate string, protocol envelope.Protocol) error {
if exec == nil {
panic("stats execer must not be nil")
}
if strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" {
return nil
}
if _, err := exec.ExecContext(ctx, clearSelectedSourceSQL, vin, statDate, string(protocol)); err != nil {
return err
}
if _, err := exec.ExecContext(ctx, projectDailyMileageSQL,
vin,
statDate,
string(protocol),
maxSelectedDailyMileageKM,
); err != nil {
return err
}
_, err := exec.ExecContext(ctx, markSelectedSourceSQL,
vin,
statDate,
string(protocol),
maxSelectedDailyMileageKM,
vin,
statDate,
string(protocol),
)
if err != nil {
return err
}
_, err = exec.ExecContext(ctx, cleanupProjectedDailyMileageSQL,
vin,
statDate,
string(protocol),
vin,
statDate,
string(protocol),
)
return err
}
const upsertSourceMileageSQL = `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
phone = VALUES(phone),
device_id = VALUES(device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
first_total_mileage_km = CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
END,
latest_total_mileage_km = CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
ELSE GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
END,
daily_mileage_km = GREATEST(
CASE
WHEN latest_total_mileage_km IS NULL OR latest_total_mileage_km <= 0
THEN VALUES(latest_total_mileage_km)
ELSE latest_total_mileage_km
END,
VALUES(latest_total_mileage_km)
) - CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km)
ELSE LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
END,
sample_count = sample_count + VALUES(sample_count),
first_event_time = CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) < first_event_time
THEN VALUES(first_event_time)
ELSE first_event_time
END,
latest_event_time = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) > latest_event_time
THEN VALUES(latest_event_time)
ELSE latest_event_time
END,
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
updated_at = CURRENT_TIMESTAMP
`
const clearSelectedSourceSQL = `
UPDATE vehicle_daily_mileage_source
SET is_selected = 0
WHERE vin = ? AND stat_date = ? AND protocol = ?
`
const projectDailyMileageSQL = `
INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, daily_mileage_km,
first_total_mileage_km, latest_total_mileage_km,
trusted_source_key, trusted_phone, trusted_source_endpoint, sample_count)
SELECT
s.vin,
s.stat_date,
s.protocol,
s.daily_mileage_km,
s.first_total_mileage_km,
s.latest_total_mileage_km,
s.source_key,
s.phone,
ds.latest_source_endpoint,
s.sample_count
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 = ?
AND s.protocol = ?
AND s.quality_status = '` + QualityOK + `'
AND s.daily_mileage_km BETWEEN 0 AND ?
AND COALESCE(ds.enabled, 1) = 1
ORDER BY COALESCE(ds.trust_priority, 100),
s.sample_count DESC,
s.latest_event_time DESC,
s.source_key ASC
LIMIT 1
ON DUPLICATE KEY UPDATE
daily_mileage_km = VALUES(daily_mileage_km),
first_total_mileage_km = VALUES(first_total_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
trusted_source_key = VALUES(trusted_source_key),
trusted_phone = VALUES(trusted_phone),
trusted_source_endpoint = VALUES(trusted_source_endpoint),
sample_count = VALUES(sample_count),
updated_at = CURRENT_TIMESTAMP
`
const markSelectedSourceSQL = `
UPDATE vehicle_daily_mileage_source s
JOIN (
SELECT
s2.source_key,
s2.vin,
s2.stat_date,
s2.protocol
FROM vehicle_daily_mileage_source s2
LEFT JOIN vehicle_data_source ds
ON ds.protocol = s2.protocol AND ds.source_ip = s2.source_ip
WHERE s2.vin = ?
AND s2.stat_date = ?
AND s2.protocol = ?
AND s2.quality_status = '` + QualityOK + `'
AND s2.daily_mileage_km BETWEEN 0 AND ?
AND COALESCE(ds.enabled, 1) = 1
ORDER BY COALESCE(ds.trust_priority, 100),
s2.sample_count DESC,
s2.latest_event_time DESC,
s2.source_key ASC
LIMIT 1
) selected_source
ON selected_source.source_key = s.source_key
AND selected_source.vin = s.vin
AND selected_source.stat_date = s.stat_date
AND selected_source.protocol = s.protocol
SET s.is_selected = 1
WHERE s.vin = ? AND s.stat_date = ? AND s.protocol = ?
`
const cleanupProjectedDailyMileageSQL = `
DELETE FROM vehicle_daily_mileage
WHERE vin = ? AND stat_date = ? AND protocol = ?
AND NOT EXISTS (
SELECT 1
FROM vehicle_daily_mileage_source
WHERE vin = ? AND stat_date = ? AND protocol = ? AND is_selected = 1
)
`
type sourceBaseline struct {
LatestTotalKM float64
LatestEventTime time.Time
}
func lookupPreviousSourceBaseline(ctx context.Context, query Queryer, vin string, statDate string, protocol envelope.Protocol, sourceKey string) (sourceBaseline, bool, error) {
if query == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(sourceKey) == "" {
return sourceBaseline{}, false, nil
}
previousDate, ok := previousStatDate(statDate)
if !ok {
return sourceBaseline{}, false, nil
}
rows, err := query.QueryContext(ctx, previousSourceBaselineSQL, vin, previousDate, string(protocol), sourceKey)
if err != nil {
return sourceBaseline{}, false, err
}
defer rows.Close()
if !rows.Next() {
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{}, false, nil
}
var latestTotal sql.NullFloat64
var latestEvent sql.NullTime
if err := rows.Scan(&latestTotal, &latestEvent); err != nil {
return sourceBaseline{}, false, err
}
if err := rows.Err(); err != nil {
return sourceBaseline{}, false, err
}
return sourceBaseline{
LatestTotalKM: latestTotal.Float64,
LatestEventTime: latestEvent.Time,
}, latestTotal.Valid, nil
}
func previousStatDate(statDate string) (string, bool) {
day, err := time.Parse("2006-01-02", strings.TrimSpace(statDate))
if err != nil {
return "", false
}
return day.AddDate(0, 0, -1).Format("2006-01-02"), true
}
const previousSourceBaselineSQL = `
SELECT latest_total_mileage_km, latest_event_time
FROM vehicle_daily_mileage_source
WHERE vin = ?
AND stat_date = ?
AND protocol = ?
AND source_key = ?
ORDER BY latest_event_time DESC
LIMIT 1
`