Files
toom1996 d6ed96624a 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。
2026-10-25 14:55:30 +08:00

143 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
}
}