121 lines
14 KiB
Markdown
121 lines
14 KiB
Markdown
# 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` 临时诊断目录。
|