// Package database 负责数据库连接的建立与生命周期管理。 // // 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository, // 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。 // // 数据库已迁移至 PostgreSQL(见 scripts/pgvector 的本地 Docker 环境)。 // 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的旧迁移脚本。 package database import ( "fmt" "time" "fashionapi/internal/config" "fashionapi/internal/model" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) // New 建立 PostgreSQL 连接并完成连接池设置。 func New(cfg config.DatabaseConfig) (*gorm.DB, error) { db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{ Logger: logger.Default.LogMode(parseLogLevel(cfg.LogLevel)), // 关闭默认事务可显著提升只读接口的吞吐;写操作按需显式开启事务 SkipDefaultTransaction: true, }) if err != nil { return nil, fmt.Errorf("连接数据库失败: %w", err) } sqlDB, err := db.DB() if err != nil { return nil, fmt.Errorf("获取底层连接池失败: %w", err) } sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) // 空闲回收:定期关闭空闲连接,避免持有被服务端回收的死连接。 // 仅当配置 > 0 时启用(0 = 沿用旧行为,不回收)。 if cfg.ConnMaxIdleTime > 0 { sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second) } // 立刻探活,避免"启动成功但首个请求才报错" if err := sqlDB.Ping(); err != nil { return nil, fmt.Errorf("数据库 Ping 失败: %w", err) } return db, nil } // AutoMigrate 幂等创建 / 更新全部表结构。 // // 表结构由 GORM 模型定义托管(替代原 scripts/sql、db/migrations 下的手写旧迁移脚本)。 // 仅在缺少列/索引时增量变更,已存在的表不会被重建;对存量大表(如 brand_runway_images) // 的加列操作可能短暂加锁,属一次性开销。 func AutoMigrate(db *gorm.DB) error { // 确保 pgvector 扩展存在(dHash 近重复 HNSW 索引与语义 embedding 向量列均依赖它)。 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{}, &model.RefreshToken{}, &model.IngestNonce{}, &model.IngestJob{}, &model.StreetSnap{}, &model.StreetSnapImage{}, &model.StreetSnapDraft{}, &model.StreetSnapDraftImage{}, &model.Favorite{}, &model.History{}, &model.BrandRunway{}, &model.BrandRunwayImage{}, &model.BrandRunwayDraft{}, &model.BrandRunwayDraftImage{}, ) } // EnsureDedupSchema 在 GORM AutoMigrate 之外,补充去重所需的索引与语义 embedding 表。 // 全部幂等(IF NOT EXISTS / DROP IF EXISTS),可随服务启动重复执行。 // // - phash 的 HNSW 索引(vector_l2_ops,L2²==汉明距离,替代原先的全表扫描); // - 幂等清理 content_sha1 精确去重废弃后遗留的旧索引(uq_*_sha1 及其 HNSW); // - image_embeddings 表 + cosine HNSW 索引:语义 embedding(CLIP/DINOv2)落库位, // 待后续推理接入填充,当前不写入。 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) } // content_sha1 精确去重已废弃:幂等清理其遗留的唯一索引 uq_*_sha1 与旧 HNSW 索引 uq_*_sha1_phash_hnsw。 // 新 HNSW 索引统一改名为