14 KiB
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 buildComplete;npx astro check0/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(各只收idprop,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 buildComplete;npx astro check0/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_opsHNSW(语义档位,当前不写,待 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 临时诊断目录。