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 StationaryCarry bool GPSFallback bool ProgressEvery int64 BaselineLookback int 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 RawText 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) } gpsFallbacks, err := addGPSCoordinateFallbackAggregates(ctx, mysqlDB, td, cfg, aggregates) if err != nil { fail("build GPS-coordinate 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, "jt808GPSFallbacks", gpsFallbacks, "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 len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT { fields = fieldsForStats(row.Protocol, row.VIN, row.RawText) } 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 agg.QualityReason != stats.QualityReasonGPSCoordinate { 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, _ *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{} 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 i := range currentSources { currentRow := currentSources[i] 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 } if cfg.StationaryCarry && !hasPrevious { continue } if cfg.StationaryCarry { // A sparse Yutong location frame can retain an older // realtime odometer than the durable daily baseline. // Stationary carry-forward represents an explicit zero // day, so both ends must use the latest trusted baseline. currentRow.FirstTotalKM = previousRow.TotalKM currentRow.TotalKM = previousRow.TotalKM currentSources[i] = currentRow } agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious) if cfg.StationaryCarry { agg.QualityReason = stats.QualityReasonStationaryCarry } else 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 } type gpsCoordinateBackfillState struct { previous stats.GPSMileagePoint firstEventTime time.Time latestEventTime time.Time distanceKM float64 pointCount int64 usableSegments int64 badJumps int64 longGaps int64 } func addGPSCoordinateFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) { if !cfg.GPSFallback || tdDB == nil || aggregates == nil { return 0, nil } totalAdded := 0 for _, protocol := range cfg.Protocols { added, err := addProtocolGPSCoordinateFallbackAggregates(ctx, mysqlDB, tdDB, cfg, aggregates, protocol) if err != nil { return totalAdded, err } totalAdded += added } return totalAdded, nil } func addProtocolGPSCoordinateFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg, protocol envelope.Protocol) (int, error) { if !supportsBackfillGPSFallback(protocol) { return 0, nil } existing, err := existingPositiveOdometerTargets(ctx, mysqlDB, cfg, protocol) if err != nil { return 0, err } for _, agg := range aggregates { if agg != nil && agg.Protocol == protocol && agg.QualityReason != stats.QualityReasonGPSCoordinate && agg.LatestKM > agg.FirstKM { existing[agg.VIN+"|"+agg.Date] = struct{}{} } } where := []string{ fmt.Sprintf("ts >= '%s 00:00:00'", quote(cfg.DateFrom)), fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(cfg.DateTo))), fmt.Sprintf("protocol = '%s'", quote(string(protocol))), "vin IS NOT NULL", "vin <> ''", "longitude BETWEEN -180 AND 180", "latitude BETWEEN -90 AND 90", "NOT (longitude = 0 AND latitude = 0)", } if protocol != envelope.ProtocolJT808 { where = append(where, "speed_kmh > 0") } sqlText := fmt.Sprintf(`SELECT vin, ts, longitude, latitude FROM %s.vehicle_locations WHERE %s ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) rows, err := tdDB.QueryContext(ctx, sqlText) if err != nil { return 0, err } defer rows.Close() states := map[string]*gpsCoordinateBackfillState{} for rows.Next() { var vin string var eventTime time.Time var longitude, latitude float64 if err := rows.Scan(&vin, &eventTime, &longitude, &latitude); err != nil { return 0, err } vin = strings.TrimSpace(vin) eventTime = eventTime.In(cfg.Location) date := eventTime.Format("2006-01-02") target := vin + "|" + date if _, ok := existing[target]; ok { continue } current := stats.GPSMileagePoint{ VIN: vin, StatDate: date, Protocol: protocol, EventTime: eventTime, Longitude: longitude, Latitude: latitude, } state := states[target] if state == nil { states[target] = &gpsCoordinateBackfillState{ previous: current, firstEventTime: eventTime, latestEventTime: eventTime, pointCount: 1, } continue } segment := stats.CalculateGPSMileageSegment(state.previous, current) if segment.OutOfOrder { continue } state.previous = current state.latestEventTime = eventTime state.pointCount++ if segment.Usable { state.distanceKM += segment.DistanceKM state.usableSegments++ } if segment.BadJump { state.badJumps++ } if segment.LongGap { state.longGaps++ } } if err := rows.Err(); err != nil { return 0, err } added := 0 for target, state := range states { if !stats.GPSMileageCandidateEligible(state.distanceKM, state.usableSegments) { continue } parts := strings.SplitN(target, "|", 2) if len(parts) != 2 { continue } vin, date := parts[0], parts[1] sourceKey := string(protocol) + ":" + vin + "@GPS_COORDINATE" key := vin + "|" + date + "|" + string(protocol) + "|" + sourceKey aggregates[key] = &metricAgg{ VIN: vin, Date: date, Protocol: protocol, FirstKM: 0, LatestKM: state.distanceKM, Count: state.pointCount, SourceKey: sourceKey, SourceEndpoint: "gps-coordinate", PlatformName: backfillGPSPlatformName(protocol), SourceKind: "UNKNOWN", FirstEventTime: state.firstEventTime, LatestEventTime: state.latestEventTime, QualityStatus: stats.QualityOK, QualityReason: stats.QualityReasonGPSCoordinate, } added++ } if len(states) > 0 { slog.Info("GPS-coordinate fallbacks loaded", "protocol", protocol, "states", len(states), "added", added) } return added, nil } func supportsBackfillGPSFallback(protocol envelope.Protocol) bool { switch protocol { case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT: return true default: return false } } func backfillGPSPlatformName(protocol envelope.Protocol) string { switch protocol { case envelope.ProtocolGB32960: return "GB32960 GPS轨迹估算" case envelope.ProtocolYutongMQTT: return "宇通 GPS轨迹估算" default: return "JT808 GPS轨迹估算" } } func existingPositiveOdometerTargets(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol) (map[string]struct{}, error) { result := map[string]struct{}{} if db == nil { return result, nil } rows, err := db.QueryContext(ctx, `SELECT DISTINCT vin, DATE_FORMAT(stat_date, '%Y-%m-%d') FROM vehicle_daily_mileage_source WHERE protocol = ? AND stat_date >= ? AND stat_date <= ? AND quality_status = ? AND COALESCE(quality_reason, '') <> ? AND daily_mileage_km > 0 AND latest_total_mileage_km IS NOT NULL`, string(protocol), cfg.DateFrom, cfg.DateTo, stats.QualityOK, stats.QualityReasonGPSCoordinate) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var vin, date string if err := rows.Scan(&vin, &date); err != nil { return nil, err } result[strings.TrimSpace(vin)+"|"+strings.TrimSpace(date)] = struct{}{} } return result, rows.Err() } func containsProtocol(protocols []envelope.Protocol, target envelope.Protocol) bool { for _, protocol := range protocols { if protocol == target { return true } } return false } 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), FIRST(raw_text), LAST(event_time), LAST(parsed_json), LAST(raw_text), 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(cfg, 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), LAST(raw_text), 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(cfg config, eventDateExclusive string) []string { lookbackDays := cfg.BaselineLookback if lookbackDays <= 0 { lookbackDays = 7 } eventDateFrom := shiftDate(eventDateExclusive, -lookbackDays) where := []string{ fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)), fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)), } if !cfg.EventTimeFullScan { where = append([]string{ fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))), fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateExclusive))), }, where...) } return where } 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) } eventTimeColumn := "l.total_mileage_event_time" timePredicate := `l.total_mileage_event_time >= ? AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)` if cfg.StationaryCarry { eventTimeColumn = "l.event_time" timePredicate = `l.event_time >= ? AND l.event_time < DATE_ADD(?, INTERVAL 1 DAY) AND l.total_mileage_event_time < ? AND COALESCE(l.speed_kmh, 0) = 0` } sqlText := `SELECT l.vin, COALESCE(NULLIF(s.peer, ''), ''), ` + eventTimeColumn + `, 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 ` + timePredicate args := []any{string(protocol), date, date} if cfg.StationaryCarry { args = append(args, date) } rows, err := db.QueryContext(ctx, sqlText, args...) 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 firstRawText string var ts time.Time var parsedJSON string var rawText string var rawSampleCount int64 if includeFirst { if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &firstRawText, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil { return nil, err } } else { if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil { return nil, err } firstTS = ts firstParsedJSON = parsedJSON firstRawText = rawText } latestTotalKM, ok := mileageFromEvidence(protocol, vin, parsedJSON, rawText, ts, cfg.Location) if !ok { continue } firstTotalKM := latestTotalKM if includeFirst { if parsedFirst, ok := mileageFromEvidence(protocol, vin, firstParsedJSON, firstRawText, 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 mileageFromEvidence(protocol envelope.Protocol, vin string, parsedJSON string, rawText string, eventTime time.Time, loc *time.Location) (float64, bool) { if totalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, eventTime, loc); ok { return totalKM, true } if protocol != envelope.ProtocolYutongMQTT || strings.TrimSpace(rawText) == "" { return 0, false } return mileageFromParsed(protocol, vin, rawText, eventTime, loc) } 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), StationaryCarry: envBool("BACKFILL_STATIONARY_CARRY_FORWARD", false), GPSFallback: envBool("BACKFILL_GPS_FALLBACK", envBool("BACKFILL_JT808_GPS_FALLBACK", true)), ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)), BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7), 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, raw_text 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))) if protocol == envelope.ProtocolYutongMQTT { conditions = append(conditions, fmt.Sprintf("raw_text 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, rawText string var messageID int64 var eventTime, receivedAt time.Time if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed, &rawText); 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, RawText: rawText, }, 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 shiftDate(date string, days int) string { parsed, err := time.Parse("2006-01-02", date) if err != nil { return date } return parsed.AddDate(0, 0, days).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) }