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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
db/migrations/2026-10-25-03-menswear-season-code.sql
Normal file
15
db/migrations/2026-10-25-03-menswear-season-code.sql
Normal 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%');
|
||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user