Files
backend_v2/internal/database/postgres.go
2026-09-20 01:06:13 +08:00

254 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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.

// 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 索引统一改名为 <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)
}
}
}
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 vector_l2_ops)",
name, name,
)).Error; err != nil {
return fmt.Errorf("create hnsw index %s: %w", name, err)
}
}
// 语义 embedding 表(CLIP/DINOv2 等 512 维向量):待后续推理接入填充;当前不写入。
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS image_embeddings (
id BIGSERIAL PRIMARY KEY,
image_id INTEGER NOT NULL,
kind SMALLINT NOT NULL,
embedding vector(512),
created_at INTEGER NOT NULL DEFAULT 0
)`).Error; err != nil {
return fmt.Errorf("create image_embeddings: %w", err)
}
if err := db.Exec(
"CREATE INDEX IF NOT EXISTS image_embeddings_vec_idx ON image_embeddings USING hnsw (embedding vector_cosine_ops)",
).Error; err != nil {
return fmt.Errorf("create embeddings hnsw: %w", err)
}
return nil
}
// Close 关闭数据库连接池。
func Close(db *gorm.DB) error {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func parseLogLevel(s string) logger.LogLevel {
switch s {
case "silent":
return logger.Silent
case "error":
return logger.Error
case "info":
return logger.Info
default:
return logger.Warn
}
}