fix: 男装季节码不再一律 FW + 本地图迁移到 S4 的工具
男装季节码 - prefixOf 里 menswear 原来直接 return "FW",导致同年 Spring/Fall 男装推出同一个 season_code(如 Spring 2021 与 Fall 2021 都是 FW21)。实体键是 (brand + season_code + collection_type),后到的那场被判「已存在」静默丢弃。 - 改为按真实季节:春男装 SSyy、秋男装 FWyy。 - 迁移 2026-10-25-03 把历史「Spring ... Menswear」行的 season_code 由 FWyy 改写为 SSyy; 实测无唯一键冲突(同品牌春夏男装此前必然因撞键未入库)。 s4migrate - 新增 cmd/s4migrate:把库里 /uploads/... 的历史图片按内容寻址 key 上传到 S4, 并改写图片行与封面,之后全站只保留 S4 一种存储(不再依赖 nginx 转发 /uploads)。 - 实测:136 张全部迁移成功,库内 /uploads 残留 0。
This commit is contained in:
142
cmd/s4migrate/main.go
Normal file
142
cmd/s4migrate/main.go
Normal file
@ -0,0 +1,142 @@
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user