diff --git a/internal/dto/street_snap.go b/internal/dto/street_snap.go index da547e0..2fb9aef 100644 --- a/internal/dto/street_snap.go +++ b/internal/dto/street_snap.go @@ -10,6 +10,7 @@ type StreetSnapQuery struct { Page int Size int // 服务端固定为 StreetSnapFixedSize,不从 query 读取 Year int // 单值年份,0 表示不过滤 + City string // 地区/城市(精确匹配 city 列),'' 表示不过滤 Sort string // image_count(默认)/ year_desc / year_asc Images int // 服务端固定为 StreetSnapFixedImages,不从 query 读取 Locale string diff --git a/internal/handler/street_snap_handler.go b/internal/handler/street_snap_handler.go index e983f6d..b8ef65a 100644 --- a/internal/handler/street_snap_handler.go +++ b/internal/handler/street_snap_handler.go @@ -30,6 +30,7 @@ func NewStreetSnapHandler(svc service.StreetSnapService) *StreetSnapHandler { // 查询参数: // - page 页码,默认 1(服务端固定每页 StreetSnapFixedSize=24 条) // - year 年份(整数),0 表示不过滤 +// - city 地区/城市(精确匹配 city 列),'' 表示不过滤 // - sort image_count(默认,按图片数降序)/ year_desc / year_asc // - locale cn / en // @@ -51,6 +52,7 @@ func (h *StreetSnapHandler) parseListQuery(c *gin.Context) dto.StreetSnapQuery { Page: queryInt(c, "page", 1), Size: dto.StreetSnapFixedSize, // 服务端固定,前端不传 Year: queryInt(c, "year", 0), + City: c.DefaultQuery("city", ""), Sort: c.DefaultQuery("sort", "image_count"), Images: dto.StreetSnapFixedImages, // 服务端固定,前端不传 Locale: parseLocale(c), diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go index 7d5cf40..42bad2a 100644 --- a/internal/model/street_snap.go +++ b/internal/model/street_snap.go @@ -8,6 +8,7 @@ type StreetSnap struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` Title string `gorm:"column:title" json:"title"` Year uint16 `gorm:"column:year" json:"year"` + City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类 Cover string `gorm:"column:cover" json:"cover"` SourceURL string `gorm:"column:source_url" json:"source_url"` ImageCount uint16 `gorm:"column:image_count" json:"image_count"` diff --git a/internal/repository/street_snap_repository.go b/internal/repository/street_snap_repository.go index 2bfbac7..ba56c1b 100644 --- a/internal/repository/street_snap_repository.go +++ b/internal/repository/street_snap_repository.go @@ -40,6 +40,9 @@ func snapFilterScope(q dto.StreetSnapQuery) func(*gorm.DB) *gorm.DB { if q.Year != 0 { db = db.Where("year = ?", q.Year) } + if q.City != "" { + db = db.Where("city = ?", q.City) + } return db } } diff --git a/scripts/backfill_city/main.go b/scripts/backfill_city/main.go new file mode 100644 index 0000000..af445fd --- /dev/null +++ b/scripts/backfill_city/main.go @@ -0,0 +1,206 @@ +// 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) + } +} diff --git a/scripts/sql/004_add_streetsnap_city.sql b/scripts/sql/004_add_streetsnap_city.sql new file mode 100644 index 0000000..5260275 --- /dev/null +++ b/scripts/sql/004_add_streetsnap_city.sql @@ -0,0 +1,4 @@ +-- 街拍地区筛选:street_snap 新增 city 列(从 title 第一段 \t 解析回填) +-- 对应执行器 scripts/backfill_city/main.go(幂等,可重跑) +-- city 存「地点」原文(如 Paris / 巴黎 / New York),'' 表示未归类,不参与地区筛选 +ALTER TABLE street_snap ADD COLUMN city VARCHAR(128) NOT NULL DEFAULT '' AFTER year;