This commit is contained in:
toom1996
2026-09-07 00:01:48 +08:00
parent b049104a0b
commit 74ba700598
31 changed files with 1113 additions and 606 deletions

View File

@ -1,206 +0,0 @@
// Command backfill_city 给 street_snap 加 city 列并从 title(首选)/ source_url(兜底)回填。
//
// 项目已移除 AutoMigrate,表结构由外部 SQL 托管(见 scripts/sql/004_add_streetsnap_city.sql)。
// 本程序是「没有 mysql 客户端时」的执行器:
// - ALTER 走 ensureColumn:先查 information_schema,缺才加,可安全重跑(幂等)。
// - 回填:优先从 title 第一段(location,\t 分隔)按城市关键词匹配;
// 命中不到再退到 source_url / cover 的 URL slug 解析。幂等(已填过的不会覆盖)。
//
// 存储的 city 值即「规范英文名首字母大写」(Paris / New York …),
// 与前端街拍列表 City 筛选项 value、header 下拉 ?city= 参数完全一致(精确匹配)。
package main
import (
"fmt"
"log"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
const dsn = "root:root@tcp(127.0.0.1:3306)/db_dev?charset=utf8mb4&parseTime=True&loc=Local"
// cityKeywords 城市关键词 → 规范存储值(首字母大写)。
// 顺序无关;extractCityFromTitle 用「包含」匹配,可容忍 "Paris Fashion Week" / "New York FW25" 等形式。
var cityKeywords = []struct {
kw string
city string
}{
{"paris", "Paris"},
{"milan", "Milan"},
{"london", "London"},
{"new york", "New York"},
{"nyfw", "New York"},
{"tokyo", "Tokyo"},
{"seoul", "Seoul"},
{"florence", "Florence"},
{"shanghai", "Shanghai"},
{"copenhagen", "Copenhagen"},
{"berlin", "Berlin"},
{"rome", "Rome"},
{"madrid", "Madrid"},
{"amsterdam", "Amsterdam"},
{"antwerp", "Antwerp"},
{"los angeles", "Los Angeles"},
{"moscow", "Moscow"},
{"mumbai", "Mumbai"},
{"beijing", "Beijing"},
}
// citySlug(URL 兜底用):slug → 规范存储值。多词城市(new-york)必须排在单词组之前被 HasPrefix 命中。
var citySlug = map[string]string{
"paris": "Paris",
"milan": "Milan",
"london": "London",
"new-york": "New York",
"tokyo": "Tokyo",
"seoul": "Seoul",
"copenhagen": "Copenhagen",
"florence": "Florence",
"berlin": "Berlin",
"shanghai": "Shanghai",
"beijing": "Beijing",
"moscow": "Moscow",
"los-angeles": "Los Angeles",
"rome": "Rome",
"madrid": "Madrid",
"amsterdam": "Amsterdam",
"antwerp": "Antwerp",
"mumbai": "Mumbai",
}
// extractCityFromTitle 从 title 第一段(location,\t 分隔)按关键词匹配规范城市名。
func extractCityFromTitle(title string) string {
seg := title
if i := strings.Index(title, "\t"); i >= 0 {
seg = title[:i]
}
segL := strings.ToLower(strings.TrimSpace(seg))
if segL == "" {
return ""
}
for _, c := range cityKeywords {
if strings.Contains(segL, c.kw) {
return c.city
}
}
return ""
}
// extractCityFromURL 兜底:从 source_url / cover 的 URL 第一段 slug 解析城市。
func extractCityFromURL(raws ...string) string {
for _, raw := range raws {
u := strings.TrimSpace(raw)
if u == "" {
continue
}
if i := strings.Index(u, "://"); i >= 0 {
u = u[i+3:]
}
if i := strings.Index(u, "/"); i >= 0 {
u = u[i+1:]
}
u = strings.Trim(u, "/")
if u == "" {
continue
}
seg := strings.SplitN(u, "/", 2)[0]
segL := strings.ToLower(seg)
for slug, city := range citySlug {
if segL == slug || strings.HasPrefix(segL, slug+"-") {
return city
}
}
}
return ""
}
func main() {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("open:", err)
}
// 1) 幂等加列(与 004_add_streetsnap_city.sql 对齐)
ensureColumn(db, "street_snap", "city", "VARCHAR(128) NOT NULL DEFAULT '' AFTER year")
// 2) 逐行回填:优先 title 第一段,兜底 URL slug
type row struct {
ID uint32
Title string
SourceURL string
Cover string
}
var rows []row
if r := db.Raw("SELECT id, title, source_url, cover FROM street_snap WHERE is_deleted=0 AND (city IS NULL OR city='')").Scan(&rows); r.Error != nil {
log.Fatal("select:", r.Error)
}
var updated int64
for _, r := range rows {
city := extractCityFromTitle(r.Title)
if city == "" {
city = extractCityFromURL(r.SourceURL, r.Cover)
}
if city == "" {
continue
}
if r2 := db.Exec("UPDATE street_snap SET city=? WHERE id=?", city, r.ID); r2.Error != nil {
log.Printf("update id=%d failed: %v", r.ID, r2.Error)
continue
}
updated++
}
fmt.Printf(" updated city for %d rows\n", updated)
// 3) 校验:输出 distinct city 分布 + 未归类样本
printCities(db)
printUnclassifiedSamples(db)
fmt.Println("backfill city done.")
}
func ensureColumn(db *gorm.DB, table, col, def string) {
var n int64
db.Raw("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='db_dev' AND TABLE_NAME=? AND COLUMN_NAME=?", table, col).Scan(&n)
if n > 0 {
fmt.Printf(" skip %s.%s (exists)\n", table, col)
return
}
stmt := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def)
fmt.Println(" +", stmt)
if r := db.Exec(stmt); r.Error != nil {
log.Fatalf("alter %s.%s failed: %v", table, col, r.Error)
}
}
func printCities(db *gorm.DB) {
type row struct {
City string
Cnt int64
}
var rows []row
db.Raw(`SELECT city, COUNT(*) cnt FROM street_snap WHERE is_deleted=0 AND city<>'' GROUP BY city ORDER BY cnt DESC`).Scan(&rows)
fmt.Printf(" distinct cities: %d\n", len(rows))
for _, r := range rows {
fmt.Printf(" %-14q %d\n", r.City, r.Cnt)
}
}
// printUnclassifiedSamples 输出仍未归类行的 title 第一段样本,便于后续补充城市词典。
func printUnclassifiedSamples(db *gorm.DB) {
type row struct {
Title string
}
var rows []row
db.Raw(`SELECT title FROM street_snap WHERE is_deleted=0 AND (city IS NULL OR city='') LIMIT 25`).Scan(&rows)
var total int64
db.Raw(`SELECT COUNT(*) FROM street_snap WHERE is_deleted=0 AND (city IS NULL OR city='')`).Scan(&total)
fmt.Printf(" unclassified rows: %d (sample locations below)\n", total)
for _, r := range rows {
loc := r.Title
if i := strings.Index(loc, "\t"); i >= 0 {
loc = loc[:i]
}
fmt.Printf(" loc=%-30q title=%-50q\n", loc, r.Title)
}
}

View File

@ -1,75 +0,0 @@
// Command backfill_title_cn 把 brand_runway.title_cn 由已解析的结构化分类字段
// (year / collection_type / season)规则生成中文走秀标题。
//
// 设计:
// - title 字段本质就是「季节/品类描述」(Vogue 格式:Spring 2026 Ready-to-Wear、
// Resort 2026、Pre-Fall 2023、Fall 2023 Couture、Spring 2024 Menswear),
// 品牌名在 JOIN 出的 brand_name 里单独给,不混进 title。
// - collection_type / season 早已由历史迁移解析回填,因此 title_cn 可纯规则生成,
// 完全不依赖外部翻译服务,且结果稳定可重跑。
//
// 幂等:每次都按分类字段重新计算并整表覆盖,重跑无害。
// 不写任何业务逻辑,仅做一次性数据回填。
package main
import (
"fmt"
"log"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"
// titleCnSQL 单条 UPDATE 用 CASE 把分类字段映射为中文标题。
// year 参与拼接(CONCAT),season 为 NULL 时 COALESCE 成 ''。
const titleCnSQL = `
UPDATE brand_runway
SET title_cn = CASE
WHEN collection_type = 'resort' THEN CONCAT(year, ' 度假系列')
WHEN collection_type = 'pre_fall' THEN CONCAT(year, ' 早秋系列')
WHEN collection_type = 'rtw' AND season = 'spring' THEN CONCAT(year, ' 春夏成衣')
WHEN collection_type = 'rtw' AND season = 'fall' THEN CONCAT(year, ' 秋冬成衣')
WHEN collection_type = 'rtw' THEN CONCAT(year, ' 成衣')
WHEN collection_type = 'menswear' AND season = 'spring' THEN CONCAT(year, ' 春夏男装')
WHEN collection_type = 'menswear' AND season = 'fall' THEN CONCAT(year, ' 秋冬男装')
WHEN collection_type = 'couture' AND season = 'spring' THEN CONCAT(year, ' 春夏高定')
WHEN collection_type = 'couture' AND season = 'fall' THEN CONCAT(year, ' 秋冬高定')
ELSE CONCAT(year, ' 系列')
END
WHERE is_deleted = 0`
func main() {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("open:", err)
}
var before int64
db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND title_cn <> ''`).Scan(&before)
fmt.Printf("before: title_cn 非空 = %d\n", before)
if r := db.Exec(titleCnSQL); r.Error != nil {
log.Fatalf("backfill title_cn failed: %v", r.Error)
} else {
fmt.Printf("updated rows = %d\n", r.RowsAffected)
}
var after int64
db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND title_cn <> ''`).Scan(&after)
fmt.Printf("after: title_cn 非空 = %d\n", after)
// 抽样核对:原始 title -> 生成 title_cn
type row struct {
Title string
TitleCn string
}
var rows []row
db.Raw(`SELECT title, title_cn FROM brand_runway WHERE is_deleted=0 ORDER BY id LIMIT 12`).Scan(&rows)
fmt.Println("samples:")
for _, r := range rows {
fmt.Printf(" %-32q -> %q\n", r.Title, r.TitleCn)
}
fmt.Println("title_cn backfill done.")
}

View File

@ -1,106 +0,0 @@
// Command i18n_migrate 落地方案 A 的 i18n 列(加列 + 回填)。
//
// 项目已移除 AutoMigrate,表结构由外部 SQL 托管(见 scripts/sql/002_i18n_columns.sql,
// 那是给人 review 的规范底稿)。本程序是「没有 mysql 客户端时」的执行器:
// - ALTER 走 ensureColumn:先查 information_schema,缺才加,可安全重跑(幂等)。
// - 回填 UPDATE 内联且幂等,重跑无害。
//
// 不写任何业务逻辑。
package main
import (
"fmt"
"log"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"
const han = "[一-鿿]"
func main() {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("open:", err)
}
// 1) 幂等加列(与 002_i18n_columns.sql 对齐)
ensureColumn(db, "brand", "name_en", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name")
ensureColumn(db, "brand", "name_cn", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name_en")
ensureColumn(db, "brand_runway", "title_en", "VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
ensureColumn(db, "brand_runway", "title_cn", "VARCHAR(255) NOT NULL DEFAULT '' AFTER title_en")
ensureColumn(db, "brand_runway", "description_en", "VARCHAR(1024) NOT NULL DEFAULT '' AFTER description")
ensureColumn(db, "brand_runway", "description_cn", "VARCHAR(1024) NOT NULL DEFAULT '' AFTER description_en")
// 2) 回填品牌名:从 name / show_name 拆拉丁与中文(幂等)
db.Exec(fmt.Sprintf(`
UPDATE brand SET
name_en = CASE
WHEN TRIM(REGEXP_REPLACE(name, '%s', '')) <> '' THEN TRIM(REGEXP_REPLACE(name, '%s', ''))
WHEN show_name LIKE '%%/%%' THEN TRIM(SUBSTRING_INDEX(show_name, '/', 1))
ELSE ''
END,
name_cn = CASE
WHEN show_name LIKE '%%/%%' AND SUBSTRING_INDEX(show_name, '/', -1) REGEXP '%s'
THEN TRIM(SUBSTRING_INDEX(show_name, '/', -1))
WHEN show_name REGEXP '%s' THEN TRIM(show_name)
WHEN name REGEXP '%s' THEN TRIM(name)
ELSE ''
END`, han, han, han, han, han))
// 3) 回填走秀:title / description 当前全英文,直接进 *_en(幂等)
db.Exec(`UPDATE brand_runway SET title_en = title, description_en = description, title_cn = '', description_cn = ''`)
// 4) 校验
printCounts(db)
fmt.Println("migration done.")
}
func ensureColumn(db *gorm.DB, table, col, def string) {
var n int64
db.Raw("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='db' AND TABLE_NAME=? AND COLUMN_NAME=?", table, col).Scan(&n)
if n > 0 {
fmt.Printf(" skip %s.%s (exists)\n", table, col)
return
}
stmt := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def)
fmt.Println(" +", stmt)
if r := db.Exec(stmt); r.Error != nil {
log.Fatalf("alter %s.%s failed: %v", table, col, r.Error)
}
}
func printCounts(db *gorm.DB) {
type c struct {
Tot, En, Cn, Both, Dcn int64
}
var b c
db.Raw(`SELECT COUNT(*) tot,
SUM(CASE WHEN name_en<>'' THEN 1 ELSE 0 END) en,
SUM(CASE WHEN name_cn<>'' THEN 1 ELSE 0 END) cn,
SUM(CASE WHEN name_en<>'' AND name_cn<>'' THEN 1 ELSE 0 END) both_cnt
FROM brand`).Scan(&b)
fmt.Printf(" brand: total=%d name_en=%d name_cn=%d both=%d\n", b.Tot, b.En, b.Cn, b.Both)
var r c
db.Raw(`SELECT COUNT(*) tot,
SUM(CASE WHEN title_en<>'' THEN 1 ELSE 0 END) en,
SUM(CASE WHEN title_cn<>'' THEN 1 ELSE 0 END) cn,
SUM(CASE WHEN description_cn<>'' THEN 1 ELSE 0 END) dcn
FROM brand_runway WHERE is_deleted=0`).Scan(&r)
fmt.Printf(" runway: total=%d title_en=%d title_cn=%d desc_cn=%d\n", r.Tot, r.En, r.Cn, r.Dcn)
type s struct {
Name string
ShowName string
NameEn string
NameCn string
}
var samples []s
db.Raw("SELECT name, show_name, name_en, name_cn FROM brand WHERE name REGEXP ? OR show_name REGEXP ? ORDER BY id LIMIT 8", han, han).Scan(&samples)
fmt.Println(" brand samples:")
for _, x := range samples {
fmt.Printf(" name=%-18q show=%-26q -> en=%-18q cn=%-18q\n", x.Name, x.ShowName, x.NameEn, x.NameCn)
}
}

View File

@ -1,98 +0,0 @@
// Command migrate_refresh 一次性建表脚本(与 scripts/sql/*.sql 同源,手写 DDL 以保持与项目"SQL 托管表结构"约定一致)。
//
// 运行:go run ./scripts/migrate_refresh
// 可用环境变量 DB_DSN 覆盖连接串(默认 root:root@127.0.0.1:3306/db_dev)。
package main
import (
"log"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
dsn := os.Getenv("DB_DSN")
if dsn == "" {
dsn = "root:root@tcp(127.0.0.1:3306)/db_dev?charset=utf8mb4&parseTime=True&loc=Local"
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("open db: %v", err)
}
stmt := `CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at INT UNSIGNED NOT NULL DEFAULT 0,
revoked TINYINT UNSIGNED NOT NULL DEFAULT 0,
user_agent VARCHAR(255) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
created_at INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_token_hash (token_hash),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
if err := db.Exec(stmt).Error; err != nil {
log.Fatalf("create refresh_tokens: %v", err)
}
log.Println("✓ refresh_tokens ready")
stmtFav := `CREATE TABLE IF NOT EXISTS favorites (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at INT UNSIGNED NOT NULL DEFAULT 0,
user_id INT UNSIGNED NOT NULL,
target_type VARCHAR(16) NOT NULL DEFAULT '',
target_uid VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(255) NOT NULL DEFAULT '',
cover VARCHAR(512) NOT NULL DEFAULT '',
brand VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY uniq_user_target (user_id, target_uid),
KEY idx_user (user_id),
KEY idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
if err := db.Exec(stmtFav).Error; err != nil {
log.Fatalf("create favorites: %v", err)
}
// 复合索引(user_id, created_at):优化收藏分页深翻页(10万收藏场景);已存在则忽略报错
if err := db.Exec("ALTER TABLE favorites ADD INDEX idx_user_created (user_id, created_at)").Error; err != nil {
log.Printf("ℹ favorites idx_user_created 已存在,可忽略: %v", err)
}
log.Println("✓ favorites ready")
// histories:文章级浏览历史(2026-09-02 收敛)。
// 仅存 user_id + target_uid(带 r=/s= 前缀的对外编码串,前缀即类型)+ viewed_at;
// kind/target_type/title/cover/brand 快照已弃用(账户页展示时回查公开详情接口)。
stmtHist := `CREATE TABLE IF NOT EXISTS histories (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
viewed_at INT UNSIGNED NOT NULL DEFAULT 0,
user_id INT UNSIGNED NOT NULL,
target_uid VARCHAR(64) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY uniq_user_target (user_id, target_uid),
KEY idx_user_viewed (user_id, viewed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
if err := db.Exec(stmtHist).Error; err != nil {
log.Fatalf("create histories: %v", err)
}
// —— 存量库收敛(幂等):清掉旧固定串行(runway-looks/street-snaps),卸掉旧列与旧唯一键,换上新的 ——
// 旧结构列/键可能不存在(新库)或已收敛(二次执行),每条失败仅提示、不中断。
trimHist := []string{
"DELETE FROM histories WHERE target_uid NOT LIKE 'r=%' AND target_uid NOT LIKE 's=%'",
"ALTER TABLE histories DROP KEY uniq_user_kind_target",
"ALTER TABLE histories DROP COLUMN kind",
"ALTER TABLE histories DROP COLUMN target_type",
"ALTER TABLE histories DROP COLUMN title",
"ALTER TABLE histories DROP COLUMN cover",
"ALTER TABLE histories DROP COLUMN brand",
"ALTER TABLE histories ADD UNIQUE KEY uniq_user_target (user_id, target_uid)",
}
for _, stmt := range trimHist {
if err := db.Exec(stmt).Error; err != nil {
log.Printf("ℹ histories 收敛步骤可忽略(可能已收敛/新库): %v", err)
}
}
log.Println("✓ histories ready (article-level: user_id+target_uid+viewed_at)")
}