Files
frontend_v2/.workbuddy/memory/2026-09-17.md
toom1996 a8fcafac10 update
2026-09-22 21:39:45 +08:00

14 KiB
Raw Blame History

2026-09-17 工作日志

修复详情页空壳 + 评估 components/views 目录 ✅(15:12–15:30)

用户问 components/views/ 是否还有必要(pages 不就是 view 吗),并指出 detail 组件是空的,疑似之前拆文件拆错。

根因(已查清)

  • 详情实现原本在 src/components/views/ItemPage.astro(一个组件用 type 参数通吃 runway/street,含收藏/历史脚本)。
  • commit 70a3304(拆分)把 4 个 [id].astro 的 import 从 ItemPage(带 type)改成 RunwayLooksDetail/StreetSnapsDetail,但这两个新文件建成空的(0 字节),且把 ItemPage.astro 删除了。
  • 后果:走秀/街拍详情页(prerender=false 的动态路由)此后一直渲染空壳,无报错不易察觉。
  • 验证手段:git log --all -- '*ItemPage*' 定位 → git show ad028c9:...ItemPage.astro 找回 158 行完整实现;70a3304 该路径已不存在(在另一分支)。

修复(恢复原始可用结构)

  • 从 ad028c9 恢复 src/components/views/ItemPage.astro(内容逐字还原,含 Props 接口、SSR 取数、TDK、收藏/历史 <script>)。
  • 4 个 [id].astro(en/cn × runway-looks/street-snaps)改回 import ItemPage 并显式传 type="runway"|"street"。
  • 删除两个空文件 RunwayLooksDetail.astro、StreetSnapsDetail.astro。
  • 验证:npm run build Complete;npx astro check 0/0/0;残留搜索 RunwayLooksDetail|StreetSnapsDetail = 0 命中;ItemPage 仅出现在 4 个页面 + 自身。

关于 components/views 是否有必要(结论)

有必要保留,不要并入 pages。

  • 本项目 prefixDefaultLocale: true → src/pages/{en,cn}/... 是两套 locale 页面树,每个页面文件都是薄壳(仅 export const prerender + import 共享视图 + 渲染)。
  • components/views/* 是 en/cn 两棵页面树共用的页面体实现(语言靠 Astro.currentLocale 运行时区分)。若把视图内容并进 pages,就得在 en/ 和 cn/ 各抄一份;保留 views/ 才是单一事实源。
  • views/ 命名与 pages 略重复是约定问题,但目录本身承担"跨 locale 共享页面体"职责,非冗余。可改名为 components/pages/ 或直接摊平到 components/,但非必要,未做(避免无谓改动)。
  • 现状 views/ 实际成员:Account / Home / Login / RunwayLooks / StreetSnaps / ItemPage(后两者非页面级路由,而是被 [id].astro 复用的详情体)。

待办/提醒

  • 详情页为 prerender=false 动态 SSR,依赖后端 :8090 接口;本次仅验证编译/类型通过,真实渲染需起后端联调(用户本地点一下 /runway-looks/ 最稳)。

ItemPage 按 type 拆成两个 Detail ✅(16:10–16:18)

用户要求把 ItemPage.astro(用 type 参数通吃 runway/street)拆成两个独立 Detail。

  • 关键事实:runway/street 的差异只在「取数函数(getSsrArticle/getSsrStreetSnap) / 卡片组件(RunwayLookCard/StreetSnapCard) / TDK 文案 / meta.type 值」;而收藏/历史 <script> 类型无关(全靠 data-* 透传)。
  • 新建 RunwayLooksDetail.astro、StreetSnapsDetail.astro(各只收 id prop,type 在组件内固化),各管自己的 SSR 取数 + TDK + 卡片 + fav 按钮标记。
  • 把那段 ~50 行收藏/历史脚本抽成 src/lib/detailFav.ts 的 initDetailFav()(读 #fav-fab 的 data-*);两个 Detail 各用 <script> import { initDetailFav } 调用,避免重复。该脚本是打包模块(有 import,无 define:vars),合规。
  • 4 个 [id].astro(en/cn × runway-looks/street-snaps)改指各自 Detail(type 不再透传);删 ItemPage.astro。旧 /item/[id] 仅做 301 重定向,不依赖 ItemPage,不受影响。
  • 注意:这 4 个页面文件在 15:14→16:10 间被改过(注释/顺序变了),首次批量 replace 因 old_str 不匹配失败;重新读取真实内容后才改成功。教训:改他人可能动过的文件前先重新读取。
  • 验证:ItemPage 引用搜索 0 命中;npm run build Complete;npx astro check 0/0/0。

后端图片去重现状调研(用户问爬虫判重怎么设计)✅(17:10–17:20)

用户想给爬虫加"爬到图先判是否库里已存过"。调研 backend_v2 发现这套已实现,无需从零设计:

  • internal/pkg/phash/phash.go:纯标准库 dhash(64-bit 指纹,非向量),DefaultThreshold=10(汉明距离≤10 判近似重复),解码失败返回 0。包名虽叫 phash 实为 dhash(注释已说明)。
  • 入库管线 ingest_service.go:downloadOne 每张图下载后 phash.Of(data);processRunway/processStreet 调 tagDuplicates→matchDuplicates→repo.ListImagePHashes 比对并标 IsDuplicate=1 + DupOf。
  • 模型 runway_image/street_snap/*_draft 均有 Phash uint64 / IsDuplicate uint8 / DupOf string 三列。
  • 关键行为:当前是「只标记、不拦截止」(留痕交人工裁决),且比对库仅已晋升正式表(runway_images+street_snap_images),跳过 is_deleted 与 phash=0,不含草稿表/本批内部。
  • 存储用 sha1 内容寻址 key → 同字节幂等不堆孤儿文件,但 DB 行仍会多一条。
  • 效率注:每次入库任务全量拉 phash 表,千级 OK;到十万级需改分批/缓存。
  • 待用户拍板:他想要的是「标出来给人看」(现在就能用)还是「命中就拦截不存」(需改 ingest_service 两处 + 引用计数)。阈值 10 可后续按真实样本调。

移除后端 phash 感知哈希去重 ✅(17:20–17:55)

用户明确:图片量会很大,不想要 phash 了(当前 ListImagePHashes 每任务全表扫描,O(N) 不具扩展性)。

  • 彻底移除感知哈希层:删 internal/pkg/phash 包;ingest_service.go 去掉 downloadOne 的 phash.Of 与 4 返回值(uint64)、fetchImages 的 []uint64 返回与 phashes、两个 fetchLookImages/fetchImages 调用处解包、processRunway/processStreet 的 phashList/phs 与 tagDuplicates 调用,并删除 tagDuplicates/matchDuplicates 两个函数;ingest_repository.go 删 ListImagePHashes 方法、ImagePHash 结构体及其接口声明;4 个 model 文件删 Phash/IsDuplicate/DupOf 三列;review_repository.go 晋升时不再拷贝这三列。
  • 实现手法:用一段 Python 脚本(正则+精确串替换,\t 转义规避 tab 对齐)批量改 ingest_service.go,go build ./... 通过后删 phash 包与临时脚本。
  • 刻意保留:downloadOne 的 sha1 内容寻址存储 key(与 phash 无关,能天然防重复文件/孤儿文件),未动。
  • 验证:go build ./... 两次均 EXIT:0;残留扫描 phash/Phash/IsDuplicate/DupOf/tagDuplicates/matchDuplicates/ListImagePHashes/phashList/phs[ = 0。
  • 提醒:库表 phash/is_duplicate/dup_of 列 GORM 不会自动 DROP(仅 ADD),已升为孤儿列(可后续加迁移清掉);若将来要多来源"近似重复"仍要,应改 LSH/ANN 索引而非全表扫描。前端从不消费这三个字段。

去重替代方案方向(用户移除 phash 后问"好的方案有哪些",18:43)💡 待用户拍板

核心认知:删的是"全表扫描的 phash 实现"≠去重需求没了。给出分层建议:

  • Tier1 精确去重(推荐先做):复用现有 sha1 存储 key,新增 content_sha1 列 + UNIQUE 索引,入库前 O(1) 查重/upsert;挡同字节/同URL重爬;代价近零、可无限扩展。
  • Tier2 近似去重:复用 dhash(纯标准库),但加 phash_bucket(高12~16bit)索引,仅桶内算汉明距离,避免全表扫;解决用户"量大跑不动"死穴。
  • Tier3 embedding+ANN 向量库(overkill):CLIP/CNN + Qdrant/Milvus/pgvector,抓语义级重复;对 runway/street snap 爬取偏重,暂不建议。
  • 待用户确认"重复"指:字节级(1)/近重复尺寸水印(2)/语义级(3);再出具体改法(含孤儿列复用 phash 名但重建结构)。

用户提议 pgvector+bit(64)+HNSW 方案(19:33)💡 已评估为 overkill

用户建议:装 pgvector,dHash 存 bit(64) 建 HNSW 索引做近似去重。核实 pgvector 确实支持 bit(N)+Hamming(bit_hamming_ops,算子<~>,hamming_distance<=10 后过滤)。

  • 评估:对 64 位 dHash 是杀鸡用牛刀——① HNSW 为高维(float/binary 数百~数千维)设计,64 维优势发挥不出且为近似索引(召回不如精确桶内比对);② 后端是 MySQL,为 64 位去重引 PG+pgvector 性价比低。
  • 更优替代:MySQL 上 BIGINT phash + 抽高16位 phash_bucket 建 B-tree 索引,桶内算汉明 → O(1) 精确、零新基建(即 Tier2 桶化版,正解原"全表扫跑不动"痛点)。
  • pgvector 正确用武之地:语义档 CLIP vector(1536) + cosine HNSW(高维优势+二进制量化省存储),那才对得起基建。
  • 仍待用户确认重复类型(字节级/同图变形/不同角度同造型)再定方案与是否引 PG。

重要更正:backend_v2 实际已是 PostgreSQL+pgvector(19:36)⚠️

核查 go.mod(gorm.io/driver/postgres + jackc/pgx,无 mysql 驱动)、internal/database/postgres.go(注释"数据库已迁移至 PostgreSQL" + gorm.Open(postgres.Open))、config.go DSN() 返回 postgres://、scripts/pgvector(PG16+pgvector Docker,initdb 自动建 vector 扩展,setup_pg_docker.sh 含 embedding vector(512)+hnsw 范例)。

  • 结论:用户说"就用pg把,早打算迁移mysql→pg"实为既成事实——后端早就是 PG 了,无需迁移。MEMORY.md 的 "GORM(MySQL)" 已更正为 PostgreSQL+pgvector。
  • 因此去重可直接在 pgvector 上落地:content_sha1 唯一索引(精确) + phash bit(64)+bit_hamming_ops HNSW(近重复) + (可选)embedding vector(512)+cosine HNSW(语义)。
  • 注意:本会话早些时候删 phash 包/列是真实发生的;现需重建 phash.go + 给 image 模型重回 phash/content_sha1 列,并把去重逻辑接成"索引查"而非之前的"全表扫"。
  • 结论:上轮我给的「SQLite+LSH 分桶」方案在其已有 MySQL+全表扫 面前偏重,量到十万级再上 LSH 即可。

三档去重写入 backend_v2(用户拍板"都加把")✅(19:40–20:00)

用户确认"都加把"——精确 + 近重复 + 语义三档全部落地。已逐个核对磁盘代码,确实落地:

1) 重建 internal/pkg/phash/phash.go(dHash,纯标准库)

  • Of(data) uint64:解码失败(webp 等)返回 0;ToVectorBits(h) string 转 vector(64) 二进制串 [0,1,...](h=0 返回空串 → NULL);Hamming(a,b) int;Size=64、DefaultThreshold=10。
  • 注释明确:指纹只写库,检索交给 pgvector HNSW,不再全表扫。

2) 4 张 image 模型加列

BrandRunwayImage/BrandRunwayDraftImage/StreetSnapImage/StreetSnapDraftImage 均加:

  • ContentSha1 string(size:64 + 部分唯一索引 uk_*_content_sha1,unique,where:content_sha1 <> '',排除遗留空串行);
  • Phash sql.NullString(type:vector(64),text 扫描,规避 pgx 原生类型坑);
  • IsDuplicate uint8 + DupOf string(标记用,不拦截)。

3) internal/database/postgres.go 加 ensureDedupSchema(db)

  • AutoMigrate 前先 CREATE EXTENSION IF NOT EXISTS vector;
  • 幂等:content_sha1 部分唯一索引 + phash vector(64) 列 + CREATE INDEX ... USING hnsw(phash bit_hamming_ops);
  • 新建 image_embeddings 表(image_id int/kind smallint/embedding vector(512)/created_at int)+ embedding vector_cosine_ops HNSW(语义档位,当前不写,待 CLIP/DINOv2 接入)。
  • 注意:AutoMigrate 只 ADD 不 DROP → 之前移除阶段遗留的孤儿列已无关(新结构用新列名)。

4) 入库管线 ingest_service.go + ingest_repository.go

  • downloadOne 复算 phash.Of 并回填 ContentSha1/Phash;
  • 新增 dedupImage:① 精确 → repo.ImageExistsBySha1(跨 4 表 content_sha1=? AND is_deleted=0)命中则整行跳过不插;② 近重复 → repo.FindNearDuplicateImage,用 phash <~> ?::vector <= threshold(10) 取最近一条,命中设 IsDuplicate=1+DupOf(仅标记不拦);③ 本批内 seen 去重;
  • draft 晋升(SaveRunwayFromDraft/SaveStreetSnapFromDraft)复制去重字段。

验证

go build ./... EXIT 0;go vet ./internal/service/ 干净;单测 TestFetchImagesCleansUpOnFailure 绿(ok fashionapi/internal/service)。

待办(未做)

  • 运行期烟测:起 pgvector Docker 实连,验证 GORM 把 vector(64) 列读进 sql.NullString(pgx text 格式扫描)无报错。尚未跑。
  • 汉明阈值 10 可按真实样本微调。

修复 cmd/dbtool dump 的 42601 + Scan 报错 ✅(22:25–22:50)

go run main.go dump 导出 brand 表时连报两错,已逐个修掉:

错 1:SQLSTATE 42601 at or near ")")

  • 根因:colQuery 里 c.column_name = $1 这行第 5 列 (c.column_default LIKE 'nextval(%')) 多了一个右括号(应为 nextval(%');多余的 ) 让整段变 ... = $1)) 语法错。
  • 修复:改为 (c.column_default LIKE 'nextval(%')(单右括号,与 pkQuery 风格一致;该 ) 是外层比较表达式收尾)。
  • 验证手段:另写 _diag2 临时诊断确认 nextval(%')(单)可查、nextval(%'))(双)必 42601,定位到就是这行。

错 2:Scan error on column index 4 ... couldn't convert <nil> into type bool

  • 根因:同上第 5 列当 column_default 为 NULL 时 NULL LIKE 'nextval(%' 返回 NULL,rows.Scan(&isIdent bool) 无法接收 NULL → 报错(brand 表无 serial 列,default 全 NULL 触发)。
  • 修复:改为 COALESCE(c.column_default LIKE 'nextval(%', false),把 NULL 收为 false。

验证

go run main.go dump → ✓ 已导出 15 张表 -> db_dump.json,无报错。已删除 _diag2 临时诊断目录。