Files
frontend_v2/.workbuddy/memory/MEMORY.md
toom1996 b04a511b26 update
2026-09-17 22:06:11 +08:00

9.4 KiB
Raw Blame History

项目长期记忆(Project MEMORY.md)

维护约定:本文件只放跨会话长期有效的事实与铁律;当日细节写 .workbuddy/memory/YYYY-MM-DD.md。 历次合并去重:2026-09-15(大幅压缩;保留架构/约定/脚本铁律/类型坑/lib分层/验证手段/门禁/账号/i18n;删冗长示例与已过时待办)。

架构总览

  • 前端 d:/project/frontend_v2:Astro(adapter: node standalone,SSR 取数)+ Tailwind v4 + Alpine.js。构建 npm run build;类型检查 npm run check(= astro check)。
  • 后端 d:/project/backend_v2:Go + Gin + GORM(PostgreSQL + pgvector)。本地库见 scripts/pgvector(Docker 一键,PG16+,容器名 pgvector,库 fashion,账号 fashion/fashion_dev_2026;initdb 自动 CREATE EXTENSION vector)。公开引擎 :8090(/api/v1/public/*、/api/v1/auth/*);SSG 引擎 :8091(仅 127.0.0.1,nginx 不反代)。
  • 接口 i18n:后端按 ?locale=cn|en 选列;语言以 URL 前缀为准;prefixDefaultLocale: true(/en、/cn 都带前缀;根 / 由服务端 302 到 /en)。
  • base 地址全在 src/lib/config/:config.baseApi(浏览器) / config.baseApiSsr(SSR 绝对) / config.baseApiSsg(构建期内部端口)。密钥类(SSG_TOKEN)直读 env。
  • 后端 Windows 编译:go build -o server-bin.exe(非 server-bin)。前端 dev 不热加载 .env,改 base 后 astro dev stop+--background+浏览器硬刷新。
  • 504 (Outdated Optimize Dep):删 node_modules/.vite 后重启 dev。

关键约定

  • 视觉:黑白极简编辑风,纯黑白禁用暖米色/柔和阴影;字体走 Tailwind 体系(系统字体栈,不引外部字体)。
  • 跨组件复用原子样式放 global.css(Astro 组件 <style> 有作用域哈希,跨组件命中不了)。现含 .fav-btn/.locate-flash/@media(hover:none)/.no-scrollbar/[x-cloak]。
  • Tailwind v4:<style> 顶部 @reference "tailwindcss";(编译期指令,不进 HTML);无零值类;能 @apply 就 @apply。
  • Header 下拉 x-show 一律带 x-cloak 防 FOUC。Layout <slot/> 包 flex-1 flex flex-col;页内根容器禁用 m-auto(用 w-full+max-w-[x] mx-auto)。
  • 未启用 View Transitions。

Astro <script> 铁律(踩坑汇总)

  1. 打包型 <script> 拿不到 frontmatter 变量,需脚本顶部重新 import。
  2. define:vars + import = 致命(包成 IIFE 后 import 进函数体 → SyntaxError)。要 import 改用 data-* 透传。
  3. 片段页(无 <html>)import '@/styles/global.css' build 不注入 head → 整页无样式。
  4. Alpine 表达式只认组件 data / $magic / global,外部函数先存进 data 属性(如 isAuthed、toAbsUrl 看似空包装但不能删)。
  5. 验证 Alpine 运行时务必真跑浏览器,只看 SSR HTML 会漏掉绑定失效。
  6. 共享组件 Alpine.data('name', () => ({...})) + x-data="name()";由工厂展开须显式标注返回类型。
  7. Swiper:initSwipers() 先 destroySwipers();setupSwipers() rAF 延后 + DOMContentLoaded;Tab 用 document 事件委托。
  8. IntersectionObserver 用模块级单例(如 gallery 的 thumbObserver),重建前 ?.disconnect()(别放 Alpine data 属性被代理包一层)。

类型检查与 Alpine 类型推断(2026-09-14 引入 npm run check,已修到 0)

  • 坑 A:@types/alpinejs 自引用约束使 data 属性退化 unknown/{} → 属性显式标注(user: null as AuthUser|null、favIds: [] as string[]);Alpine.store 先 const s: Store = {...} 再注册。
  • 坑 B:对象展开让 T 退化 {} → 给回调显式标注返回类型 (): ReturnType<typeof createGalleryView> & X => ({...})。
  • 排查铁律:「Property does not exist on type '{}'」/「Object of type 'unknown'」先怀疑这两类,别怀疑业务代码。
  • astro check 的 stdout 会被吞 → npx astro check 2>&1 | Out-File -FilePath x.txt -Encoding utf8 再读;Get-Content 须带 -Encoding utf8。

lib 模块分层(2026-09-14 核对磁盘)

  • i18n/index.ts(配置+工具+API语言透传:原 config/utils/lib/locale 三合一)+ i18n/dictionary.ts(纯数据,独立保留)。
  • auth.ts 仅会话存储原语;api.ts 业务取数+鉴权(login/logout/fetchMe/enforceIdleLogout 有意留 api.ts,避免 auth↔request 循环);ssg.ts 构建期、ssr.ts 运行期、gallery.ts 灯箱工厂、crypto.ts 签名、config/ base 址、endpoints.ts 后端路径唯一来源、routes.ts 前端跳转 URL(带前缀,href 用 astro:i18n、clientHref 浏览器内自拼)、http.ts 调试日志(开关 DEV||DEBUG_API)、favorites.ts/history.ts。
  • 依赖无环:api→{request,crypto,auth,i18n,endpoints};ssr→{request,endpoints,i18n,api(type)};ssg→{request,api(type),endpoints};gallery→{request,auth,favorites};request→{crypto,auth,i18n,config,endpoints}。

验证手段

  • 改完必跑 npm run build(全链路 import + 预渲染)+ npm run check。
  • 预渲染页:dist/client/{en,cn}/<page>/index.html 归一化对比(去 astro-cid-* 与 _astro/<asset> 逐字节比)。
  • prerender=false 页:临时建 prerender 验证页喂假数据,或 dev :4321 时 curl 真实 SSR(curl :8090/api/v1/public/runway-looks 的 data 直接是数组)。HTML 单行用 [regex]::Matches($h,'...').Count 统计关键标记。

未登录预览门禁(详情页)

  • 后端硬截断:/runway-looks/:id、/street-snaps/:id 匿名且图>5 时截断前 5 张 + 回 preview:true+image_total;已登录(Bearer)返回完整。列表接口全量。
  • 前端:SSR 前 5 张 + 剩余空图遮罩(x-show="!isAuthed && preview"+x-cloak,"login to view" + "+N photos");登录后取全量追加 extraImages。getUser() 读 localStorage fa_user。

账号体系

  • 仅预置内部账号(admin/root)。双令牌 JWT(access 7d 无状态 + refresh 30d 仅存 SHA256 落库);登录即吊销该用户全部 refresh。
  • 收藏服务端化 favorites 表(按 user_id 隔离,编码串 r=/s=、i=/j=);浏览历史 histories 表(唯一键 user_id+target_uid,FIFO 上限 2000)。

i18n 字典约定

  • dict: Record<string,{cn:string}>,key 即英文原文(en 返 key),value 只存非默认语言(仅 cn)。translate(key,locale) 对 key toLowerCase() 查(大小写不敏感,t('Home') 命中 home);缺翻译 en 回退 + dev 告警。中文绝不可当 key。

记住语言偏好(2026-09-15 新增)

  • 需求:选过语言后下次进首页直接用上次语言。
  • i18n/index.ts 新增 STORED_LOCALE_KEY='preferred_locale'、storeLocale(locale)、getStoredLocale()。
  • Layout <head> 顶部 is:inline+define:vars 脚本:进首页(path==='/' 或 '/'+defaultLocale)且记住非默认语言时 location.replace('/'+stored)。
  • 语言切换器 x-data="langSwitch()";<a> @click="open=false; pick('<code>')";footer 注册 langSwitch(pick(code){ storeLocale(code) })。
  • 铁律:只在显式点击切换器时写 localStorage,绝不按页面加载 URL 写——否则与首页自动跳转冲突(点了英文又被 URL 的 en 覆盖,首页永远跳不回去)。

目录约定(用户 2026-09-17 确认)

  • src/components/views/ 必须保留,勿并入 src/pages/,也勿把内容提取摊平。 原因:本项目 prefixDefaultLocale: true → src/pages/{en,cn}/ 是两套 locale 页面树,每个页面文件只是薄壳(路由 + export const prerender + 引入共享视图 + 渲染);views/* 是 en/cn 两棵页面树共用的页面体实现(语言靠 Astro.currentLocale 运行时区分)。并进 pages 会迫使 en/cn 各抄一份,制造重复。
  • views/ 命名与 pages 略撞语义,但目录本身职责明确(跨 locale 共享页面体单一事实源),仅属 cosmetic,非必要不改名。
  • 现状 views/ 成员:Account / Home / Login / RunwayLooks / StreetSnaps / RunwayLooksDetail / StreetSnapsDetail(RunwayLooksDetail/StreetSnapsDetail 非独立路由,是被 pages/{en,cn}/{runway-looks,street-snaps}/[id].astro 复用的详情体;2026-09-17 由 ItemPage 按 type 拆成两个,共享脚本抽到 lib/detailFav.ts 的 initDetailFav())。

图片去重(backend_v2,2026-09-17 落地)

  • 三档全上:Tier1 精确(sha1 内容寻址 + content_sha1 部分唯一索引,命中整行跳过不插);Tier2 近重复(phash 存 vector(64) 二进制串,bit_hamming_ops HNSW,<~> <= 10 命中标 IsDuplicate/DupOf,只标不拦);Tier3 语义(image_embeddings 表 embedding vector(512) + vector_cosine_ops HNSW,待 CLIP/DINOv2 接入,当前不写)。
  • 关键文件:internal/pkg/phash/phash.go(dHash 纯标准库,ToVectorBits→vector(64) 串,webp 解码失败→0→NULL)、internal/database/postgres.go(ensureDedupSchema 幂等 DDL + CREATE EXTENSION IF NOT EXISTS vector)、internal/service/ingest_service.go(dedupImage)、internal/repository/ingest_repository.go(ImageExistsBySha1/FindNearDuplicateImage)。
  • phash 走 sql.NullString + type:vector(64) text 扫描,规避 pgx 原生类型坑;空串→NULL 不参与近邻检索。
  • 索引查取代全表扫(用户"图片量会很大"痛点):原 ListImagePHashes 全表扫 O(N) 已移除。
  • 待办:pgvector Docker 实连烟测 GORM 读 vector(64) 进 sql.NullString;汉明阈值 10 可按样本微调。