// Command s4migrate 把历史上兜底存在本地的图片(库里的 `/uploads/...` 路径) // 迁移到 S4,并把库里的路径改写为对象 key。 // // 背景:S4 未启用(或上传失败)时,入库会兜底写本地磁盘,库里留下 `/uploads/runway/xxx.jpg`。 // 这类路径只有在「nginx 把 /uploads 转发给后端」时才可访问;本项目的原则是 // **图片一律进 S4**,所以这里做一次性的归并,不保留本地兜底访问通道。 // // 用法(在本机,先把服务器上的容器卷拷下来): // // s4migrate -config configs/config.local.yml -dir ./uploads -dry-run // s4migrate -config configs/config.local.yml -dir ./uploads // // 幂等:已经迁移过的行不再是 `/uploads/` 前缀,重复执行只会处理剩余的。 package main import ( "context" "flag" "fmt" "log" "os" "path/filepath" "strings" "time" "fashionapi/internal/config" "fashionapi/internal/database" "fashionapi/internal/pkg/storage" ) func main() { cfgPath := flag.String("config", "", "配置文件路径(默认 configs/config.yml + config.local.yml 叠加)") dir := flag.String("dir", "", "本地 uploads 目录(其下应有 runway/ 与 street/)") dryRun := flag.Bool("dry-run", false, "只打印将要做什么,不写库不上传") flag.Parse() if *dir == "" { log.Fatal("必须指定 -dir(本地 uploads 目录)") } cfg, err := config.Load(*cfgPath) if err != nil { log.Fatalf("加载配置失败: %v", err) } if !cfg.S4.Enabled || cfg.S4.AK == "" { log.Fatal("配置里 S4 未启用或缺少 AK/SK,无法迁移") } db, err := database.New(cfg.Database) if err != nil { log.Fatalf("连接数据库失败: %v", err) } defer func() { _ = database.Close(db) }() up := storage.NewS4Uploader(cfg.S4.AK, cfg.S4.SK, cfg.S4.Bucket, cfg.S4.Endpoint, cfg.S4.BaseURL, cfg.S4.Region) ctx := context.Background() type target struct { table string id uint32 image string } var targets []target for _, tbl := range []string{"brand_runway_images", "street_snap_images"} { var rows []struct { ID uint32 Image string } if err := db.Raw("SELECT id, image FROM " + tbl + " WHERE is_deleted = 0 AND image LIKE '/uploads/%' ORDER BY id").Scan(&rows).Error; err != nil { log.Fatalf("查询 %s 失败: %v", tbl, err) } for _, r := range rows { targets = append(targets, target{table: tbl, id: r.ID, image: r.Image}) } } log.Printf("待迁移图片 %d 张(来源 %s)", len(targets), *dir) var okCount, failCount, missing int start := time.Now() for i, t := range targets { rel := strings.TrimPrefix(t.image, "/uploads/") // runway/ab12.jpg local := filepath.Join(*dir, filepath.FromSlash(rel)) data, rerr := os.ReadFile(local) if rerr != nil { missing++ log.Printf(" [缺失] %s/%d %s: %v", t.table, t.id, rel, rerr) continue } if *dryRun { log.Printf(" [dry] %s/%d %s (%d 字节)", t.table, t.id, rel, len(data)) continue } // 文件名本身就是内容 sha1(LocalUploader 的命名),所以 key 与本地相对路径一致。 if _, uerr := up.Upload(ctx, rel, data, contentTypeOf(rel)); uerr != nil { failCount++ log.Printf(" [失败] %s/%d %s: %v", t.table, t.id, rel, uerr) continue } if err := db.Exec("UPDATE "+t.table+" SET image = ? WHERE id = ?", rel, t.id).Error; err != nil { failCount++ log.Printf(" [写库失败] %s/%d %s: %v", t.table, t.id, rel, err) continue } okCount++ if (i+1)%20 == 0 { log.Printf(" 进度 %d/%d", i+1, len(targets)) } } if *dryRun { log.Printf("dry-run 结束:%d 张可迁移,%d 张本地文件缺失", len(targets)-missing, missing) return } // 封面同样可能指向 /uploads/:一并改写成 key(否则列表封面仍是死链)。 for _, sql := range []string{ "UPDATE brand_runways SET cover = replace(cover, '/uploads/', '') WHERE cover LIKE '/uploads/%'", "UPDATE street_snaps SET cover = replace(cover, '/uploads/', '') WHERE cover LIKE '/uploads/%'", } { if err := db.Exec(sql).Error; err != nil { log.Printf(" [封面改写失败] %s: %v", sql, err) } } log.Printf("完成:成功 %d,失败 %d,本地文件缺失 %d,耗时 %v", okCount, failCount, missing, time.Since(start).Round(time.Second)) fmt.Println("提示:若仍有残留(缺失文件),用 SELECT 复核后再手工处理。") } // contentTypeOf 按扩展名给 Content-Type,与上传时保持一致。 func contentTypeOf(key string) string { switch strings.ToLower(filepath.Ext(key)) { case ".png": return "image/png" case ".gif": return "image/gif" case ".webp": return "image/webp" default: return "image/jpeg" } }