package main import ( "context" "encoding/json" "fmt" "os" "time" "wucher/internal/config" filemanager "wucher/internal/domain/file_manager" "wucher/internal/repository/mysql" s3repo "wucher/internal/repository/s3" ) type backfillReport struct { Total int `json:"total"` ActiveFiles int `json:"active_files"` ProbableOrphans int `json:"probable_orphan_files"` MissingS3Key int `json:"missing_s3_key"` InvalidReference int `json:"invalid_references"` Skipped int `json:"skipped"` DryRun bool `json:"dry_run"` } func main() { ctx := context.Background() cfg, err := config.LoadFromFile(configFilePath()) if err != nil { fmt.Fprintf(os.Stderr, "load config: %v\n", err) os.Exit(1) } db, err := mysql.Connect(cfg.MySQL) if err != nil { fmt.Fprintf(os.Stderr, "connect db: %v\n", err) os.Exit(1) } fileRepo := mysql.NewFileManagerFileRepository(db) lifecycleRepo, ok := any(fileRepo).(filemanager.FileLifecycleRepository) if !ok { fmt.Fprintln(os.Stderr, "file repository does not support lifecycle operations") os.Exit(1) } dryRun := true if raw := os.Getenv("BACKFILL_DRY_RUN"); raw != "" && (raw == "false" || raw == "0") { dryRun = false } var storage filemanager.ObjectStorage if cfg.FileStorage.Bucket != "" && cfg.FileStorage.Region != "" { objStorage, s3Err := s3repo.NewFileObjectStorage(ctx, cfg.FileStorage) if s3Err == nil { storage = objStorage } } rows := make([]filemanager.File, 0) if err := db.WithContext(ctx).Model(&filemanager.File{}).Find(&rows).Error; err != nil { fmt.Fprintf(os.Stderr, "query files: %v\n", err) os.Exit(1) } report := backfillReport{DryRun: dryRun} now := time.Now().UTC() for i := range rows { row := rows[i] report.Total++ if row.DeletedAt != nil { report.Skipped++ continue } referenced, refErr := lifecycleRepo.IsFileReferenced(ctx, row.ID) if refErr != nil { report.InvalidReference++ report.Skipped++ continue } if storage != nil { if _, headErr := storage.HeadObject(ctx, row.ObjectKey); headErr != nil { report.MissingS3Key++ } } if referenced { report.ActiveFiles++ if !dryRun { _ = lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{ ID: row.ID, Status: filemanager.FileLifecycleStatusActive, AttachedAt: &now, ClearOrphanedAt: true, }) } continue } report.ProbableOrphans++ if !dryRun { _ = lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{ ID: row.ID, Status: filemanager.FileLifecycleStatusOrphanPending, OrphanedAt: &now, }) } } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(report) } func configFilePath() string { if v := os.Getenv("CONFIG_FILE"); v != "" { return v } basePath := os.Getenv("CONFIG_BASE_PATH") if basePath == "" { basePath = "/tmp" } filename := os.Getenv("CONFIG_FILENAME") if filename == "" { filename = "config.generated.json" } return basePath + "/" + filename }