From a6724265add995eb02debf7f24121874ff8e23a8 Mon Sep 17 00:00:00 2001 From: toom1996 Date: Wed, 26 Aug 2026 19:40:16 +0800 Subject: [PATCH] update --- cmd/server/main.go | 3 +- internal/dto/brand.go | 4 +- internal/handler/auth_handler.go | 8 +-- internal/handler/ssg_handler.go | 81 ++++++++----------------- internal/repository/brand_repository.go | 22 ++++++- internal/router/router.go | 9 ++- internal/router/ssg.go | 7 +-- internal/service/article_service.go | 7 ++- internal/service/brand_service.go | 73 ++++++++++++++-------- internal/service/index_service.go | 60 ++++++++++++++++++ scripts/backfill_title_cn/main.go | 75 +++++++++++++++++++++++ scripts/diag_brand/main.go | 54 +++++++++++++++++ scripts/diag_hot/main.go | 68 +++++++++++++++++++++ scripts/sql/002_i18n_columns.sql | 30 +++++++++ 14 files changed, 400 insertions(+), 101 deletions(-) create mode 100644 internal/service/index_service.go create mode 100644 scripts/backfill_title_cn/main.go create mode 100644 scripts/diag_brand/main.go create mode 100644 scripts/diag_hot/main.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4a8cb2d..599f6f2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -78,6 +78,7 @@ func main() { articleSvc = service.NewArticleService(articleRepo) brandSvc = service.NewBrandService(brandRepo) + indexSvc = service.NewIndexService(brandRepo) authSvc = service.NewAuthService(userRepo, jwtManager) ) @@ -108,7 +109,7 @@ func main() { // 即便部署层误绑了 0.0.0.0,SSGToken 中间件也能作为最后一道闸。 ssgEngine := router.NewSSG(router.Options{ Config: cfg, - SSG: handler.NewSSGHandler(brandSvc, articleSvc), + SSG: handler.NewSSGHandler(brandSvc, indexSvc), }) srvSSG := &http.Server{ Addr: ":" + cfg.Server.SSGPort, diff --git a/internal/dto/brand.go b/internal/dto/brand.go index 734ceb7..537c74c 100644 --- a/internal/dto/brand.go +++ b/internal/dto/brand.go @@ -56,7 +56,9 @@ func (q BrandQuery) Offset() int { return (q.Page - 1) * q.Size } // 注意:对外只暴露编码后的 UID(非自增主键),防止顺序枚举。 type PublicBrand struct { UID string `json:"id"` // 编码后的品牌 id(无序串),对外唯一标识 - Name string `json:"name"` + Name string `json:"name"` // 按 locale 选列(cn→name_cn,en→name_en) + NameEn string `json:"name_en"` // 英文规范名(始终返回,供前端组合双语展示) + NameCn string `json:"name_cn"` // 中文名(可能为空,供前端组合双语展示) ShowName string `json:"show_name"` ArticleCount int `json:"article_count"` // 该品牌下的走秀档案数,用于索引计数 } diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index f3b794a..f0619ba 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -80,10 +80,4 @@ func (h *AuthHandler) Me(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"user": user}) } -// Logout 登出。 -// -// POST /api/auth/logout -// 无状态 JWT 服务端无需作废令牌,由客户端丢弃即可;保留接口以便将来扩展黑名单。 -func (h *AuthHandler) Logout(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"ok": true}) -} + diff --git a/internal/handler/ssg_handler.go b/internal/handler/ssg_handler.go index 0c1eac2..6434ced 100644 --- a/internal/handler/ssg_handler.go +++ b/internal/handler/ssg_handler.go @@ -2,9 +2,7 @@ package handler import ( "net/http" - "strings" - "fashionapi/internal/dto" "fashionapi/internal/pkg/response" "fashionapi/internal/service" @@ -19,27 +17,40 @@ import ( // - 复用现有 service 层逻辑,与对外公开接口保持同一份数据源与映射规则, // 不会出现「构建接口与线上接口返回结构不一致」的坑。 type SSGHandler struct { - brands service.BrandService - articles service.ArticleService + brands service.BrandService + index service.IndexService } // NewSSGHandler 创建 SSG handler。 -func NewSSGHandler(brands service.BrandService, articles service.ArticleService) *SSGHandler { - return &SSGHandler{brands: brands, articles: articles} +func NewSSGHandler(brands service.BrandService, index service.IndexService) *SSGHandler { + return &SSGHandler{brands: brands, index: index} } -// Popular 首页「热门品牌」区块数据(SSG 内部版)。 +// IndexRunway 首页(/index/)runway 区块数据。 // -// GET /api/ssg/brands/popular?limit=20 +// GET /api/v1/ssg/index/runway // -// 与公开版逻辑完全一致(复用 service.BrandService.Popular),只是挪到了内部端口, -// 不再挂在对外公开的 /api/public 下。limit 默认 20、上限 100。 -func (h *SSGHandler) Popular(c *gin.Context) { - limit := queryInt(c, "limit", 20) - if limit < 1 || limit > 100 { - limit = 20 +// 数据由各模块在服务端写死组合(不接收任何参数),当前仅有 hotBrand(热门品牌,固定 20 条)。 +// 拼装逻辑由 service.IndexService.Runway 负责,handler 只做转发。 +func (h *SSGHandler) IndexRunway(c *gin.Context) { + data, err := h.index.Runway(c.Request.Context(), parseLocale(c)) + if err != nil { + fail(c, err) + return } - list, err := h.brands.Popular(c.Request.Context(), limit, parseLocale(c)) + response.Data(c, http.StatusOK, data) +} + +// HotBrands 走秀页(RunwayLooks)品牌筛选的热门品牌数据。 +// +// GET /api/v1/ssg/brands/hot +// +// 固定返回热度前 30 的品牌(按走秀图片总数排名,结果按热度顺序),不接收任何参数—— +// 前端侧栏 filter 展示前 10 个,品牌弹窗 HOT 标签展示全部 30 个。 +func (h *SSGHandler) HotBrands(c *gin.Context) { + const hotBrandLimit = 30 + + list, err := h.brands.Hot(c.Request.Context(), hotBrandLimit, parseLocale(c)) if err != nil { fail(c, err) return @@ -47,44 +58,4 @@ func (h *SSGHandler) Popular(c *gin.Context) { response.Data(c, http.StatusOK, list) } -// Brands 品牌索引页(HBX 风格 A-Z)的 SSG 全量数据。 -// -// GET /api/ssg/brands?size=300&only_with_articles=1&featured=1 -// -// 直接复用 BrandQuery + service.BrandService.List,支持字母/搜索/精选/分页等全部参数, -// 返回 {data:[...]} 与公开列表同构,前端 brands.astro 无需任何适配。 -func (h *SSGHandler) Brands(c *gin.Context) { - q := dto.BrandQuery{ - Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))), - Keyword: strings.TrimSpace(c.Query("keyword")), - Page: queryInt(c, "page", 1), - Size: queryInt(c, "size", 200), - OnlyWithArticles: queryBool(c, "only_with_articles", true), - Featured: queryBool(c, "featured", false), - Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))), - Limit: queryInt(c, "limit", 200), - Locale: parseLocale(c), - } - q.Normalize() - list, total, err := h.brands.List(c.Request.Context(), q) - if err != nil { - fail(c, err) - return - } - response.Page(c, http.StatusOK, list, total, q.Page, q.Size) -} - -// ArticleIDs 供 SSG 的 getStaticPaths 枚举详情页路径用。 -// -// GET /api/ssg/articles/ids -// -// 只回 [id,...](升序),不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省带宽。 -func (h *SSGHandler) ArticleIDs(c *gin.Context) { - ids, err := h.articles.IDs(c.Request.Context()) - if err != nil { - fail(c, err) - return - } - response.Data(c, http.StatusOK, ids) -} diff --git a/internal/repository/brand_repository.go b/internal/repository/brand_repository.go index e5a5c31..c241493 100644 --- a/internal/repository/brand_repository.go +++ b/internal/repository/brand_repository.go @@ -21,6 +21,10 @@ type BrandRepository interface { // 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。 FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) + // ListByIDs 按 id 集合取品牌行(不保证顺序,调用方按热度自行重排)。 + // 供 Hot 等已知 id 列表的场景使用,避免借道 List 的 featured 旗标复用查询。 + ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) + // ArticleCountByBrandIDs 批量统计各品牌的走秀档案数,单次分组查询避免 N+1。 ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) @@ -81,7 +85,9 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3 } // 中文分桶不限档案,直接列出全部中文名品牌,便于检索 - if q.OnlyWithArticles && q.Letter != "OTHER" { + // 关键词搜索时不限制"必须有走秀档案":用户按名字找品牌, + // 即使该品牌暂无走秀档案也应被命中(如 A Bathing Ape / 猿人头)。 + if q.OnlyWithArticles && q.Keyword == "" && q.Letter != "OTHER" { db = db.Where("id IN (?)", r.hasArticlesSubQuery()) } @@ -157,6 +163,20 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit return ids, nil } +// ListByIDs 按 id 集合取品牌行;MySQL IN 查询不保证返回顺序,调用方自行重排。 +func (r *brandRepository) ListByIDs(ctx context.Context, ids []uint32) ([]model.Brand, error) { + if len(ids) == 0 { + return []model.Brand{}, nil + } + var items []model.Brand + if err := r.db.WithContext(ctx). + Where("is_deleted = 0 AND id IN ?", ids). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + func (r *brandRepository) ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) { counts := make(map[uint32]int, len(ids)) if len(ids) == 0 { diff --git a/internal/router/router.go b/internal/router/router.go index 89109c8..55cb995 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -36,7 +36,7 @@ func New(opt Options) *gin.Engine { // 前端用 toAbs() 拼上 base 后直接访问,因此这里必须对外提供静态文件。 r.Static(opt.Config.Upload.URLPrefix, opt.Config.Upload.Dir) - api := r.Group("/api") + api := r.Group("/api/v1") { // 健康检查,供容器 / 反向代理探活 api.GET("/health", opt.Health.Check) @@ -52,10 +52,9 @@ func New(opt Options) *gin.Engine { // 账号体系:注册 / 登录 / 登出 / 当前用户(JWT 无状态) auth := api.Group("/auth") { - auth.POST("/register", opt.Auth.Register) - auth.POST("/login", opt.Auth.Login) - auth.POST("/logout", opt.Auth.Logout) - auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) + auth.POST("/register", opt.Auth.Register) + auth.POST("/login", opt.Auth.Login) + auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) } } diff --git a/internal/router/ssg.go b/internal/router/ssg.go index 70a80aa..15ec757 100644 --- a/internal/router/ssg.go +++ b/internal/router/ssg.go @@ -27,11 +27,10 @@ func NewSSG(opt Options) *gin.Engine { r.Use(ssgLogger()) r.Use(middleware.SSGToken(opt.Config.Server.SSGToken)) - ssg := r.Group("/api/ssg") + ssg := r.Group("/api/v1/ssg") { - ssg.GET("/brands", opt.SSG.Brands) // 品牌索引页全量数据(A-Z) - ssg.GET("/brands/popular", opt.SSG.Popular) // 首页热门品牌区块 - ssg.GET("/articles/ids", opt.SSG.ArticleIDs) // 文章详情页路径枚举(getStaticPaths) + ssg.GET("/brands/hot", opt.SSG.HotBrands) // 走秀页热门品牌(前 30,热度顺序) + ssg.GET("/index/runway", opt.SSG.IndexRunway) // 首页(/index/)runway 区块:多模块,当前含 hotBrand } return r diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 624e33f..391064c 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -51,7 +51,9 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), Summary: textutil.Summarize(pickLocale(r.DescriptionEn, r.DescriptionCn, q.Locale), summaryLength), Cover: r.Cover, - BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, q.Locale), + // 品牌名强制优先英文("en"),仅在英文缺失时回落中文,避免留空; + // 与 title/description 不同,品牌名不随 locale 切换,保持英文观感。 + BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), Year: r.Year, ImageCount: r.ImageCount, CollectionType: r.CollectionType, @@ -111,7 +113,8 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) ( Summary: textutil.Summarize(pickLocale(row.DescriptionEn, row.DescriptionCn, locale), summaryLength), Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), Cover: row.Cover, - BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, locale), + // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 + BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), Year: row.Year, ImageCount: row.ImageCount, SourceURL: row.SourceURL, diff --git a/internal/service/brand_service.go b/internal/service/brand_service.go index b89ae01..5d9182e 100644 --- a/internal/service/brand_service.go +++ b/internal/service/brand_service.go @@ -2,8 +2,10 @@ package service import ( "context" + "sort" "fashionapi/internal/dto" + "fashionapi/internal/model" "fashionapi/internal/pkg/hashid" "fashionapi/internal/repository" ) @@ -11,9 +13,10 @@ import ( // BrandService 品牌业务接口。 type BrandService interface { List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) - // Popular 首页"热门品牌"区块:按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。 - // locale 决定品牌名/标题的展示语言(cn/en)。 - Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error) + // Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。 + // 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌; + // 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。 + Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) } type brandService struct { @@ -58,34 +61,54 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public return nil, 0, internalErr(err.Error()) } + list := s.toPublicBrands(items, counts, q.Locale) + return list, total, nil +} + +// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。 +// +// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用—— +// 前端不再从全量列表里自行切片,热度排名由后端唯一口径给出。 +func (s *brandService) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) { + ids, err := s.brands.FeaturedIDs(ctx, "images", limit) + if err != nil { + return nil, internalErr(err.Error()) + } + if len(ids) == 0 { + return []dto.PublicBrand{}, nil + } + + items, err := s.brands.ListByIDs(ctx, ids) + if err != nil { + return nil, internalErr(err.Error()) + } + counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids) + if err != nil { + return nil, internalErr(err.Error()) + } + + // IN 查询不保证顺序,这里重排回 FeaturedIDs 的热度排名顺序。 + pos := make(map[uint32]int, len(ids)) + for i, id := range ids { + pos[id] = i + } + sort.Slice(items, func(i, j int) bool { return pos[items[i].ID] < pos[items[j].ID] }) + + return s.toPublicBrands(items, counts, locale), nil +} + +// toPublicBrands 把仓储层品牌行映射为对外精简结构(编码 id + 双语名 + 档案数)。 +func (s *brandService) toPublicBrands(items []model.Brand, counts map[uint32]int, locale string) []dto.PublicBrand { list := make([]dto.PublicBrand, 0, len(items)) for _, b := range items { list = append(list, dto.PublicBrand{ UID: hashid.Encode(b.ID), - Name: pickLocale(b.NameEn, b.NameCn, q.Locale), + Name: pickLocale(b.NameEn, b.NameCn, locale), + NameEn: b.NameEn, + NameCn: b.NameCn, ShowName: b.NameCn, ArticleCount: counts[b.ID], }) } - return list, total, nil -} - -// Popular 热门品牌(首页区块用):按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。 -// 复用仓储层的 FeaturedIDs 排名逻辑,保持与"代表品牌"一致的"热门"定义。locale 选展示语言。 -func (s *brandService) Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error) { - rows, err := s.brands.PopularWithCover(ctx, limit) - if err != nil { - return nil, internalErr(err.Error()) - } - out := make([]dto.PopularBrand, 0, len(rows)) - for _, b := range rows { - out = append(out, dto.PopularBrand{ - UID: hashid.Encode(b.ID), - ArticleUID: hashid.Encode(b.ArticleID), - Brand: pickLocale(b.BrandEn, b.BrandCn, locale), - Cover: b.Cover, - Title: pickLocale(b.TitleEn, b.TitleCn, locale), - }) - } - return out, nil + return list } diff --git a/internal/service/index_service.go b/internal/service/index_service.go new file mode 100644 index 0000000..a390aa9 --- /dev/null +++ b/internal/service/index_service.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + + "fashionapi/internal/dto" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/repository" +) + +// IndexService 首页(/index/)各区块数据的聚合服务。 +// +// 首页由多个独立区块(runway 等)组成,每个区块的数据各自组合、互不干扰。 +// 该服务专管「首页区块数据」这一领域,避免把首页数据拼装逻辑散落到 BrandService 等其它服务里。 +type IndexService interface { + // Runway 首页 runway 区块数据:包含 hotBrand(热门品牌,固定 20 条)等多个模块。 + // 服务端写死各模块数量,不接收任何筛选参数;locale 决定标题展示语言。 + Runway(ctx context.Context, locale string) (IndexRunwayData, error) +} + +// IndexRunwayData 首页 runway 区块数据,按模块拆分;hotBrand 为其中之一。 +// +// 后续新增首页 runway 模块时,在此结构体追加字段即可,前端按需读取,互不影响。 +type IndexRunwayData struct { + HotBrand []dto.PopularBrand `json:"hotBrand"` +} + +type indexService struct { + brands repository.BrandRepository +} + +// NewIndexService 创建首页数据服务。 +func NewIndexService(brands repository.BrandRepository) IndexService { + return &indexService{brands: brands} +} + +// Runway 首页 runway 区块数据。 +// +// 复用仓储层 PopularWithCover 的「按图片数排名取热门品牌 + 代表走秀封面/标题」逻辑, +// 保持与品牌「代表品牌」一致的"热门"定义;品牌名强制英文观感,标题按 locale 选列。 +// hotBrand 数量写死 20,接口不接收任何参数。 +func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayData, error) { + const hotBrandLimit = 20 + rows, err := s.brands.PopularWithCover(ctx, hotBrandLimit) + if err != nil { + return IndexRunwayData{}, internalErr(err.Error()) + } + brands := make([]dto.PopularBrand, 0, len(rows)) + for _, b := range rows { + brands = append(brands, dto.PopularBrand{ + UID: hashid.Encode(b.ID), + ArticleUID: hashid.Encode(b.ArticleID), + // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 + Brand: pickLocale(b.BrandEn, b.BrandCn, "en"), + Cover: b.Cover, + Title: pickLocale(b.TitleEn, b.TitleCn, locale), + }) + } + return IndexRunwayData{HotBrand: brands}, nil +} diff --git a/scripts/backfill_title_cn/main.go b/scripts/backfill_title_cn/main.go new file mode 100644 index 0000000..32d8b2c --- /dev/null +++ b/scripts/backfill_title_cn/main.go @@ -0,0 +1,75 @@ +// 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.") +} diff --git a/scripts/diag_brand/main.go b/scripts/diag_brand/main.go new file mode 100644 index 0000000..970726f --- /dev/null +++ b/scripts/diag_brand/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local" + +func main() { + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + panic(err) + } + + // 1) brand 3100 的走秀档案数(only_with_articles 子查询依赖这个) + var artCount int64 + db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND brand_id=3100`).Scan(&artCount) + fmt.Printf("brand 3100 走秀档案数 = %d\n", artCount) + + // 2) 复现后端完整搜索:only_with_articles=1 + keyword=猿人头 + kw := "%猿人头%" + type Row struct { + ID uint32 + Name string + NameEn string + NameCn string + } + var rows []Row + db.Raw(` + SELECT id, name, name_en, name_cn FROM brand + WHERE is_deleted=0 + AND id IN (SELECT DISTINCT brand_id FROM brand_runway WHERE is_deleted=0) + AND (name_en LIKE ? OR name_cn LIKE ?)`, + kw, kw).Scan(&rows) + fmt.Printf("\n[搜索 keyword=猿人头, only_with_articles=1] 命中 %d 条:\n", len(rows)) + for _, r := range rows { + fmt.Printf(" id=%d | name=%q | name_en=%q | name_cn=%q\n", r.ID, r.Name, r.NameEn, r.NameCn) + } + + // 3) 对比:only_with_articles=0(不过滤档案)时的命中 + var rows2 []Row + db.Raw(` + SELECT id, name, name_en, name_cn FROM brand + WHERE is_deleted=0 + AND (name_en LIKE ? OR name_cn LIKE ?)`, + kw, kw).Scan(&rows2) + fmt.Printf("\n[搜索 keyword=猿人头, only_with_articles=0] 命中 %d 条:\n", len(rows2)) + for _, r := range rows2 { + fmt.Printf(" id=%d | name=%q | name_en=%q | name_cn=%q\n", r.ID, r.Name, r.NameEn, r.NameCn) + } +} diff --git a/scripts/diag_hot/main.go b/scripts/diag_hot/main.go new file mode 100644 index 0000000..d92f46f --- /dev/null +++ b/scripts/diag_hot/main.go @@ -0,0 +1,68 @@ +package main + +// 诊断:复现 FeaturedIDs("images", 30) 的 SQL,验证热门品牌排名数据是否合理。 + +import ( + "fmt" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local" + +func main() { + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + panic(err) + } + + // 1) 复现 FeaturedIDs:按图片总数 DESC 取前 30 + type Row struct { + BrandID uint32 `gorm:"column:brand_id"` + Images int64 `gorm:"column:images"` + Shows int64 `gorm:"column:shows"` + } + var rows []Row + db.Raw(` + SELECT brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows + FROM brand_runway + WHERE is_deleted = 0 + GROUP BY brand_id + ORDER BY images DESC + LIMIT 30 + `).Scan(&rows) + + fmt.Println("== FeaturedIDs(images, 30) 原始排名 ==") + for i, r := range rows { + var name string + db.Raw(`SELECT name_en FROM brand WHERE id = ?`, r.BrandID).Scan(&name) + fmt.Printf("%2d. brand_id=%-6d images=%-6d shows=%-4d %s\n", i+1, r.BrandID, r.Images, r.Shows, name) + } + + // 2) image_count 分布:有多少品牌有图、多少归零 + var zeroBrands, hasImgBrands int64 + var totalRows, zeroRows int64 + db.Raw(`SELECT COUNT(*) FROM (SELECT brand_id, SUM(image_count) s FROM brand_runway WHERE is_deleted=0 GROUP BY brand_id) t WHERE s = 0`).Scan(&zeroBrands) + db.Raw(`SELECT COUNT(*) FROM (SELECT brand_id, SUM(image_count) s FROM brand_runway WHERE is_deleted=0 GROUP BY brand_id) t WHERE s > 0`).Scan(&hasImgBrands) + db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0`).Scan(&totalRows) + db.Raw(`SELECT COUNT(*) FROM brand_runway WHERE is_deleted=0 AND image_count=0`).Scan(&zeroRows) + fmt.Printf("\n== image_count 分布 ==\n有图品牌=%d 零图品牌=%d | 走秀总行=%d 其中 image_count=0 的行=%d\n", hasImgBrands, zeroBrands, totalRows, zeroRows) + + // 3) 对比口径:按走秀数(shows) DESC 前 10 + var byShows []Row + db.Raw(` + SELECT brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows + FROM brand_runway + WHERE is_deleted = 0 + GROUP BY brand_id + ORDER BY shows DESC + LIMIT 10 + `).Scan(&byShows) + fmt.Println("\n== 若按 shows DESC 前 10 ==") + for i, r := range byShows { + var name string + db.Raw(`SELECT name_en FROM brand WHERE id = ?`, r.BrandID).Scan(&name) + fmt.Printf("%2d. brand_id=%-6d images=%-6d shows=%-4d %s\n", i+1, r.BrandID, r.Images, r.Shows, name) + } +} diff --git a/scripts/sql/002_i18n_columns.sql b/scripts/sql/002_i18n_columns.sql index a169a0b..38d963e 100644 --- a/scripts/sql/002_i18n_columns.sql +++ b/scripts/sql/002_i18n_columns.sql @@ -47,3 +47,33 @@ UPDATE brand_runway SET description_en = description, title_cn = '', description_cn = ''; + +-- ============================================================================ +-- 第 5 步(依赖「分类字段回填迁移」之后执行):title_cn 规则生成 +-- +-- 前提:collection_type / season / year 已由历史迁移解析回填(rtw/menswear/ +-- couture/resort/pre_fall × spring/fall/空)。title 本质是「季节/品类描述」, +-- 品牌名在 JOIN 的 brand_name 里单独给,因此 title_cn 可纯规则生成, +-- 完全不依赖外部翻译服务,结果稳定可重跑。 +-- +-- 映射:resort→「{year} 度假系列」、pre_fall→「{year} 早秋系列」、 +-- rtw→「{year} {春夏|秋冬}成衣」、menswear→「{year} {春夏|秋冬}男装」、 +-- couture→「{year} {春夏|秋冬}高定」。 +-- +-- 执行器:scripts/backfill_title_cn/main.go(幂等,整表覆盖重跑无害)。 +-- 该步骤与上方 i18n 加列/回填相互独立,可单独重跑。 +-- ============================================================================ +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;