// Apply reviewed event-time boundary corrections with a verified backup. package main import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "flag" "fmt" "math" "os" "sort" "strconv" "strings" "time" _ "github.com/go-sql-driver/mysql" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" ) type row struct { VIN string `json:"vin"` Date string `json:"stat_date"` Protocol string `json:"protocol"` Source string `json:"source_key"` First string `json:"first_total_mileage_km"` Last string `json:"latest_total_mileage_km"` FirstTime string `json:"first_event_time"` LastTime string `json:"latest_event_time"` Daily string `json:"daily_mileage_km"` Status string `json:"quality_status"` Reason string `json:"quality_reason"` Count int64 `json:"sample_count"` } type change struct { Before row `json:"before"` After row `json:"after"` } func main() { input := flag.String("input", "", "reviewed event boundary plan") manifest := flag.String("backup-manifest", "", "verified backup manifest") apply := flag.Bool("apply", false, "apply changes atomically") flag.Parse() data, err := os.ReadFile(*input) must(err) var plan []change must(json.Unmarshal(data, &plan)) loc := time.FixedZone("Asia/Shanghai", 8*3600) for _, c := range plan { must(validateChange(c, loc)) } if !*apply { fmt.Printf("planned=%d dry_run=true\n", len(plan)) return } var backup struct { File string `json:"backup_file"` SHA string `json:"backup_sha256"` PlanSHA string `json:"plan_sha256"` Verified bool `json:"gzip_and_json_verified"` } b, err := os.ReadFile(*manifest) must(err) must(json.Unmarshal(b, &backup)) digest := sha256.Sum256(data) if !backup.Verified || hex.EncodeToString(digest[:]) != backup.PlanSHA { panic("reviewed plan is not the backed-up plan") } compressed, err := os.ReadFile(backup.File) must(err) digest = sha256.Sum256(compressed) if hex.EncodeToString(digest[:]) != backup.SHA { panic("backup checksum mismatch") } db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN")) must(err) defer db.Close() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() tx, err := db.BeginTx(ctx, nil) must(err) defer tx.Rollback() sort.Slice(plan, func(i, j int) bool { a, b := plan[i].Before, plan[j].Before return a.Date+a.VIN+a.Source < b.Date+b.VIN+b.Source }) targets := map[string]row{} for _, c := range plan { b, a := c.Before, c.After res, err := tx.ExecContext(ctx, `UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=?,first_event_time=?,latest_total_mileage_km=?,latest_event_time=?,daily_mileage_km=?,quality_status=?,quality_reason=? WHERE vin=? AND stat_date=? AND protocol='JT808' AND source_key=? AND first_total_mileage_km <=> ? AND first_event_time <=> ? AND latest_total_mileage_km=? AND latest_event_time=? AND daily_mileage_km=? AND quality_status=?`, a.First, a.FirstTime, a.Last, a.LastTime, a.Daily, a.Status, a.Reason, b.VIN, b.Date, b.Source, b.First, b.FirstTime, b.Last, b.LastTime, b.Daily, b.Status) must(err) n, err := res.RowsAffected() must(err) if n != 1 { panic(fmt.Sprintf("concurrent change: %s %s %s", b.VIN, b.Date, b.Source)) } targets[b.Date+"|"+b.VIN] = b } keys := make([]string, 0, len(targets)) for k := range targets { keys = append(keys, k) } sort.Strings(keys) projected, preserved := 0, 0 for _, k := range keys { b := targets[k] rows, err := tx.QueryContext(ctx, `SELECT source_ip,COALESCE(quality_reason,'') FROM vehicle_daily_mileage_source WHERE vin=? AND stat_date=? AND protocol='JT808' AND is_selected=1 FOR UPDATE`, b.VIN, b.Date) must(err) manual := false for rows.Next() { var ip, reason string must(rows.Scan(&ip, &reason)) manual = manual || strings.HasPrefix(ip, "manual") || strings.HasPrefix(ip, "legacy-mysql") || strings.HasPrefix(reason, "manual") } must(rows.Err()) must(rows.Close()) if manual { preserved++ continue } must(stats.ProjectDailyMileage(ctx, tx, b.VIN, b.Date, envelope.ProtocolJT808)) projected++ } must(tx.Commit()) must(json.NewEncoder(os.Stdout).Encode(map[string]any{"committed": true, "updatedSources": len(plan), "reprojectedDays": projected, "preservedManualDays": preserved})) } func validateChange(c change, loc *time.Location) error { b, a := c.Before, c.After if b.Protocol != "JT808" || b.VIN == "" || b.Source == "" { return fmt.Errorf("invalid GPS identity") } first, err := strconv.ParseFloat(a.First, 64) if err != nil { return err } last, err := strconv.ParseFloat(a.Last, 64) if err != nil { return err } daily, err := strconv.ParseFloat(a.Daily, 64) if err != nil { return err } firstTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.FirstTime, loc) if err != nil { return err } lastTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.LastTime, loc) if err != nil { return err } oldEnd, err := time.ParseInLocation("2006-01-02 15:04:05", b.LastTime, loc) if err != nil { return err } if lastTime.Before(oldEnd) || firstTime.After(lastTime) || lastTime.Format("2006-01-02") != b.Date || first <= 0 || last <= 0 { return fmt.Errorf("invalid event-time boundary %s %s", b.VIN, b.Date) } sample := stats.SourceMileageSample{DailyKM: last - first, FirstEventTime: firstTime, LatestEventTime: lastTime, QualityStatus: stats.QualityOK, QualityReason: a.Reason} stats.ApplyMileageQualityRules(&sample) if math.IsNaN(daily) || math.IsInf(daily, 0) || math.Abs(sample.DailyKM-daily) > 0.0001 || sample.QualityStatus != a.Status { return fmt.Errorf("incorrect reviewed mileage %s %s", b.VIN, b.Date) } return nil } func must(err error) { if err != nil { panic(err) } }