package main import ( "context" "database/sql" "encoding/json" "fmt" "log/slog" "os" "regexp" "strconv" "strings" "time" _ "github.com/go-sql-driver/mysql" _ "github.com/taosdata/driver-go/v3/taosWS" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/history" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" ) type config struct { MySQLDSN string TDengineDriver string TDengineDSN string TDengineDatabase string DateFrom string DateTo string Protocols []envelope.Protocol Method string Limit int DryRun bool Reset bool Debug bool EventTimeFullScan bool ProgressEvery int64 Location *time.Location } type rawFrameRow struct { Protocol envelope.Protocol VIN string Phone string DeviceID string SourceEndpoint string EventID string MessageID string EventTimeMS int64 ReceivedAtMS int64 ParsedJSON string } type metricAgg struct { VIN string Date string Protocol envelope.Protocol FirstKM float64 LatestKM float64 Count int64 SourceKey string Phone string DeviceID string SourceEndpoint string SourceCode string PlatformName string SourceKind string FirstEventTime time.Time LatestEventTime time.Time QualityStatus string QualityReason string } type dailySourceLast struct { VIN string SourceKey string Phone string DeviceID string SourceEndpoint string SourceCode string PlatformName string SourceKind string FirstTS time.Time TS time.Time FirstTotalKM float64 TotalKM float64 RawSampleCount int64 } type sourceHistoryID struct { VIN string SourceKey string } var defaultBackfillEnvFiles = []string{ "/opt/lingniu-go-native/env/base.env", "/opt/lingniu-go-native/env/stat-writer.env", } func main() { envFiles := backfillEnvFiles(os.Getenv("BACKFILL_ENV_FILES"), os.Getenv("BACKFILL_ENV_FILE"), defaultBackfillEnvFiles) if err := loadEnvFiles(envFiles); err != nil { fail("load env file", err) } cfg, err := loadConfig() if err != nil { fail("load config", err) } ctx := context.Background() td, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN) if err != nil { fail("open tdengine", err) } defer td.Close() if err := td.PingContext(ctx); err != nil { fail("ping tdengine", err) } mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN) if err != nil { fail("open mysql", err) } defer mysqlDB.Close() if err := mysqlDB.PingContext(ctx); err != nil { fail("ping mysql", err) } schemaWriter := stats.NewWriter(mysqlDB, cfg.Location) if err := schemaWriter.EnsureSchema(ctx); err != nil { fail("ensure schema", err) } if cfg.Reset && !cfg.DryRun { deleted, err := resetStats(ctx, mysqlDB, cfg) if err != nil { fail("reset stats", err) } slog.Info("reset stats rows", "deleted", deleted) } if cfg.Method == "last_diff" { aggregates, err := buildLastDiffAggregates(ctx, mysqlDB, td, cfg) if err != nil { fail("build last-diff aggregates", err) } fallbacks, err := addRealtimeLocationFallbackAggregates(ctx, mysqlDB, td, cfg, aggregates) if err != nil { fail("build realtime-location fallback aggregates", err) } var written int64 var normalized int if !cfg.DryRun { written, err = writeAggregates(ctx, mysqlDB, aggregates, 500) if err != nil { fail("write aggregates", err) } normalized, err = normalizeHistoricalPlatformSources(ctx, mysqlDB, cfg) if err != nil { fail("normalize historical platform sources", err) } } slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "realtimeLocationFallbacks", fallbacks, "written", written, "platformSourcesNormalized", normalized) return } rows, err := queryRawFrames(ctx, td, cfg) if err != nil { fail("query raw frames", err) } defer rows.Close() aggregates := map[string]*metricAgg{} var scanned, chunked, parsed, sampled, written, skipped int64 for rows.Next() { scanned++ row, err := scanRawFrame(rows) if err != nil { fail("scan raw frame", err) } text := strings.TrimSpace(row.ParsedJSON) if isChunkManifest(text) { chunked++ text, err = readChunkedPayload(ctx, td, cfg.TDengineDatabase, row.EventID, "parsed_fields") if err != nil { slog.Warn("skip chunked parsed fields", "event_id", row.EventID, "error", err) skipped++ continue } } fields := fieldsForStats(row.Protocol, row.VIN, text) if cfg.Debug && scanned <= 5 { slog.Info("debug raw frame", "protocol", row.Protocol, "vin", row.VIN, "message_id", row.MessageID, "parsed_len", len(text), "mileage_keys", mileageKeys(row.Protocol), "extracted_fields", fields) } if len(fields) == 0 { skipped++ continue } parsed++ env := envelope.FrameEnvelope{ EventID: row.EventID, Protocol: row.Protocol, MessageID: row.MessageID, VIN: row.VIN, Phone: row.Phone, DeviceID: row.DeviceID, SourceEndpoint: row.SourceEndpoint, EventTimeMS: row.EventTimeMS, ReceivedAtMS: row.ReceivedAtMS, Fields: fields, ParsedFields: fields, ParseStatus: envelope.ParseOK, } samples, err := stats.SamplesFromEnvelope(env, cfg.Location) if err != nil { slog.Warn("skip invalid sample", "event_id", row.EventID, "error", err) skipped++ continue } if len(samples) == 0 { skipped++ continue } sampled += int64(len(samples)) addSamples(aggregates, samples) if cfg.ProgressEvery > 0 && scanned%cfg.ProgressEvery == 0 { slog.Info("stats backfill progress", "scanned", scanned, "sampled", sampled, "aggregates", len(aggregates), "skipped", skipped) } } if err := rows.Err(); err != nil { fail("iterate raw frames", err) } if !cfg.DryRun { var err error written, err = writeAggregates(ctx, mysqlDB, aggregates, 500) if err != nil { fail("write aggregates", err) } } slog.Info("stats backfill complete", "dry_run", cfg.DryRun, "scanned", scanned, "parsed", parsed, "chunked", chunked, "sampled", sampled, "aggregates", len(aggregates), "written", written, "skipped", skipped) } func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample) { for _, sample := range samples { key := sample.VIN + "|" + sample.StatDate + "|" + string(sample.Protocol) + "|" + sample.SourceKey agg, ok := aggregates[key] if !ok { aggregates[key] = &metricAgg{ VIN: sample.VIN, Date: sample.StatDate, Protocol: sample.Protocol, FirstKM: sample.TotalMileageKM, LatestKM: sample.TotalMileageKM, Count: 1, SourceKey: sample.SourceKey, Phone: sample.Phone, DeviceID: sample.DeviceID, SourceEndpoint: sample.SourceEndpoint, PlatformName: sample.PlatformName, FirstEventTime: sample.EventTime, LatestEventTime: sample.EventTime, QualityStatus: stats.QualityOK, QualityReason: "current_day_first_sample", } continue } if agg.FirstEventTime.IsZero() || sample.EventTime.Before(agg.FirstEventTime) { agg.FirstKM = sample.TotalMileageKM agg.FirstEventTime = sample.EventTime } if sample.EventTime.After(agg.LatestEventTime) { agg.LatestKM = sample.TotalMileageKM agg.LatestEventTime = sample.EventTime agg.SourceEndpoint = sample.SourceEndpoint if strings.TrimSpace(sample.Phone) != "" { agg.Phone = sample.Phone } if strings.TrimSpace(sample.DeviceID) != "" { agg.DeviceID = sample.DeviceID } if strings.TrimSpace(sample.PlatformName) != "" { agg.PlatformName = sample.PlatformName } } agg.Count++ } } func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) { if len(aggregates) == 0 { return 0, nil } var written int64 clearedTargets := map[string]struct{}{} for _, agg := range aggregates { identity := stats.SourceIdentity{ Protocol: agg.Protocol, SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint), SourceEndpoint: agg.SourceEndpoint, SourceCode: agg.SourceCode, PlatformName: agg.PlatformName, SourceKind: agg.SourceKind, } if strings.TrimSpace(identity.SourceIP) == "" { continue } target := agg.VIN + "|" + agg.Date + "|" + string(agg.Protocol) if _, ok := clearedTargets[target]; !ok { if err := clearBackfillTargetMileage(ctx, db, agg.VIN, agg.Date, agg.Protocol); err != nil { return written, err } clearedTargets[target] = struct{}{} } if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil { return written, err } dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM) candidate := stats.SourceMileageSample{ VIN: agg.VIN, StatDate: agg.Date, Protocol: agg.Protocol, SourceKey: agg.SourceKey, SourceIP: identity.SourceIP, SourceEndpoint: agg.SourceEndpoint, Phone: agg.Phone, DeviceID: agg.DeviceID, PlatformName: agg.PlatformName, FirstTotalKM: agg.FirstKM, LatestTotalKM: agg.LatestKM, DailyKM: dailyKM, SampleCount: agg.Count, FirstEventTime: agg.FirstEventTime, LatestEventTime: agg.LatestEventTime, QualityStatus: agg.QualityStatus, QualityReason: agg.QualityReason, } if candidate.QualityStatus == "" { candidate.QualityStatus = stats.QualityOK } if candidate.QualityReason == "" { candidate.QualityReason = "same_source_previous_day" } stats.ApplyMileageQualityRules(&candidate) if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil { return written, err } if err := stats.ProjectDailyMileage(ctx, db, agg.VIN, agg.Date, agg.Protocol); err != nil { return written, err } written++ } return written, nil } func normalizeHistoricalPlatformSources(ctx context.Context, db *sql.DB, cfg config) (int, error) { dates, err := dateRange(cfg.DateFrom, cfg.DateTo) if err != nil { return 0, err } normalized := 0 for _, protocol := range cfg.Protocols { if protocol != envelope.ProtocolJT808 { continue } for _, date := range dates { count, err := stats.NormalizePlatformSourceMileageForDate(ctx, db, date, protocol) if err != nil { return normalized, err } normalized += count } } return normalized, nil } func clearBackfillTargetMileage(ctx context.Context, db *sql.DB, vin string, statDate string, protocol envelope.Protocol) error { if db == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(string(protocol)) == "" { return nil } _, err := db.ExecContext(ctx, "DELETE FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ?", vin, statDate, string(protocol)) if err != nil { return err } _, err = db.ExecContext(ctx, "DELETE FROM vehicle_daily_mileage WHERE vin = ? AND stat_date = ? AND protocol = ?", vin, statDate, string(protocol)) return err } func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config) (map[string]*metricAgg, error) { targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo) if err != nil { return nil, err } aggregates := map[string]*metricAgg{} for _, protocol := range cfg.Protocols { latestHistory := map[sourceHistoryID]dailySourceLast{} preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0]) if err != nil { return nil, err } rememberLatestSourceRows(latestHistory, preWindow) slog.Info("pre-window baseline loaded", "protocol", protocol, "before_date", targetDates[0], "vehicles", len(preWindow)) for _, date := range targetDates { current, err := queryDailyLastSourceRows(ctx, tdDB, cfg, protocol, date) if err != nil { return nil, err } slog.Info("daily last loaded", "protocol", protocol, "date", date, "vehicles", len(current), "historicalSources", len(latestHistory)) for vin, currentSources := range current { for _, currentRow := range currentSources { key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey previousRow, hasPrevious, err := resolvePreviousSourceRow(ctx, mysqlDB, date, protocol, latestHistory, currentRow) if err != nil { return nil, err } aggregates[key] = aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious) } } // Current-day last values become eligible only for later target dates. rememberLatestSourceRows(latestHistory, current) } } return aggregates, nil } func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) { if aggregates == nil { return 0, fmt.Errorf("aggregates map is nil") } targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo) if err != nil { return 0, err } var added int for _, protocol := range cfg.Protocols { if protocol != envelope.ProtocolYutongMQTT { continue } latestHistory := map[sourceHistoryID]dailySourceLast{} preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0]) if err != nil { return added, err } rememberLatestSourceRows(latestHistory, preWindow) aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol) for _, date := range targetDates { current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date) if err != nil { return added, err } for vin, currentSources := range current { for _, currentRow := range currentSources { key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey if _, exists := aggregates[key]; exists { continue } previousRow, hasPrevious, err := resolvePreviousSourceRow(ctx, mysqlDB, date, protocol, latestHistory, currentRow) if err != nil { return added, err } agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious) if hasPrevious { agg.QualityReason = "realtime_location_fallback_historical_baseline" } else { agg.QualityReason = "realtime_location_fallback_current_day_first_baseline" } aggregates[key] = agg added++ } } rememberLatestSourceRows(latestHistory, aggregateRowsByDate[date]) rememberLatestSourceRows(latestHistory, current) if len(current) > 0 { slog.Info("realtime location fallback loaded", "protocol", protocol, "date", date, "vehicles", len(current), "added", added) } } } return added, nil } func indexAggregateSourceRowsByDate(aggregates map[string]*metricAgg, protocol envelope.Protocol) map[string]map[string][]dailySourceLast { indexed := map[string]map[string][]dailySourceLast{} for _, agg := range aggregates { if agg == nil || agg.Protocol != protocol || strings.TrimSpace(agg.Date) == "" { continue } rows := indexed[agg.Date] if rows == nil { rows = map[string][]dailySourceLast{} indexed[agg.Date] = rows } rows[agg.VIN] = append(rows[agg.VIN], dailySourceLast{ VIN: agg.VIN, SourceKey: agg.SourceKey, Phone: agg.Phone, DeviceID: agg.DeviceID, SourceEndpoint: agg.SourceEndpoint, SourceCode: agg.SourceCode, PlatformName: agg.PlatformName, SourceKind: agg.SourceKind, FirstTS: agg.LatestEventTime, TS: agg.LatestEventTime, FirstTotalKM: agg.LatestKM, TotalKM: agg.LatestKM, RawSampleCount: agg.Count, }) } return indexed } func rememberLatestSourceRows(history map[sourceHistoryID]dailySourceLast, rows map[string][]dailySourceLast) { for vin, sourceRows := range rows { for _, row := range sourceRows { rowVIN := strings.TrimSpace(row.VIN) if rowVIN == "" { rowVIN = strings.TrimSpace(vin) row.VIN = rowVIN } id := sourceHistoryID{VIN: rowVIN, SourceKey: strings.TrimSpace(row.SourceKey)} if id.VIN == "" || id.SourceKey == "" || row.TS.IsZero() { continue } if existing, ok := history[id]; !ok || row.TS.After(existing.TS) { history[id] = row } } } } func latestHistoricalSourceRow(history map[sourceHistoryID]dailySourceLast, current dailySourceLast) (dailySourceLast, bool) { id := sourceHistoryID{ VIN: strings.TrimSpace(current.VIN), SourceKey: strings.TrimSpace(current.SourceKey), } previous, ok := history[id] return previous, ok && previous.TS.Before(current.TS) } func resolvePreviousSourceRow(ctx context.Context, db *sql.DB, date string, protocol envelope.Protocol, history map[sourceHistoryID]dailySourceLast, current dailySourceLast) (dailySourceLast, bool, error) { if previous, ok := latestHistoricalSourceRow(history, current); ok { return previous, true, nil } return queryDurablePreviousSourceRow(ctx, db, date, protocol, current) } func aggregateFromDailySource(date string, protocol envelope.Protocol, current dailySourceLast, previous dailySourceLast, hasPrevious bool) *metricAgg { firstKM := current.FirstTotalKM firstEventTime := current.FirstTS qualityStatus := stats.QualityOK qualityReason := stats.QualityReasonCurrentDayFirst if hasPrevious { firstKM = previous.TotalKM firstEventTime = previous.TS qualityStatus = stats.QualityOK qualityReason = stats.QualityReasonHistorical } if firstEventTime.IsZero() { firstEventTime = current.TS } count := current.RawSampleCount if count <= 0 { count = 1 } agg := &metricAgg{ VIN: current.VIN, Date: date, Protocol: protocol, FirstKM: firstKM, LatestKM: current.TotalKM, Count: count, SourceKey: current.SourceKey, Phone: current.Phone, DeviceID: current.DeviceID, SourceEndpoint: current.SourceEndpoint, SourceCode: current.SourceCode, PlatformName: current.PlatformName, SourceKind: current.SourceKind, FirstEventTime: firstEventTime, LatestEventTime: current.TS, QualityStatus: qualityStatus, QualityReason: qualityReason, } candidate := stats.SourceMileageSample{ FirstTotalKM: agg.FirstKM, LatestTotalKM: agg.LatestKM, DailyKM: stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM), FirstEventTime: agg.FirstEventTime, LatestEventTime: agg.LatestEventTime, QualityStatus: agg.QualityStatus, QualityReason: agg.QualityReason, } stats.ApplyMileageQualityRules(&candidate) agg.QualityStatus = candidate.QualityStatus agg.QualityReason = candidate.QualityReason return agg } func queryDurablePreviousSourceRow(ctx context.Context, db *sql.DB, date string, protocol envelope.Protocol, current dailySourceLast) (dailySourceLast, bool, error) { if db == nil { return dailySourceLast{}, false, nil } totalKM, eventTime, found, err := stats.LookupLatestSourceBaselineBefore(ctx, db, current.VIN, date, protocol, current.SourceKey) if err != nil || !found { return dailySourceLast{}, found, err } previous := current previous.FirstTS = eventTime previous.TS = eventTime previous.FirstTotalKM = totalKM previous.TotalKM = totalKM return previous, true, nil } type trustedChoice struct { current dailySourceLast previous dailySourceLast } func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast) (trustedChoice, bool) { previousBySource := map[string]dailySourceLast{} for _, row := range previous { previousBySource[row.SourceKey] = row } var chosen trustedChoice var chosenDelta float64 for _, currentRow := range current { previousRow, ok := previousBySource[currentRow.SourceKey] if !ok { continue } delta, ok, _ := stats.NormalizeDailyMileageDeltaForWindow(stats.DailyMileageFromDayBoundary(previousRow.TotalKM, currentRow.TotalKM), previousRow.TS, currentRow.TS) if !ok { continue } if chosen.current.SourceKey == "" || delta < chosenDelta { chosen = trustedChoice{current: currentRow, previous: previousRow} chosenDelta = delta } } return chosen, chosen.current.SourceKey != "" } func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) { where := backfillTimePredicates(cfg, date, nextDate(date)) where = append(where, "parse_status = 'OK'", "vin IS NOT NULL", "vin <> ''", fmt.Sprintf("protocol = '%s'", quote(string(protocol))), realtimeMileageFramePredicate(), ) if predicate := mileageBearingFramePredicate(protocol); predicate != "" { where = append(where, predicate) } sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), LAST(event_time), LAST(parsed_json), COUNT(*) FROM %s.raw_frames WHERE %s GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) return querySourceRows(ctx, db, cfg, protocol, sqlText, true) } func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) { // The baseline is the nearest earlier sample, not necessarily yesterday's. // LAST aggregates the complete pre-window history once per source so empty // calendar days do not make a backfill fall back to the current day's first // sample. where := backfillBeforePredicates(date) where = append(where, "parse_status = 'OK'", "vin IS NOT NULL", "vin <> ''", fmt.Sprintf("protocol = '%s'", quote(string(protocol))), realtimeMileageFramePredicate(), ) if predicate := mileageBearingFramePredicate(protocol); predicate != "" { where = append(where, predicate) } sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(event_time), LAST(parsed_json), COUNT(*) FROM %s.raw_frames WHERE %s GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) return querySourceRows(ctx, db, cfg, protocol, sqlText, false) } func backfillBeforePredicates(eventDateExclusive string) []string { return []string{ fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)), } } func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) { if db == nil { return nil, nil } loc := cfg.Location if loc == nil { loc = time.FixedZone("Asia/Shanghai", 8*3600) } sqlText := `SELECT l.vin, COALESCE(NULLIF(s.peer, ''), ''), l.total_mileage_event_time, l.total_mileage_km FROM vehicle_realtime_location l LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = l.protocol AND s.vin = l.vin WHERE l.protocol = ? AND l.vin IS NOT NULL AND l.vin <> '' AND l.total_mileage_km IS NOT NULL AND l.total_mileage_km > 0 AND l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)` rows, err := db.QueryContext(ctx, sqlText, string(protocol), date, date) if err != nil { return nil, err } defer rows.Close() out := map[string][]dailySourceLast{} for rows.Next() { var vin string var peer sql.NullString var eventTime time.Time var totalKM float64 if err := rows.Scan(&vin, &peer, &eventTime, &totalKM); err != nil { return nil, err } vin = strings.TrimSpace(vin) if vin == "" || totalKM <= 0 { continue } sourceEndpoint := strings.TrimSpace(peer.String) if sourceEndpoint == "" && protocol == envelope.ProtocolYutongMQTT { sourceEndpoint = "mqtt://yutong/realtime-location" } deviceID := "" if protocol == envelope.ProtocolYutongMQTT { deviceID = vin } sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint) sourceKey := normalizedSourceKeyForSource(string(protocol), "", deviceID, sourceEndpoint, sourceKind, sourceCode) if sourceKey == "" { continue } row := dailySourceLast{ VIN: vin, SourceKey: sourceKey, DeviceID: deviceID, SourceEndpoint: sourceEndpoint, SourceCode: sourceCode, PlatformName: platformName, SourceKind: sourceKind, FirstTS: eventTime.In(loc), TS: eventTime.In(loc), FirstTotalKM: totalKM, TotalKM: totalKM, RawSampleCount: 1, } out[vin] = append(out[vin], row) } if err := rows.Err(); err != nil { return nil, err } return out, nil } func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool) (map[string][]dailySourceLast, error) { rows, err := db.QueryContext(ctx, sqlText) if err != nil { return nil, err } defer rows.Close() latestBySource := map[string]dailySourceLast{} for rows.Next() { var vin string var phone string var deviceID string var sourceEndpoint string var firstTS time.Time var firstParsedJSON string var ts time.Time var parsedJSON string var rawSampleCount int64 if includeFirst { if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &ts, &parsedJSON, &rawSampleCount); err != nil { return nil, err } } else { if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawSampleCount); err != nil { return nil, err } firstTS = ts firstParsedJSON = parsedJSON } latestTotalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, ts, cfg.Location) if !ok { continue } firstTotalKM := latestTotalKM if includeFirst { if parsedFirst, ok := mileageFromParsed(protocol, vin, firstParsedJSON, firstTS, cfg.Location); ok { firstTotalKM = parsedFirst } } sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint) sourceKey := normalizedSourceKeyForSource(string(protocol), phone, deviceID, sourceEndpoint, sourceKind, sourceCode) if sourceKey == "" { continue } row := dailySourceLast{ VIN: strings.TrimSpace(vin), SourceKey: sourceKey, Phone: strings.TrimSpace(phone), DeviceID: strings.TrimSpace(deviceID), SourceEndpoint: strings.TrimSpace(sourceEndpoint), SourceCode: sourceCode, PlatformName: platformName, SourceKind: sourceKind, FirstTS: firstTS.In(cfg.Location), TS: ts.In(cfg.Location), FirstTotalKM: firstTotalKM, TotalKM: latestTotalKM, RawSampleCount: rawSampleCount, } key := row.VIN + "|" + row.SourceKey if existing, ok := latestBySource[key]; !ok || row.TS.After(existing.TS) { latestBySource[key] = row } } if err := rows.Err(); err != nil { return nil, err } out := map[string][]dailySourceLast{} for _, row := range latestBySource { out[row.VIN] = append(out[row.VIN], row) } return out, nil } func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string, eventTime time.Time, loc *time.Location) (float64, bool) { fields := fieldsForStats(protocol, vin, parsedJSON) env := envelope.FrameEnvelope{ Protocol: protocol, VIN: strings.TrimSpace(vin), EventTimeMS: eventTime.UnixMilli(), ReceivedAtMS: eventTime.UnixMilli(), Fields: fields, ParsedFields: fields, ParseStatus: envelope.ParseOK, } samples, err := stats.SamplesFromEnvelope(env, loc) if err != nil || len(samples) == 0 { return 0, false } return samples[0].TotalMileageKM, true } func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string { return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "") } func normalizedSourceKeyForSource(protocol string, phone string, deviceID string, endpoint string, sourceKind string, sourceCode string) string { sourceIP := stats.NormalizeSourceIP(endpoint) return stats.SourceKeyForSource(envelope.Protocol(protocol), phone, deviceID, sourceIP, sourceKind, sourceCode) } func knownPlatformSourceMetadata(protocol envelope.Protocol, endpoint string) (sourceCode string, platformName string, sourceKind string) { if protocol == envelope.ProtocolYutongMQTT && stats.NormalizeSourceIP(endpoint) == "mqtt" { return "yutong", "宇通", "PLATFORM" } return "", "", "" } func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[string]any { fields := map[string]any{} text = strings.TrimSpace(text) if text == "" { return fields } parsed := map[string]any{} if err := json.Unmarshal([]byte(text), &parsed); err == nil && len(parsed) > 0 { if flattened, _, ok := realtime.ComputeParsedFieldsForEnvelope(envelope.FrameEnvelope{ Protocol: protocol, VIN: vin, Parsed: parsed, }); ok { fields = flattened } else { fields = parsed } } for _, key := range mileageKeys(protocol) { if _, exists := fields[key]; exists { continue } value, ok := extractJSONStringField(text, key) if !ok { continue } fields[key] = value } return fields } func mileageKeys(protocol envelope.Protocol) []string { switch protocol { case envelope.ProtocolGB32960: return []string{"gb32960.vehicle.total_mileage_km"} case envelope.ProtocolJT808: return []string{"jt808.location.total_mileage_km"} case envelope.ProtocolYutongMQTT: return []string{ "yutong_mqtt.data.total_mileage_km", "yutong_mqtt.root.data.total_mileage_km", "yutong_mqtt.data.total_mileage", "yutong_mqtt.root.data.total_mileage", } default: return nil } } func extractJSONStringField(text string, key string) (string, bool) { pattern := regexp.QuoteMeta(`"`+key+`"`) + `\s*:\s*"?([^",}]+)"?` match := regexp.MustCompile(pattern).FindStringSubmatch(text) if len(match) != 2 { return "", false } return strings.TrimSpace(match[1]), true } func loadConfig() (config, error) { loc, err := time.LoadLocation(env("LOCAL_TZ", "Asia/Shanghai")) if err != nil { loc = time.FixedZone("Asia/Shanghai", 8*3600) } dateFrom, dateTo := resolveBackfillDateRange(time.Now(), loc) protocols, err := parseProtocols(env("BACKFILL_PROTOCOLS", "GB32960,JT808,YUTONG_MQTT")) if err != nil { return config{}, err } return config{ MySQLDSN: env("MYSQL_DSN", ""), TDengineDriver: env("TDENGINE_DRIVER", "taosWS"), TDengineDSN: env("TDENGINE_DSN", ""), TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase), DateFrom: dateFrom, DateTo: dateTo, Protocols: protocols, Method: env("BACKFILL_METHOD", "last_diff"), Limit: envInt("BACKFILL_LIMIT", 0), DryRun: envBool("BACKFILL_DRY_RUN", true), Reset: envBool("BACKFILL_RESET", false), Debug: envBool("BACKFILL_DEBUG", false), EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false), ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)), Location: loc, }, nil } func resolveBackfillDateRange(now time.Time, loc *time.Location) (string, string) { if loc == nil { loc = time.FixedZone("Asia/Shanghai", 8*3600) } explicitFrom := strings.TrimSpace(os.Getenv("BACKFILL_DATE_FROM")) explicitTo := strings.TrimSpace(os.Getenv("BACKFILL_DATE_TO")) if explicitTo != "" || explicitFrom != "" { dateTo := explicitTo if dateTo == "" { dateTo = now.In(loc).Format("2006-01-02") } dateFrom := explicitFrom if dateFrom == "" { dateFrom = dateTo } return dateFrom, dateTo } daysBack := envInt("BACKFILL_DAYS_BACK", 0) if daysBack < 0 { daysBack = 0 } windowDays := envInt("BACKFILL_WINDOW_DAYS", 1) if windowDays < 1 { windowDays = 1 } dateToTime := now.In(loc).AddDate(0, 0, -daysBack) dateFromTime := dateToTime.AddDate(0, 0, -(windowDays - 1)) return dateFromTime.Format("2006-01-02"), dateToTime.Format("2006-01-02") } func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, error) { where := backfillTimePredicates(cfg, cfg.DateFrom, nextDate(cfg.DateTo)) where = append(where, "parse_status = 'OK'", "vin IS NOT NULL", "vin <> ''", ) if len(cfg.Protocols) > 0 { quoted := make([]string, 0, len(cfg.Protocols)) for _, protocol := range cfg.Protocols { quoted = append(quoted, "'"+quote(string(protocol))+"'") } where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")") } where = append(where, realtimeMileageFramePredicate()) sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json FROM %s.raw_frames WHERE %s ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) if cfg.Limit > 0 { sqlText += fmt.Sprintf(" LIMIT %d", cfg.Limit) } slog.Info("query raw frames", "date_from", cfg.DateFrom, "date_to", cfg.DateTo, "protocols", cfg.Protocols, "limit", cfg.Limit) return db.QueryContext(ctx, sqlText) } func backfillTimePredicates(cfg config, eventDateFrom string, eventDateToExclusive string) []string { where := make([]string, 0, 4) if !cfg.EventTimeFullScan { // ts is the TDengine primary timestamp and may be adjusted slightly to keep // rows unique. Use it only as a broad index-friendly window; event_time is // the protocol business boundary used for the final natural-day result. where = append(where, fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))), fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateToExclusive))), ) } return append(where, fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)), fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateToExclusive)), ) } func realtimeMileageFramePredicate() string { return `( (protocol = 'GB32960' AND message_id IN (2,3)) OR (protocol = 'JT808' AND message_id = 512) OR (protocol = 'YUTONG_MQTT') )` } func mileageBearingFramePredicate(protocol envelope.Protocol) string { tokens := mileageSearchTokens(protocol) if len(tokens) == 0 { return "" } conditions := make([]string, 0, len(tokens)) for _, token := range tokens { conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token))) } return "(" + strings.Join(conditions, " OR ") + ")" } func mileageSearchTokens(protocol envelope.Protocol) []string { tokens := append([]string{}, mileageKeys(protocol)...) switch protocol { case envelope.ProtocolYutongMQTT: tokens = append(tokens, "TOTAL_MILEAGE", "totalMileage", "total_mileage_km") } seen := make(map[string]struct{}, len(tokens)) out := make([]string, 0, len(tokens)) for _, token := range tokens { token = strings.TrimSpace(token) if token == "" { continue } if _, ok := seen[token]; ok { continue } seen[token] = struct{}{} out = append(out, token) } return out } func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) { var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string var messageID int64 var eventTime, receivedAt time.Time if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed); err != nil { return rawFrameRow{}, err } return rawFrameRow{ Protocol: envelope.Protocol(strings.TrimSpace(protocol)), VIN: strings.TrimSpace(vin), Phone: strings.TrimSpace(phone), DeviceID: strings.TrimSpace(deviceID), SourceEndpoint: strings.TrimSpace(sourceEndpoint), EventID: strings.TrimSpace(eventID), MessageID: fmt.Sprintf("0x%04X", messageID), EventTimeMS: eventTime.UnixMilli(), ReceivedAtMS: receivedAt.UnixMilli(), ParsedJSON: parsed, }, nil } func isChunkManifest(text string) bool { if !strings.Contains(text, "chunked") || !strings.Contains(text, "payload_kind") { return false } var manifest struct { Chunked bool `json:"chunked"` } return json.Unmarshal([]byte(text), &manifest) == nil && manifest.Chunked } func readChunkedPayload(ctx context.Context, db *sql.DB, database string, eventID string, kind string) (string, error) { sqlText := fmt.Sprintf(`SELECT chunk_text FROM %s.raw_frame_payload_chunks WHERE event_id = '%s' AND payload_kind = '%s' ORDER BY chunk_index ASC`, ident(database), quote(eventID), quote(kind)) rows, err := db.QueryContext(ctx, sqlText) if err != nil { return "", err } defer rows.Close() var b strings.Builder for rows.Next() { var chunk string if err := rows.Scan(&chunk); err != nil { return "", err } b.WriteString(chunk) } if err := rows.Err(); err != nil { return "", err } if b.Len() == 0 { return "", fmt.Errorf("no chunks found") } return b.String(), nil } func resetStats(ctx context.Context, db *sql.DB, cfg config) (int64, error) { clauses := []string{"stat_date >= ?", "stat_date <= ?"} args := []any{cfg.DateFrom, cfg.DateTo} if len(cfg.Protocols) > 0 { placeholders := make([]string, 0, len(cfg.Protocols)) for _, protocol := range cfg.Protocols { placeholders = append(placeholders, "?") args = append(args, string(protocol)) } clauses = append(clauses, "protocol IN ("+strings.Join(placeholders, ",")+")") } var deleted int64 for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage"} { result, err := db.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+strings.Join(clauses, " AND "), args...) if err != nil { return deleted, err } affected, err := result.RowsAffected() if err != nil { return deleted, err } deleted += affected } return deleted, nil } func parseProtocols(value string) ([]envelope.Protocol, error) { var protocols []envelope.Protocol for _, part := range strings.Split(value, ",") { part = strings.ToUpper(strings.TrimSpace(part)) if part == "" { continue } switch envelope.Protocol(part) { case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT: protocols = append(protocols, envelope.Protocol(part)) default: return nil, fmt.Errorf("unsupported protocol %q", part) } } return protocols, nil } func loadEnvFiles(paths string) error { for _, path := range strings.Split(paths, ",") { if err := loadEnvFile(path); err != nil { return err } } return nil } func backfillEnvFiles(explicitFiles string, explicitFile string, defaults []string) string { if value := strings.TrimSpace(explicitFiles); value != "" { return value } if value := strings.TrimSpace(explicitFile); value != "" { return value } var existing []string for _, path := range defaults { path = strings.TrimSpace(path) if path == "" { continue } if _, err := os.Stat(path); err == nil { existing = append(existing, path) } } return strings.Join(existing, ",") } func loadEnvFile(path string) error { path = strings.TrimSpace(path) if path == "" { return nil } data, err := os.ReadFile(path) if err != nil { return err } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { continue } key, value, _ := strings.Cut(line, "=") key = strings.TrimSpace(key) value = strings.Trim(strings.TrimSpace(value), "\"'") if key != "" && os.Getenv(key) == "" { _ = os.Setenv(key, value) } } return nil } func env(key string, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func envInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func envBool(key string, fallback bool) bool { value := strings.ToLower(strings.TrimSpace(os.Getenv(key))) if value == "" { return fallback } return value == "1" || value == "true" || value == "yes" || value == "y" } func nextDate(date string) string { parsed, err := time.Parse("2006-01-02", date) if err != nil { return date } return parsed.AddDate(0, 0, 1).Format("2006-01-02") } func previousDate(date string) string { parsed, err := time.Parse("2006-01-02", date) if err != nil { return date } return parsed.AddDate(0, 0, -1).Format("2006-01-02") } func dateRangeWithPrevious(from string, to string) ([]string, error) { dates, err := dateRange(from, to) if err != nil { return nil, err } return append([]string{previousDate(from)}, dates...), nil } func dateRange(from string, to string) ([]string, error) { start, err := time.Parse("2006-01-02", from) if err != nil { return nil, err } end, err := time.Parse("2006-01-02", to) if err != nil { return nil, err } if end.Before(start) { return nil, fmt.Errorf("dateTo before dateFrom") } var dates []string for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { dates = append(dates, day.Format("2006-01-02")) } return dates, nil } func ident(value string) string { value = strings.TrimSpace(strings.Trim(value, "`")) if value == "" { return history.DefaultDatabase } return value } func quote(value string) string { return strings.ReplaceAll(value, "'", "''") } func fail(stage string, err error) { slog.Error(stage, "error", err) os.Exit(1) }