update
This commit is contained in:
@ -59,32 +59,27 @@ type ServerConfig struct {
|
||||
HashIDSecret string `yaml:"hashid_secret"`
|
||||
}
|
||||
|
||||
// DatabaseConfig MySQL 连接与连接池配置。
|
||||
// DatabaseConfig PostgreSQL 连接与连接池配置。
|
||||
type DatabaseConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port string `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Name string `yaml:"name"`
|
||||
Charset string `yaml:"charset"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||
MaxOpenConns int `yaml:"max_open_conns"`
|
||||
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
||||
// ConnMaxIdleTime 空闲连接回收时间(秒)。必须 < MySQL wait_timeout,否则 MySQL 回收空闲
|
||||
// 连接后,连接池仍持有死连接,下一次查询会报 "invalid connection" / "bad connection"。
|
||||
// ConnMaxIdleTime 空闲连接回收时间(秒)。避免长时间持有被服务端回收的死连接,
|
||||
// 下一次查询报 "invalid connection" / "bad connection"。
|
||||
ConnMaxIdleTime int `yaml:"conn_max_idle_time"`
|
||||
}
|
||||
|
||||
// DSN 组装 MySQL 连接串。
|
||||
// DSN 组装 PostgreSQL 连接串(pgx 驱动,本地开发禁用 SSL)。
|
||||
func (d DatabaseConfig) DSN() string {
|
||||
charset := d.Charset
|
||||
if charset == "" {
|
||||
charset = "utf8mb4"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local&timeout=10s",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name, charset,
|
||||
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
d.User, d.Password, d.Host, d.Port, d.Name,
|
||||
)
|
||||
}
|
||||
|
||||
@ -186,11 +181,10 @@ func defaultConfig() *Config {
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: "3306",
|
||||
User: "root",
|
||||
Password: "root",
|
||||
Name: "db_dev",
|
||||
Charset: "utf8mb4",
|
||||
Port: "5432",
|
||||
User: "fashion",
|
||||
Password: "fashion_dev_2026",
|
||||
Name: "fashion",
|
||||
LogLevel: "warn",
|
||||
MaxIdleConns: 10,
|
||||
MaxOpenConns: 100,
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
// Package database 负责数据库连接的建立与生命周期管理。
|
||||
//
|
||||
// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository,
|
||||
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/config"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// New 建立 MySQL 连接并完成连接池设置。
|
||||
func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(mysql.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)
|
||||
// 空闲回收:定期关闭空闲连接,避免持有被 MySQL wait_timeout 回收的死连接。
|
||||
// 仅当配置 > 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
157
internal/database/postgres.go
Normal file
157
internal/database/postgres.go
Normal file
@ -0,0 +1,157 @@
|
||||
// Package database 负责数据库连接的建立与生命周期管理。
|
||||
//
|
||||
// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository,
|
||||
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
|
||||
//
|
||||
// 数据库已迁移至 PostgreSQL(见 scripts/pgvector 的本地 Docker 环境)。
|
||||
// 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的 MySQL 迁移脚本。
|
||||
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 下的手写 MySQL 脚本)。
|
||||
// 仅在缺少列/索引时增量变更,已存在的表不会被重建;对存量大表(如 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)
|
||||
}
|
||||
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),可随服务启动重复执行。
|
||||
//
|
||||
// - 各 image 表的 content_sha1 部分唯一索引(排除迁移前遗留的空串行,避免唯一约束冲突);
|
||||
// - phash 的 HNSW 索引(bit_hamming_ops,汉明距离近邻检索,替代原先的全表扫描);
|
||||
// - 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"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 近重复检索:dHash 向量(vector(64) 二进制)汉明 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,
|
||||
)).Error; err != nil {
|
||||
return fmt.Errorf("create hnsw index %s: %w", t.idx, 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
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。
|
||||
//
|
||||
// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending;
|
||||
@ -42,9 +44,12 @@ type BrandRunwayDraftImage struct {
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
|
||||
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
}
|
||||
|
||||
// TableName 指定草稿图片表名。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// BrandRunwayImage 走秀图片。
|
||||
type BrandRunwayImage struct {
|
||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||
@ -13,9 +15,15 @@ type BrandRunwayImage struct {
|
||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
|
||||
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
|
||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
|
||||
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算(存量)
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
|
||||
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
|
||||
|
||||
// 去重字段(精确 + 近重复 + 语义 embedding 留痕):
|
||||
// content_sha1 图片内容 sha1,与存储 key 同源,空串为迁移前遗留行;
|
||||
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
|
||||
// is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
}
|
||||
|
||||
// TableName 指定表名。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// StreetSnap 街拍主表(对应 db_dev.street_snap)。
|
||||
//
|
||||
// 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。
|
||||
@ -31,9 +33,12 @@ type StreetSnapImage struct {
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算(存量)
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
|
||||
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
}
|
||||
|
||||
// TableName 指定图片明细表名。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package model
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// StreetSnapDraft 街拍草稿(爬虫入库待审核)。
|
||||
//
|
||||
// 与 brand_runway_draft 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
|
||||
@ -36,9 +38,12 @@ type StreetSnapDraftImage struct {
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
|
||||
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
|
||||
|
||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
||||
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
|
||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
||||
}
|
||||
|
||||
// TableName 指定草稿图片表名。
|
||||
|
||||
126
internal/pkg/phash/phash.go
Normal file
126
internal/pkg/phash/phash.go
Normal file
@ -0,0 +1,126 @@
|
||||
// Package phash 计算图片的感知哈希(dHash,Difference Hash)。
|
||||
//
|
||||
// dHash 是 64-bit 轻量指纹,用于判断「同一张图被转码 / 加水印 / 改尺寸」这类近重复。
|
||||
// 去重检索不在这里做——交给 PostgreSQL + pgvector 的 HNSW 索引(见 database 包的
|
||||
// ensureDedupSchema),本包只负责产出指纹,复杂度极低、零 CGO、零 ML 依赖。
|
||||
//
|
||||
// 与之前被移除的全表扫描实现不同:现在指纹只写库、检索交给索引,O(1) 桶内比对即可,
|
||||
// 图片量级再大也扛得住。
|
||||
package phash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Size 是哈希位数(9x8 灰度 → 8 行 × 8 差 = 64 bit)。
|
||||
const Size = 64
|
||||
|
||||
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
|
||||
const DefaultThreshold = 10
|
||||
|
||||
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
|
||||
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。
|
||||
func Of(data []byte) uint64 {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return dhash(img)
|
||||
}
|
||||
|
||||
const (
|
||||
dw = 9 // 灰度矩阵宽度(列数)
|
||||
dh = 8 // 灰度矩阵高度(行数)
|
||||
)
|
||||
|
||||
// dhash 把图像缩到 9x8 灰度,逐行比较相邻像素亮度,得到 64-bit 指纹。
|
||||
func dhash(img image.Image) uint64 {
|
||||
b := img.Bounds()
|
||||
scaleX := float64(b.Dx()) / float64(dw)
|
||||
scaleY := float64(b.Dy()) / float64(dh)
|
||||
|
||||
gray := make([][]float64, dh)
|
||||
for y := 0; y < dh; y++ {
|
||||
gray[y] = make([]float64, dw)
|
||||
y0 := int(float64(y) * scaleY)
|
||||
y1 := int(math.Ceil(float64(y+1) * scaleY))
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
for x := 0; x < dw; x++ {
|
||||
x0 := int(float64(x) * scaleX)
|
||||
x1 := int(math.Ceil(float64(x+1) * scaleX))
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
var sum float64
|
||||
var n int
|
||||
for sy := y0; sy < y1 && sy < b.Dy(); sy++ {
|
||||
for sx := x0; sx < x1 && sx < b.Dx(); sx++ {
|
||||
r, g, bl, _ := img.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
|
||||
// 感知亮度(ITU-R BT.601)
|
||||
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
||||
sum += lum
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
gray[y][x] = sum / float64(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hash uint64
|
||||
i := 0
|
||||
for y := 0; y < dh; y++ {
|
||||
for x := 0; x < dw-1; x++ {
|
||||
if gray[y][x] > gray[y][x+1] {
|
||||
hash |= 1 << uint(i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
// ToVectorBits 把 64-bit 指纹转成 pgvector 的二进制向量字符串 "[0,1,...,1]"(长度 64),
|
||||
// 用于写入 vector(64) 列,并以 bit_hamming_ops 建 HNSW 索引做汉明近邻检索。
|
||||
// h 为 0(解码失败)时返回空串,调用方据此存 NULL、不参与检索。
|
||||
func ToVectorBits(h uint64) string {
|
||||
if h == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(Size + 1)
|
||||
sb.WriteByte('[')
|
||||
for i := 0; i < Size; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
if (h>>uint(i))&1 == 1 {
|
||||
sb.WriteByte('1')
|
||||
} else {
|
||||
sb.WriteByte('0')
|
||||
}
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Hamming 返回两个指纹的汉明距离(不同 bit 数)。仅在 Go 侧需要手工比对时使用;
|
||||
// 检索场景直接交给 pgvector 的 <~> 算子。
|
||||
func Hamming(a, b uint64) int {
|
||||
c := 0
|
||||
x := a ^ b
|
||||
for x != 0 {
|
||||
x &= x - 1
|
||||
c++
|
||||
}
|
||||
return c
|
||||
}
|
||||
@ -92,7 +92,7 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
||||
case q.Letter >= "A" && q.Letter <= "Z" && len(q.Letter) == 1:
|
||||
db = db.Where("UPPER(LEFT(name_en,1)) = ?", q.Letter)
|
||||
default:
|
||||
db = db.Where("name_en REGEXP ?", latinPrefixRegexp)
|
||||
db = db.Where("name_en ~ ?", latinPrefixRegexp)
|
||||
}
|
||||
|
||||
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||
|
||||
@ -44,6 +44,10 @@ type IngestRepository interface {
|
||||
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
|
||||
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
|
||||
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
|
||||
// ImageExistsBySha1 在给定图片表中是否已存在相同内容哈希(精确重复);用于入库前跳过该行。
|
||||
ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error)
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
|
||||
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
|
||||
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||
@ -56,16 +60,6 @@ type IngestRepository interface {
|
||||
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
|
||||
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
|
||||
ScheduleRetry(ctx context.Context, id uint32, errMsg string) error
|
||||
// ListImagePHashes 返回全部已晋升图片(runway + street)的 (id, phash, kind),
|
||||
// 跳过 is_deleted 与 phash=0/NULL(存量未计算)。供入库时与新增图做全局汉明比对(近似去重)。
|
||||
ListImagePHashes(ctx context.Context) ([]ImagePHash, error)
|
||||
}
|
||||
|
||||
// ImagePHash 已晋升图片的感知哈希摘要,供入库时全局近似去重比对。
|
||||
type ImagePHash struct {
|
||||
ID uint32 // 图片数字主键
|
||||
Phash uint64 // 感知哈希(SQL 已过滤 0/NULL)
|
||||
Kind string // "runway" | "street"(决定 dup_of 的 hashid 类型)
|
||||
}
|
||||
|
||||
type ingestRepository struct {
|
||||
@ -376,38 +370,45 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ListImagePHashes 返回全部已晋升图片(runway + street)的感知哈希摘要,供入库时全局近似去重。
|
||||
// 跳过 is_deleted 与 phash=0/NULL(存量未计算)。结果合并 runway + street 两类,
|
||||
// 用 Kind 标注类型,调用方据此把 dup_of 编码成对应 hashid 类型。
|
||||
//
|
||||
// 注:每次入库任务都会全量拉取一次(图片量当前为千级,可接受);若后续图片量到十万级,
|
||||
// 可改为按 runway_id/snap_id 分批或加内存缓存 + 定时刷新,避免每 job 一次全表扫描。
|
||||
func (r *ingestRepository) ListImagePHashes(ctx context.Context) ([]ImagePHash, error) {
|
||||
type phRow struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Phash uint64 `gorm:"column:phash"`
|
||||
}
|
||||
out := make([]ImagePHash, 0, 64)
|
||||
const whereActive = "is_deleted = 0 AND phash IS NOT NULL AND phash <> 0"
|
||||
|
||||
var rw []phRow
|
||||
if err := r.db.WithContext(ctx).Model(&model.BrandRunwayImage{}).
|
||||
Select("id, phash").Where(whereActive).Scan(&rw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range rw {
|
||||
out = append(out, ImagePHash{ID: x.ID, Phash: x.Phash, Kind: "runway"})
|
||||
}
|
||||
|
||||
var sn []phRow
|
||||
if err := r.db.WithContext(ctx).Model(&model.StreetSnapImage{}).
|
||||
Select("id, phash").Where(whereActive).Scan(&sn).Error; err != nil {
|
||||
return nil, err
|
||||
// ImageExistsBySha1 在给定图片表中是否已存在相同 content_sha1(精确重复)。
|
||||
func (r *ingestRepository) ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error) {
|
||||
for _, t := range tables {
|
||||
var c int64
|
||||
if err := r.db.WithContext(ctx).Table(t).
|
||||
Where("content_sha1 = ? AND is_deleted = 0", sha1).Count(&c).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if c > 0 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
for _, x := range sn {
|
||||
out = append(out, ImagePHash{ID: x.ID, Phash: x.Phash, Kind: "street"})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离(pgvector <~> 算子)检索近重复,
|
||||
// 取距离 ≤ threshold 的最近一条。phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
||||
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||
for _, t := range tables {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Dist int `gorm:"column:dist"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).Table(t).
|
||||
Select("id, (phash <~> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <~> ?::vector) <= ?", phashBits, threshold).
|
||||
Order("dist ASC").
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if row.ID != 0 {
|
||||
return row.ID, true, nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
|
||||
|
||||
@ -244,20 +244,21 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
|
||||
if len(imgs) > 0 {
|
||||
rows := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||
for i, im := range imgs {
|
||||
rows = append(rows, model.BrandRunwayImage{
|
||||
RunwayID: runwayID,
|
||||
BrandID: draft.BrandID,
|
||||
Image: im.Image,
|
||||
Name: im.Name,
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: im.LookIndex,
|
||||
IsDetail: im.IsDetail,
|
||||
Phash: im.Phash,
|
||||
IsDuplicate: im.IsDuplicate,
|
||||
DupOf: im.DupOf,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
rows = append(rows, model.BrandRunwayImage{
|
||||
RunwayID: runwayID,
|
||||
BrandID: draft.BrandID,
|
||||
Image: im.Image,
|
||||
Name: im.Name,
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: im.LookIndex,
|
||||
IsDetail: im.IsDetail,
|
||||
ContentSha1: im.ContentSha1,
|
||||
Phash: im.Phash,
|
||||
IsDuplicate: im.IsDuplicate,
|
||||
DupOf: im.DupOf,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
if cErr := tx.Create(&rows).Error; cErr != nil {
|
||||
return cErr
|
||||
@ -439,17 +440,18 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
|
||||
if len(imgs) > 0 {
|
||||
rows := make([]model.StreetSnapImage, 0, len(imgs))
|
||||
for i, im := range imgs {
|
||||
rows = append(rows, model.StreetSnapImage{
|
||||
SnapID: snapID,
|
||||
Image: im.Image,
|
||||
Name: im.Name,
|
||||
SortOrder: uint32(i + 1),
|
||||
Phash: im.Phash,
|
||||
IsDuplicate: im.IsDuplicate,
|
||||
DupOf: im.DupOf,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
rows = append(rows, model.StreetSnapImage{
|
||||
SnapID: snapID,
|
||||
Image: im.Image,
|
||||
Name: im.Name,
|
||||
SortOrder: uint32(i + 1),
|
||||
ContentSha1: im.ContentSha1,
|
||||
Phash: im.Phash,
|
||||
IsDuplicate: im.IsDuplicate,
|
||||
DupOf: im.DupOf,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
if cErr := tx.Create(&rows).Error; cErr != nil {
|
||||
return cErr
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
"fashionapi/internal/pkg/storage"
|
||||
)
|
||||
|
||||
@ -25,12 +26,12 @@ func TestFetchImagesCleansUpOnFailure(t *testing.T) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("fakeimagebytes"))
|
||||
_, _ = w.Write([]byte("fakeimagebytes" + r.URL.Path))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
|
||||
_, out, _, keys, failed := s.fetchImages(context.Background(), urls, "runway")
|
||||
_, out, keys, failed := s.fetchImages(context.Background(), urls, "runway", dto.IngestKindRunway)
|
||||
if !failed {
|
||||
t.Fatalf("expected failed=true when one image errors")
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/dto"
|
||||
@ -235,36 +237,31 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
|
||||
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||
// 同时算出每张图的 pHash(downloadOne 内基于原始字节),供后续全局近似去重标记。
|
||||
var cover string
|
||||
var cover string
|
||||
var draftImages []model.BrandRunwayDraftImage
|
||||
var keys []string
|
||||
var imgFailed bool
|
||||
var imageCount uint16
|
||||
var phashList []uint64
|
||||
if len(p.Looks) > 0 {
|
||||
cover, draftImages, keys, imgFailed = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
imageCount = uint16(len(p.Looks))
|
||||
phashList = make([]uint64, len(draftImages))
|
||||
for i, d := range draftImages {
|
||||
phashList[i] = d.Phash
|
||||
}
|
||||
} else {
|
||||
var imgs []string
|
||||
var phs []uint64
|
||||
cover, imgs, phs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway")
|
||||
imageCount = uint16(len(imgs))
|
||||
for i, img := range imgs {
|
||||
var fimgs []fetchedImage
|
||||
cover, fimgs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||
imageCount = uint16(len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
Phash: phs[i],
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
LookIndex: uint32(i + 1),
|
||||
IsDetail: 0,
|
||||
ContentSha1: fi.sha1,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
phashList = phs
|
||||
}
|
||||
if imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
@ -274,12 +271,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 4.5) 全局近似去重标记:与「已晋升图片」比对汉明距离,命中则留痕(is_duplicate=1 + dup_of),
|
||||
// 不丢弃、交后台人工裁决(只拦新增,不碰存量)。比对失败仅告警,不阻断入库。
|
||||
s.tagDuplicates(ctx, phashList, func(i int, dupOf string) {
|
||||
draftImages[i].IsDuplicate = 1
|
||||
draftImages[i].DupOf = dupOf
|
||||
})
|
||||
|
||||
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
|
||||
draft := &model.BrandRunwayDraft{
|
||||
@ -316,31 +307,44 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
||||
// 每张图入库前做去重:content_sha1 已存在则整行跳过(精确重复);命中 dHash 近重复则仍入库但标记留痕。
|
||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool) {
|
||||
cover := ""
|
||||
rows := make([]model.BrandRunwayDraftImage, 0)
|
||||
keys := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
order := 0
|
||||
for li, look := range looks {
|
||||
lookIdx := li + 1
|
||||
if look.Main != "" {
|
||||
order++
|
||||
url, key, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
} else {
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
if cover == "" {
|
||||
cover = url
|
||||
}
|
||||
keys = append(keys, key)
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 0,
|
||||
Phash: ph,
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d", lookIdx),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 0,
|
||||
ContentSha1: sha1h,
|
||||
Phash: sqlNull(ph),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -348,20 +352,31 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
url, key, ph, err := s.downloadOne(ctx, d, order, prefix)
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, d, order, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
order++
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 1,
|
||||
Phash: ph,
|
||||
Image: url,
|
||||
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
|
||||
SortOrder: uint32(order),
|
||||
LookIndex: uint32(lookIdx),
|
||||
IsDetail: 1,
|
||||
ContentSha1: sha1h,
|
||||
Phash: sqlNull(ph),
|
||||
IsDuplicate: isDup,
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -377,8 +392,8 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地);同时算 pHash 供近似去重。
|
||||
cover, imgs, phs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street")
|
||||
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地)。
|
||||
cover, fimgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||
if len(p.Images) > 0 && imgFailed {
|
||||
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
|
||||
@ -394,7 +409,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
ImageCount: uint16(len(imgs)),
|
||||
ImageCount: uint16(len(fimgs)),
|
||||
Status: model.DraftStatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
|
||||
@ -402,22 +417,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]model.StreetSnapDraftImage, 0, len(imgs))
|
||||
for i, img := range imgs {
|
||||
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
rows = append(rows, model.StreetSnapDraftImage{
|
||||
DraftID: id,
|
||||
Image: img,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
Phash: phs[i],
|
||||
DraftID: id,
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
ContentSha1: fi.sha1,
|
||||
Phash: sqlNull(fi.phash),
|
||||
IsDuplicate: fi.isDup,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
|
||||
// 3.5) 全局近似去重标记(与走秀同逻辑,只拦新增、留痕不删)。
|
||||
s.tagDuplicates(ctx, phs, func(i int, dupOf string) {
|
||||
rows[i].IsDuplicate = 1
|
||||
rows[i].DupOf = dupOf
|
||||
})
|
||||
|
||||
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
|
||||
@ -426,30 +439,48 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
}
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 各图 pHash, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true,
|
||||
// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。
|
||||
// 返回的 pHash 与图片地址按索引对齐(phash=0 表示解码失败未计算,比对时跳过)。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []string, []uint64, []string, bool) {
|
||||
|
||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||
// prefix 为七牛 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
|
||||
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
|
||||
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(精确跳过 + 近重复标记)。
|
||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool) {
|
||||
cover := ""
|
||||
out := make([]string, 0, len(urls))
|
||||
phashes := make([]uint64, 0, len(urls))
|
||||
out := make([]fetchedImage, 0, len(urls))
|
||||
keys := make([]string, 0, len(urls))
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
for i, u := range urls {
|
||||
url, key, ph, err := s.downloadOne(ctx, u, i, prefix)
|
||||
for _, u := range urls {
|
||||
url, key, sha1h, ph, err := s.downloadOne(ctx, u, 0, prefix)
|
||||
if err != nil {
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
keys = append(keys, key)
|
||||
if seen[sha1h] {
|
||||
continue
|
||||
}
|
||||
seen[sha1h] = true
|
||||
skip, dupID, isDup := s.dedupImage(ctx, kind, sha1h, ph)
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
if cover == "" {
|
||||
cover = url
|
||||
}
|
||||
out = append(out, url)
|
||||
phashes = append(phashes, ph)
|
||||
keys = append(keys, key)
|
||||
out = append(out, fetchedImage{
|
||||
url: url,
|
||||
key: key,
|
||||
sha1: sha1h,
|
||||
phash: ph,
|
||||
dupID: dupID,
|
||||
isDup: isDup,
|
||||
})
|
||||
}
|
||||
return cover, out, phashes, keys, failed
|
||||
return cover, out, keys, failed
|
||||
}
|
||||
|
||||
// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚:
|
||||
@ -471,21 +502,65 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// fetchedImage 是单张已下载图片的暂存结构,携带去重所需的指纹信息。
|
||||
type fetchedImage struct {
|
||||
url string
|
||||
key string
|
||||
sha1 string // 内容 sha1(与存储 key 同源)
|
||||
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
|
||||
dupID uint32 // 命中近重复时的参考图 id
|
||||
isDup uint8 // 是否标记为近重复(供审核留痕)
|
||||
}
|
||||
|
||||
// sqlNull 把 phash 字符串转成可空向量字段:空串 → NULL(不参与近邻检索)。
|
||||
func sqlNull(ph string) sql.NullString {
|
||||
return sql.NullString{String: ph, Valid: ph != ""}
|
||||
}
|
||||
|
||||
// dedupImage 判断单张图是否重复:
|
||||
// - 精确重复(content_sha1 已在库)→ 返回 skip=true(不入库该行);
|
||||
// - 近重复(dHash 汉明距离 ≤ 阈值)→ 仍入库,但标记 is_duplicate + dup_of;
|
||||
// - 否则正常入库。
|
||||
func (s *IngestService) dedupImage(ctx context.Context, kind, sha1hash, phashBits string) (skip bool, dupID uint32, isDup uint8) {
|
||||
// repo 未注入(如离线单测)时跳过去重,不阻断主流程。生产环境 repo 必不为空。
|
||||
if s.repo == nil {
|
||||
return false, 0, 0
|
||||
}
|
||||
var tables []string
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
tables = []string{"brand_runway_draft_images", "brand_runway_images"}
|
||||
case dto.IngestKindStreet:
|
||||
tables = []string{"street_snap_draft_images", "street_snap_images"}
|
||||
default:
|
||||
return false, 0, 0
|
||||
}
|
||||
if exists, err := s.repo.ImageExistsBySha1(ctx, tables, sha1hash); err == nil && exists {
|
||||
return true, 0, 0
|
||||
}
|
||||
if phashBits != "" {
|
||||
if id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold); err == nil && found {
|
||||
return false, id, 1
|
||||
}
|
||||
}
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地),
|
||||
// 返回 (访问地址, 对象 key, 感知哈希, error)。访问地址可直接写入数据库
|
||||
// (七牛为完整 https URL,本地为相对 /uploads 路径);感知哈希基于原始字节算一次,供入库时近似去重。
|
||||
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, uint64, error) {
|
||||
// 返回 (访问地址, 对象 key, 内容 sha1, dHash 向量串, error)。访问地址可直接写入数据库
|
||||
// (七牛为完整 https URL,本地为相对 /uploads 路径)。
|
||||
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, string, string, error) {
|
||||
resp, err := s.httpClient.Get(u)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, fmt.Errorf("status %d", resp.StatusCode)
|
||||
return "", "", "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
ext := path.Ext(u)
|
||||
if ext == "" || len(ext) > 5 {
|
||||
@ -498,74 +573,21 @@ func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, pref
|
||||
key := fmt.Sprintf("%s/%s%s", prefix, hash, ext)
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
|
||||
// 感知哈希:同一份字节在此算一次(decode 失败时 ph=0,比对时跳过,不阻断入库)。
|
||||
// 计算 dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
|
||||
ph := phash.Of(data)
|
||||
if ph == 0 {
|
||||
log.Printf("[warn] ingest pHash 未计算(解码失败或非 jpeg/png/gif 格式) url=%s", u)
|
||||
}
|
||||
phashBits := phash.ToVectorBits(ph)
|
||||
|
||||
// 主上传器(七牛)
|
||||
if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil {
|
||||
return url, key, ph, nil
|
||||
return url, key, hash, phashBits, nil
|
||||
} else if s.local != nil {
|
||||
// 兜底本地,避免图片完全丢失
|
||||
if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil {
|
||||
return lurl, key, ph, nil
|
||||
return lurl, key, hash, phashBits, nil
|
||||
} else {
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
}
|
||||
return "", "", 0, err
|
||||
return "", "", "", "", err
|
||||
}
|
||||
|
||||
// tagDuplicates 对一批图片(phashList 与 apply 下标对齐)做全局近似去重标记:
|
||||
// 与「已晋升图片」的 phash 库比汉明距离(≤ phash.DefaultThreshold 即近似重复),
|
||||
// 命中则调用 apply(i, dupOf) 由调用方把第 i 张图标 IsDuplicate=1、DupOf=命中图 uid。
|
||||
// 不丢弃任何图、仅留痕(交后台人工裁决),契合「只拦新增、全局跨所有图、标记不删」。
|
||||
// 比对库拉取失败仅告警并跳过(不阻断入库);phash=0 的图不比对、不误杀。
|
||||
func (s *IngestService) tagDuplicates(ctx context.Context, phashList []uint64, apply func(i int, dupOf string)) {
|
||||
uids, err := s.matchDuplicates(ctx, phashList)
|
||||
if err != nil {
|
||||
log.Printf("[warn] ingest 近似去重比对失败 err=%v(跳过标记,不阻断入库)", err)
|
||||
return
|
||||
}
|
||||
for i, u := range uids {
|
||||
if u != "" {
|
||||
apply(i, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matchDuplicates 返回与 phashList 等长的 dup_of uid 切片(""=未命中近似重复)。
|
||||
// 对每张新图,在已晋升图片库里找汉明距离 ≤ 阈值的命中,取距离最近者,
|
||||
// 按其 kind(runway/street)编码成对应 hashid 类型作为 dup_of。
|
||||
func (s *IngestService) matchDuplicates(ctx context.Context, phashList []uint64) ([]string, error) {
|
||||
refs, err := s.repo.ListImagePHashes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, len(phashList))
|
||||
for i, ph := range phashList {
|
||||
if ph == 0 {
|
||||
continue // 未计算 phash 的图不比对(避免误杀)
|
||||
}
|
||||
bestUID := ""
|
||||
bestDist := phash.DefaultThreshold + 1
|
||||
for _, ref := range refs {
|
||||
if ref.Phash == 0 {
|
||||
continue
|
||||
}
|
||||
d := phash.Hamming(ph, ref.Phash)
|
||||
if d <= phash.DefaultThreshold && d < bestDist {
|
||||
var typ byte = hashid.TypeRunwayImage
|
||||
if ref.Kind == "street" {
|
||||
typ = hashid.TypeSnapImage
|
||||
}
|
||||
bestUID = hashid.EncodeWithType(ref.ID, typ)
|
||||
bestDist = d
|
||||
}
|
||||
}
|
||||
out[i] = bestUID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user