update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-09-02 21:50:30 +08:00
parent 50e5775dc8
commit 64af75c0a9
23 changed files with 1730 additions and 285 deletions

View File

@ -1,69 +1,53 @@
# 项目长期记忆(Project MEMORY.md)
## 模块策略(重要)
- 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。
- **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。
- **占位模块组件已清理(2026-08-29)**:`components/pages/` 下 `About`/`Contact`/`LatestProjects`/`Portfolio`/`Login`/`Register`/`AuthForm`/`article` 等占位/旧详情组件查无引用、也不是真实路由,已删除并移除空目录。
- **导航死链清理(2026-08-30)**:用户决定移除 `/latest-projects`、`/about`、`/contact` 三个死链——已从 `Layout.astro` 主导航(早已不在)、`routes.ts`(`latestProjects`/`about`/`contact` 键)、`dictionary.ts`(about/contact/latest projects 翻译键)三处清除,全 src 扫描零残留引用。**剩余死链 `/portfolio`、`/login`、`/register` 保留未动**(Portfolio 导航项、Login/Signup 链接仍展示,用户未要求移除)。
- **「header 别动」解读(2026-08-30 明确)**:用户「header 别动」指不要擅自改导航结构与视觉;但用户**主动批准**的导航调整(Runway 下拉改 5 个 collection 分类、Street Style 下拉减为 3 城、删除死链项)均属于已授权变更,不算违规。`/en/login`、`/en/register` 实际并非真实页面(旧 MEMORY 记录有误),AuthForm 组件已随清理删除。
## 模块策略
- 站点按模块逐个完善,当前主线为「发布会模块」:首页 index + 走秀档案(RunwayLooks)+ 街拍(StreetSnaps)。
- 品牌索引页已下线(2026-08-26):`brands.astro` 与孤儿组件已删;品牌仅经走秀页侧栏 filter + 品牌弹窗呈现(热门 30 走 SSG,搜索/字母走运行时 `/api/v1/public/brands`)。
- 导航死链清理(2026-08-30):删 `/latest-projects`、`/about`、`/contact` 三处(Layout/routes/dictionary 三处同步清)。**保留未动的死链**:`/portfolio`、`/login`、`/register`(用户未要求动)。
- 「header 别动」= 不擅自改导航结构/视觉;但用户主动批准的调整(Runway 下拉分类、Street Style 减城、删死链)均属已授权,不算违规。
## 架构总览
- 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。
- 后端:**d:/project/backend_v2** — Go + Gin + GORM(MySQL),公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`),SSG 内部引擎 :8091(仅 127.0.0.1,`/api/v1/ssg/*`,SSGToken 中间件,nginx 不反代)。
- **SSG 分组现状(2026-08-26)**:只剩两个端点——`/api/v1/ssg/index/runway`(首页 runway 区块,`IndexService.Runway`,hotBrand 20 条)+ `/api/v1/ssg/brands/hot`(走秀页热门品牌,`BrandService.Hot`,固定 30 条、按图片总数热度顺序,零参数)。`/ssg/brands`(全量 A-Z)与 `/ssg/articles/ids` 均已删除。
- **接口 i18n 约定**:后端按 `?locale=cn|en` 选列返回(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 的原语(request/ssgRequest 等),**绝不裸写 fetch;语言以 URL 前缀为准**。
- 走秀标题 `title_cn` 已规则回填(backend_v2 `scripts/backfill_title_cn/main.go`,幂等);`description_cn` 为空白属预期,中文页回落英文。
- 前端:`d:/project/frontend_v2` — Astro SSG + Tailwind v4 + Alpine.js。构建 `npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。
- 后端:`d:/project/backend_v2` — Go + Gin + GORM(MySQL)。公开引擎 :8090(`/api/v1/public/*`、`/api/v1/auth/*`);SSG 引擎 :8091(仅 127.0.0.1,`/api/v1/ssg/*`,SSGToken 中间件,nginx 不反代)。
- SSG 端点仅剩:`/api/v1/ssg/index/runway`(首页 runway 区块)与 `/api/v1/ssg/brands/hot`(走秀页热门品牌,固定 30 条)。
- 接口 i18n:后端按 `?locale=cn|en` 选列(pickLocale 缺翻译回落另一语言);前端取数统一复用带 locale 原语(request/ssgRequest),**绝不裸写 fetch**;语言以 URL 前缀为准。
- 走秀标题 `title_cn` 已规则回填(幂等);`description_cn` 空白属预期(中文页回落英文)。
- 后端连接池:已设 `conn_max_idle_time`(config.yml 默认 60s,须 < MySQL wait_timeout)主动回收空闲连接,避免空闲连接被 MySQL 回收后池子持死连接报 `invalid connection`。**本地后端若长时间空闲后接口报 invalid connection,先重启后端**(`cd d:/project/backend_v2 && go build -o server-bin ./cmd/server` 后杀旧进程重启)。
- 前端 dev server **不热加载 `.env`**:改 `BASE_API` 后必须 `astro dev stop` + `astro dev --background` 重启才生效。浏览器旧 bundle 仍用旧 `BASE_API`,需**硬刷新**(Ctrl+Shift+R);旧 token 对新后端无效,需**重新登录**。改 BASE_API 指向本地/线上后端后,若历史/收藏/登录"没反应",先查 dev server 是否重启、浏览器是否硬刷新+重登录。
## 关键约定
- 视觉语言:黑白极简编辑风(monospace + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。字体全走 Tailwind 体系,禁止自写字体栈。
- **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。
- **API 命名哲学(用户原则)**:「每一个地方尽可能不要通过参数来复用接口,尽量一个功能一个接口」。查询参数仅限「同一资源的列表筛选/分页」;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。待办:公开侧 `/api/v1/public/brands` 的 featured/keyword/letter 参数拆分、`/articles` 的 `brand_id` 拆 `brands/:id/shows`、资源命名 `shows` vs `articles`、`popular` vs `featured`(目标清单见 artifact `api_endpoints_catalog.md`)。
- 视觉语言:黑白极简编辑风(monospace 小标签 + Georgia 衬线大标题 + 细线),HBX / 时尚档案调性。**纯黑白,禁用暖米色/柔和阴影**;字体全走 Tailwind 体系,禁止自写字体栈。
- 品牌名前端一律只显示英文(`display = nameEn`);走秀标题/描述仍按 locale 选列。
- API 命名哲学(用户原则):「一个功能一个接口」,查询参数仅限同资源列表筛选/分页;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
- 导航(Layout.astro):「header 别动」指不擅自改结构/视觉,但用户**主动批准**的导航调整有效(Runway 下拉改 4 个 collection 分类 rtw/menswear/couture/resort、Street Style 下拉减为「全部/Paris/New York/Milan」4 项、移除 latest-projects/about/contact 死链)。剩余死链 /portfolio、/login、/register 用户未要求动,保留。
- Layout 把 `<slot/>` 包在 `flex-1 flex flex-col`;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 抖动),用 `w-full` + `max-w-[x] mx-auto`。
## Layout 陷阱(2026-08-12)
- Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。
## Astro `<script>` 坑
- 作用域隔离:frontmatter 的 import/变量在打包型 `<script>` 拿不到,需脚本顶部重新 import。
- 不做模板插值:打包型 `<script>` 内 `{t('...')}` 不执行 → `t is not defined`;预翻译用 `define:vars` 或 data 属性注入,模板区 `{t()}` 正常。
- **`define:vars` + `import` = 致命组合(已踩坑)**:`define:vars` 会把脚本包成内联 IIFE 并注入 `const meta=...`,而原本脚本顶部的 `import` 语句被塞进函数体内 → `import` 在 IIFE/非模块上下文非法 → 整段脚本 SyntaxError 完全不执行(连带 `recordHistory`/事件绑定全失效)。**凡脚本里要 `import`,绝不能用 `define:vars`**。改用 `data-*` 属性在模板里透传变量,脚本内 `btn.dataset.xxx` 读取(Layout.astro 已用此模式)。en/cn `item/[id].astro` 曾因此导致详情页浏览历史/收藏全不工作,已修复。
- 片段页(无 `<html>/<head>` 的 .astro)的 `import '@/styles/global.css'` 在 build 时不会被注入 head → 产物缺 `<link rel=stylesheet>` 整页无样式。login.astro 曾因此丢样式,已补完整文档结构修复。
## Astro `<script>` 两大坑
- **作用域隔离**:frontmatter 的 import/变量在打包型 `<script>` 里拿不到(运行时 ReferenceError);脚本里要用什么就在脚本顶部重新 import。
- **不做模板插值**:打包型 `<script>` 里 `{t('...')}` 不会被执行 → `t is not defined`。预翻译用 `define:vars={{ hiLabel }}` 注入;模板区 `{t()}` 正常。
- 打包后脚本在 `_astro/*.js` 而非内联;查 dist 产物用 search_content(ripgrep),PowerShell 无 `Select-String -Recurse`。
## 数据分类
- 走秀 `brand_runway`:`collection_type`(rtw/menswear/couture/resort/pre_fall) + `season`(spring/fall/null) + `season_code`(SS26…),title 规则回填(25,206 行,幂等)。公开列表 `?collection_type=&season=&season_code=&year=` 筛选。地理维度暂不做。
- 街拍 `street_snap.city`(2026-08-30 落地):DB 列 VARCHAR(128) + 精确匹配 `WHERE city = ?`;前端 header 下拉 `?city=` + 侧栏 City pills + 深链选中。规范值存「首字母大写英文」(Paris/New York/Copenhagen…),前后端大小写敏感须一致;dev 库仅 12 行样本(Paris 8 / Copenhagen 4),其余城市本地为空属预期。
## 走秀数据分类(brand_runway)
- 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。
- 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。
- 地理/城市维度(runway):用户明确暂不做。
- 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。
## 街拍地区分类(street_snap.city,2026-08-30)
- 全链路已落地:DB 加 `city` 列(VARCHAR(128),脚本 `scripts/sql/004_add_streetsnap_city.sql` + 幂等回填 `scripts/backfill_city/main.go`,无 AutoMigrate)+ 后端 `StreetSnapQuery.City` 精确匹配 `WHERE city = ?` + 前端 header 下拉 `?city=` + 列表侧栏 City pills + 深链自动选中。
- **规范值约定(重要)**:city 列存「首字母大写英文规范名」(Paris / New York / Copenhagen …),前端 header 下拉与列表 `cityOptions` 的 value 用**同一大写值**,后端做精确匹配、大小写敏感,必须前后一致。backfill 首选从 title 第一段(location,`\t` 分隔)按城市关键词匹配,兜底从 source_url/cover 的 URL slug 解析。
- 前端 `t('Paris')` 经 `translate()` 自动 `key.toLowerCase()` 查字典,中文回落「巴黎」,i18n 正常。
- dev 库 db_dev.street_snap 仅 12 行样本(Paris 8 / Copenhagen 4),其余下拉城市(London/Milan…)本地预览为空属预期;生产真实爬取数据才有完整覆盖。
- 验证:重启后端 :8090 后,`?city=Paris`→8、`?city=Copenhagen`→4、`?city=London`→0、无参→12,全链路通过。client_sign 本地 `enabled:false`,可直接 curl/Node 验证。
## 前端组件复用(2026-08-29)
- **详情页统一 `/item`**:走秀 lookbook 与街拍详情全部收口到 `src/pages/{locale}/item/[id].astro`,服务端按 `getSsrArticle(id)` → `getSsrStreetSnap(id)` 回落渲染(先 runway 后 street)。`ROUTES.article` 与 `ROUTES.streetSnap` 均指向 `/item/${id}`(`runway-looks/[id]`、`street-snaps/[id]`、`article.astro(?id=)` 等旧详情页均已删除)。列表页回链仍指向列表 `/runway-looks`、`/street-snaps`。
- **列表页共享资源**:两个列表组件(RunwayLooks / StreetSnaps)同构,抽成 `src/styles/look-grid.css`(卡片/网格/分页/侧栏/spinner/骨架/动画)+ `src/lib/looks-grid.ts`(`SPIN_SVG`、`makeCardSlots(images,count,opts)`、`buildPageList(total,cur)`);品牌筛选弹窗 `src/components/BrandModal.astro`(仅 RunwayLooks 用,`openBrandModal/confirmBrandModal` 等由页面脚本驱动)。卡片标记因在 Alpine `x-for` 内客户端渲染,**不能**提成 Astro 组件。
## 前端复用
- 详情页统一 `/item`:`src/pages/{locale}/item/[id].astro`,服务端 getSsrArticle→getSsrStreetSnap 回落;`ROUTES.article`/`streetSnap` 均指向 `/item/${id}`。列表回链指向 `/runway-looks`、`/street-snaps`。
- 列表组件同构,抽 `src/styles/look-grid.css` + `src/lib/looks-grid.ts`(SPIN_SVG / makeCardSlots / buildPageList);品牌筛选弹窗 `BrandModal.astro`(仅 RunwayLooks)。卡片标记在 Alpine `x-for` 内客户端渲染,不能提成 Astro 组件。
## 账号体系(2026-08-31)
- 决策:仅预置内部账号,**不开放注册**(前端无注册入口,避免垃圾账号/撞库)。账号由 seed 脚本预置。
- 默认内部账号:`admin` / `admin@studio.local` / 密码 `Studio#2026!Admin`(env `SEED_ADMIN_PASSWORD` 可覆盖;**务必改默认密码**)。
- **双令牌 JWT(2026-08-31 落地)**:access token 短命(默认 **2h**,无状态 JWT,仅前端请求用)+ refresh token 长命(默认 **30d**,随机串只存 SHA256 哈希落库 `refresh_tokens`)。纯无状态 JWT 无法吊销,故 refresh 必须落库才能做踢下线。
- 接口(均在 `/api/v1/auth/`,除 /me 外均公开、无需 Bearer):
- `POST /login` → `{access_token, refresh_token, expires_in(秒), user}`(login 返回结构已从 `{token,user}` 改为双 token,前端已同步)。**登录即顶旧会话**:签发前先吊销该用户全部既有 refresh,保证同账号同一时刻只有一条活性会话(用户明确诉求:不希望同一账号被不同人同时登录)。
- `POST /refresh` → `{access_token, expires_in}`(用 refresh_token 换 access;**refresh 复用、不轮换**)。
- `POST /logout` → `{ok:true}`(吊销当前 refresh,单设备登出)。
- `POST /logout-all` → `{revoked:N}`(按 refresh 反查用户,吊销其全部 refresh = **踢下线/全设备登出**)。
- `GET /me`(Bearer 校验)保留。
- 后端改动:`config.JWT` 新增 `RefreshExpireHours`(env `JWT_REFRESH_EXPIRE_HOURS`);`AuthService` 加 `Refresh/Logout/RevokeAllByRefresh`;`RefreshTokenRepository`(Create/FindByHash/Revoke/RevokeAllByUser/DeleteExpired);`model.RefreshToken`(gorm 表 `refresh_tokens`)。
- 建表:`scripts/sql/006_create_refresh_tokens.sql`;一键建表脚本 `scripts/migrate_refresh/main.go`(`go run ./scripts/migrate_refresh`,DSN 默认 root:root@127.0.0.1:3306/db_dev,可由 `DB_DSN` 覆盖)。
- 前端 `api.ts`(2026-08-31 改):`saveSession(access,refresh,user)` 存 `fa_token`/`fa_refresh`/`fa_user`;新增 `refreshSession()`(POST /auth/refresh,刷新 access、复用 refresh);`fetchMe()` 走 `authedFetch`(遇 401 静默 refresh 一次再重试);`logout()`(吊销当前 refresh 再清本地)、`logoutAll()`(踢下线再清本地)。`clearSession()` 一并清三键。
- 踢下线效果:**普通踢**=吊销 refresh,已签发 access 在 2h 窗口内仍有效,到期后才被弹回登录(非即时,内部工具足够);未做 `token_version` 即时吊销方案(如需访问令牌即时失效再加 `users.token_version` 并在 `middleware.Auth` 比对)。
- 验证(2026-08-31):`go build -o bin/server.exe ./cmd/server` EXIT=0;端到端 Node 脚本确认 login→refresh→logout-all(revoked:1)→再 refresh=401(ALL_OK);同账号二次 login 后首次 refresh 返回 401、二次 refresh 返回 200(MUTEX_OK,互斥登录生效)。前端 `npm run build` EXIT=0。
- **硬门禁(用户拍板)**:RunwayLooks / StreetSnaps 的筛选与翻页入口调 `requireLogin()`,未登录则弹出内嵌登录表单(`showLoginModal`);登录成功后 `loadPage(1)` 按当前筛选刷新。首屏 `init()` 的 `loadPage(1)` 不拦(保证未登录能看到第一页)。
- 页面:`src/pages/{en,cn}/login.astro`(黑白极简风,Alpine 表单,登录后跳 `?redirect=` 或 /account)、`account.astro`(资料+登出+收藏/历史占位,未登录跳 /login?redirect=/account)。
- 视觉:登录/个人中心沿用站点黑白极简编辑风(monospace + 细线下划线输入框 + Georgia 衬线大标题 + hover:opacity-60)。
- 仅预置内部账号,不开放注册(前端无注册入口)。`seed_users/main.go` 创建两个账号:`admin`(密码取 `SEED_ADMIN_PASSWORD`,缺省 `Studio#2026!Admin`)+ `root`/`root`(测试弱密码,uid 通常为 8)。**本地库 `db_dev` 确有 root/root**;实测 `POST /api/v1/auth/login {account:"root",password:"root"}` 打到 `http://localhost:8090` 返回 200,`GET /auth/history` 显示 root 账号下已有 `street-snaps`/`runway-looks` 两条真实图集历史。**不要再说"本地无 root / root 登录必失败"——那是误判**(2026-09-02 因一次 401 错判,已纠正)。前端 `.env` 当前 `BASE_API=http://localhost:8090`。
- 双令牌 JWT:access 默认 **7 天(168h)**(无状态,前端请求用)+ refresh 默认 **30d(720h)**(随机串仅存 SHA256 哈希落库 `refresh_tokens`)。登录即吊销该用户全部既有 refresh(互斥登录)。
- 接口(`/api/v1/auth/`,除 /me 外公开):`POST /login`→`{access_token,refresh_token,expires_in,user}`;`POST /refresh`→`{access_token,expires_in}`(refresh 复用不轮换);`POST /logout`(单设备);`POST /logout-all`→`{revoked:N}`(踢下线);`GET /me`(Bearer)。
- 前端 `api.ts`:`saveSession/clearSession/refreshSession/fetchMe(authedFetch 遇 401 静默 refresh 一次)/logout(吊销当前 refresh 再清本地)/logoutAll`。
- 未做 `token_version` 即时吊销(access 在 7 天窗口内仍有效),如需即时失效再加。
- 硬门禁:RunwayLooks/StreetSnaps 筛选与翻页入口调 `requireLogin()`,未登录弹内嵌登录表单,登录成功 `loadPage(1)` 刷新;首屏 `init()` 的 `loadPage(1)` 不拦。
- 个人中心 `account.astro`:报头(方块字母头像+用户名大标题) + 两 tab(**Saved looks / My History**),已移除 Overview/Sessions & Security/Settings/退出入口/偏好设置/编辑资料(2026-09-01 精简)。
- **收藏已服务端化(2026-09-02)**:后端表 `favorites` + `/api/v1/auth/favorites`(GET 分页 `?page&per_page` / POST 新增幂等 / DELETE /:target_uid / POST `/check` 批量校验当前页收藏态),按 `user_id` 隔离,`target_uid` 存前端编码串(runway `r=` / street `s=`)。前端 `src/lib/favorites.ts`:账户页用 `fetchFavoritesPage()` 服务端分页;列表卡片用 `checkFavorited(ids)` 仅把当前页可见 id 发给后端打勾(与收藏总量解耦,10 万级也常数级);收藏/取消本地乐观 + 后台同步(失败不回滚本地)。favorites 表含复合索引 `idx_user_created(user_id,created_at)` 支撑深翻页。
- **浏览历史(2026-09-02 晚收敛为「文章级」,用户澄清纠正)**:语义=「点开过哪篇文章」,记录时机在 **item 详情页**(`recordHistory(meta.id)`,meta.id 是文章对外编码串 `r=`走秀 / `s=`街拍,编码串自带类型、无需 kind);**RunwayLooks/StreetSnaps 列表页进页不再记**(此前按"图集=列表页"实现是理解错,已纠正)。后端 `histories` 表收敛为 `(user_id, target_uid, viewed_at)`:`kind/target_type/title/cover/brand` 五列已 DROP(008 建表脚本同步改 + `migrate_refresh` 对存量库 ALTER,实测 GET items 仅 `{id,viewed_at}`);唯一键 `uniq(user_id,target_uid)`;保留 `HISTORY_CAP=2000` FIFO(文章会积累,有存在意义)。接口 `/api/v1/auth/history`:GET 分页 / POST `{target_uid}` upsert / DELETE /:target_uid / DELETE 清空,按 `user_id` 隔离;kind 参数与校验已随收敛移除(kind=all 字面过滤坑随之消失)。账户页 My History:服务端分页拿 id 列表后**逐篇回查公开详情**(`api.ts` 的 `getHistoryMeta(id)` → `/public/runway-looks|street-snaps/:id` 取 title/cover/brand;用户选"只存 id、展示回查"),卡片跳回文章详情,无 kind 筛选 tab。前端 `src/lib/history.ts`:`recordHistory(id)` / `fetchHistoryPage(page,perPage)`(服务端分页+回查,单条回查失败容忍回落本地)。
- **待办服务端化**:users 加 display_name/bio、refresh_tokens 列表接口。
- 视觉:登录/个人中心沿用站点黑白极简编辑风(monospace 小标签 + 细线下划线输入框 + Georgia 衬线大标题 + hover:opacity-60)。
## 部署(Docker + Gitea Actions)
- 交付文件:前端 Dockerfile / nginx.conf / docker-compose.yml / deploy.sh / .gitea/workflows/deploy.yml / .env.example / DEPLOY.md;后端 Dockerfile。nginx 拦截 `/api/v1/ssg/` 404;健康检查 `/api/v1/public/brands`。
## 部署
- Docker + Gitea Actions:前端 Dockerfile/nginx.conf/docker-compose.yml/deploy.sh/.gitea/workflows/deploy.yml/.env.example/DEPLOY.md + 后端 Dockerfile。nginx 拦截 `/api/v1/ssg/` 404;健康检查 `/api/v1/public/brands`。