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:
toom1996
2026-10-25 14:55:30 +08:00
parent 64b264ee73
commit d6ed96624a
3 changed files with 167 additions and 0 deletions

142
cmd/s4migrate/main.go Normal file
View 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"
}
}

View File

@ -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%');

View File

@ -46,6 +46,16 @@ func prefixOf(collectionType, season string) string {
// 高定沿用秋冬编码(FW),与排序优先级一致 // 高定沿用秋冬编码(FW),与排序优先级一致
return "FW" return "FW"
case ColMenswear: 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" return "FW"
} }
switch season { switch season {