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

121 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<id> 最稳)。
## 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` 临时诊断目录。