diff --git a/cmd/s4migrate/main.go b/cmd/s4migrate/main.go new file mode 100644 index 0000000..be2f94a --- /dev/null +++ b/cmd/s4migrate/main.go @@ -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" + } +} diff --git a/db/migrations/2026-10-25-03-menswear-season-code.sql b/db/migrations/2026-10-25-03-menswear-season-code.sql new file mode 100644 index 0000000..0e40254 --- /dev/null +++ b/db/migrations/2026-10-25-03-menswear-season-code.sql @@ -0,0 +1,15 @@ +-- 修正男装的 season_code:春/夏男装应为 SSyy,秋/冬男装为 FWyy。 +-- +-- 原实现里 menswear 一律返回 FW,于是同年的 Spring/Fall 男装共用 FWyy, +-- 而实体键是(brand_id + season_code + collection_type)→ 后到的一场被判「已存在」跳过。 +-- 实测 AMBUSH:Vogue 25 场,入库 21 场,缺的 4 场全是男装。 +-- +-- 只改「春夏男装」(标题以 Spring 开头)的历史行;秋冬男装本来就是 FWyy,保持不变。 +-- 安全性:同一品牌的春夏男装此前必然与秋冬男装撞键而未入库, +-- 因此改成 SSyy 不会与现有行冲突(唯一索引 (brand_id, season_code, collection_type))。 +UPDATE brand_runways + SET season_code = 'SS' || right(year::text, 2) + WHERE collection_type = 'menswear' + AND season_code LIKE 'FW%' + AND year >= 2000 + AND (title_en LIKE 'Spring%' OR title_en LIKE 'Resort%'); diff --git a/internal/pkg/season/season.go b/internal/pkg/season/season.go index f317b4e..92cad1a 100644 --- a/internal/pkg/season/season.go +++ b/internal/pkg/season/season.go @@ -46,6 +46,16 @@ func prefixOf(collectionType, season string) string { // 高定沿用秋冬编码(FW),与排序优先级一致 return "FW" case ColMenswear: + // 男装必须按真实季节区分:曾经一律返回 FW,导致同年的 + // 「Spring 2021 Menswear」与「Fall 2021 Menswear」推出同一个 season_code(FW21), + // 而实体键是(品牌 + season_code + 系列),于是后到的那场被判「已存在」静默丢弃。 + // 实测 AMBUSH:Vogue 25 场只入库 21 场,缺的 4 场全是男装。 + switch season { + case SeasonSpring: + return "SS" + case SeasonFall: + return "FW" + } return "FW" } switch season {