update backend

This commit is contained in:
toom1996
2026-09-20 01:06:13 +08:00
parent f7ae917603
commit b99dc3dca1
115 changed files with 24732 additions and 10842 deletions

View File

@ -4,7 +4,7 @@
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
//
// 数据库已迁移至 PostgreSQL(见 scripts/pgvector 的本地 Docker 环境)。
// 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的 MySQL 迁移脚本。
// 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的旧迁移脚本。
package database
import (
@ -52,7 +52,7 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
// AutoMigrate 幂等创建 / 更新全部表结构。
//
// 表结构由 GORM 模型定义托管(替代原 scripts/sql、db/migrations 下的手写 MySQL 脚本)。
// 表结构由 GORM 模型定义托管(替代原 scripts/sql、db/migrations 下的手写旧迁移脚本)。
// 仅在缺少列/索引时增量变更,已存在的表不会被重建;对存量大表(如 brand_runway_images)
// 的加列操作可能短暂加锁,属一次性开销。
func AutoMigrate(db *gorm.DB) error {
@ -60,6 +60,92 @@ func AutoMigrate(db *gorm.DB) error {
if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector").Error; err != nil {
return fmt.Errorf("create extension vector: %w", err)
}
// 兼容旧库:phash 早期以 bigint 存储 64-bit dHash 指纹(uint64),现模型改为 vector(64)。
// pgvector 不支持 bigint→vector 直接转换,需逐位拆成 0/1 向量(位 i → 第 i 维,与 ToVectorBits 一致,
// 保证旧数据与新写入的向量在同一语义空间可比)。已为 vector/text 类型的列跳过,幂等可重复执行。
const migratePhashSQL = `
DO $$
DECLARE
t text;
dt text;
BEGIN
FOR t IN
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN ('brand_runway_images','brand_runway_draft_images','street_snap_images','street_snap_draft_images')
LOOP
SELECT data_type INTO dt
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = t AND column_name = 'phash';
IF dt = 'bigint' THEN
-- bigint 即 64-bit dHash 指纹;pgvector 不支持 bigint->vector 直接转换,且 ALTER ... USING 的
-- transform 表达式不允许子查询(0A000)。改用纯表达式:bigint::bit(64) 取 64 位模式(MSB 在左),
-- reverse 成 LSB 在左(与 phash.ToVectorBits 维度顺序一致:第 i 维 = bit i),插逗号后 text::vector(64)。
-- NULL 自然透传。
EXECUTE format($e$
ALTER TABLE %I ALTER COLUMN phash TYPE vector(64)
USING (
('[' ||
rtrim(regexp_replace(reverse(phash::bit(64)::text), '(.)', '\1,', 'g'), ',') ||
']')::vector(64)
)
$e$, t);
RAISE NOTICE 'migrated phash (bigint -> vector) on %', t;
ELSIF dt IN ('character varying','text') THEN
EXECUTE format($e$
ALTER TABLE %I ALTER COLUMN phash TYPE vector(64) USING phash::vector(64)
$e$, t);
RAISE NOTICE 'migrated phash (text -> vector) on %', t;
END IF;
END LOOP;
END $$;`
if err := db.Exec(migratePhashSQL).Error; err != nil {
return fmt.Errorf("migrate phash column: %w", err)
}
// 回填历史 NULL:迁移 012 给 4 张 image 表的 is_duplicate/dup_of 声明了 not null,
// 但存量行可能是 NULL;GORM 直接 SET NOT NULL 会失败(23502)。先按默认值回填再交给 GORM(幂等)。
// 注意:各表 schema 演化路径不同,可能缺其中某列——故按列存在性动态拼 SQL,只回填真实存在的列;
// 缺失的列交由随后的 db.AutoMigrate 新建(带默认值,不会触发 NULL 问题)。
const backfillDedupSQL = `
DO $$
DECLARE
t text;
setc text := '';
wherec text := '';
has_dup boolean;
has_dupof boolean;
BEGIN
FOR t IN
SELECT unnest(ARRAY[
'brand_runway_images','brand_runway_draft_images',
'street_snap_images','street_snap_draft_images'])
LOOP
SELECT
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name=t AND column_name='is_duplicate'),
EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name=t AND column_name='dup_of')
INTO has_dup, has_dupof;
setc := '';
wherec := '';
IF has_dup THEN
setc := setc || 'is_duplicate = COALESCE(is_duplicate, 0)';
wherec := wherec || 'is_duplicate IS NULL';
END IF;
IF has_dupof THEN
IF setc <> '' THEN setc := setc || ', '; wherec := wherec || ' OR '; END IF;
setc := setc || 'dup_of = COALESCE(dup_of, '''')';
wherec := wherec || 'dup_of IS NULL';
END IF;
IF setc <> '' THEN
EXECUTE format('UPDATE %I SET %s WHERE %s', t, setc, wherec);
END IF;
END LOOP;
END $$;`
if err := db.Exec(backfillDedupSQL).Error; err != nil {
return fmt.Errorf("backfill dedup columns: %w", err)
}
return db.AutoMigrate(
&model.Brand{},
&model.User{},
@ -79,37 +165,47 @@ func AutoMigrate(db *gorm.DB) error {
)
}
// ensureDedupSchema 在 GORM AutoMigrate 之外,补充去重所需的索引与语义 embedding 表。
// 全部幂等(IF NOT EXISTS),可随服务启动重复执行。
// EnsureDedupSchema 在 GORM AutoMigrate 之外,补充去重所需的索引与语义 embedding 表。
// 全部幂等(IF NOT EXISTS / DROP IF EXISTS),可随服务启动重复执行。
//
// - 各 image 表的 content_sha1 部分唯一索引(排除迁移前遗留的空串行,避免唯一约束冲突);
// - phash 的 HNSW 索引(bit_hamming_ops,汉明距离近邻检索,替代原先的全表扫描);
// - phash 的 HNSW 索引(vector_l2_ops,L2²==汉明距离,替代原先的全表扫描);
// - 幂等清理 content_sha1 精确去重废弃后遗留的旧索引(uq_*_sha1 及其 HNSW);
// - image_embeddings 表 + cosine HNSW 索引:语义 embedding(CLIP/DINOv2)落库位,
// 待后续接入推理填充,当前不写入。
func ensureDedupSchema(db *gorm.DB) error {
tables := []struct {
name string
idx string
}{
{"brand_runway_images", "uq_br_imgs_sha1"},
{"brand_runway_draft_images", "uq_br_draft_imgs_sha1"},
{"street_snap_images", "uq_ss_imgs_sha1"},
{"street_snap_draft_images", "uq_ss_draft_imgs_sha1"},
// 待后续推理接入填充,当前不写入。
func EnsureDedupSchema(db *gorm.DB) error {
// 统一去重方案:废弃早期独立 image_dhash 表(与 phash 直接挂各 image 表冲突)。
// 该表从未被 Go 写入,启动即幂等清理遗留表,避免与现行方案并存造成混淆。
if err := db.Exec("DROP TABLE IF EXISTS image_dhash CASCADE").Error; err != nil {
return fmt.Errorf("drop legacy image_dhash: %w", err)
}
for _, t := range tables {
// 精确去重:content_sha1 唯一(排除空串遗留行)。
if err := db.Exec(fmt.Sprintf(
"CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (content_sha1) WHERE content_sha1 <> ''",
t.idx, t.name,
)).Error; err != nil {
return fmt.Errorf("create unique index %s: %w", t.idx, err)
// content_sha1 精确去重已废弃:幂等清理其遗留的唯一索引 uq_*_sha1 与旧 HNSW 索引 uq_*_sha1_phash_hnsw。
// 新 HNSW 索引统一改名为 <table>_phash_hnsw。
legacyIdx := []string{
"uq_br_imgs_sha1", "uq_br_draft_imgs_sha1",
"uq_ss_imgs_sha1", "uq_ss_draft_imgs_sha1",
}
for _, idx := range legacyIdx {
for _, suffix := range []string{"", "_phash_hnsw"} {
if err := db.Exec(fmt.Sprintf("DROP INDEX IF EXISTS %s%s", idx, suffix)).Error; err != nil {
return fmt.Errorf("drop legacy index %s%s: %w", idx, suffix, err)
}
}
// 近重复检索:dHash 向量(vector(64) 二进制)汉明 HNSW 索引。
}
tables := []string{
"brand_runway_images",
"brand_runway_draft_images",
"street_snap_images",
"street_snap_draft_images",
}
for _, name := range tables {
// 近重复检索:dHash 向量(vector(64) 的 0/1)汉明 HNSW 索引。
// 用 vector_l2_ops:两 {0,1}^64 向量的 L2² 恰等于汉明距离,故 L2 距离即汉明语义;
// 且 vector_l2_ops 在所有 pgvector 版本均可建 HNSW(bit_hamming_ops 在旧版不可用于 HNSW)。
if err := db.Exec(fmt.Sprintf(
"CREATE INDEX IF NOT EXISTS %s_phash_hnsw ON %s USING hnsw (phash bit_hamming_ops)",
t.idx, t.name,
"CREATE INDEX IF NOT EXISTS %s_phash_hnsw ON %s USING hnsw (phash vector_l2_ops)",
name, name,
)).Error; err != nil {
return fmt.Errorf("create hnsw index %s: %w", t.idx, err)
return fmt.Errorf("create hnsw index %s: %w", name, err)
}
}
// 语义 embedding 表(CLIP/DINOv2 等 512 维向量):待后续推理接入填充;当前不写入。