Files
frontend_v2/.workbuddy/memory/2026-08-26.md
toom1996 eee2265e15
Some checks failed
deploy / deploy (push) Has been cancelled
update
2026-08-26 19:42:10 +08:00

23 KiB
Raw Blame History

2026-08-26 工作日志

新增前端 debug mode(本地联调开关)

  • 用户要求:加一个 debug mode,启用后 SSR 用 8090、SSG 用 8091。
  • 背景:本地 npm run build 时 SSG 走 internal:8091(Docker 网络别名)在本地解析不到,且 astro dev 时 getBase() 硬写线上域名 seo.23cm.cn,无法联调本地后端。本地后端实际 8090(公开 API) 与 8091(SSG 内部端口) 都在 localhost 上。
  • 实现(src/lib/api.ts):
    1. 新增 isDebugMode():读 import.meta.env.PUBLIC_DEBUG 或 process.env.PUBLIC_DEBUG("1"/"true" 为真),得到顶层常量 DEBUG(并 export const DEBUG_MODE)。
    2. getBase():if (DEBUG) return "http://localhost:8090" 置于 import.meta.env.DEV 之前(debug 盖掉线上域名)。
    3. publicBase() / ssrBase():if (DEBUG) return "http://localhost:8090"。
    4. BASE_API_URL(fetchMe 用):DEBUG ? "http://localhost:8090" : (BASE_API || "")。
    5. ssgBase():if (DEBUG) return "http://localhost:8091"(否则 BASE_API_SSG || http://localhost:8091)。
    • 效果:debug 开启后,SSR/运行期 → localhost:8090、SSG 构建期 → localhost:8091;关闭(默认)走生产配置(DEV→seo.23cm.cn / BASE_API / BASE_API_SSG)。
  • 启用方式:构建/开发时设置环境变量 PUBLIC_DEBUG=1,如 PUBLIC_DEBUG=1 npm run build 或 PUBLIC_DEBUG=1 npm run dev。已在 .env.example 补 #PUBLIC_DEBUG=1 注释。
  • 验证(PowerShell):
    • read_lints api.ts 0 错误;默认构建 npm run build BUILD_EXIT=0(无回归)。
    • SSG 开关实证:设 BASE_API_SSG=http://127.0.0.1:9(死端口) 构建——
      • debug 开:hot-chanel(FAKE 假 id) 命中 0、真实 hashid 003xOZ9b 命中 4 → 证明 SSG 强制走 localhost:8091 拉到真实数据;
      • debug 关:hot-chanel 命中 3 → 证明回落 FAKE(死端口生效)。
    • 因 getBase/publicBase/ssrBase/BASE_API_URL 共用同一 DEBUG 判定,SSG 侧已证 DEBUG 在构建期为真,故 SSR→8090 同步生效。
  • ⚠️ SSR(8090) 当前是否运行取决于用户本地后端;debug 构建时若 8090 没跑,SSR 数据取数会 try/catch 回落样例(页面不崩)。SSG(8091) 本地实测可达。

前端接口请求接入 i18n(?locale= 透传,13:10)

  • 用户疑问:接口返回数据没有语言标识、没法判断该渲染哪种语言。排查结论:后端早已实现 i18n,无需前端改契约。
  • 后端现状(d:/project/backend_v2):DB 已加 brand.name_en/name_cn、brand_runway.title_en/title_cn/description_en/description_cn(scripts/i18n/main.go 迁移回填);service/locale.go 的 pickLocale(en,cn,locale) 按 locale 选列、缺翻译回落另一语言、出参仍是单字段(name/title/brand_name 不变);handler 的 parseLocale(c) 优先级:?locale=cn|en > Accept-Language 头 > 默认 en。公开接口(/api/public/articles、/articles/:id、/brands) 与 SSG 内部接口(/api/ssg/brands、/brands/popular) 全部读 locale。
  • 前端改动(src/lib/api.ts):
    1. 新增 setApiLocale(locale) + 模块级 _ssrLocale + currentLocale()(浏览器端用 getLocaleFromUrl(window.location.pathname) 从 URL path 推断 /cn/→cn,其余→en;服务端回落 _ssrLocale 或默认 en);withLocale(params, override?) 统一把 ?locale= 追加进 query。
    2. 所有请求原语统一带 locale:request()(getBrands/getArticlesRaw)、fetchArticleById(id, base?, locale?)、getSsrArticle(id, locale?)、fetchArticlesList(客户端)、fetchColors(客户端)、fetchMe、ssgRequest(构建期,token+locale 组合)。删除已内联的 withToken 死代码。
    3. currentLocale() 客户端分支依赖 astro:i18n 的 getLocaleByPath(经 @/i18n/utils.getLocaleFromUrl),客户端 bundle 可用(已 build 通过验证)。
  • 注入点:
    • src/layouts/Layout.astro frontmatter 顶部 setApiLocale(locale)(每页经 Layout 渲染,覆盖所有子组件服务端取数)。
    • src/pages/shows.astro 自身 frontmatter 在取数前 setApiLocale(locale)(该页在前置 frontmatter 就 getBrands/getSsgPopularBrands,早于 Layout,必须单独注入)。
    • 4 个 SSR 详情页 src/pages/{en,cn}/article.astro、src/pages/{en,cn}/item/[id].astro:getSsrArticle(id, Astro.currentLocale) 显式传 locale(按需渲染并发安全,避免全局 _ssrLocale 串语言)。
  • 验证:
    • read_lints api.ts / Layout.astro / shows.astro 均 0 错误;默认 npm run build BUILD_EXIT=0。
    • 后端契约实证:本地 8090 品牌接口 ?locale=cn→阿德达舍、默认→A Detacher;本地 8091 SSG popular ?locale=cn→香奈儿 安普里奥·阿玛尼 克里斯汀·迪奥、默认→CHANEL Emporio Armani Christian Dior。链路打通。
  • 约定(重要,跨会话):全站 API 请求一律带 ?locale=,语言由 URL path 决定,不依赖浏览器 Accept-Language(因站点语言由 URL 前缀决定,与浏览器语言无关)。新增任何取数函数都必须经 request()/ssgRequest()/fetchArticlesList 等已带 locale 的原语,不要再裸写 fetch。

后端 title_cn 规则回填(14:50)

  • 用户批准:把 brand_runway.title_cn 从空补齐,让中文走秀页有中文标题(之前 title_cn 在迁移里被故意留空,中文页 pickLocale 回落英文 title)。
  • 探查(脚本 scripts/diag_title,已删):title 本质是「季节/品类描述」(Vogue 格式),品牌名在 JOIN 的 brand_name 单独给;collection_type/season/year 早已解析回填。分布干净:无 year=0、无空 collection_type、无异常 season;合计 25,206 行。
  • 方案:纯规则生成,零外部翻译 API,可幂等重跑。映射:
    • resort→{year} 度假系列,pre_fall→{year} 早秋系列;
    • rtw→{year} {春夏|秋冬}成衣,menswear→{year} {春夏|秋冬}男装,couture→{year} {春夏|秋冬}高定(无 season 的 rtw 回落 {year} 成衣)。
  • 实现:新增 d:/project/backend_v2/scripts/backfill_title_cn/main.go(单条 UPDATE ... CASE CONCAT(year,...),WHERE is_deleted=0,整表覆盖重跑无害);并把该 CASE 语句补进 scripts/sql/002_i18n_columns.sql 作为「第 5 步」评审底稿。
  • 执行实证:本地 MySQL(root:root@127.0.0.1:3306/db) 跑通,updated rows = 25206,title_cn 非空率 0→100%。
  • 端到端验证:临时 go build 启动 backend_v2(cmd/server) 于 8090,打 /api/public/articles——
    • ?locale=cn:brand=芬迪 | title=2016 春夏成衣 / 2016 早秋系列 / 2016 秋冬男装 / 2016 秋冬成衣 / 2017 度假系列 / 2017 春夏男装;
    • 默认:brand=FENDI | title=Spring 2016 Ready-to-Wear / Pre-Fall 2016 / ...。
    • 证明 article_service.go 的 pickLocale(r.TitleEn, r.TitleCn, locale) 已自动生效,前端 cn 页(articles/article.astro,已带 ?locale=)将直接显示中文标题,无需前端改动。验证后已停掉临时后端、释放 8090。
  • 未做(范围外):description_cn 仍是空 → 中文页摘要/描述回落英文(描述是散文,规则无法生成,需真实翻译)。

品牌搜索「中文昵称搜不到」修复(15:10)

  • 现象:中文界面搜「猿人头」找不到 A Bathing Ape。用户期望展示「A Bathing Ape(猿人头)」。
  • 根因(已用一次性脚本 scripts/diag_brand 实查,已删):数据其实有 name_cn="猿人头"(brand id=3100,show_name="A Bathing Ape/猿人头");后端 brand_repository.go 搜索本就匹配 name_en OR name_cn。真正卡点:默认 only_with_articles=1 先用子查询把「无走秀档案的品牌」全滤掉,而 brand 3100 的 brand_runway 档案数=0 → 关键词搜索落空。
  • 修复(前端+后端):
    1. 后端 brand_repository.go brandFilterScope:关键词搜索时跳过 only_with_articles 子查询(if q.OnlyWithArticles && q.Keyword == "" && q.Letter != "OTHER")。语义:按名字找品牌本就不该被「必须有档案」限制。
    2. 前端 src/lib/api.ts getBrands:关键词搜索时强制 only_with_articles=0(双保险,使未重启的后端也立即生效);并改映射返回 nameCn / display。
    3. 后端 dto.PublicBrand 新增 name_en / name_cn 两字段(service 填充 b.NameEn/b.NameCn),因原 name 是 pickLocale 二选一、不含双语,前端拼不出「英文(中文)」。
    4. 前端 RunwayLooks.astro 弹窗品牌名改用 b.display || b.name,display=中文语境下 name_en + " (" + name_cn + ")",否则 name_en。
  • 端到端验证:临时 go build 起 backend_v2 于 8090,请求 /api/public/brands?keyword=猿人头&only_with_articles=0&locale=cn → 返回 {"name":"猿人头","name_en":"A Bathing Ape","name_cn":"猿人头","article_count":0},即搜得到且字段齐备。验证完毕已停后端、释放 8090、清理临时脚本与二进制。
  • 注意:brand 3100 档案数=0 是数据现实(爬取遗漏/该品牌无走秀收录),搜索能找到它,但选定后走秀列表会空——属数据层问题,不在本次范围。全站 23,375 品牌仅 2,025 个(8.7%)有 name_cn。

品牌名不再展示中文(15:49)

  • 用户反馈:「前端品牌还是不要展示中文名称了吧,感觉有点low… 标题什么的倒是可以留着」。即品牌名保持英文观感,走秀标题(中文页 title_cn)保留中文。
  • 根因:之前品牌名也被后端 pickLocale 随 locale 本地化了(article_service.go 的 BrandName、brand_service.go 的 Brand 在 ?locale=cn 时返回中文;前端 getBrands 的 display 又拼了「英文名 (中文名)」双语)。导致 /cn/ 页文章卡片、精选品牌、RunwayLooks 弹窗都出现中文品牌名。
  • 修改:
    1. 后端 d:/project/backend_v2/internal/service/article_service.go:列表(:54)与详情(:114)的 BrandName 由 pickLocale(En, Cn, locale) 改为 pickLocale(En, Cn, "en")——强制优先英文,仅英文缺失时回落中文避免留空;Title/Summary/Description 维持按 locale。
    2. 后端 d:/project/backend_v2/internal/service/brand_service.go:精选 Brand(:87) 同改为 pickLocale(En, Cn, "en")。
    3. 前端 d:/project/frontend_v2/src/lib/api.ts:getBrands 的 display 改为 display = nameEn(去掉双语拼接),并删除已无用的 const loc = currentLocale()。
  • 验证:后端 go build ./internal/service/ 通过(exit 0);前端 read_lints api.ts 0 错误。品牌名全站统一英文;标题仍随 locale(中文页显示 title_cn)。
  • 边界:dto.PublicBrand.Name(品牌列表 name 字段)仍为 pickLocale 二选一,但前端 getBrands 始终用 nameEn 覆盖,不显示中文,故未改该字段(避免扩大 API 契约改动面)。

后端接口命名复盘(15:59,仅分析未改动)

  • 用户觉得后端接口命名乱,要求按现有网站设计给命名方案。已产出提案文档(artifact: api_naming_proposal.md),未改任何代码。
  • 现状路由:公开 :8090=/api/health、/api/public/articles、/api/public/articles/:id、/api/public/brands、/api/auth/{register,login,logout,me};构建期内部 :8091=/api/ssg/brands、/api/ssg/brands/popular、/api/ssg/articles/ids。
  • 识别出的 6 个痛点:① 资源名前后端错位——后端 articles=走秀,前端页面已叫 /shows;② public vs ssg 把同类公开数据拆两世界,ssg 泄漏 Astro 构建机制;③ 同概念双命名 popular(SSG 子路径) vs featured=1(public 参数);④ 幽灵接口 /api/public/colors 后端无实现、前端靠降级兜底;⑤ 一个 /public/articles 端点有 4 个前端 getter(getArticlesRaw/getArticles/fetchArticlesList/getYears);⑥ 缺 brands/:id 详情与缺 /api/v1 版本前缀。
  • 推荐方案:资源名对齐前端路由(articles→shows)、ssg→internal、popular统一为featured、/api/v1 前缀、抽 ENDPOINTS 常量表、合并 articles getter、删/实现 colors。待用户拍板 5 个问题后落地。

落地 /api/v1 前缀(16:13)

  • 用户要求:先给所有接口加 /api/v1 前缀;并确立命名哲学「一个功能一个接口,尽量不通过参数复用接口」,让我把接口全集确定出来,他逐条确认。
  • 改动:
    • 后端 internal/router/router.go:r.Group("/api") → /api/v1(连带 health/public//auth/ 全部前缀)。
    • 后端 internal/router/ssg.go:/api/ssg → /api/v1/ssg。
    • 前端 src/lib/api.ts:request() 原语 getBase()+"/api" → "/api/v1";fetchArticleById/fetchArticlesList/fetchMe 3 处手动 fetch 路径加 v1;getSsgPopularBrands/getSsgBrands/getSsgArticleIds 3 处字面量 /api/ssg → /api/v1/ssg;同步头部/分区注释。
    • 前端 src/components/pages/AuthForm.astro:登录注册提交 /api/auth/* → /api/v1/auth/*。
    • 部署:nginx.conf 404 拦截 /api/ssg/ → /api/v1/ssg/;docker-compose.yml 健康检查 …/api/public/brands → …/api/v1/public/brands;DEPLOY.md 同步说明。
  • 验证:后端 go build ./... 通过(BUILD_EXIT=0);前端 read_lints api.ts / AuthForm.astro 均 0 错误。
  • 已产出目标接口全集清单(artifact: api_endpoints_catalog.md),按「一功能一接口」原则拆出 shows/brands/内部 各独立端点(A1–A6/B1–B5/C1/D1–D4/E1–E4/F1),并标注旧名作废映射。待用户确认 3 个总决命名(shows vs articles、internal vs ssg、featured vs popular)后逐条落地。

前端未用接口清理(16:20)

  • 用户要求:先查前端实际没用到的接口,在后端去掉,再列精简清单确认。
  • 排查结论(前端调用点全量核对):
    • /api/v1/ssg/articles/ids:仅 getSsgArticleIds 定义,全站无任何 getStaticPaths/页面调用(详情页走 getSsrArticle 按需 SSR)→ 死端点。
    • /api/v1/auth/logout:前端退出按钮只做 clearSession()+刷新,从不 POST 该接口 → 死端点。
    • 附带死代码:前端 getYears、getSsgArticleIds、fetchColors(colors 后端本无实现,已不在 api.ts)三个 getter 从未被调用。
  • 已删除:后端 router.go 的 auth.POST("/logout")、ssg.go 的 ssg.GET("/articles/ids");前端 api.ts 的 getYears 与 getSsgArticleIds 函数。
  • 验证:后端 go build ./... 通过(BUILD_EXIT=0);前端 read_lints api.ts 0 错误。
  • 剩余后端接口(供用户确认):/api/v1/health(infra) + /api/v1/public/articles、/api/v1/public/articles/:id、/api/v1/public/brands、/api/v1/auth/register|login|me、/api/v1/ssg/brands、/api/v1/ssg/brands/popular,共 9 个功能端点。
  • 注:后端 AuthHandler.Logout 与 SSGHandler.ArticleIDs 两个 handler 方法因路由删除已成为孤儿(Go 编译无误),可后续一并删除。

重构 SSG 首页接口为 /api/v1/ssg/index/runway(16:48)

  • 用户要求:把首页 runway 的 SSG 数据接口从「按品牌热门」改为「首页(/index/)runway 区块多模块数据」,已命名为 /api/v1/ssg/index/runway,hotBrand 为其中一个模块;接口尽量写死、避免多余参数。
  • 后端:
    • ssg.go:ssg.GET("/brands/popular", opt.SSG.Popular) → ssg.GET("/index/runway", opt.SSG.IndexRunway)。
    • ssg_handler.go:删 Popular 方法,新增 IndexRunwayData{HotBrand []dto.PopularBrand json:"hotBrand"} 结构体 + IndexRunway 方法;写死 hotBrandLimit=20、去掉 limit 查询参数、不读任何入参;调用 h.brands.Popular(ctx, 20, parseLocale(c))。dto.PopularBrand 字段 id/article_id/brand/cover/title 与前端 Index.astro 用法吻合。
  • 前端:
    • api.ts:getSsgPopularBrands(limit) → 新增 getSsgIndexRunway(): Promise<{hotBrand: SsgPopularBrand[]}>,返回 j?.data ?? {hotBrand:[]}(沿用 response.Data 的 data 信封)。
    • Index.astro:改用 getSsgIndexRunway().hotBrand 作为 popular(galleryItems 映射 b.cover/article_id/brand/title 不变)。
    • RunwayLooks.astro(/runway-looks 独立页,非首页):因该 SSG 接口已限定 /index/ 专属,改为复用侧栏已拉取的 featured 品牌 sidebarBrands.slice(0,12) 派生 hotBrands,移除对首页 SSG 接口的跨页耦合与 getSsgPopularBrands 导入。
    • 删除旧 getSsgPopularBrands(全仓 0 引用)。
  • 验证:go build ./... 通过(BUILD_EXIT=0);前端 read_lints api.ts / RunwayLooks.astro 均 0 错误;全仓 grep 无 getSsgPopularBrands / 旧路径残留。
  • 设计约定(重要):/api/v1/ssg/index/runway 为「首页 runway 区块数据」聚合端点,按模块拆字段(当前仅 hotBrand);后续新增首页 runway 模块(如 featuredShows/latestShows)直接在 IndexRunwayData 加字段,前端按需读取,互不干扰。该端点零查询参数、数量写死。

删除孤儿 handler(16:32)

  • 用户确认「没用到的都删掉」:删掉 AuthHandler.Logout(auth_handler.go)与 SSGHandler.ArticleIDs(ssg_handler.go)两个方法体及其注释,logout 不恢复。
  • 全仓 grep 确认无测试/他处引用后删除;go build ./... 通过(BUILD_EXIT=0)。
  • 余留:SSGHandler.articles 字段在 ArticleIDs 删除后不再被读取(Go 允许未用结构体字段,编译无碍)。如需极致干净,可后续移除该字段及 NewSSGHandler 的 articles 参数与 main 装配——属可选清理,未动以免扩大改动面。

抽出 IndexService(17:00)

  • 用户质疑:/api/v1/ssg/index/runway 的实现挂在 service.BrandService.Popular 上(ssg_handler.go 调 h.brands.Popular(...)),首页区块数据不该借用品牌服务。要求新建专属 service。
  • 命名:采用 IndexService(对应 URL 的 /index/ 段「首页区块数据聚合服务」;备选 HomeService 偏口语被否)。文件 internal/service/index_service.go。
  • 改动:
    • 新建 service/index_service.go:IndexService 接口(Runway(ctx, locale) (IndexRunwayData, error))+ IndexRunwayData{HotBrand []dto.PopularBrand}(从 handler 迁入)+ indexService 结构体(持 repository.BrandRepository)+ NewIndexService(brands);Runway 写死 hotBrandLimit=20,复用 brands.PopularWithCover 并保持「品牌名强制英文 / 标题按 locale」映射。
    • handler/ssg_handler.go:SSGHandler 增 index service.IndexService 字段、构造签名 NewSSGHandler(brands, index);删 handler 内 IndexRunwayData 定义;IndexRunway 改为 h.index.Runway(...);并顺手删掉自 ArticleIDs 删除后空置的 articles 字段与参数。
    • service/brand_service.go:从接口与实现中移除 Popular(已无调用方,逻辑整体迁至 IndexService)。
    • cmd/server/main.go:新增 indexSvc = service.NewIndexService(brandRepo);NewSSGHandler(brandSvc, articleSvc) → NewSSGHandler(brandSvc, indexSvc)(articleSvc 仍被 NewArticleHandler 使用,未变未用)。
  • 验证:go build ./... 通过(exit 0);read_lints ssg_handler.go 0 错误;全仓 grep 仅 index_service.go 含 IndexRunwayData/Popular 残留(repository.PopularWithCover 仍被新 service 复用)。
  • 架构约定(稳定):首页区块数据由 IndexService 专属承载,未来 /index/* 各区块(runway/inspiration…)都归它;IndexRunwayData 按模块加字段扩展,handler 只做转发。SSGHandler 现仅依赖 BrandService(供 brands 索引)+ IndexService(供 index/runway)。

SSG 品牌接口重做:/ssg/brands → /ssg/brands/hot(17:45)

  • 背景:用户发现 /api/v1/ssg/brands 唯一消费者(brands.astro 页面→组件)在 i18n 重构中已成孤儿链 → 确认品牌索引页下线,死端点不改造、直接删。用户改为要求:新写一个 SSG 方法取热门前 30 品牌,替换 RunwayLooks 里的 getBrands;侧栏 filter 展示前 10,弹窗 HOT 展示全部;顺带清掉没用到的 JS 函数。
  • 后端(backend_v2):
    • service/brand_service.go:新增 Hot(ctx, limit, locale)——FeaturedIDs("images", limit) 取热度排名 → repo.List(按 id 集合) → 按热度排名重排(repo.List 固定 name_en ASC,必须重排否则「前 10」不是真前 10 热)→ 映射。抽出公共 toPublicBrands(items, counts, locale) helper,List 复用。
    • handler/ssg_handler.go:删 Brands(连带 strings/dto import),新增 HotBrands——固定 hotBrandLimit=30 写死,零查询参数(符合「一个功能一个接口」原则),response.Data 返回。
    • router/ssg.go:/brands → /brands/hot。SSG 分组现仅 brands/hot + index/runway。
  • 前端(frontend_v2):
    • api.ts:删 SsgBrand + getSsgBrands;新增 getSsgHotBrands(): Promise<BrandEntry[]>(ssgRequest /api/v1/ssg/brands/hot,display=nameEn 保持英文观感)。getBrands 保留——弹窗搜索(modalSearch)/字母(modalPickLetter)运行时仍用。
    • RunwayLooks.astro:frontmatter 数据源换 getSsgHotBrands()(30 条热度顺序),删 sidebarBrands/allBrands 数据岛(showsbrands-data);删死代码 visibleBrandItems/hasMoreBrands/brandsExpanded(模板零引用);pills findName 改 hotBrands + modalResults 兜底;modalLoadHot 去掉 allBrands.slice(0,50) 兜底直接展示 30 条;侧栏 filter 模板原有 hotBrands.slice(0,10) 不变。
    • 删除孤儿组件 src/components/pages/brands.astro。
  • 验证:go build ./... exit 0;前端 npm run build exit 0;read_lints api.ts / RunwayLooks.astro 均 0;全仓 grep 无 allBrands/getSsgBrands/SsgBrand/sidebarBrands/visibleBrandItems 残留。本地 :8091 跑的是旧进程(新路由 404),重启后端后生效。
  • MEMORY.md 本次同步精简:品牌索引页下线、SSG 分组现状、前后端路径修正(frontend_v2 / backend_v2)。

修复 Hot 未按热度排名的 bug(19:15)

  • 现象:/api/v1/ssg/brands/hot 返回 A-Z 字母序、article_count 全 0(用户发现「没按热度排」)。
  • 根因:Hot 借道 repo.List(q, restrictIDs),但仓储 brandFilterScope 里 restrictIDs 仅在 q.Featured==true 时才拼进 WHERE;Hot 构造的 BrandQuery 没设 Featured → id IN (热门30) 完全没生效,实际返回全量品牌字母序第一页;counts map 只含热门品牌 id → 查全空 → article_count 全 0。
  • 排查路径:先写 scripts/diag_hot/main.go 复现 FeaturedIDs SQL 验证数据源正常(CHANEL 11106 图第一,梯度清晰,排除数据问题)→ 再 curl 8091 实测发现返回与热度排名完全对不上 → 定位到 scope 的隐藏耦合。
  • 修复(符合「一个功能一个接口」哲学,不用 featured 假旗标复用 List):
    • repository/brand_repository.go:接口+实现新增 ListByIDs(ctx, ids)(WHERE is_deleted=0 AND id IN ?,不保证顺序,调用方重排)。
    • service/brand_service.go Hot:改用 ListByIDs 取行 → 按FeaturedIDs 顺序 map 重排 → 映射。删掉 BrandQuery 借道逻辑。
  • 验证:go build ./... exit 0;重启 8091 dev 进程(go run 临时 exe,杀旧 PID + Start-Process go run ./cmd/server 后台拉起,日志 dev-ssg.log/err)后实测返回 CHANEL(151)→Emporio Armani(110)→Dior(147)→Valentino(107)→GUCCI(109)…与热度排名完全一致;8090 公开引擎 200 正常。
  • 教训:repo.List 的 restrictIDs 是 featured 专属语义,其他场景要按 id 集合取行一律走 ListByIDs,别再踩。