update
This commit is contained in:
10
README.md
10
README.md
@ -24,14 +24,14 @@ cmd/ 组合根:只做依赖装配,不含业务逻辑
|
|||||||
internal/
|
internal/
|
||||||
config/ 配置加载(yml + 环境变量覆盖 + 默认值兜底)
|
config/ 配置加载(yml + 环境变量覆盖 + 默认值兜底)
|
||||||
model/ 实体定义(gorm tag + json tag,PasswordHash 永不出参)
|
model/ 实体定义(gorm tag + json tag,PasswordHash 永不出参)
|
||||||
database/ PostgreSQL 连接池 / 自动迁移 / 去重 schema
|
database/ PostgreSQL 连接池 / 自动迁移 / 迁移 schema
|
||||||
repository/ 数据访问(接口 + GORM 实现,查询条件用 Scope 安全组合)
|
repository/ 数据访问(接口 + GORM 实现,查询条件用 Scope 安全组合)
|
||||||
service/ 业务逻辑(JWT 签发、图片治理、去重、审核、入库管线)
|
service/ 业务逻辑(JWT 签发、图片治理、审核、入库管线)
|
||||||
dto/ 请求 / 响应结构体与边界常量
|
dto/ 请求 / 响应结构体与边界常量
|
||||||
handler/ HTTP 层(仅做参数解析与响应封装)
|
handler/ HTTP 层(仅做参数解析与响应封装)
|
||||||
middleware/ 鉴权 / CORS / 前端签名 / ingest 验签 / SSG token
|
middleware/ 鉴权 / CORS / 前端签名 / ingest 验签 / SSG token
|
||||||
router/ 路由注册(公开 / SSG / 后台三个引擎)
|
router/ 路由注册(公开 / SSG / 后台三个引擎)
|
||||||
pkg/ 可复用工具(hashid / jwt / phash / imgurl / storage / ...)
|
pkg/ 可复用工具(hashid / jwt / imgurl / storage / ...)
|
||||||
```
|
```
|
||||||
|
|
||||||
**核心约束**:
|
**核心约束**:
|
||||||
@ -94,7 +94,7 @@ internal/
|
|||||||
- 库里只存对象 key(`runway/<sha1>.jpg`)或本地相对路径,完整 URL 由 `imgurl.Composer` 渲染时拼装(换域名只改配置)。
|
- 库里只存对象 key(`runway/<sha1>.jpg`)或本地相对路径,完整 URL 由 `imgurl.Composer` 渲染时拼装(换域名只改配置)。
|
||||||
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
|
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
|
||||||
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
|
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
|
||||||
- **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
|
- **图片语义向量**:入库时由独立的视觉服务(FashionSigLIP,768 维归一化向量)编码,写入 `image_embeddings`(HNSW 余弦索引),用于后台「疑似同款」提示;**不做自动去重**,重复图全部平铺由人工清理。视觉服务不可用时自动降级为不写向量,不影响入库。存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
|
||||||
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、**直写正式表**(`status=pending`,已无草稿表);后台人工审核只改状态(`published` / `rejected`),不重建图片;公开读走 `public_*` 只读视图,未发布内容结构性不可见。失败按指数退避重试,单图失败整任务回滚。
|
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、**直写正式表**(`status=pending`,已无草稿表);后台人工审核只改状态(`published` / `rejected`),不重建图片;公开读走 `public_*` 只读视图,未发布内容结构性不可见。失败按指数退避重试,单图失败整任务回滚。
|
||||||
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
|
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
|
||||||
|
|
||||||
@ -182,7 +182,7 @@ backend/
|
|||||||
│ ├── handler/ # HTTP 层
|
│ ├── handler/ # HTTP 层
|
||||||
│ ├── middleware/ # 鉴权 / CORS / 签名 / 验签
|
│ ├── middleware/ # 鉴权 / CORS / 签名 / 验签
|
||||||
│ ├── router/ # 公开 / SSG / 后台三引擎路由
|
│ ├── router/ # 公开 / SSG / 后台三引擎路由
|
||||||
│ └── pkg/ # hashid / jwt / phash / imgurl / storage / ...
|
│ └── pkg/ # hashid / jwt / imgurl / storage / ...
|
||||||
├── scripts/ # pgvector 起库脚本等
|
├── scripts/ # pgvector 起库脚本等
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
|
|||||||
@ -70,6 +70,9 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
"fashionapi/internal/config"
|
"fashionapi/internal/config"
|
||||||
|
"fashionapi/internal/database"
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -109,14 +112,14 @@ func main() {
|
|||||||
runDump(os.Args[2:])
|
runDump(os.Args[2:])
|
||||||
case "import":
|
case "import":
|
||||||
runImport(os.Args[2:])
|
runImport(os.Args[2:])
|
||||||
case "fixdup":
|
|
||||||
runFixDup(os.Args[2:])
|
|
||||||
case "filltitlecn":
|
case "filltitlecn":
|
||||||
runFillTitleCn(os.Args[2:])
|
runFillTitleCn(os.Args[2:])
|
||||||
case "purge-rejected":
|
case "purge-rejected":
|
||||||
runPurgeRejected(os.Args[2:])
|
runPurgeRejected(os.Args[2:])
|
||||||
case "migrate":
|
case "migrate":
|
||||||
runMigrate(os.Args[2:])
|
runMigrate(os.Args[2:])
|
||||||
|
case "similarbackfill":
|
||||||
|
runSimilarBackfill(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@ -131,9 +134,6 @@ func usage() {
|
|||||||
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||||
dbtool import [-config <yml>] [-in <file>] [-clean]
|
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||||
把 dump 出来的 SQL 文件灌入目标库
|
把 dump 出来的 SQL 文件灌入目标库
|
||||||
dbtool fixdup [-config <yml>] [-dry-run]
|
|
||||||
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
|
||||||
-dry-run 只统计/列出将受影响的行,不实际改写(推荐先跑一次确认)
|
|
||||||
dbtool filltitlecn [-config <yml>] [-dry-run]
|
dbtool filltitlecn [-config <yml>] [-dry-run]
|
||||||
历史回填中文标题:扫描 brand_runways / street_snaps 里 title_cn 为空且英文标题
|
历史回填中文标题:扫描 brand_runways / street_snaps 里 title_cn 为空且英文标题
|
||||||
非空的存活记录,用与入库同源的规则(记忆优先、规则兜底)补全;规则无法完整翻译
|
非空的存活记录,用与入库同源的规则(记忆优先、规则兜底)补全;规则无法完整翻译
|
||||||
@ -147,6 +147,10 @@ func usage() {
|
|||||||
每个文件执行成功后记入 schema_migrations,重跑已记录的会跳过;库内「已存在」
|
每个文件执行成功后记入 schema_migrations,重跑已记录的会跳过;库内「已存在」
|
||||||
视为已应用(也记入记录表后跳过),因此历史已手动 psql 过的迁移重跑不会报错中断。
|
视为已应用(也记入记录表后跳过),因此历史已手动 psql 过的迁移重跑不会报错中断。
|
||||||
-in 指定单个文件时,只执行该文件。本子命令替代 psql,免装客户端即可落库。
|
-in 指定单个文件时,只执行该文件。本子命令替代 psql,免装客户端即可落库。
|
||||||
|
dbtool similarbackfill [-config <yml>] [-kind runway|street] [-limit N] [-dry-run]
|
||||||
|
给存量图集回填「疑似同款」近邻(image_similar_pairs 表)。近邻改为「入库时预计算」
|
||||||
|
后,此前入库的图集在结果表里没有数据、后台看不到提示,本命令按图集重算一遍
|
||||||
|
(可重复执行,同一图集先删后插)。-dry-run 只列出将处理的图集与图片数。
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||||
@ -159,6 +163,97 @@ func usage() {
|
|||||||
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
|
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runSimilarBackfill 给存量图集回填「疑似同款」近邻(image_similar_pairs 预计算表)。
|
||||||
|
//
|
||||||
|
// 为什么需要:近邻从「打开页面时实时算」改为「入库时算一次存表」后,改动之前的存量图集
|
||||||
|
// 在结果表里没有数据,后台就看不到提示。本命令按图集重算一遍。
|
||||||
|
//
|
||||||
|
// 可重复执行:PrecomputeForGallery 对同一图集是先删后插。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// dbtool similarbackfill [-config <yml>] [-kind runway|street] [-limit N] [-dry-run]
|
||||||
|
func runSimilarBackfill(args []string) {
|
||||||
|
fs := flag.NewFlagSet("similarbackfill", flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml 叠加本地覆盖)")
|
||||||
|
kind := fs.String("kind", "", "只处理某一类:runway | street;留空=两类都做")
|
||||||
|
limit := fs.Int("limit", 0, "最多处理多少个图集(0=不限;调试用)")
|
||||||
|
dryRun := fs.Bool("dry-run", false, "只列出将处理的图集与图片数,不计算不写库")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
log.Fatalf("参数解析失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
db, err := database.New(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.Close(db) }()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := service.NewSimilarService(repository.NewEmbeddingRepository(db), cfg.Vision.SimilarThreshold)
|
||||||
|
|
||||||
|
kinds := []string{dto.IngestKindRunway, dto.IngestKindStreet}
|
||||||
|
if *kind != "" {
|
||||||
|
kinds = []string{*kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for _, k := range kinds {
|
||||||
|
table, fk, ok := repository.OwnerTable(k)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[backfill] 未知 kind=%s,跳过", k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var ownerIDs []uint32
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Raw("SELECT DISTINCT " + fk + " AS id FROM " + table +
|
||||||
|
" WHERE is_deleted = 0 ORDER BY " + fk).Scan(&ownerIDs).Error; err != nil {
|
||||||
|
log.Printf("[backfill] 列出图集失败 kind=%s: %v", k, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("[backfill] kind=%s 图集数=%d", k, len(ownerIDs))
|
||||||
|
|
||||||
|
for i, ownerID := range ownerIDs {
|
||||||
|
if *limit > 0 && i >= *limit {
|
||||||
|
log.Printf("[backfill] kind=%s 已达 -limit=%d,停止", k, *limit)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var imgIDs []uint32
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Raw("SELECT id FROM "+table+" WHERE "+fk+" = ? AND is_deleted = 0 ORDER BY id", ownerID).
|
||||||
|
Scan(&imgIDs).Error; err != nil {
|
||||||
|
log.Printf("[backfill] kind=%s 图集=%d 取图片失败: %v", k, ownerID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(imgIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *dryRun {
|
||||||
|
processed++
|
||||||
|
log.Printf("[backfill] [dry-run] kind=%s 图集=%d 图=%d", k, ownerID, len(imgIDs))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if err := svc.PrecomputeForGallery(ctx, k, imgIDs); err != nil {
|
||||||
|
log.Printf("[backfill] kind=%s 图集=%d 预计算失败: %v", k, ownerID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
log.Printf("[backfill] kind=%s 图集=%d 图=%d 耗时=%v",
|
||||||
|
k, ownerID, len(imgIDs), time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *dryRun {
|
||||||
|
log.Printf("[backfill] [dry-run] 将处理 %d 个图集(未写库)", processed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[backfill] 完成,处理 %d 个图集", processed)
|
||||||
|
}
|
||||||
|
|
||||||
// openDB 按 config 加载 DSN 并探活。
|
// openDB 按 config 加载 DSN 并探活。
|
||||||
func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
|
func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
|
||||||
cfg, err := config.Load(cfgPath)
|
cfg, err := config.Load(cfgPath)
|
||||||
@ -308,86 +403,6 @@ func runImport(args []string) {
|
|||||||
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
|
||||||
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
|
||||||
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
|
||||||
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
|
||||||
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
|
||||||
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
|
||||||
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
|
||||||
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
|
||||||
//
|
|
||||||
// -dry-run 只统计并打印将受影响的行(含其 dup_of,便于确认源确已消失),不实际改写;
|
|
||||||
// 不带该参数则直接 UPDATE。
|
|
||||||
func runFixDup(args []string) {
|
|
||||||
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
|
|
||||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
|
||||||
dryRun := fs.Bool("dry-run", false, "只统计并打印将受影响的行,不实际改写")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
db, _, err := openDB(*cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("✗ %v", err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
for _, t := range []string{"brand_runway_images", "street_snap_images"} {
|
|
||||||
// 悬空判定:标了重复,但其 dup_of 为空 / 为 NULL / 不在「存活源」集合里。
|
|
||||||
// 子查询只取 is_deleted=0 的 id,因此「源已软删」与「源行已不存在」两种情况都被覆盖。
|
|
||||||
where := `is_duplicate = 1
|
|
||||||
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
|
|
||||||
SELECT CAST(id AS text) FROM public.` + qi(t) + ` WHERE is_deleted = 0))`
|
|
||||||
|
|
||||||
if *dryRun {
|
|
||||||
var cnt int
|
|
||||||
if err := db.QueryRow(`SELECT count(*) FROM public.` + qi(t) + ` WHERE ` + where).Scan(&cnt); err != nil {
|
|
||||||
log.Fatalf("✗ 统计 %s 失败: %v", t, err)
|
|
||||||
}
|
|
||||||
log.Printf("→ %s: 将清理悬空重复标记 %d 行(dry-run,未改写)", t, cnt)
|
|
||||||
const sampleLimit = 50
|
|
||||||
if cnt > 0 {
|
|
||||||
rows, qerr := db.Query(fmt.Sprintf(
|
|
||||||
`SELECT id, dup_of FROM public.%s WHERE %s ORDER BY id LIMIT %d`, qi(t), where, sampleLimit))
|
|
||||||
if qerr != nil {
|
|
||||||
log.Fatalf("✗ 列举 %s 失败: %v", t, qerr)
|
|
||||||
}
|
|
||||||
type aff struct {
|
|
||||||
ID uint32
|
|
||||||
DupOf string
|
|
||||||
}
|
|
||||||
var list []aff
|
|
||||||
for rows.Next() {
|
|
||||||
var a aff
|
|
||||||
if err := rows.Scan(&a.ID, &a.DupOf); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
log.Fatalf("✗ 扫描 %s 失败: %v", t, err)
|
|
||||||
}
|
|
||||||
list = append(list, a)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
for _, a := range list {
|
|
||||||
log.Printf(" id=%d dup_of=%q", a.ID, a.DupOf)
|
|
||||||
}
|
|
||||||
if cnt > sampleLimit {
|
|
||||||
log.Printf(" …(仅显示前 %d 行,共 %d 行)", sampleLimit, cnt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
UPDATE public.%[1]s
|
|
||||||
SET is_duplicate = 0, dup_of = ''
|
|
||||||
WHERE %[2]s;`, qi(t), where)
|
|
||||||
res, err := db.Exec(q)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("✗ 清理 %s 失败: %v", t, err)
|
|
||||||
}
|
|
||||||
n, _ := res.RowsAffected()
|
|
||||||
log.Printf("✓ %s: 清理悬空重复标记 %d 行", t, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPurgeRejected 把现存「已驳回(rejected)」且未下架的记录转删除:
|
// runPurgeRejected 把现存「已驳回(rejected)」且未下架的记录转删除:
|
||||||
// 级联软删其全部图片、记录自身置 is_deleted=1(后台 legWhere 强制 is_deleted=0,记录即从列表消失),
|
// 级联软删其全部图片、记录自身置 is_deleted=1(后台 legWhere 强制 is_deleted=0,记录即从列表消失),
|
||||||
// 并把图片 key 作为 media_cleanup 任务入队,由运行中的 worker 按引用计数真删 S4 孤儿文件。
|
// 并把图片 key 作为 media_cleanup 任务入队,由运行中的 worker 按引用计数真删 S4 孤儿文件。
|
||||||
@ -432,7 +447,10 @@ func runFillTitleCn(args []string) {
|
|||||||
if qerr != nil {
|
if qerr != nil {
|
||||||
log.Fatalf("✗ 扫描 %s 失败: %v", t.table, qerr)
|
log.Fatalf("✗ 扫描 %s 失败: %v", t.table, qerr)
|
||||||
}
|
}
|
||||||
type aff struct{ ID uint32; En, Cn string }
|
type aff struct {
|
||||||
|
ID uint32
|
||||||
|
En, Cn string
|
||||||
|
}
|
||||||
var list []aff
|
var list []aff
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id uint32
|
var id uint32
|
||||||
|
|||||||
@ -1,363 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"image"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
dsn = "host=127.0.0.1 port=5432 user=fashion password=fashion_dev_2026 dbname=fashion sslmode=disable"
|
|
||||||
base = "https://toomstudio.s3.bitiful.net/"
|
|
||||||
)
|
|
||||||
|
|
||||||
type row struct {
|
|
||||||
ID int64
|
|
||||||
Image string
|
|
||||||
Phash sql.NullString
|
|
||||||
IsDuplicate int16
|
|
||||||
DupOf string
|
|
||||||
RunwayID int64
|
|
||||||
BrandID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryRow(db *sql.DB, like string) *row {
|
|
||||||
r := &row{}
|
|
||||||
q := `SELECT id, image, phash, is_duplicate, dup_of, runway_id, brand_id
|
|
||||||
FROM brand_runway_images WHERE image LIKE $1 ORDER BY id LIMIT 1`
|
|
||||||
err := db.QueryRow(q, like).Scan(&r.ID, &r.Image, &r.Phash, &r.IsDuplicate, &r.DupOf, &r.RunwayID, &r.BrandID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" 查询 %s 失败: %v\n", like, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// vectorToUint64 把 "[0,1,...,1]" 转回 uint64(与 phash.ToVectorBits 互逆)。
|
|
||||||
func vectorToUint64(s string) uint64 {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
s = strings.TrimPrefix(s, "[")
|
|
||||||
s = strings.TrimSuffix(s, "]")
|
|
||||||
parts := strings.Split(s, ",")
|
|
||||||
var h uint64
|
|
||||||
for i, p := range parts {
|
|
||||||
if i >= 64 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(p) == "1" {
|
|
||||||
h |= 1 << uint(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func hamming(a, b uint64) int {
|
|
||||||
c := 0
|
|
||||||
x := a ^ b
|
|
||||||
for x != 0 {
|
|
||||||
x &= x - 1
|
|
||||||
c++
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchImage 下载原图,返回解码后的 image 与原始字节。
|
|
||||||
func fetchImage(key string) (image.Image, []byte, bool) {
|
|
||||||
url := base + strings.TrimPrefix(key, "/")
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" 下载 %s 失败: %v\n", url, err)
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
fmt.Printf(" 下载 %s 状态 %d\n", url, resp.StatusCode)
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" 读 %s 失败: %v\n", url, err)
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
img, _, err := image.Decode(bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" 解码 %s 失败: %v\n", url, err)
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
return img, data, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// aHash 平均哈希:缩到 8x8 灰度,与整图均值比大小,得 64-bit。比 dHash 更关注整体明暗分布。
|
|
||||||
func aHash(img image.Image) uint64 {
|
|
||||||
const n = 8
|
|
||||||
gray := make([][]float64, n)
|
|
||||||
for i := range gray {
|
|
||||||
gray[i] = make([]float64, n)
|
|
||||||
}
|
|
||||||
b := img.Bounds()
|
|
||||||
var total float64
|
|
||||||
for y := 0; y < n; y++ {
|
|
||||||
for x := 0; x < n; x++ {
|
|
||||||
sx := b.Min.X + (x*b.Dx())/n + b.Dx()/(2*n)
|
|
||||||
sy := b.Min.Y + (y*b.Dy())/n + b.Dy()/(2*n)
|
|
||||||
r, g, bl, _ := img.At(sx, sy).RGBA()
|
|
||||||
l := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
|
||||||
gray[y][x] = l
|
|
||||||
total += l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mean := total / float64(n*n)
|
|
||||||
var h uint64
|
|
||||||
i := 0
|
|
||||||
for y := 0; y < n; y++ {
|
|
||||||
for x := 0; x < n; x++ {
|
|
||||||
if gray[y][x] > mean {
|
|
||||||
h |= 1 << uint(i)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// pHash DCT 感知哈希:缩到 32x32 灰度 → 2D DCT → 取左上 8x8 低频系数 → 与中位数比大小得 64-bit。
|
|
||||||
func pHash(img image.Image) uint64 {
|
|
||||||
const N = 32
|
|
||||||
g := [N][N]float64{}
|
|
||||||
b := img.Bounds()
|
|
||||||
for cy := 0; cy < N; cy++ {
|
|
||||||
for cx := 0; cx < N; cx++ {
|
|
||||||
x0 := b.Min.X + cx*b.Dx()/N
|
|
||||||
x1 := b.Min.X + (cx+1)*b.Dx()/N
|
|
||||||
y0 := b.Min.Y + cy*b.Dy()/N
|
|
||||||
y1 := b.Min.Y + (cy+1)*b.Dy()/N
|
|
||||||
if x1 <= x0 {
|
|
||||||
x1 = x0 + 1
|
|
||||||
}
|
|
||||||
if y1 <= y0 {
|
|
||||||
y1 = y0 + 1
|
|
||||||
}
|
|
||||||
var sum float64
|
|
||||||
var n int
|
|
||||||
for y := y0; y < y1 && y < b.Max.Y; y++ {
|
|
||||||
for x := x0; x < x1 && x < b.Max.X; x++ {
|
|
||||||
r, gg, bl, _ := img.At(x, y).RGBA()
|
|
||||||
sum += 0.299*float64(r>>8) + 0.587*float64(gg>>8) + 0.114*float64(bl>>8)
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n > 0 {
|
|
||||||
g[cy][cx] = sum / float64(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 行 DCT-II
|
|
||||||
var rows [N][N]float64
|
|
||||||
for y := 0; y < N; y++ {
|
|
||||||
for u := 0; u < N; u++ {
|
|
||||||
var s float64
|
|
||||||
for x := 0; x < N; x++ {
|
|
||||||
s += g[y][x] * math.Cos(math.Pi*float64(u)*(float64(x)+0.5)/float64(N))
|
|
||||||
}
|
|
||||||
rows[y][u] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 列 DCT-II
|
|
||||||
var dct [N][N]float64
|
|
||||||
for x := 0; x < N; x++ {
|
|
||||||
for v := 0; v < N; v++ {
|
|
||||||
var s float64
|
|
||||||
for y := 0; y < N; y++ {
|
|
||||||
s += rows[y][x] * math.Cos(math.Pi*float64(v)*(float64(y)+0.5)/float64(N))
|
|
||||||
}
|
|
||||||
dct[v][x] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 取左上 8x8 低频,与中位数比大小
|
|
||||||
var coeffs [64]float64
|
|
||||||
i := 0
|
|
||||||
for v := 0; v < 8; v++ {
|
|
||||||
for u := 0; u < 8; u++ {
|
|
||||||
coeffs[i] = dct[v][u]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sorted := append([]float64{}, coeffs[:]...)
|
|
||||||
for a := 0; a < len(sorted); a++ {
|
|
||||||
for c := a + 1; c < len(sorted); c++ {
|
|
||||||
if sorted[c] < sorted[a] {
|
|
||||||
sorted[a], sorted[c] = sorted[c], sorted[a]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
median := sorted[len(sorted)/2]
|
|
||||||
var h uint64
|
|
||||||
for k := 0; k < 64; k++ {
|
|
||||||
if coeffs[k] > median {
|
|
||||||
h |= 1 << uint(k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// luminanceStats 算亮度均值/标准差,并用 phash.Of 复算指纹。
|
|
||||||
func luminanceStats(img image.Image, data []byte) (mean, std float64, recomputed uint64, ok bool) {
|
|
||||||
recomputed = phash.Of(data)
|
|
||||||
b := img.Bounds()
|
|
||||||
var sum, sum2 float64
|
|
||||||
var n int
|
|
||||||
step := 1
|
|
||||||
if b.Dx() > 300 || b.Dy() > 300 {
|
|
||||||
step = int(math.Max(1, float64(b.Dx())/300))
|
|
||||||
}
|
|
||||||
for y := b.Min.Y; y < b.Max.Y; y += step {
|
|
||||||
for x := b.Min.X; x < b.Max.X; x += step {
|
|
||||||
r, g, bl, _ := img.At(x, y).RGBA()
|
|
||||||
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
|
||||||
sum += lum
|
|
||||||
sum2 += lum * lum
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mean = sum / float64(n)
|
|
||||||
variance := sum2/float64(n) - mean*mean
|
|
||||||
if variance < 0 {
|
|
||||||
variance = 0
|
|
||||||
}
|
|
||||||
std = math.Sqrt(variance)
|
|
||||||
ok = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func dumpBits(h uint64) string {
|
|
||||||
var sb strings.Builder
|
|
||||||
for i := 0; i < 64; i++ {
|
|
||||||
if (h>>uint(i))&1 == 1 {
|
|
||||||
sb.WriteByte('1')
|
|
||||||
} else {
|
|
||||||
sb.WriteByte('0')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
db, err := sql.Open("pgx", dsn)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("open db:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
k1 := "6837152087e6b9849368860811920d98d6b986dc"
|
|
||||||
k2 := "53f47b14a8799e682864fb7ffc1f942caaeeb56b"
|
|
||||||
if len(os.Args) >= 3 {
|
|
||||||
k1, k2 = os.Args[1], os.Args[2]
|
|
||||||
}
|
|
||||||
|
|
||||||
r1 := queryRow(db, "%"+k1+"%")
|
|
||||||
r2 := queryRow(db, "%"+k2+"%")
|
|
||||||
if r1 == nil || r2 == nil {
|
|
||||||
fmt.Println("未能取到两行,退出")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("==== 行 1 ====")
|
|
||||||
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
|
|
||||||
r1.ID, r1.Image, r1.IsDuplicate, r1.DupOf, r1.RunwayID, r1.BrandID)
|
|
||||||
fmt.Println("==== 行 2 ====")
|
|
||||||
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
|
|
||||||
r2.ID, r2.Image, r2.IsDuplicate, r2.DupOf, r2.RunwayID, r2.BrandID)
|
|
||||||
|
|
||||||
var h1, h2 uint64
|
|
||||||
if r1.Phash.Valid {
|
|
||||||
h1 = vectorToUint64(r1.Phash.String)
|
|
||||||
fmt.Printf("phash1=%s\nbits1=%s\n", r1.Phash.String, dumpBits(h1))
|
|
||||||
} else {
|
|
||||||
fmt.Println("phash1=NULL")
|
|
||||||
}
|
|
||||||
if r2.Phash.Valid {
|
|
||||||
h2 = vectorToUint64(r2.Phash.String)
|
|
||||||
fmt.Printf("phash2=%s\nbits2=%s\n", r2.Phash.String, dumpBits(h2))
|
|
||||||
} else {
|
|
||||||
fmt.Println("phash2=NULL")
|
|
||||||
}
|
|
||||||
|
|
||||||
if r1.Phash.Valid && r2.Phash.Valid {
|
|
||||||
ham := hamming(h1, h2)
|
|
||||||
fmt.Printf("\n>>> 两张图之间的真实汉明距离 = %d (系统阈值 DefaultThreshold=4)\n", ham)
|
|
||||||
if ham <= 4 {
|
|
||||||
fmt.Println(">>> 系统会把这两者判为近重复(≤4)。")
|
|
||||||
} else {
|
|
||||||
fmt.Println(">>> 这两张彼此并不在 ≤4 内;若被标重,必是各自 dup_of 指向了不同的第三者。")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 亮度标准差 + 指纹复算(验证“平淡图哈希塌缩”假说)+ aHash 区分度
|
|
||||||
fmt.Println("\n==== 像素核验(原图,无 style)====")
|
|
||||||
var imgs [2]image.Image
|
|
||||||
var ahs [2]uint64
|
|
||||||
var phs [2]uint64
|
|
||||||
for i, r := range []*row{r1, r2} {
|
|
||||||
img, data, ok := fetchImage(r.Image)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
imgs[i] = img
|
|
||||||
ahs[i] = aHash(img)
|
|
||||||
phs[i] = pHash(img)
|
|
||||||
mean, std, recomputed, ok2 := luminanceStats(img, data)
|
|
||||||
if !ok2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("image=%s\n 亮度均值=%.1f 标准差=%.1f (标准差越低越“平”,哈希越易塌缩)\n",
|
|
||||||
r.Image, mean, std)
|
|
||||||
stored := vectorToUint64(r.Phash.String)
|
|
||||||
fmt.Printf(" 复算phash与库存phash一致=%v 复算bits=%s\n", recomputed == stored, dumpBits(recomputed))
|
|
||||||
fmt.Printf(" aHash bits=%s\n", dumpBits(ahs[i]))
|
|
||||||
fmt.Printf(" pHash bits=%s\n", dumpBits(phs[i]))
|
|
||||||
}
|
|
||||||
if imgs[0] != nil && imgs[1] != nil {
|
|
||||||
dh := 0
|
|
||||||
if r1.Phash.Valid && r2.Phash.Valid {
|
|
||||||
dh = hamming(vectorToUint64(r1.Phash.String), vectorToUint64(r2.Phash.String))
|
|
||||||
}
|
|
||||||
fmt.Printf("\n>>> dHash 汉明距离 = %d(库存判定,≤4 判重)\n", dh)
|
|
||||||
fmt.Printf(">>> aHash 汉明距离 = %d\n", hamming(ahs[0], ahs[1]))
|
|
||||||
fmt.Printf(">>> pHash 汉明距离 = %d (pHash 常规判重阈值约 10~15,远超 dHash 的 4)\n", hamming(phs[0], phs[1]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若某行 dup_of 指向第三者,把它也拉出来看
|
|
||||||
for _, r := range []*row{r1, r2} {
|
|
||||||
if r.DupOf != "" && r.DupOf != "0" {
|
|
||||||
tgt := &row{}
|
|
||||||
err := db.QueryRow(`SELECT id, image, phash, runway_id, brand_id FROM brand_runway_images WHERE id=$1`, r.DupOf).
|
|
||||||
Scan(&tgt.ID, &tgt.Image, &tgt.Phash, &tgt.RunwayID, &tgt.BrandID)
|
|
||||||
if err == nil {
|
|
||||||
fmt.Printf("\n>>> 行 %d 的 dup_of=%s 指向:\n id=%d image=%s runway_id=%d brand_id=%d\n",
|
|
||||||
r.ID, r.DupOf, tgt.ID, tgt.Image, tgt.RunwayID, tgt.BrandID)
|
|
||||||
if tgt.Phash.Valid {
|
|
||||||
th := vectorToUint64(tgt.Phash.String)
|
|
||||||
fmt.Printf(" 与该目标汉明距离=%d\n", hamming(vectorToUint64(r.Phash.String), th))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -27,6 +27,7 @@ import (
|
|||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
"fashionapi/internal/router"
|
"fashionapi/internal/router"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
"fashionapi/internal/vision"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@ -169,9 +170,30 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
slog.Info("图片存储(本地)", "prefix", cfg.Upload.URLPrefix)
|
slog.Info("图片存储(本地)", "prefix", cfg.Upload.URLPrefix)
|
||||||
}
|
}
|
||||||
|
// 视觉服务(FashionSigLIP):可选,仅用于后台「疑似同款」提示,不参与任何去重。
|
||||||
|
// 未启用时两者均为 nil,IngestService 会整体跳过 embed(零开销),入库行为与启用前一致。
|
||||||
|
var (
|
||||||
|
visionClient *vision.Client
|
||||||
|
embedRepo repository.EmbeddingRepository
|
||||||
|
)
|
||||||
|
if cfg.Vision.Enabled {
|
||||||
|
visionClient = vision.New(
|
||||||
|
cfg.Vision.Endpoint,
|
||||||
|
cfg.Vision.Dim,
|
||||||
|
time.Duration(cfg.Vision.TimeoutSeconds)*time.Second,
|
||||||
|
cfg.Vision.FailThreshold,
|
||||||
|
)
|
||||||
|
embedRepo = repository.NewEmbeddingRepository(db)
|
||||||
|
slog.Info("视觉服务已启用",
|
||||||
|
"endpoint", cfg.Vision.Endpoint,
|
||||||
|
"dim", cfg.Vision.Dim,
|
||||||
|
"similar_threshold", cfg.Vision.SimilarThreshold)
|
||||||
|
} else {
|
||||||
|
slog.Info("视觉服务未启用(不产出语义向量,后台无「疑似同款」提示)")
|
||||||
|
}
|
||||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc)
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy, translateSvc, visionClient, embedRepo)
|
||||||
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
||||||
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
@ -183,13 +205,21 @@ func main() {
|
|||||||
Brand: handler.NewBrandHandler(brandSvc),
|
Brand: handler.NewBrandHandler(brandSvc),
|
||||||
StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc),
|
StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc),
|
||||||
Auth: handler.NewAuthHandler(authSvc),
|
Auth: handler.NewAuthHandler(authSvc),
|
||||||
Favorite: handler.NewFavoriteHandler(favSvc),
|
Favorite: handler.NewFavoriteHandler(favSvc),
|
||||||
History: handler.NewHistoryHandler(histSvc),
|
History: handler.NewHistoryHandler(histSvc),
|
||||||
Health: handler.NewHealthHandler(),
|
Health: handler.NewHealthHandler(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
||||||
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo, translateSvc)
|
// 「疑似同款」服务与视觉服务同开关:未启用时 embedRepo 为 nil,页面上不出现该角标。
|
||||||
|
similarSvc := service.NewSimilarService(embedRepo, cfg.Vision.SimilarThreshold)
|
||||||
|
// 把「疑似同款」服务注入入库流程:写完向量后顺手预计算近邻并落库,
|
||||||
|
// 后台页面因此只需读一次小查询(原先是页面内对每张图实时检索,一个 116 张图的图集要 5 秒)。
|
||||||
|
ingestSvc.SetSimilar(similarSvc)
|
||||||
|
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo, translateSvc, similarSvc)
|
||||||
|
// 手工建稿:复用入库仓储的写入方法(Create*WithImages,事务内完成)与同一套存储上传
|
||||||
|
// (imgUp = S4/本地主上传器,sha1 内容寻址),保证与爬虫入库产出的 key / 行结构一致。
|
||||||
|
backstageHandler.SetManual(service.NewManualArticleService(ingestRepo, brandRepo, imgUp, localUp, visionClient, embedRepo))
|
||||||
// 爬虫上报 handler(HMAC 验签入口)
|
// 爬虫上报 handler(HMAC 验签入口)
|
||||||
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
||||||
|
|
||||||
|
|||||||
@ -127,3 +127,22 @@ s4:
|
|||||||
base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net
|
base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net
|
||||||
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
|
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
|
||||||
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display
|
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display
|
||||||
|
|
||||||
|
# 视觉服务(FashionSigLIP):入库时把每张图编码成 768 维归一化向量写入 image_embeddings,
|
||||||
|
# 后台图集页据此提示「疑似同款」(仅提示,不做任何自动去重)。
|
||||||
|
# - 服务为独立进程(D:\project\fashion-siglip),仅监听回环地址。
|
||||||
|
# - enabled=false(默认)时完全跳过 embed 调用;服务不可用/超时/维度不符一律降级为「不写向量」,
|
||||||
|
# 绝不阻断入库。连续失败达 fail_threshold 次后熔断冷却 5 分钟。
|
||||||
|
# - dim 须与模型一致(Marqo/marqo-fashionSigLIP 实测 768 维)。
|
||||||
|
# - similar_threshold 为「疑似同款」余弦相似度阈值,已用真实 runway 数据实测校准:
|
||||||
|
# 排除同图集后,「不同图」的跨图集最佳相似度上限约 0.90,而「同一张照片」的重复落在 1.0000,
|
||||||
|
# 故取空隙中点 0.93(0.68 时实测 100% 的图都会挂提示,纯噪音)。
|
||||||
|
# 环境变量覆盖:VISION_ENABLED / VISION_ENDPOINT / VISION_TIMEOUT / VISION_DIM /
|
||||||
|
# VISION_SIMILAR_THRESHOLD / VISION_FAIL_THRESHOLD
|
||||||
|
vision:
|
||||||
|
enabled: false # env: VISION_ENABLED
|
||||||
|
endpoint: "http://127.0.0.1:8900" # env: VISION_ENDPOINT
|
||||||
|
timeout_seconds: 5 # env: VISION_TIMEOUT
|
||||||
|
dim: 768 # env: VISION_DIM
|
||||||
|
similar_threshold: 0.93 # env: VISION_SIMILAR_THRESHOLD
|
||||||
|
fail_threshold: 3 # env: VISION_FAIL_THRESHOLD
|
||||||
|
|||||||
68
db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql
Normal file
68
db/migrations/2026-10-12-01-drop-dedup-add-embeddings.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- 2026-10-12-01 下线 pHash 去重 + 启用 image_embeddings 存 FashionSigLIP 向量
|
||||||
|
--
|
||||||
|
-- 对应规格:docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §5.2 / §8
|
||||||
|
--
|
||||||
|
-- 不可逆:DROP COLUMN / DROP TABLE 无回滚手段,执行前务必确认 db/backups/ 有备份。
|
||||||
|
--
|
||||||
|
-- 应用后请重新导出结构:
|
||||||
|
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ① 下线自动去重:DROP 三列 + 两个 HNSW 索引
|
||||||
|
--
|
||||||
|
-- 注意:两个图片视图为 `SELECT i.*`,因此**必须先删视图**,删列后再按 `i.*` 重建——
|
||||||
|
-- 否则 DROP COLUMN 会报 "cannot drop column ... because other objects depend on it"。
|
||||||
|
-- 重建后 `i.*` 展开的列集自动不含已删的三列,无需显式枚举。
|
||||||
|
-- (与 db/migrations/2026-09-24-01-runway-parent-image-id.sql 处理 look_index 的做法一致。)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DROP VIEW IF EXISTS public_brand_runway_images;
|
||||||
|
DROP VIEW IF EXISTS public_street_snap_images;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS brand_runway_images_phash_hnsw;
|
||||||
|
DROP INDEX IF EXISTS street_snap_images_phash_hnsw;
|
||||||
|
|
||||||
|
ALTER TABLE brand_runway_images
|
||||||
|
DROP COLUMN IF EXISTS phash,
|
||||||
|
DROP COLUMN IF EXISTS is_duplicate,
|
||||||
|
DROP COLUMN IF EXISTS dup_of;
|
||||||
|
|
||||||
|
ALTER TABLE street_snap_images
|
||||||
|
DROP COLUMN IF EXISTS phash,
|
||||||
|
DROP COLUMN IF EXISTS is_duplicate,
|
||||||
|
DROP COLUMN IF EXISTS dup_of;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runway_images AS
|
||||||
|
SELECT i.* FROM brand_runway_images i
|
||||||
|
JOIN brand_runways r ON r.id = i.runway_id
|
||||||
|
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snap_images AS
|
||||||
|
SELECT i.* FROM street_snap_images i
|
||||||
|
JOIN street_snaps s ON s.id = i.snap_id
|
||||||
|
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ② 删死表 image_duplicates(2026-09-21 建表后全仓零引用)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DROP TABLE IF EXISTS image_duplicates;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ③ image_embeddings 改造为可用状态
|
||||||
|
-- 该表 2026-09-21 建表后一直零引用、零数据,此处原地改造:
|
||||||
|
-- - kind: smallint -> varchar(8),对齐 dto.IngestKind("runway" / "street")
|
||||||
|
-- - embedding: vector(512) -> vector(768),M0 实测模型输出 768 维
|
||||||
|
-- - 补 (kind, image_id) 唯一索引:原表只有自增主键,
|
||||||
|
-- 不加唯一约束会导致同一张图重复写入多行
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
ALTER TABLE image_embeddings
|
||||||
|
ALTER COLUMN kind TYPE varchar(8) USING kind::varchar(8),
|
||||||
|
ALTER COLUMN embedding TYPE vector(768);
|
||||||
|
|
||||||
|
-- 旧向量维度与新维度不同,重建该列上的 HNSW 索引(pgvector 索引绑定列的 typmod)
|
||||||
|
DROP INDEX IF EXISTS image_embeddings_vec_idx;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_image_embeddings_kind_image
|
||||||
|
ON image_embeddings (kind, image_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS image_embeddings_vec_idx
|
||||||
|
ON image_embeddings USING hnsw (embedding vector_cosine_ops);
|
||||||
21
db/migrations/2026-10-13-01-image-list-indexes.sql
Normal file
21
db/migrations/2026-10-13-01-image-list-indexes.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- 为「按所属图集列出图片」补索引。
|
||||||
|
--
|
||||||
|
-- 背景:`brand_runway_images` / `street_snap_images` 原本只有主键 (id),
|
||||||
|
-- 而列表与编辑页的取图查询是:
|
||||||
|
-- SELECT * FROM <图片表> WHERE <fk> = ? AND is_deleted = 0 ORDER BY sort_order
|
||||||
|
-- EXPLAIN 显示为 Seq Scan + Sort(含 Index = false):图片表已有上万行,
|
||||||
|
-- 每打开一次图集页都要全表扫一遍再排序。
|
||||||
|
--
|
||||||
|
-- 索引列顺序 (外键, is_deleted, sort_order) 同时满足等值过滤与排序,
|
||||||
|
-- 使该查询可以走 Index Scan 并直接按序返回,省掉 Sort。
|
||||||
|
--
|
||||||
|
-- 幂等:IF NOT EXISTS。可重复执行。
|
||||||
|
-- DROP 无回滚需求:索引可随时重建。
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_br_images_runway
|
||||||
|
ON public.brand_runway_images (runway_id, is_deleted, sort_order);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ss_images_snap
|
||||||
|
ON public.street_snap_images (snap_id, is_deleted, sort_order);
|
||||||
|
|
||||||
|
-- 应用后请重新导出结构:./bin/dbtool dump -clean -out db/backups/db_dump.sql
|
||||||
31
db/migrations/2026-10-13-02-image-similar-pairs.sql
Normal file
31
db/migrations/2026-10-13-02-image-similar-pairs.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- 「疑似同款」结果表:把跨图集近邻对在**入库时**算好落库,后台页面只读结果。
|
||||||
|
--
|
||||||
|
-- 背景(2026-10-13 实测):原实现在**打开图集页时**对每张图实时做一次向量近邻检索 ——
|
||||||
|
-- 一个 116 张图的图集要发 116 次查询、每次约 20KB SQL(768 维向量文本在 SELECT 与
|
||||||
|
-- ORDER BY 各出现一次)。本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay 访问,
|
||||||
|
-- 该链路大 payload 吞吐实测约 0.47MB/s:服务端执行单条只要 0.45ms,客户端往返却要 45ms,
|
||||||
|
-- 合计 116×45ms ≈ 5 秒。改为「入库时(或 dbtool similarbackfill 回填时)算一次存表」后,
|
||||||
|
-- 页面退化为一次几百字节的小查询,瓶颈消失。
|
||||||
|
--
|
||||||
|
-- 设计要点:
|
||||||
|
-- 1. **双向存两行**(A→B 与 B→A):这样打开任一张图所在的图集都能直接看到对方,读侧无需 UNION。
|
||||||
|
-- 2. **只存 id 与相似度**,不存对方图片的描述:描述靠读侧 join 图片表/图集表取得,
|
||||||
|
-- 图片被改(改名、换图集)时不会留下陈旧文案。
|
||||||
|
-- 3. **不存「重复」语义**:与规格 §9 一致 —— 只提示、不隐藏、不计入重复率、不参与任何去重。
|
||||||
|
-- 4. 图片被软删后这里的行可能残留,读侧 join 时按 is_deleted 过滤即可,无需级联清理。
|
||||||
|
--
|
||||||
|
-- 幂等:IF NOT EXISTS。无不可逆点(纯新增表,可 DROP 重建)。
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.image_similar_pairs (
|
||||||
|
kind varchar(8) NOT NULL, -- 图片类型:runway | street(一对近邻同属一种)
|
||||||
|
self_id integer NOT NULL, -- 本图
|
||||||
|
other_id integer NOT NULL, -- 近邻图
|
||||||
|
sim real NOT NULL, -- 余弦相似度 0~1
|
||||||
|
created_at integer NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (kind, self_id, other_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 主键 (kind, self_id, other_id) 已完整覆盖读法「按 kind+self_id 取某张图的所有近邻」,
|
||||||
|
-- 无需额外索引。
|
||||||
|
|
||||||
|
-- 应用后请重新导出结构:./bin/dbtool dump -clean -out db/backups/db_dump.sql
|
||||||
62
db/migrations/2026-10-16-01-brands-renumber-imported-ids.sql
Normal file
62
db/migrations/2026-10-16-01-brands-renumber-imported-ids.sql
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
-- 2026-10-16-01-brands-renumber-imported-ids.sql
|
||||||
|
--
|
||||||
|
-- 背景:2026-10-16 从 app_brands(MySQL dump)导入的 23218 个品牌带的是**源系统的显式 id**
|
||||||
|
-- (3097..26472)。而 brands.id 本身是 IDENTITY 列(GENERATED ... AS IDENTITY,序列
|
||||||
|
-- brands_id_seq),导入后序列仍停在 30 —— 新增品牌会从 31 开始编号,一旦涨到 3097
|
||||||
|
-- 就会与这批导入行**撞主键**。本迁移把那批行重排进真正的自增空间,并把序列对齐过去。
|
||||||
|
--
|
||||||
|
-- 安全性(迁移前已核对):全库没有任何行引用这些 id ——
|
||||||
|
-- * brand_runways.brand_id 最大只有 7(115 场走秀全部落在 19 个种子品牌内);
|
||||||
|
-- * favorites / histories 存的是内容(走秀/街拍)快照,不存品牌 id。
|
||||||
|
-- 19 个种子品牌(id ≤ 19)完全不动。
|
||||||
|
--
|
||||||
|
-- 三步:① 整批挪到负区间(避开主键中途相撞)② 按原 id 升序落回 20 起的连续区间
|
||||||
|
-- ③ 把 IDENTITY 序列对齐到新的最大值(下一次 nextval 即 max+1)。
|
||||||
|
--
|
||||||
|
-- ⚠️ 第 ② 步的 JOIN 必须用「当前的负数 id 本身」去匹配,**不能**拿子查询里的 -id(正数)
|
||||||
|
-- 去比 b.id(负数)—— 那样一行都匹配不上,会留下满表负数 id 的半途状态(本文件初版就踩了)。
|
||||||
|
--
|
||||||
|
-- 幂等:仅当「种子(≤19) 与导入批次(≥3097) 之间存在空洞(20..3096 全空)」时才重排;
|
||||||
|
-- 重排后 20..3096 已被占满,手动重跑会直接跳过。dbtool migrate 另按 schema_migrations 记账。
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_before_max integer;
|
||||||
|
v_moved integer;
|
||||||
|
v_after_max integer;
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM brands WHERE id BETWEEN 20 AND 3096) THEN
|
||||||
|
RAISE NOTICE '[brands-renumber] 20..3096 非空,说明已重排过,跳过';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 待处理的行:原始状态的 id >= 3097,或上一次中断留下的负数行。
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM brands WHERE id < 0 OR id >= 3097) THEN
|
||||||
|
RAISE NOTICE '[brands-renumber] 没有待重排的行,跳过';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT max(id) INTO v_before_max FROM brands;
|
||||||
|
|
||||||
|
-- 1) 原始状态:整批挪到负区间(若已是负数则天然不匹配,空跑)。
|
||||||
|
UPDATE brands SET id = -id WHERE id >= 3097;
|
||||||
|
GET DIAGNOSTICS v_moved = ROW_COUNT;
|
||||||
|
|
||||||
|
-- 2) 按原 id 升序落回「紧接 19 个种子之后」的连续区间(排序固定,结果可复现)。
|
||||||
|
UPDATE brands b
|
||||||
|
SET id = m.new_id
|
||||||
|
FROM (
|
||||||
|
SELECT id AS neg_id,
|
||||||
|
(row_number() OVER (ORDER BY -id))::integer + 19 AS new_id
|
||||||
|
FROM brands
|
||||||
|
WHERE id < 0
|
||||||
|
) m
|
||||||
|
WHERE b.id = m.neg_id;
|
||||||
|
|
||||||
|
-- 3) 把 IDENTITY 序列对齐到新的最大值。
|
||||||
|
SELECT max(id) INTO v_after_max FROM brands;
|
||||||
|
PERFORM setval(pg_get_serial_sequence('brands', 'id'), v_after_max);
|
||||||
|
|
||||||
|
RAISE NOTICE '[brands-renumber] 完成:负区间挪动 % 行,id 最大值 % -> %,序列下次给出 %',
|
||||||
|
v_moved, v_before_max, v_after_max, v_after_max + 1;
|
||||||
|
END $$;
|
||||||
43
db/migrations/2026-10-16-02-brands-renumber-repair.sql
Normal file
43
db/migrations/2026-10-16-02-brands-renumber-repair.sql
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
-- 2026-10-16-02-brands-renumber-repair.sql
|
||||||
|
--
|
||||||
|
-- 修复 2026-10-16-01 初版留下的半途状态:初版第 ② 步的 JOIN 条件写错(拿子查询里的 -id
|
||||||
|
-- 去比 b.id),一行都没匹配上,于是只完成了「整批挪到负区间」,紧接着第 ③ 步按当时的最大正值
|
||||||
|
-- (19)设了序列。结果:23218 个品牌的 id 变成负数、序列指向 20。
|
||||||
|
--
|
||||||
|
-- 本迁移把负数行按原 id 升序落回 20.. 的连续区间,并重新对齐序列。
|
||||||
|
-- 若 01 已正确完成(不存在负数行),本迁移直接跳过 —— 因此对全新建库也是无副作用的。
|
||||||
|
--
|
||||||
|
-- 安全性同上:没有任何行引用这批 id(brand_runways.brand_id 最大 7)。19 个种子不动。
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_moved integer;
|
||||||
|
v_after integer;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM brands WHERE id < 0) THEN
|
||||||
|
RAISE NOTICE '[brands-renumber-repair] 不存在负数行,无需修复,跳过';
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 目标区间若已被占用,继续重排会撞主键:宁可中止也不要写出坏数据。
|
||||||
|
IF EXISTS (SELECT 1 FROM brands WHERE id BETWEEN 20 AND 3096) THEN
|
||||||
|
RAISE EXCEPTION '[brands-renumber-repair] 20..3096 已被占用,重排会撞主键,已中止';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE brands b
|
||||||
|
SET id = m.new_id
|
||||||
|
FROM (
|
||||||
|
SELECT id AS neg_id,
|
||||||
|
(row_number() OVER (ORDER BY -id))::integer + 19 AS new_id
|
||||||
|
FROM brands
|
||||||
|
WHERE id < 0
|
||||||
|
) m
|
||||||
|
WHERE b.id = m.neg_id;
|
||||||
|
GET DIAGNOSTICS v_moved = ROW_COUNT;
|
||||||
|
|
||||||
|
SELECT max(id) INTO v_after FROM brands;
|
||||||
|
PERFORM setval(pg_get_serial_sequence('brands', 'id'), v_after);
|
||||||
|
|
||||||
|
RAISE NOTICE '[brands-renumber-repair] 完成:% 行落回连续区间,id 最大值 %,序列下次给出 %',
|
||||||
|
v_moved, v_after, v_after + 1;
|
||||||
|
END $$;
|
||||||
@ -53,3 +53,22 @@ git show c4bafc5:scripts/migrate_single_table/main.go
|
|||||||
- 若库里已有同名但口径更窄的旧 `uq_ss_entity (city, year)`,03 会静默跳过、留下错误结构;
|
- 若库里已有同名但口径更窄的旧 `uq_ss_entity (city, year)`,03 会静默跳过、留下错误结构;
|
||||||
这时必须跑 04 纠正(04 的 `DROP` 正是为此)。
|
这时必须跑 04 纠正(04 的 `DROP` 正是为此)。
|
||||||
- 反过来,若只跑 03 而库里已存在正确的同名索引,跳过是无害的。
|
- 反过来,若只跑 03 而库里已存在正确的同名索引,跳过是无害的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、2026-10-12 系列(下线 pHash 去重 + 语义向量)
|
||||||
|
|
||||||
|
| 顺序 | 文件 | 前提 / 幂等 / 不可逆点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `2026-10-12-01-drop-dedup-add-embeddings.sql` | 下线 pHash 去重(DROP `phash`/`is_duplicate`/`dup_of` + 两个 HNSW 索引 + 死表 `image_duplicates`);`image_embeddings` 的 `kind` 改 `varchar(8)`、`embedding` 改 `vector(768)`,补唯一索引并重建 HNSW。**不可逆**:DROP COLUMN / DROP TABLE 无回滚手段,执行前务必确认 `db/backups/` 有可用备份。`image_embeddings` 零数据,列类型变更无需 `USING`。 |
|
||||||
|
|
||||||
|
> 排序提示:本步必须排在「二、2026-09-22」系列**之后**执行——它 DROP 的列由那一系列的代码写入,先删列会让中间态代码报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、2026-10-13 系列(性能)
|
||||||
|
|
||||||
|
| 顺序 | 文件 | 前提 / 幂等 / 不可逆点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `2026-10-13-01-image-list-indexes.sql` | 给 `brand_runway_images (runway_id, is_deleted, sort_order)` 与 `street_snap_images (snap_id, is_deleted, sort_order)` 补索引。原先两张图片表**只有主键 (id)**,「按图集列图片」的查询是 Seq Scan + Sort,图片表上万行时图集页会变慢。`IF NOT EXISTS` 幂等,可重复执行,无不可逆点。 |
|
||||||
|
| 2 | `2026-10-13-02-image-similar-pairs.sql` | 新建 `image_similar_pairs`(「疑似同款」预计算结果表)。原实现是在**打开图集页时**对每张图实时做向量近邻检索,116 张图的图集要发 116 次查询、每次约 20KB SQL;本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay 访问,该链路大 payload 吞吐约 0.47MB/s,合计要 5 秒。改为入库时(或 `dbtool similarbackfill`)算一次存表后,页面退化为一次小查询。纯新增表,`IF NOT EXISTS` 幂等,无不可逆点。 |
|
||||||
|
|||||||
17207
db_dump.sql
17207
db_dump.sql
File diff suppressed because one or more lines are too long
1456
docs/superpowers/plans/2026-10-12-drop-phash-add-siglip-similar.md
Normal file
1456
docs/superpowers/plans/2026-10-12-drop-phash-add-siglip-similar.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,288 @@
|
|||||||
|
# 下线 pHash 去重 + FashionSigLIP「疑似同款」提示 — 设计文档
|
||||||
|
|
||||||
|
- 日期:2026-10-12
|
||||||
|
- 状态:待实现
|
||||||
|
- 相关代码:`internal/pkg/phash`、`internal/service/ingest_service.go`、`internal/repository/ingest_repository.go`、`internal/handler/backstage_handler.go`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
现有图片去重基于 `internal/pkg/phash` 的 dHash(64 位),存入图片表的 `phash vector(64)` 列,配HNSW 索引(`brand_runway_images_phash_hnsw` / `street_snap_images_phash_hnsw`,L2 距离),在 `IngestService.dedupImage` 中判定,命中则写 `is_duplicate` + `dup_of`,前台折叠、后台显示「重复」徽章与重复率统计。
|
||||||
|
|
||||||
|
dHash 只看像素,只能识别「同一文件被重复上传 / 裁剪 / 缩放 / 重压缩」,识别不了「同一件衣服被不同角度重新拍摄」。且这套机制带来持续的维护成本:`is_duplicate` / `dup_of` 是持久化关系,删图、删文章时都必须重算(`article_service.go`、`street_snap_service.go` 各有一段专门的重算逻辑)。
|
||||||
|
|
||||||
|
**决策:下线整套自动去重**,不再有任何自动折叠,重复图全部平铺,靠人工清理。
|
||||||
|
|
||||||
|
新项目 `D:\project\fashion-siglip` 已用 `marqo-fashionSigLIP`(open_clip,ViT-B/16 SigLIP)验证语义向量可行:图片编码为归一化向量后,余弦相似度可反映语义层面的相似性。**SigLIP 只用于后台提示「疑似同款」,不参与任何去重判定。**
|
||||||
|
|
||||||
|
## 2. 目标
|
||||||
|
|
||||||
|
1. 下线 pHash 去重能力(代码、schema、模板、统计、测试一并清理)。
|
||||||
|
2. 新增独立 Python 视觉服务,常驻加载 FashionSigLIP,提供图片向量(embed)能力。
|
||||||
|
3. Go worker 入库时获取向量落库;后台查看图集时展示「疑似同款」提示。
|
||||||
|
|
||||||
|
## 3. 非目标(YAGNI)
|
||||||
|
|
||||||
|
- **不做自动去重、不做自动折叠**(本次明确下线)。
|
||||||
|
- 不做文本搜图 / 图文跨模态检索。
|
||||||
|
- 不实现 `/classify` 分类接口(仅在服务边界预留)。
|
||||||
|
- 不回填历史图片向量。
|
||||||
|
- 不做阈值自动调优。
|
||||||
|
|
||||||
|
## 4. 架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
spider ──HTTP──▶ │ Go server (worker) │
|
||||||
|
│ IngestService │
|
||||||
|
│ 下载 → embed ──上传 │
|
||||||
|
└───┬──────────────────┬───────┘
|
||||||
|
│ 写/查向量 │ POST /embed (批量)
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────┐ ┌───────────────────────────┐
|
||||||
|
│ PostgreSQL │ │ Python 视觉服务 (CPU) │
|
||||||
|
│ image_embeddings │◀─│ FashionSigLIP 常驻 │
|
||||||
|
│ (vector(768)+HNSW)│ │ /embed /health │
|
||||||
|
└───────────────────┘ └───────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ 后台看图集时按需 HNSW 查询
|
||||||
|
│
|
||||||
|
BackstageHandler
|
||||||
|
```
|
||||||
|
|
||||||
|
**进程边界理由**:FashionSigLIP 是 PyTorch 模型,Go 无法直接执行。独立服务做到模型常驻一次、与 Go 发布解耦,并为将来分类能力复用同一进程。
|
||||||
|
|
||||||
|
## 5. 下线现有去重能力(删除清单)
|
||||||
|
|
||||||
|
### 5.1 代码
|
||||||
|
|
||||||
|
| 位置 | 删除内容 |
|
||||||
|
|---|---|
|
||||||
|
| `internal/pkg/phash/` | 整个包(`phash.go`、`phash_test.go`) |
|
||||||
|
| `internal/model/runway_image.go`、`internal/model/street_snap.go` | `Phash` / `IsDuplicate` / `DupOf` 字段 |
|
||||||
|
| `internal/service/ingest_service.go` | `dedupImage()` 整个函数;`concurrentFetch` 中 `res.phashBits` 计算;`timing` 的 `phash` / `dedup` 字段;落库处 `Phash` / `IsDuplicate` 赋值;`formatTiming` 中「phash / 近重复」两段 |
|
||||||
|
| `internal/service/article_service.go:297-354` | 删图 / 下架时的 `dup_of` 重算逻辑 |
|
||||||
|
| `internal/service/street_snap_service.go:398-449` | 同上 |
|
||||||
|
| `internal/repository/ingest_repository.go` | `FindNearDuplicateImage`;写入处 `phash` / `is_duplicate` / `dup_of` 三列 |
|
||||||
|
| `internal/repository/article_repository.go:430` | `phash <->` 近邻查询 |
|
||||||
|
| `internal/repository/street_snap_repository.go:372` | 同上 |
|
||||||
|
| `internal/repository/admin_repository.go` | 重复率统计查询 |
|
||||||
|
| `internal/handler/backstage_handler.go` | 重复率统计(约 333/388 行)、徽章与折叠数据装配(约 683/702/713/732 等)、`buildDupSources`(约 961 行起) |
|
||||||
|
| `internal/handler/templates/partials.html:62` | 「重复」徽章与折叠模板 |
|
||||||
|
| `cmd/diagdup/` | 整个诊断命令 |
|
||||||
|
|
||||||
|
### 5.2 Schema(新增迁移)
|
||||||
|
|
||||||
|
- `brand_runway_images`:DROP 列 `phash` / `is_duplicate` / `dup_of`,DROP 索引 `brand_runway_images_phash_hnsw`
|
||||||
|
- `street_snap_images`:DROP 列同上,DROP 索引 `street_snap_images_phash_hnsw`
|
||||||
|
- DROP 死表 `image_duplicates`(2026-09-21 建表后全仓零引用,已确认的死物)
|
||||||
|
|
||||||
|
> 该迁移不可逆(**已确认接受**),执行前确认 `db/backups/` 里有备份。
|
||||||
|
|
||||||
|
### 5.3 测试
|
||||||
|
|
||||||
|
- 删除 `internal/repository/dedup_integration_test.go`
|
||||||
|
- 清理 `editor_image_delete_integration_test.go`、`ingest_single_table_integration_test.go` 中依赖 `phash.ToVectorBits` / 重复标记的用例
|
||||||
|
- 清理 `internal/service/ingest_image_count_test.go` 中 fake 仓储的 `FindNearDuplicateImage` 方法
|
||||||
|
- 清理 `cmd/dbtool/main.go` 中相关引用
|
||||||
|
- 更新 `README.md` 中 pHash / 去重的描述
|
||||||
|
|
||||||
|
### 5.4 保留
|
||||||
|
|
||||||
|
**`sha1` 批次内内容去重保留**——它是内容哈希(同一 URL / 同一文件在一次入库批次内只处理一次),与 pHash 无关,且几乎零成本。
|
||||||
|
|
||||||
|
## 6. Python 视觉服务
|
||||||
|
|
||||||
|
由 `D:\project\fashion-siglip` 改造而来(现有 `embed_images.py` / `compare_images.py` 是每次重新加载模型的脚本原型,不可直接用于生产)。
|
||||||
|
|
||||||
|
### 6.1 接口契约
|
||||||
|
|
||||||
|
| 接口 | 入参 | 出参 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST /embed` | `{"images": ["<图片字节的 base64 数组>"]}` | `{"dim": 768, "vectors": [[...], ...]}` | 批量编码;向量已 L2 归一化 |
|
||||||
|
| `GET /health` | — | `{"ok": true, "dim": 768, "device": "cpu"}` | 探活与维度自检 |
|
||||||
|
|
||||||
|
- 单批上限由服务侧限制(建议 ≤ 32 张)。
|
||||||
|
- 损坏 / 无法解码的图片跳过,不导致整批失败。
|
||||||
|
- 维度**已由 M0 spike 实测确认为 768**(`marqo-fashionSigLIP` 输出 768 维,非 512)。Go 侧 `vision.dim` 默认值与 `image_embeddings.embedding` 列类型均按 768 处理。
|
||||||
|
|
||||||
|
### 6.2 部署
|
||||||
|
|
||||||
|
- 生产无 GPU,**CPU 推理**。日入库几百张的量级,CPU 足够。
|
||||||
|
- 依赖本地模型缓存(沿用现有脚本的 `HF_HUB_OFFLINE=1` 策略),避免运行时网络请求。
|
||||||
|
- 独立进程 / 容器,仅监听内网。
|
||||||
|
|
||||||
|
### 6.3 启动方式与端口
|
||||||
|
|
||||||
|
- **框架**:FastAPI + uvicorn(异步、批量接口顺手、自带 `/docs` 便于排查)。
|
||||||
|
- **端口**:`8900`,与 Go 侧 `vision.endpoint` 默认值一致;生产仅监听 `127.0.0.1`,启动前确认端口未被占用。
|
||||||
|
- **模型常驻单进程**:在应用 lifespan 启动阶段加载一次;加载完成前 `/health` 返回 `ready=false`,避免首个请求承担加载延迟并被 Go 侧超时打断。
|
||||||
|
- **`--workers 1`**:模型常驻进程内,多 worker 会各加载一份模型导致内存翻倍;吞吐靠**批量**(单次 `/embed` 传多张)提升,不靠多进程。
|
||||||
|
- **生产离线**:模型权重烤进镜像或挂载至 `HF_HOME`,并设 `HF_HUB_OFFLINE=1`,避免运行时访问 HuggingFace。
|
||||||
|
- **配置项**(环境变量):`VISION_HOST` / `VISION_PORT` / `VISION_MODEL` / `HF_HOME`。
|
||||||
|
- **部署**:独立 Dockerfile(`python:3.11-slim` + CPU 版 torch + open_clip + Pillow),健康检查打 `/health`。
|
||||||
|
|
||||||
|
启动命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地开发:用项目自带的 .venv(torch 2.14.1+cu132,走GPU)
|
||||||
|
.venv/Scripts/python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||||||
|
|
||||||
|
# 生产:CPU 版 torch 的容器
|
||||||
|
uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||||||
|
```
|
||||||
|
|
||||||
|
> 本地 `.venv` 已具备 CUDA 版 torch,无需重复安装;`fastapi` / `uvicorn` 已装入该venv。
|
||||||
|
> 服务会**自动探测** `cuda` 可用性并使用GPU,不可用时回退 CPU,无需配置区分。
|
||||||
|
|
||||||
|
## 7. Go 侧集成
|
||||||
|
|
||||||
|
在现有 `concurrentFetch` 的并发段中插入 embed 调用:
|
||||||
|
|
||||||
|
```
|
||||||
|
并发段(每张图):下载 → embed → 上传
|
||||||
|
串行段(按下标):组装 → 落库(同时写入图片记录与 image_embeddings)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 向量随图片记录一起写入 `image_embeddings`。
|
||||||
|
- embed 失败 / 超时 → 不写 embedding 行,**不阻断入库**。
|
||||||
|
- `timing` 结构新增 `embed` 字段,沿用既有各阶段的统计方式。
|
||||||
|
|
||||||
|
## 8. 数据模型——复用 `image_embeddings`
|
||||||
|
|
||||||
|
该表**已存在**(当前列类型 `vector(512)` + 已建好的 HNSW 余弦索引 `image_embeddings_vec_idx`),但全仓零代码引用、零数据,是死表。直接复用,不在两张图片表新增列。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 目标结构
|
||||||
|
image_embeddings (
|
||||||
|
id bigint identity primary key,
|
||||||
|
image_id integer not null,
|
||||||
|
kind varchar(8) not null, -- runway | street,对齐 dto.IngestKind
|
||||||
|
embedding vector(768), -- 实测维度,由 512 扩到 768
|
||||||
|
created_at integer not null default 0
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
需要三处改造(同样在迁移中):
|
||||||
|
|
||||||
|
1. **`embedding` 由 `vector(512)` 改为 `vector(768)`**:M0 spike 实测模型输出 768 维。表中无数据,类型变更即时完成。
|
||||||
|
2. **`kind` 由 `smallint` 改为 `varchar(8)`**,取值对齐既有的 `dto.IngestKind`(`"runway"` / `"street"`),与 `image_duplicates` 当年用 `varchar` 的做法一致。表中无数据、无代码引用,改动安全。
|
||||||
|
3. **新增唯一索引 `(kind, image_id)`**:现表只有 `id` 主键、没有业务唯一约束,不加会导致同一张图重复写入多行。
|
||||||
|
|
||||||
|
> HNSW 索引 `image_embeddings_vec_idx` 建在 `embedding` 上,列类型变更后需重建索引。
|
||||||
|
|
||||||
|
**软删一致性**:图片表有 `is_deleted`,`image_embeddings` 没有。删图后向量会残留,因此**读侧必须 join 图片表过滤 `is_deleted = 0`**(沿用 `image_duplicates` 当年的处理约定,不做主动级联清理)。
|
||||||
|
|
||||||
|
**不存持久化的「同款关系」**——同款提示在后台查看时现算现展示,避免新增/删图导致的陈旧标记。
|
||||||
|
|
||||||
|
## 9. 「疑似同款」判定与展示
|
||||||
|
|
||||||
|
- 后台查看图集时,对每张有向量的图做一次 HNSW top-k 近邻查询(k 建议 5)。
|
||||||
|
- 余弦相似度 ≥ `vision.similar_threshold` →挂「疑似同款」标签,显示「与 #\<id\> 相似 0.70」,可跳转对比。
|
||||||
|
- **阈值必须实测校准,不可拍脑袋定**(见 §12)。初稿假设的 0.85 已被实测证伪。
|
||||||
|
- **只排除图片自身**;同文章内的近邻同样参与提示——这能发现同一图集里的连拍糊片。
|
||||||
|
- **仅后台可见**,不影响前台展示、不写入任何「重复」语义。
|
||||||
|
|
||||||
|
## 10. 配置项
|
||||||
|
|
||||||
|
| 配置 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `vision.enabled` | `false` | 总开关,关闭则完全跳过 embed |
|
||||||
|
| `vision.endpoint` | `http://127.0.0.1:8900` | 服务地址 |
|
||||||
|
| `vision.timeout` | `5s` | 单批超时 |
|
||||||
|
| `vision.dim` | `768` | 向量维度(须与模型一致,已实测确认) |
|
||||||
|
| `vision.similar_threshold` | `0.85` | 「疑似同款」相似度阈值 |
|
||||||
|
| `vision.fail_threshold` | `3` | 连续失败多少次后熔断冷却 |
|
||||||
|
|
||||||
|
## 11. 降级与容错
|
||||||
|
|
||||||
|
核心原则:**视觉服务不可用绝不能影响入库。**
|
||||||
|
|
||||||
|
| 故障 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 服务未启用 | 跳过 embed,不写 embedding 行 |
|
||||||
|
| 连接失败 / 超时 | 该批向量为空,记日志,继续入库 |
|
||||||
|
| 连续失败 ≥ `fail_threshold` | 熔断冷却 **5 分钟**,期间直接跳过 embed(避免每张图都卡超时) |
|
||||||
|
| 返回维度与配置不符 | 拒绝写入并告警,防止写入脏数据 |
|
||||||
|
|
||||||
|
冷却结束后半开重试一次,成功则恢复。
|
||||||
|
|
||||||
|
## 12. 阈值校准(必须做,不可跳过)
|
||||||
|
|
||||||
|
**已实测到的关键反例**:`c1.jpg` 与 `c2.jpg` 是同一个人、同一地点、同一套穿搭(麂皮短外套 + 格纹百褶裙 + 麂皮短靴 + 托特包)的连拍,仅姿势不同——属于最典型的「同款不同拍」,但**余弦相似度只有 0.6965**。
|
||||||
|
|
||||||
|
初稿假设的 `0.85` 因此被证伪:按该阈值,这两张最该被认出来的图反而认不出来,会大量漏判。CLIP 类模型的归一化余弦相似度整体偏低,「同款不同拍」通常落在 **0.6~0.8**,「不同图」落在 0.3~0.5,真实分界线约在 **0.65~0.70**。
|
||||||
|
|
||||||
|
校准方法:取一批已知「同款不同拍」与「不同款」样本,统计相似度分布,确认两个分布的可分性,再定 `similar_threshold`。**至少需要覆盖:同套连拍、同款不同场次、不同款同风格**三类样本。
|
||||||
|
|
||||||
|
因该阈值只影响一个后台提示标签、不影响前台展示与任何去重逻辑,可以偏宽松(宁可多提示)。
|
||||||
|
|
||||||
|
## 13. 测试策略
|
||||||
|
|
||||||
|
**Go 侧**
|
||||||
|
- 入库主流程回归:图片正常入库,删除 phash 后 `go build ./...` 与全部现有测试通过。
|
||||||
|
- 降级路径:视觉服务返回错误 / 超时 / 维度不符时,入库正常完成且不产生 embedding 行。
|
||||||
|
- `vision.enabled=false` 时不产生任何 embed 请求。
|
||||||
|
- 同款查询:读侧正确过滤 `is_deleted = 1` 的图片。
|
||||||
|
|
||||||
|
**Python 侧**
|
||||||
|
- `/embed` 返回维度正确、向量已 L2 归一化(模长≈1)。
|
||||||
|
- 损坏图片被跳过而非整批失败。
|
||||||
|
- `/health` 可用。
|
||||||
|
|
||||||
|
## 14. 里程碑
|
||||||
|
|
||||||
|
**已确认:下线去重与 SigLIP 集成合成一个大改动**(同分支、同一次交付),内部按下列顺序推进,每步保持可编译、可回归。
|
||||||
|
|
||||||
|
**M0 — spike(已完成)**
|
||||||
|
把 `fashion-siglip` 包成常驻 `/embed` 服务(`app.py` + `smoke_test.py`),实测结论:
|
||||||
|
|
||||||
|
| 项 | 实测值 |
|
||||||
|
|---|---|
|
||||||
|
| 向量维度 | **768**(非设计初稿假设的 512,schema 与配置已按 768 修正) |
|
||||||
|
| 模型加载 | CPU 1.9s / GPU 2.5s(启动时一次) |
|
||||||
|
| CPU 推理 | 3 张图 217ms(约 **72ms/张**,`torch 2.14.0+cpu`) |
|
||||||
|
| GPU 推理 | 3 张图 57ms(约 **19ms/张**,`torch 2.14.1+cu132`,RTX 5060 Ti) |
|
||||||
|
| GPU/CPU 一致性 | 同一批图相似度 0.6965(GPU) vs 0.6964(CPU),**数值一致** |
|
||||||
|
| 向量归一化 | 模长 1.0000,点积即余弦相似度 |
|
||||||
|
|
||||||
|
结论:
|
||||||
|
|
||||||
|
1. CPU 单图约 72ms,日入库几百张的量级完全无压力,**生产无 GPU 的部署假设成立**。
|
||||||
|
2. 本地 GPU 比 CPU 快约 3.8 倍,且**两种设备算出的向量数值一致**——本地 GPU 调试、生产 CPU 部署不会产生数据不一致。
|
||||||
|
|
||||||
|
**M1 — 主改动(一个分支,一次交付)**,内部顺序:
|
||||||
|
|
||||||
|
| 步骤 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| ① Schema 迁移 | DROP 三列 + 两个 phash HNSW 索引 + 死表 `image_duplicates`;`image_embeddings` 的 `kind` 改 `varchar(8)` + 补 `(kind, image_id)` 唯一索引 |
|
||||||
|
| ② 下线去重 | 按 §5 清单删除 `phash` / `is_duplicate` / `dup_of` 的全部代码、模板、统计、测试、文档 |
|
||||||
|
| ③ 服务化 | Python 服务正式化(配置、批处理、错误处理、`/health`) |
|
||||||
|
| ④ Go 集成 | `image_embeddings` 仓储读写 + `concurrentFetch` 挂钩 + 降级熔断 + 配置项 |
|
||||||
|
| ⑤ 后台展示 | 图集页「疑似同款」标签与对比入口 |
|
||||||
|
|
||||||
|
每步结束时 `go build ./...` 与现有测试须通过;步骤②完成后系统进入「无自动去重」状态(前台不再折叠),需在交付说明中显式提示这一行为变化。
|
||||||
|
|
||||||
|
**M2 — 阈值校准**
|
||||||
|
dry-run 诊断,定 `vision.similar_threshold`。
|
||||||
|
|
||||||
|
## 15. 后续(不在本次范围)
|
||||||
|
|
||||||
|
### 15.1 zero-shot 分类:已存向量可直接复用
|
||||||
|
|
||||||
|
FashionSigLIP 是图文对齐模型(CLIP 架构),文本与图像编码到同一向量空间,**点积即类别分数**。`D:\project\fashion-siglip\test_similarity_debug.py` 已用 8 组分类验证过该能力:Gender / Scene、Clothing、Color、Material、Pattern、Brand、Style、Shoes / Accessories。
|
||||||
|
|
||||||
|
关键性质:**文本向量是固定的,可在服务启动时算一次并缓存**;分类因此退化为「已存图像向量 · 预计算文本向量」的纯点积运算。于是:
|
||||||
|
|
||||||
|
- ✅ **性别 / 品类 / 颜色 / 品牌 / 风格 / 材质 / 图案 / 鞋包等 zero-shot 分类,可直接复用已存向量**,读时计算,无需重新编码任何图片、无需再调 GPU。
|
||||||
|
- ❌ **若要超越 zero-shot 的准确率**(微调专用分类器或更换模型),则**必须重新编码全部图片**——不同模型的向量空间不通用,旧向量作废。
|
||||||
|
|
||||||
|
这条性质使「入库时存向量」成为**一次投入、两处收益**的选择:既买下同款检索,也买下可无限扩展的 zero-shot 分类能力。
|
||||||
|
|
||||||
|
注意:zero-shot 判定的是**造型风格**而非人物生理性别(模型主要依据服装)。中性 / oversize 穿搭、多人合影、背影、无人物图易误判,建议输出**分数而非硬标签**,允许人工覆盖。
|
||||||
|
|
||||||
|
### 15.2 其他
|
||||||
|
|
||||||
|
- 历史图片向量回填(本次确认不做)。
|
||||||
|
- 阈值自动调优。
|
||||||
@ -28,6 +28,8 @@ type Config struct {
|
|||||||
Ingest IngestConfig `yaml:"ingest"`
|
Ingest IngestConfig `yaml:"ingest"`
|
||||||
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
S4 S4Config `yaml:"s4"`
|
S4 S4Config `yaml:"s4"`
|
||||||
|
// Vision 视觉服务(FashionSigLIP)配置,用于「疑似同款」提示。
|
||||||
|
Vision VisionConfig `yaml:"vision"`
|
||||||
|
|
||||||
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
||||||
// 小写不导出,yml 无法覆盖它。
|
// 小写不导出,yml 无法覆盖它。
|
||||||
@ -154,6 +156,36 @@ type S4Config struct {
|
|||||||
StyleThumb string `yaml:"style_thumb"`
|
StyleThumb string `yaml:"style_thumb"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VisionConfig 视觉服务(FashionSigLIP)配置。
|
||||||
|
//
|
||||||
|
// 入库时把每张图编码成 768 维归一化向量写入 image_embeddings(HNSW 余弦索引),
|
||||||
|
// 后台图集页据此提示「疑似同款」。服务不可用时自动降级为「不产出向量」,绝不阻断入库。
|
||||||
|
//
|
||||||
|
// 维度 768 为 M0 spike 实测值(模型 Marqo/marqo-fashionSigLIP 输出 768 维,非 512)。
|
||||||
|
// 生产部署无 GPU,CPU 推理;本地开发可用项目 .venv(CUDA torch),两者算出的向量数值一致。
|
||||||
|
type VisionConfig struct {
|
||||||
|
// Enabled 总开关;关闭则完全跳过 embed 调用。env: VISION_ENABLED
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
// Endpoint 服务地址,仅监听内网。env: VISION_ENDPOINT
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
// TimeoutSeconds 单批 embed 超时(秒),默认 5。env: VISION_TIMEOUT
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||||
|
// Dim 向量维度,必须与模型一致;M0 实测为 768。env: VISION_DIM
|
||||||
|
Dim int `yaml:"dim"`
|
||||||
|
// SimilarThreshold 「疑似同款」余弦相似度阈值,由真实 runway 数据实测校准。
|
||||||
|
// env: VISION_SIMILAR_THRESHOLD
|
||||||
|
//
|
||||||
|
// 默认 0.93。实测依据(已排除同图集,只比跨图集):
|
||||||
|
// - 「不同图」的跨图集最佳相似度:min 0.676 / 中位 0.781 / 上限约 0.90
|
||||||
|
// (同品牌各季同风格,语义相似度天然偏高)
|
||||||
|
// - 「同一张照片」的跨图集重复:整齐落在 1.0000
|
||||||
|
// 0.93 取在噪音上限与真重复之间的空隙里。参照:0.68 时实测 100% 的图都会挂提示
|
||||||
|
// (纯噪音);而 0.85 已能滤掉绝大多数噪音。
|
||||||
|
SimilarThreshold float64 `yaml:"similar_threshold"`
|
||||||
|
// FailThreshold 连续失败多少次后熔断冷却(5 分钟)。env: VISION_FAIL_THRESHOLD
|
||||||
|
FailThreshold int `yaml:"fail_threshold"`
|
||||||
|
}
|
||||||
|
|
||||||
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
||||||
//
|
//
|
||||||
// 给 /runway-looks(列表)与 /brands 两个批量查询接口加一层请求签名校验,
|
// 给 /runway-looks(列表)与 /brands 两个批量查询接口加一层请求签名校验,
|
||||||
@ -230,8 +262,8 @@ func defaultConfig() *Config {
|
|||||||
Burst: 40,
|
Burst: 40,
|
||||||
},
|
},
|
||||||
Ingest: IngestConfig{
|
Ingest: IngestConfig{
|
||||||
Secret: "",
|
Secret: "",
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
DownloadProxy: "",
|
DownloadProxy: "",
|
||||||
},
|
},
|
||||||
S4: S4Config{
|
S4: S4Config{
|
||||||
@ -378,6 +410,14 @@ func (c *Config) applyEnv() {
|
|||||||
envStrFirst(&c.S4.BaseURL, "S4_BASE_URL", "QINIU_BASE_URL")
|
envStrFirst(&c.S4.BaseURL, "S4_BASE_URL", "QINIU_BASE_URL")
|
||||||
envStrFirst(&c.S4.StyleDisplay, "S4_STYLE_DISPLAY", "QINIU_STYLE_NORMAL")
|
envStrFirst(&c.S4.StyleDisplay, "S4_STYLE_DISPLAY", "QINIU_STYLE_NORMAL")
|
||||||
envStr("S4_STYLE_THUMB", &c.S4.StyleThumb)
|
envStr("S4_STYLE_THUMB", &c.S4.StyleThumb)
|
||||||
|
|
||||||
|
// 视觉服务(FashionSigLIP):入库时产出「疑似同款」用的语义向量。
|
||||||
|
envBool("VISION_ENABLED", &c.Vision.Enabled)
|
||||||
|
envStr("VISION_ENDPOINT", &c.Vision.Endpoint)
|
||||||
|
envInt("VISION_TIMEOUT", &c.Vision.TimeoutSeconds)
|
||||||
|
envInt("VISION_DIM", &c.Vision.Dim)
|
||||||
|
envFloat("VISION_SIMILAR_THRESHOLD", &c.Vision.SimilarThreshold)
|
||||||
|
envInt("VISION_FAIL_THRESHOLD", &c.Vision.FailThreshold)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
||||||
@ -432,6 +472,23 @@ func (c *Config) normalize() {
|
|||||||
if len(c.CORS.AllowOrigins) == 0 {
|
if len(c.CORS.AllowOrigins) == 0 {
|
||||||
c.CORS.AllowOrigins = []string{"*"}
|
c.CORS.AllowOrigins = []string{"*"}
|
||||||
}
|
}
|
||||||
|
// 视觉服务(FashionSigLIP)默认值:留空时兜底为本地服务与实测维度。
|
||||||
|
if c.Vision.Endpoint == "" {
|
||||||
|
c.Vision.Endpoint = "http://127.0.0.1:8900"
|
||||||
|
}
|
||||||
|
if c.Vision.TimeoutSeconds <= 0 {
|
||||||
|
c.Vision.TimeoutSeconds = 5
|
||||||
|
}
|
||||||
|
if c.Vision.Dim <= 0 {
|
||||||
|
c.Vision.Dim = 768
|
||||||
|
}
|
||||||
|
if c.Vision.FailThreshold <= 0 {
|
||||||
|
c.Vision.FailThreshold = 3
|
||||||
|
}
|
||||||
|
if c.Vision.SimilarThreshold <= 0 {
|
||||||
|
// 0.93 为实测校准值:跨图集「不同图」上限约 0.90,真重复落在 1.0000,取空隙中点。
|
||||||
|
c.Vision.SimilarThreshold = 0.93
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envStr(key string, dst *string) {
|
func envStr(key string, dst *string) {
|
||||||
@ -473,3 +530,11 @@ func envBool(key string, dst *bool) {
|
|||||||
*dst = v == "1" || v == "true" || v == "yes"
|
*dst = v == "1" || v == "true" || v == "yes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envFloat(key string, dst *float64) {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
*dst = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,10 @@ package dto
|
|||||||
type BrandQuery struct {
|
type BrandQuery struct {
|
||||||
Letter string // A-Z 字母索引;OTHER 表示中文分桶;空表示全部拉丁字母
|
Letter string // A-Z 字母索引;OTHER 表示中文分桶;空表示全部拉丁字母
|
||||||
Keyword string
|
Keyword string
|
||||||
|
// All 仅后台列表使用:连「没有任何走秀档案」的品牌一起列出。
|
||||||
|
// 品牌目录有 2 万+ 条导入数据,默认仍按「有档案」收敛(否则 A-Z 索引被淹),
|
||||||
|
// 后台需要翻到任意品牌时开这个开关。公开侧忽略此字段。
|
||||||
|
All bool
|
||||||
Page int
|
Page int
|
||||||
Size int // 每页条数,已写死 brandDefaultSize(30),前端不传
|
Size int // 每页条数,已写死 brandDefaultSize(30),前端不传
|
||||||
|
|
||||||
|
|||||||
@ -243,9 +243,12 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
/* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
|
/* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
|
||||||
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
||||||
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
|
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
|
||||||
.badge.dup { background: #f3e8ff; color: #6b21a8; }
|
|
||||||
.badge.free { background: #eef2ff; color: #3b4bb5; }
|
.badge.free { background: #eef2ff; color: #3b4bb5; }
|
||||||
.badge.vip { background: #fff4e0; color: #a85b00; }
|
.badge.vip { background: #fff4e0; color: #a85b00; }
|
||||||
|
/* 列表页「跨图集重复」标记:用紫(沿用历史上「重复」的色),刻意区别于状态徽标的红/绿。
|
||||||
|
* 点击直达该图集里第一张重复的图,免得逐张找。 */
|
||||||
|
.badge.dup { background: #f3ecfd; color: #6b3fa0; text-decoration: none; }
|
||||||
|
.badge.dup:hover { background: #e9dcfa; }
|
||||||
.kind {
|
.kind {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -502,21 +505,38 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 编辑页重复图标记 ---------- */
|
/* ---------- 编辑页「疑似同款」标记 ---------- */
|
||||||
.cell .badge.dup { margin-left: 8px; vertical-align: middle; }
|
/* 仅提示、不隐藏:角标用琥珀色,刻意区别于「删除/驳回」的红与旧「重复」的紫,避免误读为已折叠。
|
||||||
.cell .dup-src { margin: 0 8px 6px; font-size: 12px; }
|
* 角标内带对方的缩略图与 Look 名:必须让人一眼看出「和哪张图像」,
|
||||||
.cell .dup-src > summary { cursor: pointer; color: #6b21a8; user-select: none; outline: none; }
|
* 只给一个相似度分数,浏览者无法在图集里定位到具体那张。 */
|
||||||
.cell .dup-src-body {
|
.badge.similar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 6px;
|
gap: 6px;
|
||||||
padding: 6px;
|
margin: 0 8px 6px;
|
||||||
background: #faf5ff;
|
padding: 4px 6px;
|
||||||
border: 1px solid #e9d5ff;
|
background: #fff8e1;
|
||||||
border-radius: 6px;
|
color: #8a6d00;
|
||||||
|
border: 1px solid #f0e0a0;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.cell .dup-src-body img { width: 56px; height: 84px; object-fit: cover; border-radius: 4px; flex: 0 0 auto; background: #eee; }
|
.badge.similar:hover { background: #fff3c4; }
|
||||||
.cell .dup-title { margin-bottom: 2px; color: #333; }
|
.badge.similar img {
|
||||||
.cell .dup-link { color: #6b21a8; font-weight: 600; text-decoration: none; }
|
width: 26px;
|
||||||
.cell .dup-link:hover { text-decoration: underline; }
|
height: 39px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: #f1f1f2;
|
||||||
|
}
|
||||||
|
.badge.similar .sim-text { font-size: 11px; line-height: 1.3; min-width: 0; }
|
||||||
|
.badge.similar .sim-text b {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 近邻若落在默认隐藏的副图(.cell.det)上,锚点定位后必须可见,否则跳过去是一片空白。 */
|
||||||
|
.cell.det:target { display: flex; }
|
||||||
|
|||||||
@ -16,7 +16,9 @@
|
|||||||
var img = document.getElementById('zoom-img');
|
var img = document.getElementById('zoom-img');
|
||||||
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
|
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
img.src = btn.querySelector('img').src;
|
// 列表里显示的是缩略图(省流量、页面快),点开时才取 data-full 的全尺寸图;
|
||||||
|
// 未提供 data-full 时回落可见的 img.src。
|
||||||
|
img.src = btn.dataset.full || btn.querySelector('img').src;
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -37,11 +39,27 @@ type BackstageHandler struct {
|
|||||||
ingest *service.IngestService
|
ingest *service.IngestService
|
||||||
adminRepo repository.AdminRepository
|
adminRepo repository.AdminRepository
|
||||||
translate *service.TranslateService // 标题翻译(可为 nil:未启用时跳过记忆写入/建议)
|
translate *service.TranslateService // 标题翻译(可为 nil:未启用时跳过记忆写入/建议)
|
||||||
|
// similar 「疑似同款」提示(可为 nil:视觉服务未启用时页面不显示该角标)。
|
||||||
|
// 只提示、不隐藏、不计入重复率——与去重无关。
|
||||||
|
similar *service.SimilarService
|
||||||
|
// manual 后台「手工新建文章」(可为 nil:未装配时新建入口提示不可用)。
|
||||||
|
// 用 SetManual 注入,避免改构造签名(同 SetSimilar 的取舍)。
|
||||||
|
manual *service.ManualArticleService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetManual 注入手工建稿服务(装配期调用;未注入时新建页提示不可用)。
|
||||||
|
func (h *BackstageHandler) SetManual(svc *service.ManualArticleService) { h.manual = svc }
|
||||||
|
|
||||||
|
// manualSeasonOptions / manualCollectionOptions 手工建稿表单的下拉选项。
|
||||||
|
// 与 season.Derive 支持的取值保持一致(collection_type 优先决定 season_code 前缀)。
|
||||||
|
var (
|
||||||
|
manualSeasonOptions = []string{"spring", "fall", "resort", "pre_fall"}
|
||||||
|
manualCollectionOptions = []string{"rtw", "menswear", "couture", "resort", "pre_fall"}
|
||||||
|
)
|
||||||
|
|
||||||
// NewBackstageHandler 创建后台 handler。
|
// NewBackstageHandler 创建后台 handler。
|
||||||
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository, translate *service.TranslateService) *BackstageHandler {
|
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository, translate *service.TranslateService, similar *service.SimilarService) *BackstageHandler {
|
||||||
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo, translate: translate}
|
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo, translate: translate, similar: similar}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
|
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
|
||||||
@ -122,8 +140,11 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
|
|||||||
q := dto.BrandQuery{
|
q := dto.BrandQuery{
|
||||||
Keyword: c.Query("keyword"),
|
Keyword: c.Query("keyword"),
|
||||||
Letter: c.Query("letter"),
|
Letter: c.Query("letter"),
|
||||||
Page: atoiDefault(c.Query("page"), 1),
|
// all=1:连「没有图集」的品牌一起列。品牌目录有 2 万+ 条导入数据,
|
||||||
Locale: "en",
|
// 默认按「有图集」收敛,否则后台列表等于看不到导入的品牌。
|
||||||
|
All: c.Query("all") == "1",
|
||||||
|
Page: atoiDefault(c.Query("page"), 1),
|
||||||
|
Locale: "en",
|
||||||
}
|
}
|
||||||
items, total, err := h.brand.AdminList(c.Request.Context(), q)
|
items, total, err := h.brand.AdminList(c.Request.Context(), q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -141,6 +162,8 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
|
|||||||
"Total": total,
|
"Total": total,
|
||||||
"Keyword": q.Keyword,
|
"Keyword": q.Keyword,
|
||||||
"Letter": q.Letter,
|
"Letter": q.Letter,
|
||||||
|
"All": q.All,
|
||||||
|
"Letters": lettersAtoZ(),
|
||||||
"Pager": pagination.Pager{
|
"Pager": pagination.Pager{
|
||||||
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
NextURL: pagination.PageLink(c, meta.NextPage),
|
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||||
@ -151,6 +174,25 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lettersAtoZ 返回 A-Z 字母索引分桶,供品牌页渲染索引条(避免模板里硬写 26 个 <a>)。
|
||||||
|
func lettersAtoZ() []string {
|
||||||
|
out := make([]string, 0, 26)
|
||||||
|
for ch := 'A'; ch <= 'Z'; ch++ {
|
||||||
|
out = append(out, string(ch))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrandDelete 软删品牌。护栏在 service 层(有未删除图集时拒绝),
|
||||||
|
// 失败时把原因渲染到错误页(含还剩多少图集),便于运营先处理档案。
|
||||||
|
func (h *BackstageHandler) BrandDelete(c *gin.Context) {
|
||||||
|
if err := h.brand.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/admin/brands")
|
||||||
|
}
|
||||||
|
|
||||||
// BrandNew 渲染品牌新建表单。
|
// BrandNew 渲染品牌新建表单。
|
||||||
func (h *BackstageHandler) BrandNew(c *gin.Context) {
|
func (h *BackstageHandler) BrandNew(c *gin.Context) {
|
||||||
renderPage(c, http.StatusOK, "brand-form", gin.H{})
|
renderPage(c, http.StatusOK, "brand-form", gin.H{})
|
||||||
@ -330,10 +372,10 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
|
// 批量取每篇图集的原图统计(字节数 + 主/副图行数),与列表共用一次扫描。
|
||||||
dupStats := h.dupStatsByCards(c.Request.Context(), cards)
|
stats := h.imageStatsByCards(c.Request.Context(), cards)
|
||||||
// 批量统计每篇图集的原图总大小(基于 image_size 列,worker 下载时写入源文件字节数)。
|
// 跨图集重复概况:列表上直接标出「哪些图集有重复」,否则只能逐个点进图集才能发现。
|
||||||
imageSizes := h.imageSizeByCards(c.Request.Context(), cards)
|
dupStats := h.similarDupStats(c.Request.Context())
|
||||||
items := make([]gin.H, 0, len(cards))
|
items := make([]gin.H, 0, len(cards))
|
||||||
for _, card := range cards {
|
for _, card := range cards {
|
||||||
var t byte
|
var t byte
|
||||||
@ -342,22 +384,32 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
t = hashid.TypeSnap
|
t = hashid.TypeSnap
|
||||||
}
|
}
|
||||||
ds := dupStats[card.ID]
|
st := stats[fmt.Sprintf("%s:%d", card.Kind, card.ID)]
|
||||||
dupPct := 0.0
|
dup := service.DupStat{}
|
||||||
if ds.Total > 0 {
|
if byOwner, ok := dupStats[card.Kind]; ok {
|
||||||
dupPct = float64(ds.Dup) / float64(ds.Total) * 100
|
dup = byOwner[card.ID]
|
||||||
|
}
|
||||||
|
// 重复率的分母用实时统计的真实图片数,不用过期的 image_count 缓存列。
|
||||||
|
dupRate := 0.0
|
||||||
|
if dup.Total > 0 {
|
||||||
|
dupRate = float64(dup.Images) * 100 / float64(dup.Total)
|
||||||
}
|
}
|
||||||
items = append(items, gin.H{
|
items = append(items, gin.H{
|
||||||
"Kind": card.Kind,
|
"Kind": card.Kind,
|
||||||
"UID": hashid.EncodeWithType(card.ID, t),
|
"UID": hashid.EncodeWithType(card.ID, t),
|
||||||
"ID": card.ID,
|
"ID": card.ID,
|
||||||
"Title": card.Title,
|
"Title": card.Title,
|
||||||
"Subtitle": card.Subtitle,
|
"Subtitle": card.Subtitle,
|
||||||
"ImageCount": card.ImageCount,
|
"Status": card.Status,
|
||||||
"ImageTotalSize": imageSizes[card.ID],
|
"ImageCount": card.ImageCount, // 图集表缓存值:只数主图(公开角标口径)
|
||||||
"Status": card.Status,
|
"ImageMain": st.Main, // 真实主图行数
|
||||||
"DupCount": ds.Dup,
|
"ImageDetail": st.Detail, // 真实副图(细节图)行数
|
||||||
"DupPct": dupPct,
|
"ImageTotal": st.Main + st.Detail, // 真实图数 = 主 + 副
|
||||||
|
"ImageTotalSize": st.Size,
|
||||||
|
"DupImages": dup.Images,
|
||||||
|
"DupTotal": dup.Total,
|
||||||
|
"DupRate": dupRate,
|
||||||
|
"DupFirstImageID": dup.FirstImageID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// 状态 tab 角标(空串=全部)。
|
// 状态 tab 角标(空串=全部)。
|
||||||
@ -371,9 +423,11 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
|
|||||||
p := pagination.Params{Page: page, PageSize: size}
|
p := pagination.Params{Page: page, PageSize: size}
|
||||||
meta := p.NewMeta(total)
|
meta := p.NewMeta(total)
|
||||||
renderPage(c, http.StatusOK, "articles", gin.H{
|
renderPage(c, http.StatusOK, "articles", gin.H{
|
||||||
"Items": items,
|
"Items": items,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"Kind": kind,
|
"Kind": kind,
|
||||||
|
// ShowDup 为 false 时模板不渲染「重复」列(视觉服务未启用时整列无意义)。
|
||||||
|
"ShowDup": len(dupStats) > 0,
|
||||||
"TabCounts": tabCounts,
|
"TabCounts": tabCounts,
|
||||||
"Pager": pagination.Pager{
|
"Pager": pagination.Pager{
|
||||||
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
@ -385,64 +439,146 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
|
// similarDupStats 取「跨图集重复」汇总(kind -> 图集 id -> 概况)。
|
||||||
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
|
// 服务未注入(视觉服务未启用)时返回 nil,列表页据此整列不渲染。
|
||||||
func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
|
func (h *BackstageHandler) similarDupStats(ctx context.Context) map[string]map[uint32]service.DupStat {
|
||||||
out := make(map[uint32]repository.DupStat)
|
if h.similar == nil {
|
||||||
runwayIDs := make([]uint32, 0, len(cards))
|
return nil
|
||||||
streetIDs := make([]uint32, 0, len(cards))
|
}
|
||||||
|
return h.similar.DupStats(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarHintView 是「疑似同款」提示的模板视图:在 service 的 SimilarHint 基础上
|
||||||
|
// 补上 hashid 编码后的所属记录 UID(模板里用于生成跳转链接)。
|
||||||
|
//
|
||||||
|
// Image / Name / OwnerTitle 让角标能直接显示「和哪张图像」——
|
||||||
|
// 只给一个分数,浏览者无法在图集里定位到具体是哪张。
|
||||||
|
type similarHintView struct {
|
||||||
|
ImageID uint32
|
||||||
|
Similar float64
|
||||||
|
UID string // 近邻所属走秀 / 街拍的编码 UID
|
||||||
|
Image string // 近邻图片存储 key(模板显示缩略图)
|
||||||
|
Name string // 近邻图片名(如 "Look 12")
|
||||||
|
OwnerTitle string // 近邻所属图集标题
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarHints 查询一批图片的「疑似同款」提示;similar 未注入(视觉服务未启用)时返回 nil。
|
||||||
|
//
|
||||||
|
// 该查询只服务于后台角标展示:service 层已吞掉所有错误(取向量失败、近邻查询失败都只记日志),
|
||||||
|
// 因此这里不返回 error —— 查不到同款绝不能让图集页渲染失败。
|
||||||
|
func (h *BackstageHandler) similarHints(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]similarHintView {
|
||||||
|
if h.similar == nil || len(imageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw := h.similar.HintsForImages(ctx, kind, imageIDs)
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
typ := byte(hashid.TypeRunway)
|
||||||
|
if kind == "street" {
|
||||||
|
typ = byte(hashid.TypeSnap)
|
||||||
|
}
|
||||||
|
out := make(map[uint32][]similarHintView, len(raw))
|
||||||
|
for imgID, hints := range raw {
|
||||||
|
views := make([]similarHintView, 0, len(hints))
|
||||||
|
for _, hint := range hints {
|
||||||
|
views = append(views, similarHintView{
|
||||||
|
ImageID: hint.ImageID,
|
||||||
|
Similar: hint.Similar,
|
||||||
|
UID: hashid.EncodeWithType(hint.OwnerID, typ),
|
||||||
|
Image: hint.Image,
|
||||||
|
Name: hint.Name,
|
||||||
|
OwnerTitle: hint.OwnerTitle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out[imgID] = views
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageStatsByCards 按卡片类型批量取每篇图集的原图统计(字节数 + 主/副图行数)。
|
||||||
|
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无数据」(不阻断列表)。
|
||||||
|
//
|
||||||
|
// key 用 "kind:id" 而非裸 id:两张表主键各自独立,裸 id 会让同号记录(如 runway 7 与 street 7)
|
||||||
|
// 互相覆盖,把对方的图数与大小显示成本条的值。
|
||||||
|
func (h *BackstageHandler) imageStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[string]repository.AdminRecordStats {
|
||||||
|
out := make(map[string]repository.AdminRecordStats, len(cards))
|
||||||
|
byKind := make(map[string][]uint32, 2)
|
||||||
for _, c := range cards {
|
for _, c := range cards {
|
||||||
if c.Kind == "runway" {
|
byKind[c.Kind] = append(byKind[c.Kind], c.ID)
|
||||||
runwayIDs = append(runwayIDs, c.ID)
|
|
||||||
} else {
|
|
||||||
streetIDs = append(streetIDs, c.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(runwayIDs) > 0 {
|
for kind, ids := range byKind {
|
||||||
if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
|
if len(ids) == 0 {
|
||||||
for id, s := range m {
|
continue
|
||||||
out[id] = s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
m, err := h.adminRepo.ImageSizeByRecords(ctx, kind, ids)
|
||||||
if len(streetIDs) > 0 {
|
if err != nil {
|
||||||
if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
|
continue
|
||||||
for id, s := range m {
|
}
|
||||||
out[id] = s
|
for id, st := range m {
|
||||||
}
|
out[fmt.Sprintf("%s:%d", kind, id)] = st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageSizeByCards 按卡片类型批量取每篇图集的原图字节数之和(image_size 列)。
|
// ArticleNew 渲染手工新建文章表单(走秀 / 街拍二选一,一步到位带图片上传)。
|
||||||
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无大小」(不阻断列表)。
|
func (h *BackstageHandler) ArticleNew(c *gin.Context) {
|
||||||
func (h *BackstageHandler) imageSizeByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]int64 {
|
kind := c.Query("kind")
|
||||||
out := make(map[uint32]int64)
|
if kind != "street" {
|
||||||
runwayIDs := make([]uint32, 0, len(cards))
|
kind = "runway"
|
||||||
streetIDs := make([]uint32, 0, len(cards))
|
|
||||||
for _, c := range cards {
|
|
||||||
if c.Kind == "runway" {
|
|
||||||
runwayIDs = append(runwayIDs, c.ID)
|
|
||||||
} else {
|
|
||||||
streetIDs = append(streetIDs, c.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(runwayIDs) > 0 {
|
renderPage(c, http.StatusOK, "article-form", gin.H{
|
||||||
if m, err := h.adminRepo.ImageSizeByRecords(ctx, "runway", runwayIDs); err == nil {
|
"Kind": kind,
|
||||||
for id, s := range m {
|
"Seasons": manualSeasonOptions,
|
||||||
out[id] = s
|
"Collections": manualCollectionOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleCreate 处理手工新建:解析 multipart(元数据 + 多张图片)→ 上传 + 落库(status=pending)。
|
||||||
|
// 校验失败时把错误和已填内容一起回填到表单,避免重填。
|
||||||
|
func (h *BackstageHandler) ArticleCreate(c *gin.Context) {
|
||||||
|
if h.manual == nil {
|
||||||
|
renderPage(c, http.StatusServiceUnavailable, "error", gin.H{"Error": "手工建稿未启用(服务未装配)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := service.ManualArticleInput{
|
||||||
|
Kind: c.PostForm("kind"),
|
||||||
|
BrandName: c.PostForm("brand_name"),
|
||||||
|
TitleEn: c.PostForm("title_en"),
|
||||||
|
TitleCn: c.PostForm("title_cn"),
|
||||||
|
Year: uint16(atoiDefault(c.PostForm("year"), 0)),
|
||||||
|
Season: c.PostForm("season"),
|
||||||
|
CollectionType: c.PostForm("collection_type"),
|
||||||
|
City: c.PostForm("city"),
|
||||||
|
}
|
||||||
|
// 图片全量读入内存(单张上限由 service 校验;这里只负责搬运)。
|
||||||
|
if form, err := c.MultipartForm(); err == nil && form != nil {
|
||||||
|
for _, fh := range form.File["images"] {
|
||||||
|
f, oerr := fh.Open()
|
||||||
|
if oerr != nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
data, rerr := io.ReadAll(f)
|
||||||
}
|
_ = f.Close()
|
||||||
if len(streetIDs) > 0 {
|
if rerr != nil {
|
||||||
if m, err := h.adminRepo.ImageSizeByRecords(ctx, "street", streetIDs); err == nil {
|
continue
|
||||||
for id, s := range m {
|
|
||||||
out[id] = s
|
|
||||||
}
|
}
|
||||||
|
in.Images = append(in.Images, service.ManualImage{Filename: fh.Filename, Data: data})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
|
id, err := h.manual.Create(c.Request.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
renderPage(c, http.StatusBadRequest, "article-form", gin.H{
|
||||||
|
"Kind": in.Kind, "Error": err.Error(),
|
||||||
|
"BrandName": in.BrandName, "TitleEn": in.TitleEn, "TitleCn": in.TitleCn,
|
||||||
|
"Year": in.Year, "Season": in.Season, "CollectionType": in.CollectionType, "City": in.City,
|
||||||
|
"Seasons": manualSeasonOptions, "Collections": manualCollectionOptions,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, fmt.Sprintf("/admin/articles?created=%d", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
|
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
|
||||||
@ -657,6 +793,9 @@ func usernameString(v any) string {
|
|||||||
// RunwayEdit 渲染走秀编辑表单(预填现有字段 + 图片管理)。
|
// RunwayEdit 渲染走秀编辑表单(预填现有字段 + 图片管理)。
|
||||||
func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
||||||
raw := c.Param("id")
|
raw := c.Param("id")
|
||||||
|
// 临时性能探针:分段计时,定位页面 7 秒慢在哪一段。
|
||||||
|
start := time.Now()
|
||||||
|
mark := start
|
||||||
numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway)
|
numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||||
@ -674,15 +813,22 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
brandName = b.NameCn
|
brandName = b.NameCn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tFetch := time.Since(mark)
|
||||||
|
mark = time.Now()
|
||||||
cells := make([]gin.H, 0, len(imgs))
|
cells := make([]gin.H, 0, len(imgs))
|
||||||
var dupCount int
|
// 语义向量「疑似同款」提示:只提示、不隐藏、不计入重复率。视觉服务未启用时返回 nil(渲染无角标)。
|
||||||
|
// 现算现展示、不持久化,因此无需任何「删图后重算」逻辑。
|
||||||
|
imgIDs := make([]uint32, 0, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
imgIDs = append(imgIDs, im.ID)
|
||||||
|
}
|
||||||
|
hintByImage := h.similarHints(c.Request.Context(), "runway", imgIDs)
|
||||||
|
tHint := time.Since(mark)
|
||||||
|
mark = time.Now()
|
||||||
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
|
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
|
||||||
detailsByParent := map[uint32][]model.BrandRunwayImage{}
|
detailsByParent := map[uint32][]model.BrandRunwayImage{}
|
||||||
var mains []model.BrandRunwayImage
|
var mains []model.BrandRunwayImage
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
if im.IsDuplicate == 1 {
|
|
||||||
dupCount++
|
|
||||||
}
|
|
||||||
if im.IsDetail == 1 {
|
if im.IsDetail == 1 {
|
||||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||||
} else {
|
} else {
|
||||||
@ -693,25 +839,23 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
for _, m := range mains {
|
for _, m := range mains {
|
||||||
mainIDs[m.ID] = true
|
mainIDs[m.ID] = true
|
||||||
cells = append(cells, gin.H{
|
cells = append(cells, gin.H{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"image": m.Image,
|
"image": m.Image,
|
||||||
"name": m.Name,
|
"name": m.Name,
|
||||||
"isDetail": 0,
|
"isDetail": 0,
|
||||||
"role": "主图",
|
"role": "主图",
|
||||||
"DetCount": len(detailsByParent[m.ID]),
|
"DetCount": len(detailsByParent[m.ID]),
|
||||||
"isDuplicate": int(m.IsDuplicate),
|
"similarHints": hintByImage[m.ID],
|
||||||
"dupOf": m.DupOf,
|
|
||||||
})
|
})
|
||||||
for _, d := range detailsByParent[m.ID] {
|
for _, d := range detailsByParent[m.ID] {
|
||||||
cells = append(cells, gin.H{
|
cells = append(cells, gin.H{
|
||||||
"id": d.ID,
|
"id": d.ID,
|
||||||
"image": d.Image,
|
"image": d.Image,
|
||||||
"name": d.Name,
|
"name": d.Name,
|
||||||
"isDetail": 1,
|
"isDetail": 1,
|
||||||
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
|
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
|
||||||
"parent": d.ParentImageID,
|
"parent": d.ParentImageID,
|
||||||
"isDuplicate": int(d.IsDuplicate),
|
"similarHints": hintByImage[d.ID],
|
||||||
"dupOf": d.DupOf,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -722,42 +866,36 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
for _, d := range ds {
|
for _, d := range ds {
|
||||||
cells = append(cells, gin.H{
|
cells = append(cells, gin.H{
|
||||||
"id": d.ID,
|
"id": d.ID,
|
||||||
"image": d.Image,
|
"image": d.Image,
|
||||||
"name": d.Name,
|
"name": d.Name,
|
||||||
"isDetail": 1,
|
"isDetail": 1,
|
||||||
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
|
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
|
||||||
"parent": d.ParentImageID,
|
"parent": d.ParentImageID,
|
||||||
"orphan": true,
|
"orphan": true,
|
||||||
"isDuplicate": int(d.IsDuplicate),
|
"similarHints": hintByImage[d.ID],
|
||||||
"dupOf": d.DupOf,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dupPct := 0.0
|
|
||||||
if n := len(imgs); n > 0 {
|
|
||||||
dupPct = float64(dupCount) / float64(n) * 100
|
|
||||||
}
|
|
||||||
dupSources := h.buildDupSources(c.Request.Context(), "runway", cells)
|
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
totalSize += im.ImageSize
|
totalSize += im.ImageSize
|
||||||
}
|
}
|
||||||
renderPage(c, http.StatusOK, "runway-edit", gin.H{
|
renderPage(c, http.StatusOK, "runway-edit", gin.H{
|
||||||
"UID": raw,
|
"UID": raw,
|
||||||
"Kind": "runway",
|
"Kind": "runway",
|
||||||
"RecordID": rw.ID,
|
"RecordID": rw.ID,
|
||||||
"Status": rw.Status,
|
"Status": rw.Status,
|
||||||
"Runway": rw,
|
"Runway": rw,
|
||||||
"ImageTotalSize": totalSize,
|
"ImageTotalSize": totalSize,
|
||||||
"Images": imgs,
|
"Images": imgs,
|
||||||
"Cells": cells,
|
"Cells": cells,
|
||||||
"BrandName": brandName,
|
"BrandName": brandName,
|
||||||
"IsDown": rw.IsDeleted == 1,
|
"IsDown": rw.IsDeleted == 1,
|
||||||
"DupCount": dupCount,
|
|
||||||
"DupPct": dupPct,
|
|
||||||
"DupSources": dupSources,
|
|
||||||
})
|
})
|
||||||
|
tRender := time.Since(mark)
|
||||||
|
log.Printf("[slow-edit] %s id=%d 图=%d | 取数=%v 同款=%v 渲染=%v 合计=%v",
|
||||||
|
raw, rw.ID, len(imgs), tFetch, tHint, tRender, time.Since(start))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunwayUpdate 应用走秀字段编辑。
|
// RunwayUpdate 应用走秀字段编辑。
|
||||||
@ -874,14 +1012,16 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cells := make([]gin.H, 0, len(imgs))
|
cells := make([]gin.H, 0, len(imgs))
|
||||||
var dupCount int
|
// 语义向量「疑似同款」提示(同走秀编辑页:只提示、不隐藏、不计入重复率)。
|
||||||
|
imgIDs := make([]uint32, 0, len(imgs))
|
||||||
|
for _, im := range imgs {
|
||||||
|
imgIDs = append(imgIDs, im.ID)
|
||||||
|
}
|
||||||
|
hintByImage := h.similarHints(c.Request.Context(), "street", imgIDs)
|
||||||
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
|
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
|
||||||
detailsByParent := map[uint32][]model.StreetSnapImage{}
|
detailsByParent := map[uint32][]model.StreetSnapImage{}
|
||||||
var mains []model.StreetSnapImage
|
var mains []model.StreetSnapImage
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
if im.IsDuplicate == 1 {
|
|
||||||
dupCount++
|
|
||||||
}
|
|
||||||
if im.IsDetail == 1 {
|
if im.IsDetail == 1 {
|
||||||
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||||
} else {
|
} else {
|
||||||
@ -892,25 +1032,23 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
for _, m := range mains {
|
for _, m := range mains {
|
||||||
mainIDs[m.ID] = true
|
mainIDs[m.ID] = true
|
||||||
cells = append(cells, gin.H{
|
cells = append(cells, gin.H{
|
||||||
"id": m.ID,
|
"id": m.ID,
|
||||||
"image": m.Image,
|
"image": m.Image,
|
||||||
"name": m.Name,
|
"name": m.Name,
|
||||||
"isDetail": 0,
|
"isDetail": 0,
|
||||||
"role": "主图",
|
"role": "主图",
|
||||||
"DetCount": len(detailsByParent[m.ID]),
|
"DetCount": len(detailsByParent[m.ID]),
|
||||||
"isDuplicate": int(m.IsDuplicate),
|
"similarHints": hintByImage[m.ID],
|
||||||
"dupOf": m.DupOf,
|
|
||||||
})
|
})
|
||||||
for _, d := range detailsByParent[m.ID] {
|
for _, d := range detailsByParent[m.ID] {
|
||||||
cells = append(cells, gin.H{
|
cells = append(cells, gin.H{
|
||||||
"id": d.ID,
|
"id": d.ID,
|
||||||
"image": d.Image,
|
"image": d.Image,
|
||||||
"name": d.Name,
|
"name": d.Name,
|
||||||
"isDetail": 1,
|
"isDetail": 1,
|
||||||
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
|
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
|
||||||
"parent": d.ParentImageID,
|
"parent": d.ParentImageID,
|
||||||
"isDuplicate": int(d.IsDuplicate),
|
"similarHints": hintByImage[d.ID],
|
||||||
"dupOf": d.DupOf,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -921,99 +1059,34 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
for _, d := range ds {
|
for _, d := range ds {
|
||||||
cells = append(cells, gin.H{
|
cells = append(cells, gin.H{
|
||||||
"id": d.ID,
|
"id": d.ID,
|
||||||
"image": d.Image,
|
"image": d.Image,
|
||||||
"name": d.Name,
|
"name": d.Name,
|
||||||
"isDetail": 1,
|
"isDetail": 1,
|
||||||
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
|
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
|
||||||
"parent": d.ParentImageID,
|
"parent": d.ParentImageID,
|
||||||
"orphan": true,
|
"orphan": true,
|
||||||
"isDuplicate": int(d.IsDuplicate),
|
"similarHints": hintByImage[d.ID],
|
||||||
"dupOf": d.DupOf,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dupPct := 0.0
|
|
||||||
if n := len(imgs); n > 0 {
|
|
||||||
dupPct = float64(dupCount) / float64(n) * 100
|
|
||||||
}
|
|
||||||
dupSources := h.buildDupSources(c.Request.Context(), "street", cells)
|
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
totalSize += im.ImageSize
|
totalSize += im.ImageSize
|
||||||
}
|
}
|
||||||
renderPage(c, http.StatusOK, "snap-edit", gin.H{
|
renderPage(c, http.StatusOK, "snap-edit", gin.H{
|
||||||
"UID": raw,
|
"UID": raw,
|
||||||
"Kind": "street",
|
"Kind": "street",
|
||||||
"RecordID": snap.ID,
|
"RecordID": snap.ID,
|
||||||
"Status": snap.Status,
|
"Status": snap.Status,
|
||||||
"Snap": snap,
|
"Snap": snap,
|
||||||
"ImageTotalSize": totalSize,
|
"ImageTotalSize": totalSize,
|
||||||
"Images": imgs,
|
"Images": imgs,
|
||||||
"Cells": cells,
|
"Cells": cells,
|
||||||
"IsDown": snap.IsDeleted == 1,
|
"IsDown": snap.IsDeleted == 1,
|
||||||
"DupCount": dupCount,
|
|
||||||
"DupPct": dupPct,
|
|
||||||
"DupSources": dupSources,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDupSources 反查重复源上下文,返回以源图 id(dup_of 字符串)为键的模板数据。
|
|
||||||
// 仅对有重复标记(isDuplicate==1 且 dup_of 非空)的图做批量反查,避免逐图 N+1。
|
|
||||||
func (h *BackstageHandler) buildDupSources(ctx context.Context, kind string, cells []gin.H) map[string]gin.H {
|
|
||||||
out := map[string]gin.H{}
|
|
||||||
seen := map[uint32]bool{}
|
|
||||||
ids := make([]uint32, 0)
|
|
||||||
for _, c := range cells {
|
|
||||||
if v, ok := c["isDuplicate"].(int); !ok || v != 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s, ok := c["dupOf"].(string)
|
|
||||||
if !ok || s == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseUint(s, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
u := uint32(id)
|
|
||||||
if seen[u] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[u] = true
|
|
||||||
ids = append(ids, u)
|
|
||||||
}
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
var (
|
|
||||||
srcs map[uint32]dto.DupSource
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if kind == "street" {
|
|
||||||
srcs, err = h.street.GetStreetSnapDupSources(ctx, ids)
|
|
||||||
} else {
|
|
||||||
srcs, err = h.article.GetRunwayDupSources(ctx, ids)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
typ := byte(hashid.TypeRunway)
|
|
||||||
if kind == "street" {
|
|
||||||
typ = byte(hashid.TypeSnap)
|
|
||||||
}
|
|
||||||
for id, s := range srcs {
|
|
||||||
out[strconv.FormatUint(uint64(id), 10)] = gin.H{
|
|
||||||
"srcImage": s.Image,
|
|
||||||
"srcTitle": s.Title,
|
|
||||||
"srcBrand": s.Brand,
|
|
||||||
"srcUID": hashid.EncodeWithType(s.RecordID, typ),
|
|
||||||
"srcKind": kind,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreetSnapUpdate 应用街拍字段编辑。
|
// StreetSnapUpdate 应用街拍字段编辑。
|
||||||
func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
||||||
raw := c.Param("id")
|
raw := c.Param("id")
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import (
|
|||||||
// (renderPage 会 500 "unknown backstage page: xxx")。
|
// (renderPage 会 500 "unknown backstage page: xxx")。
|
||||||
func TestBackstageTemplatesParsed(t *testing.T) {
|
func TestBackstageTemplatesParsed(t *testing.T) {
|
||||||
want := []string{
|
want := []string{
|
||||||
|
"article-form",
|
||||||
"articles",
|
"articles",
|
||||||
"brand-form",
|
"brand-form",
|
||||||
"brands",
|
"brands",
|
||||||
@ -92,13 +93,29 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
|||||||
data[k] = v
|
data[k] = v
|
||||||
}
|
}
|
||||||
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
||||||
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
|
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
|
||||||
data["Kind"] = kind
|
data["Kind"] = kind
|
||||||
data["RecordID"] = uint32(7)
|
data["RecordID"] = uint32(7)
|
||||||
data["UID"] = "abc123"
|
data["UID"] = "abc123"
|
||||||
data["Status"] = "pending"
|
data["Status"] = "pending"
|
||||||
data["Cells"] = []gin.H{}
|
// Cells 里放一个带「疑似同款」提示的单元格:确保徽章 markup 能真正渲染
|
||||||
data["BrandName"] = "Brand"
|
// (视觉服务未启用时 similarHints 为 nil,range 不产出任何角标)。
|
||||||
|
// 字段与 handler 的 similarHintView 保持一致:角标要显示对方的缩略图与 Look 名,
|
||||||
|
// 缺 Image 会让 imgURL 拿到 nil 而渲染失败。
|
||||||
|
data["Cells"] = []gin.H{{
|
||||||
|
"id": 1, "image": "/uploads/sample.jpg", "name": "Look 1",
|
||||||
|
"isDetail": 0, "role": "主图",
|
||||||
|
"similarHints": []any{
|
||||||
|
map[string]any{
|
||||||
|
"UID": "abc123", "Similar": 0.91, "ImageID": uint32(9),
|
||||||
|
"Image": "runway/abc.jpg", "Name": "Look 12", "OwnerTitle": "Fall 2019 Menswear",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
data["BrandName"] = "Brand"
|
||||||
|
// 编辑页会渲染「原图总大小」;真实 handler 注入 int64,测试需给同类型零值,
|
||||||
|
// 否则 formatBytes 收到 nil 会报 "invalid value; expected int64"。
|
||||||
|
data["ImageTotalSize"] = int64(0)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
@ -121,6 +138,66 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestArticlesShowsCrossGalleryDuplicates 文章列表必须直接标出「哪些图集有重复」——
|
||||||
|
// 否则只能一个个点进图集才能发现,132 个图集根本看不过来。
|
||||||
|
//
|
||||||
|
// 钉住三件事:有重复时渲染标记并锚点到本图集第一张重复的图;无重复时只渲染占位符;
|
||||||
|
// 视觉服务未启用(ShowDup=false)时整列不出现。
|
||||||
|
func TestArticlesShowsCrossGalleryDuplicates(t *testing.T) {
|
||||||
|
render := func(showDup bool, items []gin.H) string {
|
||||||
|
data := gin.H{
|
||||||
|
"Items": items, "Status": "", "Kind": "", "ShowDup": showDup,
|
||||||
|
"TabCounts": map[string]int64{}, "Username": "admin",
|
||||||
|
"Pager": pagination.Pager{Label: "第 1 页"},
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/articles", nil)
|
||||||
|
renderPage(c, http.StatusOK, "articles", data)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("文章列表渲染失败(%d):%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
row := gin.H{
|
||||||
|
"Kind": "runway", "UID": "abc123", "ID": uint32(7),
|
||||||
|
"Title": "T", "Subtitle": "",
|
||||||
|
// image_count 只记主图(49),真实行数是 49 主 + 175 副:列表必须显示后者。
|
||||||
|
"ImageCount": 49, "ImageMain": 49, "ImageDetail": 175, "ImageTotal": 224,
|
||||||
|
"ImageTotalSize": int64(0), "Status": "pending",
|
||||||
|
"DupImages": 3, "DupTotal": 120, "DupRate": 2.5, "DupFirstImageID": uint32(55),
|
||||||
|
}
|
||||||
|
|
||||||
|
body := render(true, []gin.H{row})
|
||||||
|
if !strings.Contains(body, "跨图集重复") {
|
||||||
|
t.Fatal("ShowDup 为真时应渲染「跨图集重复」列")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/admin/runways/abc123/edit#img-55") {
|
||||||
|
t.Fatal("重复标记应锚点到本图集第一张重复的图(否则还得逐张找)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "3 张") {
|
||||||
|
t.Fatal("重复标记应显示重复图片数")
|
||||||
|
}
|
||||||
|
// 图数必须把副图算上:曾只显示 image_count(主图 49),与编辑页看到的 224 张对不上。
|
||||||
|
if !strings.Contains(body, "224") || !strings.Contains(body, "主49+副175") {
|
||||||
|
t.Fatal("图数列应展示真实图数(主+副),而不是只记主图的 image_count")
|
||||||
|
}
|
||||||
|
|
||||||
|
noDup := gin.H{}
|
||||||
|
for k, v := range row {
|
||||||
|
noDup[k] = v
|
||||||
|
}
|
||||||
|
noDup["DupImages"] = 0
|
||||||
|
if b := render(true, []gin.H{noDup}); strings.Contains(b, "#img-55") {
|
||||||
|
t.Fatal("无重复的行不应渲染重复标记")
|
||||||
|
}
|
||||||
|
|
||||||
|
if b := render(false, []gin.H{row}); strings.Contains(b, "跨图集重复") {
|
||||||
|
t.Fatal("ShowDup 为假(视觉服务未启用)时不应渲染该列")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cloneData 复制一份页面数据:renderPage 会往里注入 NavKey / PendingReviews,不能跨子测试共享。
|
// cloneData 复制一份页面数据:renderPage 会往里注入 NavKey / PendingReviews,不能跨子测试共享。
|
||||||
func cloneData(src gin.H) gin.H {
|
func cloneData(src gin.H) gin.H {
|
||||||
dst := make(gin.H, len(src))
|
dst := make(gin.H, len(src))
|
||||||
|
|||||||
39
internal/handler/templates/pages/article-form.html
Normal file
39
internal/handler/templates/pages/article-form.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{{define "title"}}新建文章 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 新建文章{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/articles">← 返回列表</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>新建文章</h1>
|
||||||
|
<p class="muted">走秀与街拍二选一:走秀填品牌/季节/系列,街拍填城市/年份;图片一步到位上传,创建后状态为「待审核」。</p>
|
||||||
|
{{if .Error}}<p class="badge rejected">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/admin/articles" enctype="multipart/form-data">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>类型</th><td>
|
||||||
|
<select name="kind">
|
||||||
|
<option value="runway"{{if ne .Kind "street"}} selected{{end}}>走秀</option>
|
||||||
|
<option value="street"{{if eq .Kind "street"}} selected{{end}}>街拍</option>
|
||||||
|
</select>
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>品牌英文名</th><td><input name="brand_name" value="{{.BrandName}}" placeholder="如 Gucci(走秀必填,须与品牌管理里的英文名完全一致)"></td></tr>
|
||||||
|
<tr><th>标题(英文)</th><td><input name="title_en" value="{{.TitleEn}}"></td></tr>
|
||||||
|
<tr><th>标题(中文)</th><td><input name="title_cn" value="{{.TitleCn}}"></td></tr>
|
||||||
|
<tr><th>年份</th><td><input name="year" value="{{.Year}}" size="6" placeholder="如 2027"></td></tr>
|
||||||
|
<tr><th>季节</th><td>
|
||||||
|
<select name="season">{{range .Seasons}}<option value="{{.}}"{{if eq . $.Season}} selected{{end}}>{{.}}</option>{{end}}</select>
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>系列</th><td>
|
||||||
|
<select name="collection_type">{{range .Collections}}<option value="{{.}}"{{if eq . $.CollectionType}} selected{{end}}>{{.}}</option>{{end}}</select>
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>城市</th><td><input name="city" value="{{.City}}" placeholder="街拍必填,如 Paris"></td></tr>
|
||||||
|
<tr><th>图片</th><td>
|
||||||
|
<input type="file" name="images" multiple required accept="image/jpeg,image/png,image/webp">
|
||||||
|
<div class="muted">至少 1 张;JPG / PNG / WEBP;单张 ≤ 10MB。按选择顺序作为 Look 1..N,第一张作封面;同内容图片会自动去重。</div>
|
||||||
|
</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p><button type="submit" class="op op-primary">创建</button> <a href="/admin/articles">取消</a></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{{define "title"}}文章管理 · 管理后台{{end}}
|
{{define "title"}}文章管理 · 管理后台{{end}}
|
||||||
{{define "barLabel"}}管理后台 · 文章管理{{end}}
|
{{define "barLabel"}}管理后台 · 文章管理{{end}}
|
||||||
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
{{define "barRight"}}<a href="/admin/articles/new">+ 新建</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "bar" .}}
|
{{template "bar" .}}
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
|
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
|
||||||
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>原图总大小</th><th>重复率</th><th>状态</th><th>操作</th>
|
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数(主+副)</th>{{if .ShowDup}}<th>跨图集重复</th>{{end}}<th>原图总大小</th><th>状态</th><th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
||||||
@ -30,8 +30,7 @@
|
|||||||
<td><code>{{.ID}}</code></td>
|
<td><code>{{.ID}}</code></td>
|
||||||
<td>{{.Title}}</td>
|
<td>{{.Title}}</td>
|
||||||
<td class="muted">{{.Subtitle}}</td>
|
<td class="muted">{{.Subtitle}}</td>
|
||||||
<td>{{.ImageCount}}</td><td>{{formatBytes .ImageTotalSize}}</td>
|
<td>{{.ImageTotal}}{{if .ImageDetail}}<span class="muted" title="真实图片行数:主图 {{.ImageMain}} 张 + 细节图 {{.ImageDetail}} 张。公开卡片角标用的是图集表的 image_count,它只记主图(当前 {{.ImageCount}}),所以两者对不上是正常的。"> 主{{.ImageMain}}+副{{.ImageDetail}}</span>{{end}}</td>{{if $.ShowDup}}<td>{{if .DupImages}}<a class="badge dup" href="/admin/{{if eq .Kind "runway"}}runways{{else}}street-snaps{{end}}/{{.UID}}/edit#img-{{.DupFirstImageID}}" title="本图集 {{.DupTotal}} 张图里有 {{.DupImages}} 张在其他图集里出现过({{printf "%.1f" .DupRate}}%)。点开直接跳到其中第一张。">{{.DupImages}} 张 · {{printf "%.0f" .DupRate}}%</a>{{else}}<span class="muted">—</span>{{end}}</td>{{end}}<td>{{formatBytes .ImageTotalSize}}</td>
|
||||||
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
|
|
||||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="ops">
|
<div class="ops">
|
||||||
|
|||||||
@ -7,43 +7,22 @@
|
|||||||
<h1>品牌列表</h1>
|
<h1>品牌列表</h1>
|
||||||
<form class="toolbar" method="get" action="/admin/brands">
|
<form class="toolbar" method="get" action="/admin/brands">
|
||||||
<input name="keyword" placeholder="搜索品牌名…" value="{{.Keyword}}">
|
<input name="keyword" placeholder="搜索品牌名…" value="{{.Keyword}}">
|
||||||
|
{{if .All}}<input type="hidden" name="all" value="1">{{end}}
|
||||||
<button type="submit">搜索</button>
|
<button type="submit">搜索</button>
|
||||||
{{if .Keyword}}<a href="/admin/brands">清除</a>{{end}}
|
{{if .Keyword}}<a href="/admin/brands{{if .All}}?all=1{{end}}">清除</a>{{end}}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{{if .All}}<a class="view" href="/admin/brands?keyword={{.Keyword}}">只看有图集的品牌</a>{{else}}<a class="view" href="/admin/brands?keyword={{.Keyword}}&all=1">显示全部品牌(含无图集)</a>{{end}}
|
||||||
</form>
|
</form>
|
||||||
|
<p class="muted">默认只显示「已有图集」的品牌;品牌目录共 2 万余条,其余请用上面的搜索或开关查看。</p>
|
||||||
<div class="idx">
|
<div class="idx">
|
||||||
<a href="/admin/brands?keyword={{.Keyword}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
|
<a href="/admin/brands?keyword={{.Keyword}}{{if .All}}&all=1{{end}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
|
||||||
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
|
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}{{if .All}}&all=1{{end}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
|
||||||
<a href="/admin/brands?letter=A&keyword={{.Keyword}}" class="{{if eq .Letter "A"}}on{{end}}">A</a>
|
{{range $l := .Letters}}<a href="/admin/brands?letter={{$l}}&keyword={{$.Keyword}}{{if $.All}}&all=1{{end}}" class="{{if eq $l $.Letter}}on{{end}}">{{$l}}</a>{{end}}
|
||||||
<a href="/admin/brands?letter=B&keyword={{.Keyword}}" class="{{if eq .Letter "B"}}on{{end}}">B</a>
|
|
||||||
<a href="/admin/brands?letter=C&keyword={{.Keyword}}" class="{{if eq .Letter "C"}}on{{end}}">C</a>
|
|
||||||
<a href="/admin/brands?letter=D&keyword={{.Keyword}}" class="{{if eq .Letter "D"}}on{{end}}">D</a>
|
|
||||||
<a href="/admin/brands?letter=E&keyword={{.Keyword}}" class="{{if eq .Letter "E"}}on{{end}}">E</a>
|
|
||||||
<a href="/admin/brands?letter=F&keyword={{.Keyword}}" class="{{if eq .Letter "F"}}on{{end}}">F</a>
|
|
||||||
<a href="/admin/brands?letter=G&keyword={{.Keyword}}" class="{{if eq .Letter "G"}}on{{end}}">G</a>
|
|
||||||
<a href="/admin/brands?letter=H&keyword={{.Keyword}}" class="{{if eq .Letter "H"}}on{{end}}">H</a>
|
|
||||||
<a href="/admin/brands?letter=I&keyword={{.Keyword}}" class="{{if eq .Letter "I"}}on{{end}}">I</a>
|
|
||||||
<a href="/admin/brands?letter=J&keyword={{.Keyword}}" class="{{if eq .Letter "J"}}on{{end}}">J</a>
|
|
||||||
<a href="/admin/brands?letter=K&keyword={{.Keyword}}" class="{{if eq .Letter "K"}}on{{end}}">K</a>
|
|
||||||
<a href="/admin/brands?letter=L&keyword={{.Keyword}}" class="{{if eq .Letter "L"}}on{{end}}">L</a>
|
|
||||||
<a href="/admin/brands?letter=M&keyword={{.Keyword}}" class="{{if eq .Letter "M"}}on{{end}}">M</a>
|
|
||||||
<a href="/admin/brands?letter=N&keyword={{.Keyword}}" class="{{if eq .Letter "N"}}on{{end}}">N</a>
|
|
||||||
<a href="/admin/brands?letter=O&keyword={{.Keyword}}" class="{{if eq .Letter "O"}}on{{end}}">O</a>
|
|
||||||
<a href="/admin/brands?letter=P&keyword={{.Keyword}}" class="{{if eq .Letter "P"}}on{{end}}">P</a>
|
|
||||||
<a href="/admin/brands?letter=Q&keyword={{.Keyword}}" class="{{if eq .Letter "Q"}}on{{end}}">Q</a>
|
|
||||||
<a href="/admin/brands?letter=R&keyword={{.Keyword}}" class="{{if eq .Letter "R"}}on{{end}}">R</a>
|
|
||||||
<a href="/admin/brands?letter=S&keyword={{.Keyword}}" class="{{if eq .Letter "S"}}on{{end}}">S</a>
|
|
||||||
<a href="/admin/brands?letter=T&keyword={{.Keyword}}" class="{{if eq .Letter "T"}}on{{end}}">T</a>
|
|
||||||
<a href="/admin/brands?letter=U&keyword={{.Keyword}}" class="{{if eq .Letter "U"}}on{{end}}">U</a>
|
|
||||||
<a href="/admin/brands?letter=V&keyword={{.Keyword}}" class="{{if eq .Letter "V"}}on{{end}}">V</a>
|
|
||||||
<a href="/admin/brands?letter=W&keyword={{.Keyword}}" class="{{if eq .Letter "W"}}on{{end}}">W</a>
|
|
||||||
<a href="/admin/brands?letter=X&keyword={{.Keyword}}" class="{{if eq .Letter "X"}}on{{end}}">X</a>
|
|
||||||
<a href="/admin/brands?letter=Y&keyword={{.Keyword}}" class="{{if eq .Letter "Y"}}on{{end}}">Y</a>
|
|
||||||
<a href="/admin/brands?letter=Z&keyword={{.Keyword}}" class="{{if eq .Letter "Z"}}on{{end}}">Z</a>
|
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th></tr></thead>
|
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th><th>操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a><form class="inline" method="post" action="/admin/brands/{{.UID}}/delete" onsubmit="return confirm('确认删除品牌「{{.NameEn}}」?该品牌下若还有图集会拒绝删除。')"><button type="submit" class="op op-warn">删除</button></form></td></tr>{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{template "pager" .Pager}}
|
{{template "pager" .Pager}}
|
||||||
|
|||||||
@ -46,22 +46,20 @@
|
|||||||
.Cells []gin.H,每项 {id, image, name, isDetail, role}
|
.Cells []gin.H,每项 {id, image, name, isDetail, role}
|
||||||
*/}}
|
*/}}
|
||||||
{{define "imageGrid"}}
|
{{define "imageGrid"}}
|
||||||
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
|
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}})</h2>
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
|
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
|
||||||
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
|
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
|
||||||
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
|
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
|
||||||
</div>
|
</div>
|
||||||
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}{{$dupSources := .DupSources}}
|
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}
|
||||||
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
|
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
|
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" id="img-{{.id}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
|
||||||
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
|
<button class="thumb" type="button" data-zoom data-full="{{imgURL .image}}"><img src="{{imgThumb .image}}" loading="lazy"></button>
|
||||||
<span class="role">{{.role}}</span>
|
<span class="role">{{.role}}</span>
|
||||||
<span class="nm">{{.name}}</span>
|
<span class="nm">{{.name}}</span>
|
||||||
{{if eq .isDuplicate 1}}<span class="badge dup">重复</span>
|
{{range .similarHints}}<a class="badge similar" href="/admin/{{if eq $kind "runway"}}runways{{else}}street-snaps{{end}}/{{.UID}}/edit#img-{{.ImageID}}" target="_blank" title="疑似同款:与「{{.OwnerTitle}}」的 {{.Name}}(#{{.ImageID}})余弦相似度 {{printf "%.2f" .Similar}}。点击跳到那张图。仅供人工参考,不影响前台展示">{{if .Image}}<img src="{{imgThumb .Image}}" loading="lazy" alt="">{{end}}<span class="sim-text"><b>{{.OwnerTitle}}</b>{{.Name}} · {{printf "%.2f" .Similar}}</span></a>{{end}}
|
||||||
{{with index $dupSources .dupOf}}{{if .srcTitle}}<details class="dup-src"><summary>查看重复来源</summary><div class="dup-src-body"><img src="{{imgURL .srcImage}}" loading="lazy" alt="源图缩略图"><div><div class="dup-title">属于:<b>{{.srcTitle}}</b>{{if .srcBrand}} · {{.srcBrand}}{{end}}</div><a class="dup-link" href="/admin/{{if eq .srcKind "runway"}}runways{{else}}street-snaps{{end}}/{{.srcUID}}/edit" target="_blank">打开所属{{if eq .srcKind "runway"}}走秀{{else}}街拍{{end}} →</a></div></div></details>{{end}}{{end}}
|
|
||||||
{{end}}
|
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
|
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
|
||||||
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
|
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "database/sql"
|
|
||||||
|
|
||||||
// BrandRunwayImage 走秀图片。
|
// BrandRunwayImage 走秀图片。
|
||||||
type BrandRunwayImage struct {
|
type BrandRunwayImage struct {
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||||
@ -23,13 +21,6 @@ type BrandRunwayImage struct {
|
|||||||
// 原图大小(字节):worker 下载图片时记录源文件字节数,供后台「图集原图总大小」展示。
|
// 原图大小(字节):worker 下载图片时记录源文件字节数,供后台「图集原图总大小」展示。
|
||||||
// 历史已入库图片为 0(未重爬、无源数据),后台按「—」处理;重爬后自动补齐。
|
// 历史已入库图片为 0(未重爬、无源数据),后台按「—」处理;重爬后自动补齐。
|
||||||
ImageSize int64 `gorm:"column:image_size;not null;default:0" json:"image_size"`
|
ImageSize int64 `gorm:"column:image_size;not null;default:0" json:"image_size"`
|
||||||
|
|
||||||
// 去重字段(近重复 + 语义 embedding 留痕):
|
|
||||||
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
|
|
||||||
// is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。
|
|
||||||
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 指定表名。
|
// TableName 指定表名。
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "database/sql"
|
|
||||||
|
|
||||||
// StreetSnap 街拍主表(对应 db_dev.street_snaps)。
|
// StreetSnap 街拍主表(对应 db_dev.street_snaps)。
|
||||||
//
|
//
|
||||||
// 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。
|
// 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。
|
||||||
@ -41,11 +39,6 @@ type StreetSnapImage struct {
|
|||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|
||||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
|
||||||
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"`
|
|
||||||
|
|
||||||
// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
|
// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
|
||||||
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。
|
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。
|
||||||
IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
|
IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
|
||||||
|
|||||||
@ -1,127 +0,0 @@
|
|||||||
// Package phash 计算图片的感知哈希(dHash,Difference Hash)。
|
|
||||||
//
|
|
||||||
// dHash 是 64-bit 轻量指纹,用于判断「同一张图被转码 / 加水印 / 改尺寸」这类近重复。
|
|
||||||
// 去重检索不在这里做——交给 PostgreSQL + pgvector 的 HNSW 索引(索引随全库结构一起,
|
|
||||||
// 由 cmd/dbtool 导出的 SQL 维护),本包只负责产出指纹,复杂度极低、零 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 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
|
|
||||||
// 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。
|
|
||||||
const DefaultThreshold = 4
|
|
||||||
|
|
||||||
// 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) 列,并以 vector_l2_ops 建 HNSW 索引做汉明近邻检索(L2²==汉明)。
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
package phash
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"image"
|
|
||||||
"image/color"
|
|
||||||
"image/png"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// genGradient 生成一张水平灰阶渐变的 PNG 字节流(w 宽、h 高)。
|
|
||||||
// 用于验证 dHash 对「同一图案」的稳定性,以及对「缩放 / 转码」的鲁棒性。
|
|
||||||
func genGradient(w, h int) []byte {
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
v := uint8(x * 255 / w)
|
|
||||||
img.Set(x, y, color.Gray{Y: v})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// genGradientReverse 生成一张「左亮右暗」的反向渐变 PNG。
|
|
||||||
// 与 genGradient(左暗右亮)方向相反,dHash 的相邻比较结果完全相反,
|
|
||||||
// 汉明距离应为 64(最大),用于验证「差异显著」的图能被正确区分。
|
|
||||||
func genGradientReverse(w, h int) []byte {
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
v := uint8(255 - x*255/w)
|
|
||||||
img.Set(x, y, color.Gray{Y: v})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// genSolid 生成一张纯色 PNG。
|
|
||||||
func genSolid(w, h int, v uint8) []byte {
|
|
||||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
||||||
c := color.Gray{Y: v}
|
|
||||||
for y := 0; y < h; y++ {
|
|
||||||
for x := 0; x < w; x++ {
|
|
||||||
img.Set(x, y, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOfIdentical 同一字节流 → 指纹完全一致。
|
|
||||||
func TestOfIdentical(t *testing.T) {
|
|
||||||
a := genGradient(64, 64)
|
|
||||||
b := genGradient(64, 64)
|
|
||||||
if Of(a) != Of(b) {
|
|
||||||
t.Fatalf("相同图片应得到相同 dHash,got %016x vs %016x", Of(a), Of(b))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOfResizeSimilar 同一图案缩放到不同尺寸 → 汉明距离很小(近重复可容忍范围内)。
|
|
||||||
func TestOfResizeSimilar(t *testing.T) {
|
|
||||||
a := genGradient(64, 64)
|
|
||||||
b := genGradient(128, 128)
|
|
||||||
d := Hamming(Of(a), Of(b))
|
|
||||||
if d > 20 {
|
|
||||||
t.Fatalf("同一图案缩放后汉明距离应很小,got %d", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOfDifferent 方向相反的图案(左暗右亮 vs 左亮右暗)→ 汉明距离应显著大于近重复阈值。
|
|
||||||
func TestOfDifferent(t *testing.T) {
|
|
||||||
a := genGradient(64, 64)
|
|
||||||
b := genGradientReverse(64, 64)
|
|
||||||
d := Hamming(Of(a), Of(b))
|
|
||||||
if d <= DefaultThreshold {
|
|
||||||
t.Fatalf("不同图片汉明距离应大于阈值 %d,got %d", DefaultThreshold, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestToVectorBits 验证向量串格式与维度(64 位、方括号包裹、0/1 组成)。
|
|
||||||
func TestToVectorBits(t *testing.T) {
|
|
||||||
s := ToVectorBits(0b1) // 仅最低位为 1
|
|
||||||
if len(s) != 2*Size+1 { // 64 个 0/1 + 63 个逗号 + 2 个方括号 = 129
|
|
||||||
t.Fatalf("向量串长度应为 %d,got %d(%s)", 2*Size+1, len(s), s)
|
|
||||||
}
|
|
||||||
if s[0] != '[' || s[len(s)-1] != ']' {
|
|
||||||
t.Fatalf("向量串应以方括号包裹,got %s", s)
|
|
||||||
}
|
|
||||||
if s[1] != '1' { // bit0 = 1
|
|
||||||
t.Fatalf("第 0 维应为 1,got %c", s[1])
|
|
||||||
}
|
|
||||||
if s[3] != '0' { // bit1 = 0([1,0,...] 中第二个元素)
|
|
||||||
t.Fatalf("第 1 维应为 0,got %c", s[3])
|
|
||||||
}
|
|
||||||
if ToVectorBits(0) != "" {
|
|
||||||
t.Fatalf("解码失败(0)应返回空串,got %q", ToVectorBits(0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHamming 验证汉明距离的对称性与已知值。
|
|
||||||
func TestHamming(t *testing.T) {
|
|
||||||
if Hamming(0, 0) != 0 {
|
|
||||||
t.Fatalf("相同指纹汉明距离应为 0")
|
|
||||||
}
|
|
||||||
if Hamming(0b1111, 0b0000) != 4 {
|
|
||||||
t.Fatalf("1111 vs 0000 汉明距离应为 4")
|
|
||||||
}
|
|
||||||
if Hamming(0b1111, 0b0000) != Hamming(0b0000, 0b1111) {
|
|
||||||
t.Fatalf("汉明距离应对称")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -46,7 +46,13 @@ func TestAdminMixedListFilterAndOrder(t *testing.T) {
|
|||||||
|
|
||||||
repo := NewAdminRepository(db)
|
repo := NewAdminRepository(db)
|
||||||
|
|
||||||
all, total, err := repo.ListMixed(t.Context(), "", "", 1, 100)
|
// 页大小必须显著大于库中总行数:本测试连的是**活库**(与开发库同一套 configs),
|
||||||
|
// 重爬会持续写入 pending 行,而排序是「pending 优先、其次 id 倒序」——种子里的走秀是
|
||||||
|
// published,恒排在所有 pending 之后。曾用固定 100,重爬把 pending 写到 115 行后,
|
||||||
|
// 种子就被挤出第一页,断言随数据量随机失败(与被测逻辑无关)。
|
||||||
|
const pageSize = 100000
|
||||||
|
|
||||||
|
all, total, err := repo.ListMixed(t.Context(), "", "", 1, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListMixed 全部失败: %v", err)
|
t.Fatalf("ListMixed 全部失败: %v", err)
|
||||||
}
|
}
|
||||||
@ -67,7 +73,7 @@ func TestAdminMixedListFilterAndOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。
|
// 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。
|
||||||
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, 100)
|
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListMixed pending 失败: %v", err)
|
t.Fatalf("ListMixed pending 失败: %v", err)
|
||||||
}
|
}
|
||||||
@ -164,11 +170,28 @@ func TestAdminMixedKindFilter(t *testing.T) {
|
|||||||
t.Fatalf("kind=street 至少应含 1 条")
|
t.Fatalf("kind=street 至少应含 1 条")
|
||||||
}
|
}
|
||||||
|
|
||||||
all, _, err := repo.ListMixed(ctx, "", "", 1, 50)
|
// 注意:必须比对**总数**而非本页行数——三种查询都按 size=50 截断,
|
||||||
|
// 库里 runway 超过 50 条时 len(all) 恒为 50,拿页长相加会假失败(与数据量相关的脆弱断言)。
|
||||||
|
all, allTotal, err := repo.ListMixed(ctx, "", "", 1, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("kind='' 失败: %v", err)
|
t.Fatalf("kind='' 失败: %v", err)
|
||||||
}
|
}
|
||||||
if len(all) != len(runwayOnly)+len(streetOnly) {
|
_, runwayTotal, err := repo.ListMixed(ctx, "", "runway", 1, 50)
|
||||||
t.Fatalf("全部应等于两类型之和:all=%d runway=%d street=%d", len(all), len(runwayOnly), len(streetOnly))
|
if err != nil {
|
||||||
|
t.Fatalf("kind=runway 计数失败: %v", err)
|
||||||
|
}
|
||||||
|
_, streetTotal, err := repo.ListMixed(ctx, "", "street", 1, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("kind=street 计数失败: %v", err)
|
||||||
|
}
|
||||||
|
if allTotal != runwayTotal+streetTotal {
|
||||||
|
t.Fatalf("全部总数应等于两类型之和:all=%d runway=%d street=%d", allTotal, runwayTotal, streetTotal)
|
||||||
|
}
|
||||||
|
// 本页行数不得超过分页上限,且 kind='' 的第一页应同时反映两种类型的存在。
|
||||||
|
if len(all) == 0 {
|
||||||
|
t.Fatal("kind='' 至少应含 1 条")
|
||||||
|
}
|
||||||
|
if len(all) > 50 {
|
||||||
|
t.Fatalf("本页行数不应超过 size=50,实际 %d", len(all))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,10 +22,17 @@ type AdminRecordCard struct {
|
|||||||
Status string // pending / published / rejected
|
Status string // pending / published / rejected
|
||||||
}
|
}
|
||||||
|
|
||||||
// DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。
|
// AdminRecordStats 是一条记录(图集)在列表上要展示的派生统计:原图字节数之和 + 主图 / 副图行数。
|
||||||
type DupStat struct {
|
//
|
||||||
Total int // 存活图片总数(含主图与细节图)
|
// 为什么主副要分开、而不能直接用 brand_runways.image_count:
|
||||||
Dup int // is_duplicate=1 的张数
|
// 那一列的口径**只数主图**(见 service.countMainImages),并驱动公开卡片的「N 张」角标
|
||||||
|
// 与 FeaturedIDs 的 SUM(image_count) 热度,不能改。但后台「图数」列若照搬它,就会和编辑页
|
||||||
|
// 看到的图片总数对不上(实测 image_count=49 的图集实际有 224 行:49 主图 + 175 细节图)。
|
||||||
|
// 因此列表另取真实行数,主副分别展示。
|
||||||
|
type AdminRecordStats struct {
|
||||||
|
Size int64 // 原图字节数之和(image_size 列,worker 下载时写入)
|
||||||
|
Main int // 主图行数(is_detail = 0)
|
||||||
|
Detail int // 副图(细节图)行数(is_detail = 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRepository 文章管理页所需的跨表只读查询。
|
// AdminRepository 文章管理页所需的跨表只读查询。
|
||||||
@ -36,12 +43,9 @@ type AdminRepository interface {
|
|||||||
ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error)
|
ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error)
|
||||||
// CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。
|
// CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。
|
||||||
CountMixed(ctx context.Context, status, kind string) (int64, error)
|
CountMixed(ctx context.Context, status, kind string) (int64, error)
|
||||||
// DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。
|
// ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表);
|
||||||
// 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。
|
// 返回 owner id → 统计,不存在的 id 不出现在 map 中。供文章管理混合列表展示「图数」「原图总大小」。
|
||||||
DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error)
|
ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error)
|
||||||
// ImageSizeByRecords 批量返回若干记录的原图字节数之和(按 kind 分别查对应图片表);
|
|
||||||
// 返回 owner id → 字节数。供文章管理混合列表展示「图集原图总大小」。
|
|
||||||
ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminRepository struct {
|
type adminRepository struct {
|
||||||
@ -114,11 +118,13 @@ func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, pa
|
|||||||
return cards, total, nil
|
return cards, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DupRateByRecords 批量统计若干记录的图片去重情况。
|
// ImageSizeByRecords 批量返回若干记录的原图统计(字节数之和 + 主/副图行数,按 kind 分别查对应图片表)。
|
||||||
// kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。
|
// 返回 owner id → 统计,不存在的 id 不出现在 map 中。
|
||||||
// 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。
|
//
|
||||||
func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) {
|
// 字节数、主图数、副图数在**同一次扫描**里算完(都只依赖同一批图片行),避免为「图数」再查一遍库。
|
||||||
out := make(map[uint32]DupStat)
|
// 一律过滤 is_deleted = 0:软删的图不参与展示,也不该计入图数。
|
||||||
|
func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]AdminRecordStats, error) {
|
||||||
|
out := make(map[uint32]AdminRecordStats)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@ -132,56 +138,24 @@ func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
type agg struct {
|
type agg struct {
|
||||||
Owner uint32 `gorm:"column:owner"`
|
Owner uint32 `gorm:"column:owner"`
|
||||||
Total int `gorm:"column:total"`
|
Total int64 `gorm:"column:total"`
|
||||||
Dup int `gorm:"column:dup"`
|
Main int `gorm:"column:main"`
|
||||||
|
Detail int `gorm:"column:detail"`
|
||||||
}
|
}
|
||||||
var rows []agg
|
var rows []agg
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Table(table).
|
Table(table).
|
||||||
Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup").
|
Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total, " +
|
||||||
|
"COUNT(*) FILTER (WHERE is_detail = 0) AS main, " +
|
||||||
|
"COUNT(*) FILTER (WHERE is_detail = 1) AS detail").
|
||||||
Where(fk+" IN ? AND is_deleted = 0", ids).
|
Where(fk+" IN ? AND is_deleted = 0", ids).
|
||||||
Group(fk).
|
Group(fk).
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out[row.Owner] = DupStat{Total: row.Total, Dup: row.Dup}
|
out[row.Owner] = AdminRecordStats{Size: row.Total, Main: row.Main, Detail: row.Detail}
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageSizeByRecords 批量返回若干记录的原图字节数之和(按 kind 分别查对应图片表)。
|
|
||||||
// 返回 owner id → 字节数;不存在的 id 不出现在 map 中。供文章管理混合列表展示「图集原图总大小」。
|
|
||||||
func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error) {
|
|
||||||
out := make(map[uint32]int64)
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
var table, fk string
|
|
||||||
switch kind {
|
|
||||||
case "runway":
|
|
||||||
table, fk = "brand_runway_images", "runway_id"
|
|
||||||
case "street":
|
|
||||||
table, fk = "street_snap_images", "snap_id"
|
|
||||||
default:
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
type agg struct {
|
|
||||||
Owner uint32 `gorm:"column:owner"`
|
|
||||||
Total int64 `gorm:"column:total"`
|
|
||||||
}
|
|
||||||
var rows []agg
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Table(table).
|
|
||||||
Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total").
|
|
||||||
Where(fk+" IN ? AND is_deleted = 0", ids).
|
|
||||||
Group(fk).
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, row := range rows {
|
|
||||||
out[row.Owner] = row.Total
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,11 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -39,17 +37,6 @@ type ArticleRepository interface {
|
|||||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||||
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
// GetRunwayDupSources 批量反查重复源:给定一组走秀图片 id(即 dup_of 指向的源图 id),
|
|
||||||
// 返回每张源图及其所属走秀(标题、品牌)的上下文,供编辑页标记重复图时展示来源。
|
|
||||||
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
|
||||||
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
|
|
||||||
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
|
||||||
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
|
|
||||||
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
|
|
||||||
// ReresolveRunwayDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
|
||||||
ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
|
||||||
// GetRunwayLiveImageIDs 取某走秀全部存活图片 id(级联下架前用于跨文章去重清理)。
|
|
||||||
GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error)
|
|
||||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||||
@ -404,182 +391,6 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
|
|||||||
return img.Image, nil
|
return img.Image, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,
|
|
||||||
// 供编辑页删图后重算去重时定位所属画廊)。
|
|
||||||
func (r *articleRepository) GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
|
|
||||||
var img model.BrandRunwayImage
|
|
||||||
err := r.db.WithContext(ctx).Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return img.RunwayID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// nearDupExceptRunway 在 brand_runway_images 内做全局近重复检索(is_deleted=0),
|
|
||||||
// 但排除自身与「同 runway 且 sort_order 在后的图」——后者防止重算时后入者反成源导致互指环。
|
|
||||||
// 命中返回源图 id,无则 0。
|
|
||||||
func nearDupExceptRunway(db *gorm.DB, phashBits string, threshold int, excludeID, runwayID, sortOrder uint32) (uint32, error) {
|
|
||||||
l2Limit := math.Sqrt(float64(threshold))
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
if err := db.Table("brand_runway_images").
|
|
||||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
|
||||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
|
||||||
Where("is_deleted = 0").
|
|
||||||
Where("id <> ?", excludeID).
|
|
||||||
Where("(runway_id <> ? OR sort_order < ?)", runwayID, sortOrder).
|
|
||||||
Order("dist ASC").
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return row.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReresolveRunwayDedup 删除图片后重解本画廊去重:先清空全部存活图的 is_duplicate/dup_of,
|
|
||||||
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
|
|
||||||
//
|
|
||||||
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;
|
|
||||||
// 删除「源图」后,剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」,且「查看重复来源」指向已删
|
|
||||||
// 源图(其 S4 文件可能已被清理)而打不开。重算后,被删源图不再作为参照,剩余近重复图重新确立
|
|
||||||
// 「先入者为准」的源/副本关系,使「重复」标记始终反映当前真实图片。
|
|
||||||
func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error {
|
|
||||||
var imgs []model.BrandRunwayImage
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
|
||||||
Order("sort_order ASC, id ASC").
|
|
||||||
Find(&imgs).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(imgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, im := range imgs {
|
|
||||||
if !im.Phash.Valid || im.Phash.String == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, runwayID, im.SortOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if src != 0 && src != im.ID {
|
|
||||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReresolveRunwayDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记。
|
|
||||||
// 给定刚被软删的走秀图片 id 列表,找出所有仍存活(其它文章)且 dup_of 指向它们的图片,
|
|
||||||
// 在全局存活图里重新近重复判定:找到新源则改指新源,否则清掉重复标记。
|
|
||||||
// 为什么需要:删 B 文章/图时,A 文章中 dup_of 指向 B 图的行不会被 ReresolveRunwayDedup
|
|
||||||
// (仅本集内重算)清理,导致 A 仍误显「重复」且「查看来源」指向已删图打不开。
|
|
||||||
func (r *articleRepository) ReresolveRunwayDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
|
||||||
if len(deletedImageIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
strIDs := make([]string, 0, len(deletedImageIDs))
|
|
||||||
for _, id := range deletedImageIDs {
|
|
||||||
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
|
||||||
}
|
|
||||||
var affected []model.BrandRunwayImage
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
|
||||||
Find(&affected).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(affected) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
for _, im := range affected {
|
|
||||||
if !im.Phash.Valid || im.Phash.String == "" {
|
|
||||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.RunwayID, im.SortOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if src != 0 && src != im.ID {
|
|
||||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
|
|
||||||
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
|
|
||||||
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
|
||||||
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
|
||||||
if len(imageIDs) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
type srcRow struct {
|
|
||||||
ImageID uint32
|
|
||||||
Image string
|
|
||||||
RunwayID uint32
|
|
||||||
TitleEn string
|
|
||||||
TitleCn string
|
|
||||||
BrandEn string
|
|
||||||
BrandCn string
|
|
||||||
}
|
|
||||||
var rows []srcRow
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Table("brand_runway_images bri").
|
|
||||||
Select("bri.id AS image_id, bri.image AS image, bri.runway_id AS runway_id, br.title_en AS title_en, br.title_cn AS title_cn, b.name_en AS brand_en, b.name_cn AS brand_cn").
|
|
||||||
Joins("JOIN brand_runways br ON br.id = bri.runway_id").
|
|
||||||
Joins("LEFT JOIN brands b ON b.id = br.brand_id").
|
|
||||||
Where("bri.id IN ?", imageIDs).
|
|
||||||
Where("bri.is_deleted = 0").
|
|
||||||
Find(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, rw := range rows {
|
|
||||||
title := rw.TitleEn
|
|
||||||
if title == "" {
|
|
||||||
title = rw.TitleCn
|
|
||||||
}
|
|
||||||
brand := rw.BrandEn
|
|
||||||
if brand == "" {
|
|
||||||
brand = rw.BrandCn
|
|
||||||
}
|
|
||||||
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.RunwayID, Title: title, Brand: brand}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
||||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||||
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
||||||
@ -612,18 +423,6 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRunwayLiveImageIDs 取某走秀全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
|
||||||
func (r *articleRepository) GetRunwayLiveImageIDs(ctx context.Context, runwayID uint32) ([]uint32, error) {
|
|
||||||
var ids []uint32
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
|
||||||
Pluck("id", &ids).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
func (r *articleRepository) SetRunwayCover(ctx context.Context, runwayID uint32, image string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
|
|||||||
@ -37,6 +37,11 @@ type BrandRepository interface {
|
|||||||
Create(ctx context.Context, b *model.Brand) (uint32, error)
|
Create(ctx context.Context, b *model.Brand) (uint32, error)
|
||||||
// Update 按数字主键更新品牌中英文名。
|
// Update 按数字主键更新品牌中英文名。
|
||||||
Update(ctx context.Context, id uint32, nameEn, nameCn string) error
|
Update(ctx context.Context, id uint32, nameEn, nameCn string) error
|
||||||
|
// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数(品牌删除的护栏依据)。
|
||||||
|
CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error)
|
||||||
|
// SetDeleted 软删 / 恢复品牌(只改 is_deleted 与 updated_at,不动其他列)。
|
||||||
|
// 用软删而非物理删除:品牌可能被历史数据引用,且需要可恢复。不存在返回 ErrNotFound。
|
||||||
|
SetDeleted(ctx context.Context, id uint32, deleted uint8) error
|
||||||
|
|
||||||
// CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。
|
// CrawlBrands 返回所有可被爬虫抓取的品牌(is_deleted=0 且 name_en 非空)。
|
||||||
// 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。
|
// 供爬虫取任务接口使用:不限制"是否已有走秀档案",因为爬虫本就要去补全尚无档案的品牌。
|
||||||
@ -111,13 +116,13 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
|||||||
db = db.Where("name_en ~ ?", latinPrefixRegexp)
|
db = db.Where("name_en ~ ?", latinPrefixRegexp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
// 字母索引默认只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||||
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
|
// 不过滤会污染 A-Z 索引)。两个例外:
|
||||||
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
|
// 1) 关键词搜索时不限制(用户按名字找品牌,即使暂无档案也应命中);
|
||||||
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
// 2) 中文分桶(OTHER)同样列出全部中文名品牌便于检索;
|
||||||
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
// 3) 后台显式开 All(q.All):品牌目录有 2 万+ 条,后台需要能翻到任意品牌
|
||||||
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
// (否则导入的品牌在界面上等于"不存在")。公开侧永远忽略 All。
|
||||||
if q.Keyword == "" && q.Letter != "OTHER" {
|
if q.Keyword == "" && q.Letter != "OTHER" && !(admin && q.All) {
|
||||||
if admin {
|
if admin {
|
||||||
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
|
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
|
||||||
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
|
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
|
||||||
@ -247,6 +252,39 @@ func (r *brandRepository) Create(ctx context.Context, b *model.Brand) (uint32, e
|
|||||||
return b.ID, nil
|
return b.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountRunwaysByBrand 统计该品牌下「未删除」的走秀档案数。
|
||||||
|
// 品牌删除的护栏依据:有档案时禁止删除,否则会留下一批 brand_id 指不到品牌的图集
|
||||||
|
// (brand_runways.brand_id 无外键级联),前台品牌页 / 热门榜都会异常。
|
||||||
|
func (r *brandRepository) CountRunwaysByBrand(ctx context.Context, brandID uint32) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Where("brand_id = ? AND is_deleted = 0", brandID).
|
||||||
|
Count(&n).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDeleted 软删 / 恢复品牌:只改 is_deleted 与 updated_at。
|
||||||
|
// 不存在返回 ErrNotFound(而不是静默成功),避免「点了删除但其实没删」。
|
||||||
|
func (r *brandRepository) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
|
||||||
|
res := r.db.WithContext(ctx).
|
||||||
|
Model(&model.Brand{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"is_deleted": deleted,
|
||||||
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。
|
// Update 按数字主键更新品牌中英文名(同时刷新 updated_at)。
|
||||||
func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error {
|
func (r *brandRepository) Update(ctx context.Context, id uint32, nameEn, nameCn string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
|
|||||||
@ -1,108 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
// 集成测试:去重核心查询需要真实 PostgreSQL + pgvector 环境。
|
|
||||||
//
|
|
||||||
// 运行方式(先起库,见 scripts/pgvector):
|
|
||||||
//
|
|
||||||
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
|
||||||
// go test -tags integration ./internal/repository/ -run TestDedup -v
|
|
||||||
//
|
|
||||||
// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。
|
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"fashionapi/internal/config"
|
|
||||||
"fashionapi/internal/database"
|
|
||||||
"fashionapi/internal/model"
|
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testDB 连真实库并校验去重所需的结构已就绪。
|
|
||||||
//
|
|
||||||
// 结构不再由 AutoMigrate 自动创建(已移除),须先自行准备好库,例如:
|
|
||||||
//
|
|
||||||
// ./bin/dbtool dump -out db_dump.sql
|
|
||||||
// psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db_dump.sql
|
|
||||||
//
|
|
||||||
// 这里只做存在性校验并给出明确提示,避免后续查询报出难懂的错。
|
|
||||||
func testDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
cfg, err := config.Load("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("加载配置失败: %v", err)
|
|
||||||
}
|
|
||||||
db, err := database.New(cfg.Database)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("连接数据库失败: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.Close(db) })
|
|
||||||
|
|
||||||
if err := db.Exec("SELECT 1 FROM brand_runway_images LIMIT 1").Error; err != nil {
|
|
||||||
t.Fatalf("brand_runway_images 不可用(库结构未就绪?先 dbtool dump 再 psql -f 灌库): %v", err)
|
|
||||||
}
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertRunwayImage 直接插入一条走秀图片行(无外键约束,runway_id / brand_id 可填占位值)。
|
|
||||||
func insertRunwayImage(t *testing.T, db *gorm.DB, image, phashBits string) {
|
|
||||||
t.Helper()
|
|
||||||
var ph sql.NullString
|
|
||||||
if phashBits != "" {
|
|
||||||
ph = sql.NullString{String: phashBits, Valid: true}
|
|
||||||
}
|
|
||||||
row := model.BrandRunwayImage{
|
|
||||||
RunwayID: 1,
|
|
||||||
BrandID: 1,
|
|
||||||
Image: image,
|
|
||||||
Name: "look",
|
|
||||||
SortOrder: 1,
|
|
||||||
Phash: ph,
|
|
||||||
}
|
|
||||||
if err := db.Create(&row).Error; err != nil {
|
|
||||||
t.Fatalf("插入图片失败: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
db.Where("image = ?", image).Delete(&model.BrandRunwayImage{})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDedupNearDuplicate 近重复:dHash 汉明距离 ≤ 阈值应被 FindNearDuplicateImage 命中。
|
|
||||||
func TestDedupNearDuplicate(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
repo := NewIngestRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// 插入一张已有图片,phash 为 64 维全 1 向量。
|
|
||||||
insertRunwayImage(t, db, "runway/near-dup.jpg", phash.ToVectorBits(^uint64(0)))
|
|
||||||
|
|
||||||
tables := []string{"brand_runway_images"}
|
|
||||||
|
|
||||||
// 探测向量:仅第 0 位为 0、其余为 1 → 与全 1 向量汉明距离 = 1,应命中。
|
|
||||||
probe := ^uint64(0) &^ 1
|
|
||||||
_, found, err := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(probe), phash.DefaultThreshold)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("汉明距离 1 的近重复应被命中")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题),
|
|
||||||
// 这里改用与库中那条完全相同的全 1 探针(非空、合法)并先断言查询本身不报错。
|
|
||||||
probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold)
|
|
||||||
if errSame != nil {
|
|
||||||
t.Fatalf("去重查询不应报错: %v", errSame)
|
|
||||||
}
|
|
||||||
if !foundSame {
|
|
||||||
t.Fatalf("与库中那条完全相同的 phash 应命中")
|
|
||||||
}
|
|
||||||
if probeSame == 0 {
|
|
||||||
t.Fatalf("命中的行 id 不应为 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,14 +13,11 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -93,86 +90,6 @@ func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string,
|
|||||||
}
|
}
|
||||||
return img.ID
|
return img.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedEditorRunwayImagePhash 插入一张带 phash 的走秀图(isDetail 表示主/细节),返回行 id。
|
|
||||||
func seedEditorRunwayImagePhash(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8, phashBits string) uint32 {
|
|
||||||
t.Helper()
|
|
||||||
var ph sql.NullString
|
|
||||||
if phashBits != "" {
|
|
||||||
ph = sql.NullString{String: phashBits, Valid: true}
|
|
||||||
}
|
|
||||||
img := model.BrandRunwayImage{
|
|
||||||
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
|
||||||
SortOrder: order, IsDetail: isDetail, Phash: ph,
|
|
||||||
}
|
|
||||||
if err := db.Create(&img).Error; err != nil {
|
|
||||||
t.Fatalf("插入带 phash 走秀图片失败: %v", err)
|
|
||||||
}
|
|
||||||
return img.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// editorImageFlag 读某图片的 is_duplicate / dup_of(验证去重重算结果)。
|
|
||||||
func editorImageFlag(t *testing.T, db *gorm.DB, id uint32) (uint8, string) {
|
|
||||||
t.Helper()
|
|
||||||
var row struct {
|
|
||||||
IsDuplicate uint8 `gorm:"column:is_duplicate"`
|
|
||||||
DupOf string `gorm:"column:dup_of"`
|
|
||||||
}
|
|
||||||
if err := db.Table("brand_runway_images").Select("is_duplicate, dup_of").Where("id = ?", id).Scan(&row).Error; err != nil {
|
|
||||||
t.Fatalf("读去重标记失败: %v", err)
|
|
||||||
}
|
|
||||||
return row.IsDuplicate, row.DupOf
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEditorReresolveRunwayDedupAfterSourceDeleted 模拟「删掉源图后」重算去重应消除悬空 dup_of:
|
|
||||||
// A 为源(被删),B、C 曾 dup_of=A;重算后 A 退出参照,B 成为新源(dup=0),C 重指 B(dup=1),
|
|
||||||
// 不再有任何图指向已删的 A —— 对应线上「删了源图后仍显示重复、源图查看重复来源打不开」。
|
|
||||||
func TestEditorReresolveRunwayDedupAfterSourceDeleted(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
repo := NewArticleRepository(db)
|
|
||||||
|
|
||||||
id := seedEditorRunway(t, db, 3)
|
|
||||||
// A=全1 向量;B 翻转 1 位(距 A=1);C 翻转 2 位(距 A=2)—— 三者互为近重复。
|
|
||||||
allOne := phash.ToVectorBits(^uint64(0))
|
|
||||||
bVec := phash.ToVectorBits(^uint64(0) &^ (uint64(1) << 0))
|
|
||||||
cVec := phash.ToVectorBits(^uint64(0) &^ ((uint64(1) << 0) | (uint64(1) << 1)))
|
|
||||||
a := seedEditorRunwayImagePhash(t, db, id, "A", 1, 0, allOne)
|
|
||||||
b := seedEditorRunwayImagePhash(t, db, id, "B", 2, 0, bVec)
|
|
||||||
c := seedEditorRunwayImagePhash(t, db, id, "C", 3, 0, cVec)
|
|
||||||
|
|
||||||
// 预置入库去重后的陈旧态:B、C 都 dup_of=A。
|
|
||||||
if err := db.Model(&model.BrandRunwayImage{}).Where("id IN ?", []uint32{b, c}).
|
|
||||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(a), 10)}).Error; err != nil {
|
|
||||||
t.Fatalf("预置陈旧去重标记失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删掉源图 A(软删,模拟编辑页删除)。
|
|
||||||
if err := repo.DeleteRunwayImage(ctx, a); err != nil {
|
|
||||||
t.Fatalf("删源图失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重算去重(编辑页删图会自动调用,这里直接验证仓储方法)。
|
|
||||||
if err := repo.ReresolveRunwayDedup(ctx, id); err != nil {
|
|
||||||
t.Fatalf("重算去重失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
diB, ofB := editorImageFlag(t, db, b)
|
|
||||||
diC, ofC := editorImageFlag(t, db, c)
|
|
||||||
if diB != 0 {
|
|
||||||
t.Fatalf("B 应成为新源(is_duplicate=0),实际 %d", diB)
|
|
||||||
}
|
|
||||||
if ofB != "" {
|
|
||||||
t.Fatalf("B 的 dup_of 应为空,实际 %q", ofB)
|
|
||||||
}
|
|
||||||
if diC != 1 {
|
|
||||||
t.Fatalf("C 应标记为重复(is_duplicate=1),实际 %d", diC)
|
|
||||||
}
|
|
||||||
if ofC != strconv.FormatUint(uint64(b), 10) {
|
|
||||||
t.Fatalf("C 的 dup_of 应指向 B(%d),实际 %q", b, ofC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
||||||
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@ -409,48 +326,3 @@ func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 回归「跨文章」去重清理:
|
|
||||||
// A(重复方)的图 dup_of 指向 B(源方)的图;删掉整篇 B 后,A 的图不应再挂指向已删图的 dup_of,
|
|
||||||
// 否则编辑页继续误显「重复」且「查看来源」打不开。对应线上「删了 B,A 的重复标记还在」。
|
|
||||||
func TestEditorCrossArticleRunwayDupClearedOnSourceDeleted(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
repo := NewArticleRepository(db)
|
|
||||||
|
|
||||||
// 两篇相互独立文章(不同实体键),各含一张近重复图(同 phash)。
|
|
||||||
srcRunway := seedEditorRunway(t, db, 1) // 源方 B
|
|
||||||
dupRunway := seedEditorRunway(t, db, 1) // 重复方 A
|
|
||||||
allOne := phash.ToVectorBits(^uint64(0))
|
|
||||||
srcImg := seedEditorRunwayImagePhash(t, db, srcRunway, "src", 1, 0, allOne)
|
|
||||||
dupImg := seedEditorRunwayImagePhash(t, db, dupRunway, "dup", 1, 0, allOne)
|
|
||||||
|
|
||||||
// 预置陈旧态:A 的图标记为重复、dup_of 指向 B 的图(跨文章)。
|
|
||||||
if err := db.Model(&model.BrandRunwayImage{}).Where("id = ?", dupImg).
|
|
||||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(srcImg), 10)}).Error; err != nil {
|
|
||||||
t.Fatalf("预置跨文章重复标记失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模拟后台删除整篇 B:软删其全部图片,再跨文章重算去重(PurgeGallery 的调用序列)。
|
|
||||||
delIDs, err := repo.GetRunwayLiveImageIDs(ctx, srcRunway)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("取存活图片 id 失败: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := repo.SoftDeleteRunwayImages(ctx, srcRunway); err != nil {
|
|
||||||
t.Fatalf("下架删图失败: %v", err)
|
|
||||||
}
|
|
||||||
if err := repo.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
|
||||||
t.Fatalf("跨文章重算去重失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
di, of := editorImageFlag(t, db, dupImg)
|
|
||||||
if di != 0 {
|
|
||||||
t.Fatalf("删掉源方 B 后,A 的图应清掉重复标记(is_duplicate=0),实际 %d", di)
|
|
||||||
}
|
|
||||||
if of != "" {
|
|
||||||
t.Fatalf("删掉源方 B 后,A 的图 dup_of 应为空,实际 %q", of)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注:街拍跨文章去重清理(ReresolveSnapDupOfSources / GetSnapLiveImageIDs)为走秀版的逐行镜像,
|
|
||||||
// 逻辑由 TestEditorCrossArticleRunwayDupClearedOnSourceDeleted 覆盖。本条对应的街拍集成测试因本地
|
|
||||||
// street_snaps 表缺 title_cn 列(预存 schema 漂移,连既有街拍测试也跑不了)暂未加入,待本地库补迁移后补回。
|
|
||||||
|
|||||||
180
internal/repository/embedding_integration_test.go
Normal file
180
internal/repository/embedding_integration_test.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:在真实 PostgreSQL + pgvector 上验证 image_embeddings 的读写与余弦近邻检索。
|
||||||
|
//
|
||||||
|
// 单测(embedding_repository_test.go)只覆盖向量串的格式化/解析等纯逻辑;
|
||||||
|
// 本测试覆盖真正跑 SQL 的部分——`?::vector` 转换、`<=>` 余弦算子、
|
||||||
|
// 以及与图片表 join 过滤软删行的行为。这些在 fake 上无法验证。
|
||||||
|
//
|
||||||
|
// 运行:go test -tags integration ./internal/repository/ -run TestEmbeddingRepo -v
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEmbeddingRepoUpsertListAndSimilar 覆盖写入 → 批量读回 → 余弦近邻检索这一整条链路。
|
||||||
|
func TestEmbeddingRepoUpsertListAndSimilar(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewEmbeddingRepository(db)
|
||||||
|
|
||||||
|
// 1) 造两条走秀图片(近邻检索要 join 图片表过滤软删,因此图片必须真实存在且 is_deleted=0)。
|
||||||
|
now := uint32(1)
|
||||||
|
runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(&runway).Error; err != nil {
|
||||||
|
t.Fatalf("建走秀失败: %v", err)
|
||||||
|
}
|
||||||
|
imgs := []model.BrandRunwayImage{
|
||||||
|
{RunwayID: runway.ID, BrandID: 1, Image: "it/emb-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now},
|
||||||
|
{RunwayID: runway.ID, BrandID: 1, Image: "it/emb-b.jpg", Name: "b", SortOrder: 2, CreatedAt: now, UpdatedAt: now},
|
||||||
|
}
|
||||||
|
if err := db.Create(&imgs).Error; err != nil {
|
||||||
|
t.Fatalf("建图片失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID)
|
||||||
|
db.Delete(&model.BrandRunwayImage{}, imgs[0].ID)
|
||||||
|
db.Delete(&model.BrandRunwayImage{}, imgs[1].ID)
|
||||||
|
db.Delete(&model.BrandRunway{}, runway.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) 两个几乎相同的 768 维向量(第二个与第一个只差一个分量),相似度应远高于阈值。
|
||||||
|
va := make([]float32, 768)
|
||||||
|
for i := range va {
|
||||||
|
va[i] = 0.1
|
||||||
|
}
|
||||||
|
va[0] = 0.5
|
||||||
|
vb := make([]float32, 768)
|
||||||
|
copy(vb, va)
|
||||||
|
vb[0] = 0.499
|
||||||
|
|
||||||
|
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, va); err != nil {
|
||||||
|
t.Fatalf("写向量 A 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vb); err != nil {
|
||||||
|
t.Fatalf("写向量 B 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 批量读回:维度必须保持 768,且值能精确往返。
|
||||||
|
got, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID, imgs[1].ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListEmbeddings 失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("应读回 2 个向量,得到 %d", len(got))
|
||||||
|
}
|
||||||
|
if len(got[imgs[0].ID]) != 768 {
|
||||||
|
t.Fatalf("向量维度应为 768,得到 %d", len(got[imgs[0].ID]))
|
||||||
|
}
|
||||||
|
if got[imgs[0].ID][0] != 0.5 {
|
||||||
|
t.Fatalf("向量分量往返后不一致:%v", got[imgs[0].ID][0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 近邻检索:不排除任何图时,A 应找到同图集的 B(高相似度),且结果里不含自身。
|
||||||
|
neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, got[imgs[0].ID], 0.9, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindSimilarImages 失败: %v", err)
|
||||||
|
}
|
||||||
|
var found *SimilarImage
|
||||||
|
for i := range neighbors {
|
||||||
|
if neighbors[i].ImageID == imgs[0].ID {
|
||||||
|
t.Fatalf("近邻结果不应包含自身 imageID=%d", imgs[0].ID)
|
||||||
|
}
|
||||||
|
if neighbors[i].ImageID == imgs[1].ID {
|
||||||
|
found = &neighbors[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("应找到近邻 B(imageID=%d),实际结果 %+v", imgs[1].ID, neighbors)
|
||||||
|
}
|
||||||
|
if found.Similar < 0.99 {
|
||||||
|
t.Fatalf("两向量几乎相同,相似度应接近 1,实际 %f", found.Similar)
|
||||||
|
}
|
||||||
|
if found.OwnerID != runway.ID {
|
||||||
|
t.Fatalf("近邻所属记录应为 %d,实际 %d", runway.ID, found.OwnerID)
|
||||||
|
}
|
||||||
|
// 近邻的描述字段必须带回,后台靠它们显示「和哪张图像」。
|
||||||
|
if found.Image != "it/emb-b.jpg" || found.Name != "b" || found.OwnerTitle != "embedding-it" {
|
||||||
|
t.Fatalf("近邻描述字段不完整: %+v", found)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 传入同图集排除集后,B 必须被排除——需求是只看「本图集 vs 其他图集」,
|
||||||
|
// 同图集内所有照片共享背景/灯光/造型,相似度天然偏高,参与比对只会是噪音。
|
||||||
|
excluded, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID,
|
||||||
|
[]uint32{imgs[0].ID, imgs[1].ID}, got[imgs[0].ID], 0.9, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("排除同图集时 FindSimilarImages 失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, n := range excluded {
|
||||||
|
if n.ImageID == imgs[1].ID {
|
||||||
|
t.Fatalf("同图集的图应被排除,但仍返回了 imageID=%d", imgs[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 重复写入同一 (kind, image_id) 应走 UPSERT 更新而非插入新行。
|
||||||
|
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vb); err != nil {
|
||||||
|
t.Fatalf("重复写向量失败: %v", err)
|
||||||
|
}
|
||||||
|
again, err := repo.ListEmbeddings(ctx, "runway", []uint32{imgs[0].ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("重复写后 ListEmbeddings 失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(again) != 1 {
|
||||||
|
t.Fatalf("同一 (kind,image_id) 应只有一行,得到 %d", len(again))
|
||||||
|
}
|
||||||
|
if again[imgs[0].ID][0] != 0.499 {
|
||||||
|
t.Fatalf("UPSERT 应更新为新值,实际 %v", again[imgs[0].ID][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbeddingRepoSkipsSoftDeletedImages 软删的图片不得作为「疑似同款」参照
|
||||||
|
// (image_embeddings 无 is_deleted 列,靠 join 图片表过滤)。
|
||||||
|
func TestEmbeddingRepoSkipsSoftDeletedImages(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewEmbeddingRepository(db)
|
||||||
|
|
||||||
|
now := uint32(1)
|
||||||
|
runway := model.BrandRunway{BrandID: 1, TitleEn: "embedding-it-softdel", Status: model.StatusPending, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(&runway).Error; err != nil {
|
||||||
|
t.Fatalf("建走秀失败: %v", err)
|
||||||
|
}
|
||||||
|
imgs := []model.BrandRunwayImage{
|
||||||
|
{RunwayID: runway.ID, BrandID: 1, Image: "it/sd-a.jpg", Name: "a", SortOrder: 1, CreatedAt: now, UpdatedAt: now},
|
||||||
|
{RunwayID: runway.ID, BrandID: 1, Image: "it/sd-b.jpg", Name: "b", SortOrder: 2, IsDeleted: 1, CreatedAt: now, UpdatedAt: now},
|
||||||
|
}
|
||||||
|
if err := db.Create(&imgs).Error; err != nil {
|
||||||
|
t.Fatalf("建图片失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM image_embeddings WHERE kind = 'runway' AND image_id IN (?, ?)", imgs[0].ID, imgs[1].ID)
|
||||||
|
db.Delete(&model.BrandRunwayImage{}, imgs[0].ID)
|
||||||
|
db.Delete(&model.BrandRunwayImage{}, imgs[1].ID)
|
||||||
|
db.Delete(&model.BrandRunway{}, runway.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
vec := make([]float32, 768)
|
||||||
|
for i := range vec {
|
||||||
|
vec[i] = 0.1
|
||||||
|
}
|
||||||
|
if err := repo.UpsertEmbedding(ctx, "runway", imgs[0].ID, vec); err != nil {
|
||||||
|
t.Fatalf("写向量 A 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.UpsertEmbedding(ctx, "runway", imgs[1].ID, vec); err != nil {
|
||||||
|
t.Fatalf("写向量 B(软删图)失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
neighbors, err := repo.FindSimilarImages(ctx, "runway", imgs[0].ID, nil, vec, 0.0, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindSimilarImages 失败: %v", err)
|
||||||
|
}
|
||||||
|
for _, n := range neighbors {
|
||||||
|
if n.ImageID == imgs[1].ID {
|
||||||
|
t.Fatalf("已软删的图片(imageID=%d)不应作为同款参照", imgs[1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
512
internal/repository/embedding_repository.go
Normal file
512
internal/repository/embedding_repository.go
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SimilarImage 是一张「疑似同款」的近邻结果。
|
||||||
|
//
|
||||||
|
// 除 id 与分数外,还带回对方的图片 key、名称与所属图集标题:
|
||||||
|
// 后台必须能直接看出「和哪张图像」,只给一个分数等于让人在全库里翻找。
|
||||||
|
type SimilarImage struct {
|
||||||
|
ImageID uint32 // 近邻图片 id
|
||||||
|
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
|
||||||
|
Similar float64 // 余弦相似度 0~1
|
||||||
|
Image string // 近邻图片存储 key(前端据此显示缩略图)
|
||||||
|
Name string // 近邻图片名(如 "Look 12")
|
||||||
|
OwnerTitle string // 近邻所属图集标题(跨图集命中时用来指明在哪个图集)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarPair 是 image_similar_pairs 的写入行:只带 id 与分数,不带描述。
|
||||||
|
//
|
||||||
|
// 描述靠读侧 join 图片表/图集表取得,这样图片改名、换图集后不会留下陈旧文案。
|
||||||
|
type SimilarPair struct {
|
||||||
|
SelfID uint32
|
||||||
|
OtherID uint32
|
||||||
|
Similar float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarNeighbor 是读出来的近邻:在 SimilarImage 基础上补齐对方的描述字段。
|
||||||
|
type SimilarNeighbor struct {
|
||||||
|
OtherID uint32 // 近邻图片 id
|
||||||
|
OwnerID uint32 // 近邻所属图集 id(供前端跳转)
|
||||||
|
Similar float64 // 余弦相似度 0~1
|
||||||
|
Image string // 近邻图片存储 key(前端显示缩略图)
|
||||||
|
Name string // 近邻图片名(如 "Look 12")
|
||||||
|
OwnerTitle string // 近邻所属图集标题
|
||||||
|
}
|
||||||
|
|
||||||
|
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
|
||||||
|
//
|
||||||
|
// Total 由图片表实时统计得出,**刻意不用 brand_runways.image_count** ——
|
||||||
|
// 那一列是过期缓存(实测 runway 124 实际 240 张、该列却写 87),拿它当分母会把重复率算错。
|
||||||
|
type DupStat struct {
|
||||||
|
Total int // 该图集存活图片总数(实时统计)
|
||||||
|
Images int // 其中有跨图集重复的图片数
|
||||||
|
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmbeddingRepository 读写 image_embeddings 并做近邻检索。
|
||||||
|
//
|
||||||
|
// 该表存 FashionSigLIP 产出的 768 维归一化向量,仅用于后台「疑似同款」提示,
|
||||||
|
// **不参与任何自动去重**。表无 is_deleted 列,因此读侧一律 join 图片表过滤软删行。
|
||||||
|
//
|
||||||
|
// 近邻结果走 image_similar_pairs 预计算表:入库时(或 dbtool similarbackfill 回填时)
|
||||||
|
// 算一次存表,后台页面只读结果。原因见 ReplaceSimilarPairs 注释。
|
||||||
|
type EmbeddingRepository interface {
|
||||||
|
// UpsertEmbedding 写入或更新一张图的向量((kind, image_id) 唯一)。
|
||||||
|
UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error
|
||||||
|
// BulkUpsertEmbedding 批量写入;vecs[i] 对应 ids[i],vecs[i] 为 nil 则跳过该行。
|
||||||
|
BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error
|
||||||
|
// ListEmbeddings 批量取一批图片的已存向量;无向量的图片不出现在结果中。
|
||||||
|
ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error)
|
||||||
|
// FindSimilarImages 返回该图的近邻(已排除自身与 excludeIDs、已过滤软删图片,按相似度降序)。
|
||||||
|
//
|
||||||
|
// excludeIDs 用于排除「同图集的图」:当前需求只看「本图集的图与其他图集有没有重复」,
|
||||||
|
// 同图集内所有照片共享背景/灯光/模特/造型,相似度天然偏高(实测同场中位 0.69、最高 0.91),
|
||||||
|
// 参与比对只会产生噪音。
|
||||||
|
FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error)
|
||||||
|
// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对(先删后插,同一事务)。
|
||||||
|
//
|
||||||
|
// rows 必须**双向**(A→B 与 B→A 各一行):这样打开任一侧所在的图集都能看到提示,
|
||||||
|
// 读侧无需 UNION。入库写完向量后调用。
|
||||||
|
ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error
|
||||||
|
// ListSimilarPairs 读一批图的近邻(含对方的图片 key / 名称 / 所属图集标题,已过滤软删)。
|
||||||
|
ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error)
|
||||||
|
// DupStatsByOwner 按图集汇总跨图集重复情况(含每组存活图片总数),供文章列表标注。
|
||||||
|
//
|
||||||
|
// 返回「图集 id -> 概况」;没有任何图片的图集不会出现(调用方按缺失处理为 0)。
|
||||||
|
DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatEmbeddingVector 把向量格式化为 pgvector 的文本字面量(`[x,y,...]`)。
|
||||||
|
//
|
||||||
|
// 用 'g' + -1 位精度:float32 的每个值都能精确往返,避免向量被截断后相似度漂移。
|
||||||
|
func FormatEmbeddingVector(vec []float32) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(vec)*12 + 2)
|
||||||
|
b.WriteByte('[')
|
||||||
|
for i, v := range vec {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteByte(',')
|
||||||
|
}
|
||||||
|
b.WriteString(strconv.FormatFloat(float64(v), 'g', -1, 32))
|
||||||
|
}
|
||||||
|
b.WriteByte(']')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEmbeddingVector 把 pgvector 的文本表示(`[x,y,...]`)解析回 float32 切片。
|
||||||
|
// 解析失败返回 nil(调用方据此跳过该行,不阻断渲染)。
|
||||||
|
func ParseEmbeddingVector(s string) []float32 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) < 2 || s[0] != '[' || s[len(s)-1] != ']' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s = s[1 : len(s)-1]
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]float32, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, float32(f))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageOwner 描述一种图片所属关系的表名、外键列名,以及主表用于展示的标题列。
|
||||||
|
//
|
||||||
|
// 上述表名与列名均为代码内常量,不接受外部输入,因此拼接进 SQL 无注入面。
|
||||||
|
type imageOwner struct {
|
||||||
|
table string // 图片表
|
||||||
|
fk string // 图片表里指向主表的外键列
|
||||||
|
titleTable string // 主表(图集)
|
||||||
|
titleCol string // 主表的标题列
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerTable 返回某类图片的「图片表名 / 指向图集的外键列名」,供 dbtool 按图集遍历(回填近邻)使用。
|
||||||
|
func OwnerTable(kind string) (table, fk string, ok bool) {
|
||||||
|
o, ok := ownerOf(kind)
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return o.table, o.fk, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerOf 按 kind 返回对应图片表的所属关系;未知 kind 返回 ok=false。
|
||||||
|
func ownerOf(kind string) (imageOwner, bool) {
|
||||||
|
switch kind {
|
||||||
|
case "runway":
|
||||||
|
// 走秀主表的英文标题列是 title_en(原通用 title 列已由 i18n 迁移删除)。
|
||||||
|
return imageOwner{
|
||||||
|
table: "brand_runway_images", fk: "runway_id",
|
||||||
|
titleTable: "brand_runways", titleCol: "title_en",
|
||||||
|
}, true
|
||||||
|
case "street":
|
||||||
|
return imageOwner{
|
||||||
|
table: "street_snap_images", fk: "snap_id",
|
||||||
|
titleTable: "street_snaps", titleCol: "title",
|
||||||
|
}, true
|
||||||
|
default:
|
||||||
|
return imageOwner{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type embeddingRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmbeddingRepository 创建向量仓储。
|
||||||
|
func NewEmbeddingRepository(db *gorm.DB) EmbeddingRepository {
|
||||||
|
return &embeddingRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *embeddingRepository) UpsertEmbedding(ctx context.Context, kind string, imageID uint32, vec []float32) error {
|
||||||
|
return r.BulkUpsertEmbedding(ctx, kind, []uint32{imageID}, [][]float32{vec})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkUpsertEmbedding 逐行写入而非 unnest 批量:日入库仅几百张,量级极小,
|
||||||
|
// 换来的是可读性与低出错概率。ON CONFLICT 保证同一图重复入库不会写出多行。
|
||||||
|
func (r *embeddingRepository) BulkUpsertEmbedding(ctx context.Context, kind string, ids []uint32, vecs [][]float32) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
for i, id := range ids {
|
||||||
|
if i >= len(vecs) || vecs[i] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Exec(`
|
||||||
|
INSERT INTO image_embeddings (kind, image_id, embedding, created_at)
|
||||||
|
VALUES (?, ?, ?::vector, ?)
|
||||||
|
ON CONFLICT (kind, image_id) DO UPDATE
|
||||||
|
SET embedding = EXCLUDED.embedding, created_at = EXCLUDED.created_at`,
|
||||||
|
kind, id, FormatEmbeddingVector(vecs[i]), now).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *embeddingRepository) ListEmbeddings(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]float32, error) {
|
||||||
|
if len(imageIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
ImageID uint32 `gorm:"column:image_id"`
|
||||||
|
Embedding string `gorm:"column:embedding"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table("image_embeddings").
|
||||||
|
Select("image_id, embedding").
|
||||||
|
Where("kind = ? AND image_id IN ?", kind, imageIDs).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[uint32][]float32, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if vec := ParseEmbeddingVector(row.Embedding); vec != nil {
|
||||||
|
out[row.ImageID] = vec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSimilarImages 两步走:先走 HNSW 取候选(只查 image_embeddings,索引用得上),
|
||||||
|
// 再按 kind join 图片表过滤软删行并取所属文章 id(这一步不走索引,但候选集很小)。
|
||||||
|
//
|
||||||
|
// ⚠️ 第一步必须用 Raw 写死参数顺序,不能用 GORM 的 Table/Select/Where/Order 链式组装:
|
||||||
|
// 链式写法里 `Select(..., vecStr)` 与 `Order(gorm.Expr("embedding <=> ?::vector", vecStr))`
|
||||||
|
// 各带一个同名参数,实测 GORM 会把 ORDER BY 的那个参数绑到错误位置,
|
||||||
|
// 结果 `ORDER BY embedding <=> ...` 退化为按垃圾值排序 —— 候选集变成随机若干行,
|
||||||
|
// 真正的近邻被大量漏掉(相似度阈值那一步仍用正确的向量,因此现象是「提示的并不是最像的那张」)。
|
||||||
|
func (r *embeddingRepository) FindSimilarImages(ctx context.Context, kind string, imageID uint32, excludeIDs []uint32, vec []float32, minSim float64, limit int) ([]SimilarImage, error) {
|
||||||
|
if len(vec) == 0 || limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
owner, ok := ownerOf(kind)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// 放大候选倍数:部分候选会因图片已软删被第二步过滤掉。
|
||||||
|
candidate := limit * 5
|
||||||
|
if candidate < 20 {
|
||||||
|
candidate = 20
|
||||||
|
}
|
||||||
|
vecStr := FormatEmbeddingVector(vec)
|
||||||
|
|
||||||
|
// 排除同图集的图必须写在候选查询里、而不是留到第二步过滤:
|
||||||
|
// 若留到第二步,同图集的图会先占满候选名额(同场相似度天然偏高),
|
||||||
|
// 真正要看的跨图集近邻反而被 LIMIT 截断在外面。
|
||||||
|
//
|
||||||
|
// ⚠️ 已知性能瓶颈(2026-10-13 实测):本函数每张图调用一次,一条 116 张图的图集要跑
|
||||||
|
// 116 次,而每次往返要传约 20KB(768 维向量文本在 SELECT 与 ORDER BY 各出现一次)。
|
||||||
|
// 本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay 访问,该链路大 payload 吞吐极差
|
||||||
|
// (实测约 0.47MB/s):服务端执行这条查询只要 0.45ms,客户端往返却要 ~45ms,
|
||||||
|
// 合计 116×45ms ≈ 5 秒。**这是页面慢的真正原因**,且与 GORM/参数化/PG 日志/连接池均无关
|
||||||
|
// (已逐项实测排除:原生 database/sql 同样 45ms、SELECT 1 只要 1ms、池等待为 0)。
|
||||||
|
// 后续方向见 docs 里的讨论:把向量改为二进制参数、或整图集一条 LATERAL 查询、或用内容哈希替代。
|
||||||
|
where := "kind = ? AND image_id <> ?"
|
||||||
|
args := []any{kind, imageID}
|
||||||
|
if len(excludeIDs) > 0 {
|
||||||
|
ids := make([]string, 0, len(excludeIDs))
|
||||||
|
for _, id := range excludeIDs {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
where += " AND image_id NOT IN (" + strings.Join(ids, ",") + ")"
|
||||||
|
}
|
||||||
|
args = append(args, candidate)
|
||||||
|
sqlText := "SELECT image_id, 1 - (embedding <=> '" + vecStr + "'::vector) AS sim " +
|
||||||
|
"FROM image_embeddings " +
|
||||||
|
"WHERE " + where + " " +
|
||||||
|
"ORDER BY embedding <=> '" + vecStr + "'::vector LIMIT ?"
|
||||||
|
|
||||||
|
type cand struct {
|
||||||
|
ImageID uint32 `gorm:"column:image_id"`
|
||||||
|
Sim float64 `gorm:"column:sim"`
|
||||||
|
}
|
||||||
|
var cands []cand
|
||||||
|
if err := r.db.WithContext(ctx).Raw(sqlText, args...).Scan(&cands).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]uint32, 0, len(cands))
|
||||||
|
for _, c := range cands {
|
||||||
|
ids = append(ids, c.ImageID)
|
||||||
|
}
|
||||||
|
// 第二步:join 图集主表,一次性取回「对方的图片 key / 名称 / 所属图集标题」。
|
||||||
|
// 有了这三项,后台角标就能显示缩略图并指明是哪张图、在哪个图集,
|
||||||
|
// 而不是只给一个相似度分数让人自己去找。
|
||||||
|
var neighbors []struct {
|
||||||
|
ID uint32 `gorm:"column:id"`
|
||||||
|
Image string `gorm:"column:image"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Owner uint32 `gorm:"column:owner"`
|
||||||
|
OwnerTitle string `gorm:"column:owner_title"`
|
||||||
|
}
|
||||||
|
// 显式展开占位符,不写 `IN ?`:Raw 路径下切片展开依赖 GORM 内部实现,
|
||||||
|
// 显式 `IN (?,?,…)` 才能确定逐值绑定。占位符是常量、表名列名来自 ownerOf 常量,
|
||||||
|
// 值一律走参数绑定,无注入面。
|
||||||
|
placeholders := make([]string, len(ids))
|
||||||
|
joinArgs := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
joinArgs[i] = id
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(
|
||||||
|
"SELECT i.id AS id, i.image AS image, i.name AS name, "+
|
||||||
|
"i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+
|
||||||
|
"FROM "+owner.table+" AS i "+
|
||||||
|
"JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+
|
||||||
|
"WHERE i.id IN ("+strings.Join(placeholders, ",")+") AND i.is_deleted = 0",
|
||||||
|
joinArgs...).Scan(&neighbors).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type neighborMeta struct {
|
||||||
|
image string
|
||||||
|
name string
|
||||||
|
owner uint32
|
||||||
|
title string
|
||||||
|
}
|
||||||
|
alive := make(map[uint32]neighborMeta, len(neighbors))
|
||||||
|
for _, n := range neighbors {
|
||||||
|
alive[n.ID] = neighborMeta{image: n.Image, name: n.Name, owner: n.Owner, title: n.OwnerTitle}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]SimilarImage, 0, limit)
|
||||||
|
for _, c := range cands {
|
||||||
|
meta, ok := alive[c.ImageID]
|
||||||
|
if !ok {
|
||||||
|
continue // 图片已软删,不作为「疑似同款」参照
|
||||||
|
}
|
||||||
|
if c.Sim < minSim {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res = append(res, SimilarImage{
|
||||||
|
ImageID: c.ImageID,
|
||||||
|
OwnerID: meta.owner,
|
||||||
|
Similar: c.Sim,
|
||||||
|
Image: meta.image,
|
||||||
|
Name: meta.name,
|
||||||
|
OwnerTitle: meta.title,
|
||||||
|
})
|
||||||
|
if len(res) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceSimilarPairs 用 rows 整体替换 selfIDs 这些图的近邻对。
|
||||||
|
//
|
||||||
|
// 为什么要有这张预计算表:原实现是在**打开图集页时**对每张图实时做一次向量近邻检索,
|
||||||
|
// 一个 116 张图的图集要发 116 次查询、每次约 20KB SQL。本机 PostgreSQL 跑在 WSL2 里、
|
||||||
|
// Windows 经 wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计要 5 秒。
|
||||||
|
// 改成入库时算一次存表后,页面只读结果(一次几百字节的小查询)。
|
||||||
|
//
|
||||||
|
// 排除 id 用内联字面量而非参数:这些 id 是 uint32,格式化为十进制串后字符集受限,无注入面。
|
||||||
|
func (r *embeddingRepository) ReplaceSimilarPairs(ctx context.Context, kind string, selfIDs []uint32, rows []SimilarPair) error {
|
||||||
|
if len(selfIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(selfIDs))
|
||||||
|
for _, id := range selfIDs {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(id), 10))
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 先删掉这批图的旧近邻:图集重爬/重算时避免残留过期对。
|
||||||
|
if err := tx.Exec(
|
||||||
|
"DELETE FROM image_similar_pairs WHERE kind = ? AND self_id IN ("+strings.Join(ids, ",")+")",
|
||||||
|
kind).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
vals := make([]string, 0, len(rows))
|
||||||
|
args := make([]any, 0, len(rows)*5)
|
||||||
|
for _, p := range rows {
|
||||||
|
vals = append(vals, "(?, ?, ?, ?, ?)")
|
||||||
|
args = append(args, kind, p.SelfID, p.OtherID, p.Similar, now)
|
||||||
|
}
|
||||||
|
return tx.Exec(
|
||||||
|
"INSERT INTO image_similar_pairs (kind, self_id, other_id, sim, created_at) VALUES "+
|
||||||
|
strings.Join(vals, ",")+
|
||||||
|
" ON CONFLICT (kind, self_id, other_id) DO UPDATE SET sim = EXCLUDED.sim, created_at = EXCLUDED.created_at",
|
||||||
|
args...).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSimilarPairs 批量读近邻,并 join 图片表与图集主表补齐对方描述。
|
||||||
|
//
|
||||||
|
// 两条查询、payload 极小(这是页面变快的关键):先按 (kind, self_id) 主键前缀取对,
|
||||||
|
// 再按对方 id 一次性取描述。软删图片在此被过滤掉——预计算表里的行可能残留,
|
||||||
|
// 不级联清理,靠这里过滤即可。
|
||||||
|
func (r *embeddingRepository) ListSimilarPairs(ctx context.Context, kind string, imageIDs []uint32) (map[uint32][]SimilarNeighbor, error) {
|
||||||
|
if len(imageIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
owner, ok := ownerOf(kind)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var pairs []struct {
|
||||||
|
SelfID uint32 `gorm:"column:self_id"`
|
||||||
|
OtherID uint32 `gorm:"column:other_id"`
|
||||||
|
Sim float64 `gorm:"column:sim"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table("image_similar_pairs").
|
||||||
|
Select("self_id, other_id, sim").
|
||||||
|
Where("kind = ? AND self_id IN ?", kind, imageIDs).
|
||||||
|
Scan(&pairs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
otherIDs := make([]uint32, 0, len(pairs))
|
||||||
|
seen := make(map[uint32]bool, len(pairs))
|
||||||
|
for _, p := range pairs {
|
||||||
|
if !seen[p.OtherID] {
|
||||||
|
seen[p.OtherID] = true
|
||||||
|
otherIDs = append(otherIDs, p.OtherID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ph := make([]string, len(otherIDs))
|
||||||
|
args := make([]any, len(otherIDs))
|
||||||
|
for i, id := range otherIDs {
|
||||||
|
ph[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
var metas []struct {
|
||||||
|
ID uint32 `gorm:"column:id"`
|
||||||
|
Image string `gorm:"column:image"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Owner uint32 `gorm:"column:owner"`
|
||||||
|
OwnerTitle string `gorm:"column:owner_title"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(
|
||||||
|
"SELECT i.id AS id, i.image AS image, i.name AS name, "+
|
||||||
|
"i."+owner.fk+" AS owner, t."+owner.titleCol+" AS owner_title "+
|
||||||
|
"FROM "+owner.table+" AS i "+
|
||||||
|
"JOIN "+owner.titleTable+" AS t ON t.id = i."+owner.fk+" "+
|
||||||
|
"WHERE i.id IN ("+strings.Join(ph, ",")+") AND i.is_deleted = 0",
|
||||||
|
args...).Scan(&metas).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
metaByID := make(map[uint32]SimilarNeighbor, len(metas))
|
||||||
|
for _, m := range metas {
|
||||||
|
metaByID[m.ID] = SimilarNeighbor{
|
||||||
|
OtherID: m.ID, OwnerID: m.Owner,
|
||||||
|
Image: m.Image, Name: m.Name, OwnerTitle: m.OwnerTitle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make(map[uint32][]SimilarNeighbor, len(pairs))
|
||||||
|
for _, p := range pairs {
|
||||||
|
m, ok := metaByID[p.OtherID]
|
||||||
|
if !ok {
|
||||||
|
continue // 对方已软删或不存在,不作为提示
|
||||||
|
}
|
||||||
|
m.Similar = p.Sim
|
||||||
|
out[p.SelfID] = append(out[p.SelfID], m)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DupStatsByOwner 按图集汇总跨图集重复情况,一次查完供列表页使用。
|
||||||
|
//
|
||||||
|
// 用 LEFT JOIN 同时拿到两件事:每组存活图片总数(做分母)与其中有重复的图片数。
|
||||||
|
// 分母刻意实时统计而非读 brand_runways.image_count —— 那列是**只记主图**的口径(见 AdminRecordStats)。
|
||||||
|
//
|
||||||
|
// 必须同时校验「对端那张图还活着」:image_similar_pairs 是**预计算表**,删除图集只会软删
|
||||||
|
// 图片行、不会删近邻对,于是对端已删的陈旧近邻会继续留在表里。编辑页的提示路径(ListSimilarPairs)
|
||||||
|
// 会过滤掉这些死链,列表汇总若不过滤就会长期显示已不存在的重复(实测:图集 391 的 88 个近邻
|
||||||
|
// 对端全部已删,列表仍显示「88 张 · 37%」)。恢复图集(is_deleted 置回 0)后会自动重新计入。
|
||||||
|
//
|
||||||
|
// 表名/列名来自 ownerOf 常量,kind 走参数绑定,无注入面。
|
||||||
|
func (r *embeddingRepository) DupStatsByOwner(ctx context.Context, kind string) (map[uint32]DupStat, error) {
|
||||||
|
owner, ok := ownerOf(kind)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
Owner uint32 `gorm:"column:owner"`
|
||||||
|
Total int `gorm:"column:total"`
|
||||||
|
Images int `gorm:"column:images"`
|
||||||
|
FirstImg uint32 `gorm:"column:first_img"`
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Raw(
|
||||||
|
"SELECT i."+owner.fk+" AS owner, "+
|
||||||
|
"count(DISTINCT i.id) AS total, "+
|
||||||
|
"count(DISTINCT p.self_id) AS images, "+
|
||||||
|
"coalesce(min(p.self_id), 0) AS first_img "+
|
||||||
|
"FROM "+owner.table+" AS i "+
|
||||||
|
"LEFT JOIN image_similar_pairs AS p ON p.kind = ? AND p.self_id = i.id "+
|
||||||
|
" AND EXISTS (SELECT 1 FROM "+owner.table+" AS o WHERE o.id = p.other_id AND o.is_deleted = 0) "+
|
||||||
|
"WHERE i.is_deleted = 0 "+
|
||||||
|
"GROUP BY i."+owner.fk,
|
||||||
|
kind).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[uint32]DupStat, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.Owner] = DupStat{Total: r.Total, Images: r.Images, FirstImageID: r.FirstImg}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
81
internal/repository/embedding_repository_test.go
Normal file
81
internal/repository/embedding_repository_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFormatEmbeddingVector 向量串格式化是写入正确性的关键:
|
||||||
|
// 维度必须精确、float32 精度不能丢(截断会让相似度漂移)。
|
||||||
|
func TestFormatEmbeddingVector(t *testing.T) {
|
||||||
|
v := make([]float32, 768)
|
||||||
|
v[0] = 0.5
|
||||||
|
v[767] = -0.25
|
||||||
|
|
||||||
|
s := FormatEmbeddingVector(v)
|
||||||
|
if !strings.HasPrefix(s, "[") || !strings.HasSuffix(s, "]") {
|
||||||
|
t.Fatalf("应输出 [..] 形式的向量串,得到 %q", truncForTest(s))
|
||||||
|
}
|
||||||
|
if got := strings.Count(s, ",") + 1; got != 768 {
|
||||||
|
t.Fatalf("维度应为 768,得到 %d", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "0.5") || !strings.Contains(s, "-0.25") {
|
||||||
|
t.Fatalf("首尾元素未按 float32 精度输出: %s", truncForTest(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatEmbeddingVectorEmpty 空向量必须能安全格式化,不能 panic。
|
||||||
|
func TestFormatEmbeddingVectorEmpty(t *testing.T) {
|
||||||
|
if got := FormatEmbeddingVector(nil); got != "[]" {
|
||||||
|
t.Fatalf("nil 应格式化为 [],得到 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseEmbeddingVectorRoundTrip 格式化与解析必须能精确往返,
|
||||||
|
// 否则「写进去的向量」与「读出来算相似度的向量」不是同一个,阈值校准会失真。
|
||||||
|
func TestParseEmbeddingVectorRoundTrip(t *testing.T) {
|
||||||
|
v := make([]float32, 768)
|
||||||
|
for i := range v {
|
||||||
|
v[i] = float32(i%17-8) * 0.0173
|
||||||
|
}
|
||||||
|
got := ParseEmbeddingVector(FormatEmbeddingVector(v))
|
||||||
|
if len(got) != len(v) {
|
||||||
|
t.Fatalf("往返后长度应一致:%d != %d", len(got), len(v))
|
||||||
|
}
|
||||||
|
for i := range v {
|
||||||
|
if got[i] != v[i] {
|
||||||
|
t.Fatalf("第 %d 维往返后不一致:%v != %v", i, got[i], v[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseEmbeddingVectorInvalid 非法输入返回 nil(调用方据此跳过该行,不阻断渲染)。
|
||||||
|
func TestParseEmbeddingVectorInvalid(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", "[]", "0.1,0.2", "[0.1,abc]", "[", "null"} {
|
||||||
|
if got := ParseEmbeddingVector(bad); got != nil {
|
||||||
|
t.Fatalf("非法输入 %q 应返回 nil,得到 %v", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatEmbeddingVectorNormalized 归一化向量点积等于余弦相似度:
|
||||||
|
// 用同一向量的自点积验证模长≈1(服务端已归一化的前提)。
|
||||||
|
func TestFormatEmbeddingVectorNormalized(t *testing.T) {
|
||||||
|
v := []float32{0.6, 0.8} // 模长为 1
|
||||||
|
got := ParseEmbeddingVector(FormatEmbeddingVector(v))
|
||||||
|
var dot float64
|
||||||
|
for _, f := range got {
|
||||||
|
dot += float64(f) * float64(f)
|
||||||
|
}
|
||||||
|
if math.Abs(dot-1) > 1e-6 {
|
||||||
|
t.Fatalf("模长应≈1,得到 %f", dot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncForTest(s string) string {
|
||||||
|
if len(s) > 80 {
|
||||||
|
return s[:80] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -63,8 +62,6 @@ type IngestRepository interface {
|
|||||||
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
|
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
|
||||||
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
|
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
|
||||||
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error
|
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) 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 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||||
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
|
||||||
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
|
||||||
@ -409,13 +406,16 @@ func (r *ingestRepository) BackfillRunwayPublishedAt(ctx context.Context, runway
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
|
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
|
||||||
|
// 注意:占位符顺序必须是 (runwayID, targetSeasonCode) —— 本 Where 有两个 ?,
|
||||||
|
// 漏传 runwayID 会让 season_code 落到 id 槽位,生成 `WHERE id = 'FW27'`
|
||||||
|
// 这种必然语法错误的 SQL(该缺陷曾导致「重爬已存在走秀」的任务无限重试)。
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Where("id = ? AND year = 0 AND is_deleted = 0 "+
|
Where("id = ? AND year = 0 AND is_deleted = 0 "+
|
||||||
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
|
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
|
||||||
"WHERE b.brand_id = brand_runways.brand_id AND b.season_code = ? "+
|
"WHERE b.brand_id = brand_runways.brand_id AND b.season_code = ? "+
|
||||||
"AND b.collection_type = brand_runways.collection_type AND b.id <> brand_runways.id AND b.is_deleted = 0)",
|
"AND b.collection_type = brand_runways.collection_type AND b.id <> brand_runways.id AND b.is_deleted = 0)",
|
||||||
targetSeasonCode).
|
runwayID, targetSeasonCode).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"year": year,
|
"year": year,
|
||||||
"season_code": targetSeasonCode,
|
"season_code": targetSeasonCode,
|
||||||
@ -570,39 +570,6 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
|
|
||||||
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
|
||||||
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
|
||||||
//
|
|
||||||
// 只比对未软删的行(is_deleted = 0):已被软删(审核剔除 / 复用重审)的旧图不应再作为
|
|
||||||
// 「重复」的参照。注意复用驳回行的时序 —— 去重发生在软删旧图**之前**,那一刻旧行仍是
|
|
||||||
// is_deleted = 0、会被正常命中;dup_of 只是人工留痕,不承担可见性语义。
|
|
||||||
// 本方法被 runway / street 两条入库路径共用,过滤对两者语义一致。
|
|
||||||
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
|
||||||
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
|
||||||
l2Limit := math.Sqrt(float64(threshold))
|
|
||||||
for _, t := range tables {
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
Dist float64 `gorm:"column:dist"`
|
|
||||||
}
|
|
||||||
err := r.db.WithContext(ctx).Table(t).
|
|
||||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
|
||||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
|
||||||
Where("is_deleted = 0").
|
|
||||||
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 0, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
|
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,
|
||||||
// 命中驱动返回的唯一约束冲突错误信息更稳(兼容 MySQL/PostgreSQL/SQLite 不同措辞)。
|
// 命中驱动返回的唯一约束冲突错误信息更稳(兼容 MySQL/PostgreSQL/SQLite 不同措辞)。
|
||||||
func isDuplicateKey(err error) bool {
|
func isDuplicateKey(err error) bool {
|
||||||
|
|||||||
@ -5,12 +5,9 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway {
|
func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway {
|
||||||
@ -181,40 +178,6 @@ func TestIngestReuseRejectedRefusesPublished(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDedupIgnoresSoftDeletedImage 去重只比对**未软删**的行(is_deleted = 0)。
|
|
||||||
//
|
|
||||||
// 别把它误读成「复用驳回行时不会自我命中」:复用路径里去重发生在软删旧图**之前**,那一刻
|
|
||||||
// 旧行仍是 is_deleted = 0、仍会被命中——dup_of 只是给人工看的留痕,不参与任何可见性判定。
|
|
||||||
// 本用例锁的是另一条独立保证:**已经**软删的行不再被当作重复参照。
|
|
||||||
//
|
|
||||||
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证它若被纳入就必然是「最近一条」,
|
|
||||||
// 从而确定性地验证 is_deleted = 0 过滤生效,与库中其它数据无关。
|
|
||||||
func TestDedupIgnoresSoftDeletedImage(t *testing.T) {
|
|
||||||
db := testDB(t)
|
|
||||||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
|
||||||
repo := NewIngestRepository(db)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
bits := phash.ToVectorBits(^uint64(0))
|
|
||||||
now := uint32(time.Now().Unix())
|
|
||||||
row := model.BrandRunwayImage{
|
|
||||||
RunwayID: 1, BrandID: 1, Image: "ingest/soft-deleted.jpg", Name: "look",
|
|
||||||
SortOrder: 1, IsDeleted: 1, CreatedAt: now, UpdatedAt: now,
|
|
||||||
Phash: sql.NullString{String: bits, Valid: true},
|
|
||||||
}
|
|
||||||
if err := db.Create(&row).Error; err != nil {
|
|
||||||
t.Fatalf("插入软删图片失败: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Where("id = ?", row.ID).Delete(&model.BrandRunwayImage{}) })
|
|
||||||
|
|
||||||
dupID, found, err := repo.FindNearDuplicateImage(ctx, []string{"brand_runway_images"}, bits, phash.DefaultThreshold)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
|
|
||||||
}
|
|
||||||
if found && dupID == row.ID {
|
|
||||||
t.Fatalf("已软删的行(id=%d)不应参与近重复比对", row.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
|
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -3,13 +3,11 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -41,17 +39,6 @@ type StreetSnapRepository interface {
|
|||||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||||
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
// GetStreetSnapDupSources 批量反查重复源:给定一组街拍图片 id(dup_of 指向的源图 id),
|
|
||||||
// 返回每张源图及其所属街拍的上下文,供编辑页标记重复图时展示来源。
|
|
||||||
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
|
||||||
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
|
|
||||||
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
|
||||||
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
|
|
||||||
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
|
|
||||||
// ReresolveSnapDupOfSources 删图后清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
|
||||||
ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error
|
|
||||||
// GetSnapLiveImageIDs 取某街拍全部存活图片 id(级联下架前用于跨文章去重清理)。
|
|
||||||
GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error)
|
|
||||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||||
// SetSnapCover 更新街拍封面。
|
// SetSnapCover 更新街拍封面。
|
||||||
@ -348,168 +335,6 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
|
|||||||
return img.Image, nil
|
return img.Image, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供编辑页删图后重算去重)。
|
|
||||||
func (r *streetSnapRepository) GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
|
|
||||||
var img model.StreetSnapImage
|
|
||||||
err := r.db.WithContext(ctx).Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return img.SnapID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// nearDupExceptSnap 在 street_snap_images 内做全局近重复检索(is_deleted=0),但排除自身与
|
|
||||||
// 「同 snap 且 sort_order 在后的图」——防止重算时后入者反成源导致互指环。命中返回源图 id,无则 0。
|
|
||||||
func nearDupExceptSnap(db *gorm.DB, phashBits string, threshold int, excludeID, snapID, sortOrder uint32) (uint32, error) {
|
|
||||||
l2Limit := math.Sqrt(float64(threshold))
|
|
||||||
var row struct {
|
|
||||||
ID uint32 `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
if err := db.Table("street_snap_images").
|
|
||||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
|
||||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
|
||||||
Where("is_deleted = 0").
|
|
||||||
Where("id <> ?", excludeID).
|
|
||||||
Where("(snap_id <> ? OR sort_order < ?)", snapID, sortOrder).
|
|
||||||
Order("dist ASC").
|
|
||||||
Limit(1).
|
|
||||||
Scan(&row).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return row.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReresolveSnapDedup 删除图片后重解本专辑去重:先清空全部存活图的 is_duplicate/dup_of,
|
|
||||||
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
|
|
||||||
//
|
|
||||||
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;删除「源图」
|
|
||||||
// 后剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」且「查看重复来源」指向已删源图而打不开。重算后
|
|
||||||
// 被删源图不再作为参照,剩余近重复图重新确立「先入者为准」的源/副本关系。
|
|
||||||
func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID uint32) error {
|
|
||||||
var imgs []model.StreetSnapImage
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
|
||||||
Order("sort_order ASC, id ASC").
|
|
||||||
Find(&imgs).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(imgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.StreetSnapImage{}).
|
|
||||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, im := range imgs {
|
|
||||||
if !im.Phash.Valid || im.Phash.String == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, snapID, im.SortOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if src != 0 && src != im.ID {
|
|
||||||
if err := tx.Model(&model.StreetSnapImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReresolveSnapDupOfSources 删除图片后,清理「以被删图为源」的跨文章重复标记(见方法注释)。
|
|
||||||
// 逻辑同 ReresolveRunwayDupOfSources,仅表换成 street_snap_images。
|
|
||||||
func (r *streetSnapRepository) ReresolveSnapDupOfSources(ctx context.Context, deletedImageIDs []uint32) error {
|
|
||||||
if len(deletedImageIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
strIDs := make([]string, 0, len(deletedImageIDs))
|
|
||||||
for _, id := range deletedImageIDs {
|
|
||||||
strIDs = append(strIDs, strconv.FormatUint(uint64(id), 10))
|
|
||||||
}
|
|
||||||
var affected []model.StreetSnapImage
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("is_deleted = 0 AND dup_of IN ?", strIDs).
|
|
||||||
Find(&affected).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(affected) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
for _, im := range affected {
|
|
||||||
if !im.Phash.Valid || im.Phash.String == "" {
|
|
||||||
if err := tx.Model(&model.StreetSnapImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, im.SnapID, im.SortOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if src != 0 && src != im.ID {
|
|
||||||
if err := tx.Model(&model.StreetSnapImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.Model(&model.StreetSnapImage{}).
|
|
||||||
Where("id = ? AND is_deleted = 0", im.ID).
|
|
||||||
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
|
|
||||||
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
|
||||||
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
|
||||||
if len(imageIDs) == 0 {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
type srcRow struct {
|
|
||||||
ImageID uint32
|
|
||||||
Image string
|
|
||||||
SnapID uint32
|
|
||||||
Title string
|
|
||||||
TitleCn string
|
|
||||||
}
|
|
||||||
var rows []srcRow
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Table("street_snap_images ssi").
|
|
||||||
Select("ssi.id AS image_id, ssi.image AS image, ssi.snap_id AS snap_id, ss.title AS title, ss.title_cn AS title_cn").
|
|
||||||
Joins("JOIN street_snaps ss ON ss.id = ssi.snap_id").
|
|
||||||
Where("ssi.id IN ?", imageIDs).
|
|
||||||
Where("ssi.is_deleted = 0").
|
|
||||||
Find(&rows).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, rw := range rows {
|
|
||||||
title := rw.Title
|
|
||||||
if title == "" {
|
|
||||||
title = rw.TitleCn
|
|
||||||
}
|
|
||||||
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.SnapID, Title: title}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
||||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||||
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
||||||
@ -542,18 +367,6 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSnapLiveImageIDs 取某街拍全部存活图片的 id(级联下架前取出,供跨文章去重清理)。
|
|
||||||
func (r *streetSnapRepository) GetSnapLiveImageIDs(ctx context.Context, snapID uint32) ([]uint32, error) {
|
|
||||||
var ids []uint32
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.StreetSnapImage{}).
|
|
||||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
|
||||||
Pluck("id", &ids).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
func (r *streetSnapRepository) SetSnapCover(ctx context.Context, snapID uint32, image string) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
|
|||||||
46
internal/repository/testhelper_integration_test.go
Normal file
46
internal/repository/testhelper_integration_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试的公共辅助:连接真实 PostgreSQL + pgvector。
|
||||||
|
//
|
||||||
|
// 运行方式(先起库,见 scripts/pgvector):
|
||||||
|
//
|
||||||
|
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
||||||
|
// go test -tags integration ./internal/repository/ -run <TestName> -v
|
||||||
|
//
|
||||||
|
// 默认 `go test ./...` 不会编译本文件(build tag 隔离),CI 无库环境不受影响。
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/config"
|
||||||
|
"fashionapi/internal/database"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testDB 连真实库并校验所需结构已就绪。
|
||||||
|
//
|
||||||
|
// 结构不再由 AutoMigrate 自动创建(已移除),须先自行准备好库,例如:
|
||||||
|
//
|
||||||
|
// ./bin/dbtool dump -out db_dump.sql
|
||||||
|
// psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db_dump.sql
|
||||||
|
//
|
||||||
|
// 这里只做存在性校验并给出明确提示,避免后续查询报出难懂的错。
|
||||||
|
func testDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := config.Load("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
db, err := database.New(cfg.Database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("连接数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close(db) })
|
||||||
|
|
||||||
|
if err := db.Exec("SELECT 1 FROM brand_runway_images LIMIT 1").Error; err != nil {
|
||||||
|
t.Fatalf("brand_runway_images 不可用(库结构未就绪?先 dbtool dump 再 psql -f 灌库): %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
@ -91,8 +91,13 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
admin.POST("/brands", opt.Backstage.BrandCreate)
|
admin.POST("/brands", opt.Backstage.BrandCreate)
|
||||||
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
|
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
|
||||||
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
|
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
|
||||||
|
// 软删品牌(is_deleted=1)。有未删除图集时 service 会拒绝,不做隐式级联删除。
|
||||||
|
admin.POST("/brands/:id/delete", opt.Backstage.BrandDelete)
|
||||||
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。
|
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。
|
||||||
admin.GET("/articles", opt.Backstage.Articles)
|
admin.GET("/articles", opt.Backstage.Articles)
|
||||||
|
// 手工新建文章:表单 + 提交(multipart:元数据 + 多张图片,一步到位)。
|
||||||
|
admin.GET("/articles/new", opt.Backstage.ArticleNew)
|
||||||
|
admin.POST("/articles", opt.Backstage.ArticleCreate)
|
||||||
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
|
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
|
||||||
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
|
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
|
||||||
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
|
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
|
||||||
|
|||||||
@ -75,6 +75,12 @@ func (f *fakeBrand) Update(ctx context.Context, uid, nameEn, nameCn string) erro
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeBrand) Delete(ctx context.Context, uid string) error {
|
||||||
|
if uid != "001DESke" {
|
||||||
|
return service.ErrBrandNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (f *fakeBrand) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
func (f *fakeBrand) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
||||||
return []dto.CrawlBrand{{BrandUID: "001DESke", Name: "Acne Studios"}}, nil
|
return []dto.CrawlBrand{{BrandUID: "001DESke", Name: "Acne Studios"}}, nil
|
||||||
}
|
}
|
||||||
@ -147,9 +153,6 @@ func (f *fakeArticle) SetDeleted(ctx context.Context, id uint32, deleted uint8)
|
|||||||
func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
|
func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeArticle) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
|
||||||
return map[uint32]dto.DupSource{}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeArticle) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) {
|
func (f *fakeArticle) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@ -210,9 +213,6 @@ func (f *fakeStreet) SetDeleted(ctx context.Context, id uint32, deleted uint8) e
|
|||||||
func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
|
func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeStreet) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
|
||||||
return map[uint32]dto.DupSource{}, nil
|
|
||||||
}
|
|
||||||
func (f *fakeStreet) SumImageSize(ctx context.Context, snapID uint32) (int64, error) {
|
func (f *fakeStreet) SumImageSize(ctx context.Context, snapID uint32) (int64, error) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@ -220,8 +220,8 @@ func (f *fakeStreet) SumImageSize(ctx context.Context, snapID uint32) (int64, er
|
|||||||
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
||||||
type fakeAdminRepo struct{}
|
type fakeAdminRepo struct{}
|
||||||
|
|
||||||
func (f *fakeAdminRepo) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error) {
|
func (f *fakeAdminRepo) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]repository.AdminRecordStats, error) {
|
||||||
return map[uint32]int64{}, nil
|
return map[uint32]repository.AdminRecordStats{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
|
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
|
||||||
@ -270,17 +270,13 @@ func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int6
|
|||||||
}
|
}
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
func (f *fakeAdminRepo) DupRateByRecords(_ context.Context, kind string, ids []uint32) (map[uint32]repository.DupStat, error) {
|
|
||||||
return map[uint32]repository.DupStat{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
||||||
|
|
||||||
func TestBackstage(t *testing.T) {
|
func TestBackstage(t *testing.T) {
|
||||||
m := jwt.NewManager("test-secret", 168)
|
m := jwt.NewManager("test-secret", 168)
|
||||||
auth := &fakeAuth{jwt: m}
|
auth := &fakeAuth{jwt: m}
|
||||||
brand := &fakeBrand{}
|
brand := &fakeBrand{}
|
||||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{}, nil)
|
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{}, nil, nil)
|
||||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||||
|
|
||||||
// 1) 登录页可访问
|
// 1) 登录页可访问
|
||||||
@ -488,7 +484,7 @@ func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
|||||||
auth := &fakeAuth{jwt: m}
|
auth := &fakeAuth{jwt: m}
|
||||||
brand := &fakeBrand{}
|
brand := &fakeBrand{}
|
||||||
rev := &fakeReview{attachOK: true}
|
rev := &fakeReview{attachOK: true}
|
||||||
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{}, nil)
|
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{}, nil, nil)
|
||||||
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||||
return r, rev
|
return r, rev
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,8 +30,6 @@ type ArticleService interface {
|
|||||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||||
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释),供编辑页标记重复图。
|
|
||||||
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
|
||||||
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
||||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// DeleteImage 软删单张走秀图片。
|
// DeleteImage 软删单张走秀图片。
|
||||||
@ -241,11 +239,6 @@ func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.Bran
|
|||||||
return rw, orderRunwayImagesForEdit(imgs), nil
|
return rw, orderRunwayImagesForEdit(imgs), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释)。
|
|
||||||
func (s *articleService) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
|
||||||
return s.articles.GetRunwayDupSources(ctx, imageIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
|
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
|
||||||
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
|
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
|
||||||
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
|
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
|
||||||
@ -294,17 +287,6 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
|
|||||||
if key != "" {
|
if key != "" {
|
||||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||||
}
|
}
|
||||||
// 删图后重算本画廊去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
|
||||||
if runwayID, err := s.articles.GetRunwayImageRunwayID(ctx, imageID); err == nil && runwayID != 0 {
|
|
||||||
if err := s.articles.ReresolveRunwayDedup(ctx, runwayID); err != nil {
|
|
||||||
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 跨文章清理:本图若是其它文章近重复的「源」,那些文章的 dup_of 需重算/清除
|
|
||||||
// (ReresolveRunwayDedup 只算本集内,不会碰其它文章指向本图的标记)。
|
|
||||||
if err := s.articles.ReresolveRunwayDupOfSources(ctx, []uint32{imageID}); err != nil {
|
|
||||||
log.Printf("[warn] 跨文章重算走秀图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,11 +316,6 @@ func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint
|
|||||||
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
// 仍被其他图集引用的 key 不会被误删(内容寻址共享 key 安全);入队失败仅告警,
|
||||||
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
// 图集已下架、图片已软删,S4文件留待后续手动清理或周期性兜底扫描。
|
||||||
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
// 下架前先取存活图片 id:删整篇后,其它文章中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
|
||||||
delIDs, err := s.articles.GetRunwayLiveImageIDs(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
keys, err := s.articles.SoftDeleteRunwayImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -351,12 +328,6 @@ func (s *articleService) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
log.Printf("[warn] 下架图集 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 跨文章去重清理:删除整篇后,其它文章中 dup_of 指向本篇图片的标记重算/清除。
|
|
||||||
if len(delIDs) > 0 {
|
|
||||||
if err := s.articles.ReresolveRunwayDupOfSources(ctx, delIDs); err != nil {
|
|
||||||
log.Printf("[warn] 跨文章重算走秀 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,11 +343,11 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
|
|||||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
out = append(out, dto.PublicArticleImage{
|
out = append(out, dto.PublicArticleImage{
|
||||||
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
||||||
Image: s.img.Compose(im.Image),
|
Image: s.img.Compose(im.Image),
|
||||||
Thumb: s.img.ComposeThumb(im.Image),
|
Thumb: s.img.ComposeThumb(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
IsDetail: im.IsDetail,
|
IsDetail: im.IsDetail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -391,10 +362,10 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
|
|||||||
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
out := make([]dto.PublicArticleImage, 0, len(imgs))
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
out = append(out, dto.PublicArticleImage{
|
out = append(out, dto.PublicArticleImage{
|
||||||
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage),
|
||||||
Image: s.img.ComposeThumb(im.Image),
|
Image: s.img.ComposeThumb(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
IsDetail: im.IsDetail,
|
IsDetail: im.IsDetail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
@ -29,6 +30,9 @@ type BrandService interface {
|
|||||||
Create(ctx context.Context, nameEn, nameCn string) (string, error)
|
Create(ctx context.Context, nameEn, nameCn string) (string, error)
|
||||||
// Update 按编码 id 更新品牌名称。
|
// Update 按编码 id 更新品牌名称。
|
||||||
Update(ctx context.Context, uid, nameEn, nameCn string) error
|
Update(ctx context.Context, uid, nameEn, nameCn string) error
|
||||||
|
// Delete 软删品牌(is_deleted=1)。品牌下仍有未删除的走秀档案时拒绝删除,
|
||||||
|
// 避免留下 brand_id 指不到品牌的图集(无外键级联)。
|
||||||
|
Delete(ctx context.Context, uid string) error
|
||||||
// CrawlTasks 返回爬虫抓取所需的品牌列表(编码 brand_uid + 英文品牌名)。
|
// CrawlTasks 返回爬虫抓取所需的品牌列表(编码 brand_uid + 英文品牌名)。
|
||||||
// brandID>0 时仅返回该品牌(对应 --brand 单品牌调试)。
|
// brandID>0 时仅返回该品牌(对应 --brand 单品牌调试)。
|
||||||
CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error)
|
CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error)
|
||||||
@ -170,6 +174,31 @@ func (s *brandService) Update(ctx context.Context, uid, nameEn, nameCn string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete 软删品牌:先确认该品牌下没有未删除的走秀档案,再置 is_deleted=1。
|
||||||
|
//
|
||||||
|
// 护栏的理由:brand_runways.brand_id 没有外键级联,直接删品牌会留下一批指不到品牌的图集,
|
||||||
|
// 前台品牌页、热门榜、图集列表都会出现异常数据。宁可让运营先处理档案,也不引入隐式级联删除。
|
||||||
|
func (s *brandService) Delete(ctx context.Context, uid string) error {
|
||||||
|
id, err := hashid.Decode(uid)
|
||||||
|
if err != nil {
|
||||||
|
return ErrBrandNotFound
|
||||||
|
}
|
||||||
|
n, err := s.brands.CountRunwaysByBrand(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return internalErr(err.Error())
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return NewError(http.StatusConflict, fmt.Sprintf("该品牌下还有 %d 个图集,请先删除或下架后再删品牌", n))
|
||||||
|
}
|
||||||
|
if err := s.brands.SetDeleted(ctx, id, 1); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return ErrBrandNotFound
|
||||||
|
}
|
||||||
|
return internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。
|
// CrawlTasks 返回爬虫取任务所需的品牌列表:编码 brand_uid + 英文品牌名。
|
||||||
// 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。
|
// 爬虫直接拿 brand_uid 上送 ingest,无需自己编码;name 用于拼 vogue 抓取 URL。
|
||||||
func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
func (s *brandService) CrawlTasks(ctx context.Context, brandID uint32) ([]dto.CrawlBrand, error) {
|
||||||
|
|||||||
73
internal/service/brand_service_test.go
Normal file
73
internal/service/brand_service_test.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubBrandRepo 只实现删除链路上用到的两个方法;其余方法通过嵌入的接口转发
|
||||||
|
// (本测试不会调用它们,因此不必为 7 个无关方法写样板)。
|
||||||
|
type stubBrandRepo struct {
|
||||||
|
repository.BrandRepository
|
||||||
|
runways int64
|
||||||
|
deleted uint8
|
||||||
|
setCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubBrandRepo) CountRunwaysByBrand(_ context.Context, _ uint32) (int64, error) {
|
||||||
|
return s.runways, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubBrandRepo) SetDeleted(_ context.Context, _ uint32, deleted uint8) error {
|
||||||
|
s.setCalled = true
|
||||||
|
s.deleted = deleted
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrandDeleteBlockedByRunways 品牌下还有图集时必须拒绝删除,且绝不能写 is_deleted。
|
||||||
|
// 这是删除功能唯一的护栏:brand_runways.brand_id 没有外键级联,误删会留下指不到品牌的图集。
|
||||||
|
func TestBrandDeleteBlockedByRunways(t *testing.T) {
|
||||||
|
repo := &stubBrandRepo{runways: 3}
|
||||||
|
svc := NewBrandService(repo)
|
||||||
|
|
||||||
|
err := svc.Delete(context.Background(), hashid.Encode(1))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("品牌下还有图集时应拒绝删除")
|
||||||
|
}
|
||||||
|
if repo.setCalled {
|
||||||
|
t.Fatal("被拒绝时不得写入 is_deleted")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "3") {
|
||||||
|
t.Fatalf("错误信息应带上图集数量,便于运营处理,实际: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrandDeleteAllowedWhenNoRunways 没有图集时可以软删,且写入的必须是 is_deleted=1。
|
||||||
|
func TestBrandDeleteAllowedWhenNoRunways(t *testing.T) {
|
||||||
|
repo := &stubBrandRepo{}
|
||||||
|
svc := NewBrandService(repo)
|
||||||
|
|
||||||
|
if err := svc.Delete(context.Background(), hashid.Encode(1)); err != nil {
|
||||||
|
t.Fatalf("无图集时应允许删除,实际: %v", err)
|
||||||
|
}
|
||||||
|
if !repo.setCalled || repo.deleted != 1 {
|
||||||
|
t.Fatalf("应写入 is_deleted=1,实际 called=%v deleted=%d", repo.setCalled, repo.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrandDeleteInvalidUID 非法 id 直接报错,不得触达数据库。
|
||||||
|
func TestBrandDeleteInvalidUID(t *testing.T) {
|
||||||
|
repo := &stubBrandRepo{}
|
||||||
|
svc := NewBrandService(repo)
|
||||||
|
|
||||||
|
if err := svc.Delete(context.Background(), "!!!not-a-hashid"); err == nil {
|
||||||
|
t.Fatal("非法 id 应返回错误")
|
||||||
|
}
|
||||||
|
if repo.setCalled {
|
||||||
|
t.Fatal("非法 id 不应写库")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
|
||||||
"fashionapi/internal/pkg/storage"
|
"fashionapi/internal/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -31,7 +30,7 @@ func TestFetchImagesCleansUpOnFailure(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
|
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
|
||||||
_, out, keys, failed, _ := s.fetchImages(context.Background(), urls, "runway", dto.IngestKindRunway)
|
_, out, keys, failed, _ := s.fetchImages(context.Background(), urls, "runway")
|
||||||
if !failed {
|
if !failed {
|
||||||
t.Fatalf("expected failed=true when one image errors")
|
t.Fatalf("expected failed=true when one image errors")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,10 +35,6 @@ func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *mode
|
|||||||
return 1, nil
|
return 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
|
||||||
return 0, false, nil // 近重复不影响本用例
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
||||||
|
|
||||||
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -11,18 +10,20 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/pkg/phash"
|
|
||||||
"fashionapi/internal/pkg/season"
|
"fashionapi/internal/pkg/season"
|
||||||
"fashionapi/internal/pkg/storage"
|
"fashionapi/internal/pkg/storage"
|
||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
|
"fashionapi/internal/vision"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。
|
// IngestService 爬虫入库管线的业务逻辑层:Submit 入队,worker 异步处理。
|
||||||
@ -39,14 +40,26 @@ type IngestService struct {
|
|||||||
local *storage.LocalUploader // S4失败时的兜底落地
|
local *storage.LocalUploader // S4失败时的兜底落地
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
translate *TranslateService // 标题中文翻译(可为 nil:测试/未启用时跳过自动补全)
|
translate *TranslateService // 标题中文翻译(可为 nil:测试/未启用时跳过自动补全)
|
||||||
|
// 视觉服务与向量仓储(均可为 nil:未启用视觉服务时跳过 embed,不影响任何入库行为)。
|
||||||
|
// 只服务于后台「疑似同款」提示,不参与去重。
|
||||||
|
vision *vision.Client
|
||||||
|
embedRep repository.EmbeddingRepository
|
||||||
|
// similar 「疑似同款」预计算服务(可为 nil:装配期未注入或视觉服务未启用时跳过)。
|
||||||
|
// 写完向量后顺手把该图集的跨图集近邻算好落库,使后台页面只需读一次小查询。
|
||||||
|
// 注入用 SetSimilar,避免改动构造签名(该服务在主装配期晚于本服务创建)。
|
||||||
|
similar *SimilarService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSimilar 注入「疑似同款」服务,供写向量后顺手预计算近邻。
|
||||||
|
// 装配期调用;未注入(nil)时完全跳过预计算,不影响入库。
|
||||||
|
func (s *IngestService) SetSimilar(sv *SimilarService) { s.similar = sv }
|
||||||
|
|
||||||
// NewIngestService 创建入库服务。
|
// NewIngestService 创建入库服务。
|
||||||
//
|
//
|
||||||
// uploader: 主上传器(S4或本地)
|
// uploader: 主上传器(S4或本地)
|
||||||
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
||||||
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
||||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string, translateSvc *TranslateService) *IngestService {
|
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string, translateSvc *TranslateService, vis *vision.Client, embedRep repository.EmbeddingRepository) *IngestService {
|
||||||
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
||||||
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
||||||
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
||||||
@ -76,6 +89,8 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
|||||||
del: del,
|
del: del,
|
||||||
local: local,
|
local: local,
|
||||||
translate: translateSvc,
|
translate: translateSvc,
|
||||||
|
vision: vis,
|
||||||
|
embedRep: embedRep,
|
||||||
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
// 图片下载是并发执行的(见 imageFetchConcurrency),连接池须匹配并发度:
|
||||||
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
// Go 默认 MaxIdleConnsPerHost=2,多余连接会在每轮下载时反复重建、白付 TLS 握手开销。
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
@ -319,12 +334,14 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||||
var cover string
|
var cover string
|
||||||
var images []model.BrandRunwayImage
|
var images []model.BrandRunwayImage
|
||||||
|
// vecs 与 images 下标一一对应(nil = 该张未产出向量);落库拿到图片 id 后写入 image_embeddings。
|
||||||
|
var vecs [][]float32
|
||||||
var keys []string
|
var keys []string
|
||||||
var imgFailed bool
|
var imgFailed bool
|
||||||
var imageCount uint16
|
var imageCount uint16
|
||||||
var timing *fetchTiming
|
var timing *fetchTiming
|
||||||
if len(p.Looks) > 0 {
|
if len(p.Looks) > 0 {
|
||||||
cover, images, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
cover, images, vecs, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||||
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
|
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
|
||||||
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
|
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
|
||||||
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
|
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
|
||||||
@ -332,19 +349,17 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
imageCount = countMainImages(images)
|
imageCount = countMainImages(images)
|
||||||
} else {
|
} else {
|
||||||
var fimgs []fetchedImage
|
var fimgs []fetchedImage
|
||||||
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway")
|
||||||
imageCount = uint16(len(fimgs))
|
imageCount = uint16(len(fimgs))
|
||||||
for i, fi := range fimgs {
|
for i, fi := range fimgs {
|
||||||
images = append(images, model.BrandRunwayImage{
|
images = append(images, model.BrandRunwayImage{
|
||||||
Image: fi.url,
|
Image: fi.url,
|
||||||
Name: fmt.Sprintf("Look %d", i+1),
|
Name: fmt.Sprintf("Look %d", i+1),
|
||||||
SortOrder: uint32(i + 1),
|
SortOrder: uint32(i + 1),
|
||||||
IsDetail: 0,
|
IsDetail: 0,
|
||||||
Phash: sqlNull(fi.phash),
|
ImageSize: fi.size,
|
||||||
IsDuplicate: fi.isDup,
|
|
||||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
|
||||||
ImageSize: fi.size,
|
|
||||||
})
|
})
|
||||||
|
vecs = append(vecs, fi.vec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d runway 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
|
log.Printf("[ingest] job=%d runway 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
|
||||||
@ -396,6 +411,15 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 落库成功后写入语义向量(仅后台「疑似同款」用)。GORM 批量 Create 会把自增主键回填到
|
||||||
|
// images,因此此处 images[i].ID 即新建图片 id;写失败只记日志,绝不影响入库结果。
|
||||||
|
embedIDs := make([]uint32, 0, len(images))
|
||||||
|
for i := range images {
|
||||||
|
embedIDs = append(embedIDs, images[i].ID)
|
||||||
|
}
|
||||||
|
s.saveEmbeddings(ctx, dto.IngestKindRunway, embedIDs, vecs)
|
||||||
|
// 顺手把本场的「疑似同款」近邻算好落库(失败不影响入库)。
|
||||||
|
s.precomputeSimilar(ctx, dto.IngestKindRunway, embedIDs)
|
||||||
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
|
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
|
||||||
runwayID := rw.ID
|
runwayID := rw.ID
|
||||||
if runwayID == 0 {
|
if runwayID == 0 {
|
||||||
@ -414,16 +438,17 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||||
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||||
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||||
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
|
|
||||||
//
|
//
|
||||||
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
||||||
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
|
// 「下载 → 上传」这段 IO 密集操作;随后再按下标顺序串行做批次内去重与组装。
|
||||||
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
|
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
|
||||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) {
|
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, [][]float32, []string, bool, *fetchTiming) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
timing := &fetchTiming{}
|
timing := &fetchTiming{}
|
||||||
cover := ""
|
cover := ""
|
||||||
rows := make([]model.BrandRunwayImage, 0)
|
rows := make([]model.BrandRunwayImage, 0)
|
||||||
|
// vecs 与 rows 下标一一对应(nil 表示该张未产出向量),供落库后写入 image_embeddings。
|
||||||
|
vecs := make([][]float32, 0)
|
||||||
keys := make([]string, 0)
|
keys := make([]string, 0)
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
failed := false
|
failed := false
|
||||||
@ -443,7 +468,7 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并发段:下载 + phash + 上传(耗时大头)。
|
// 并发段:下载 + 上传(耗时大头)。
|
||||||
results := concurrentFetch(ctx, len(tasks), func(c context.Context, i int) downloadResult {
|
results := concurrentFetch(ctx, len(tasks), func(c context.Context, i int) downloadResult {
|
||||||
return s.downloadAndUpload(c, tasks[i].url, prefix)
|
return s.downloadAndUpload(c, tasks[i].url, prefix)
|
||||||
})
|
})
|
||||||
@ -453,8 +478,8 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
task := tasks[i]
|
task := tasks[i]
|
||||||
timing.download += r.download
|
timing.download += r.download
|
||||||
timing.phash += r.phash
|
|
||||||
timing.upload += r.upload
|
timing.upload += r.upload
|
||||||
|
timing.embed += r.embed
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
failed = true
|
failed = true
|
||||||
continue
|
continue
|
||||||
@ -464,7 +489,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[r.sha1] = true
|
seen[r.sha1] = true
|
||||||
dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, r.phashBits, timing)
|
|
||||||
order++
|
order++
|
||||||
if cover == "" && !task.isDetail {
|
if cover == "" && !task.isDetail {
|
||||||
cover = r.url
|
cover = r.url
|
||||||
@ -476,19 +500,17 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
isDetail = 1
|
isDetail = 1
|
||||||
}
|
}
|
||||||
rows = append(rows, model.BrandRunwayImage{
|
rows = append(rows, model.BrandRunwayImage{
|
||||||
Image: r.url,
|
Image: r.url,
|
||||||
Name: name,
|
Name: name,
|
||||||
SortOrder: uint32(order),
|
SortOrder: uint32(order),
|
||||||
IsDetail: isDetail,
|
IsDetail: isDetail,
|
||||||
Phash: sqlNull(r.phashBits),
|
ImageSize: r.size,
|
||||||
IsDuplicate: isDup,
|
|
||||||
DupOf: strconv.FormatUint(uint64(dupID), 10),
|
|
||||||
ImageSize: r.size,
|
|
||||||
})
|
})
|
||||||
|
vecs = append(vecs, r.vec)
|
||||||
}
|
}
|
||||||
timing.images = len(rows)
|
timing.images = len(rows)
|
||||||
timing.total = time.Since(start)
|
timing.total = time.Since(start)
|
||||||
return cover, rows, keys, failed, timing
|
return cover, rows, vecs, keys, failed, timing
|
||||||
}
|
}
|
||||||
|
|
||||||
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
|
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
|
||||||
@ -530,7 +552,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
||||||
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street")
|
||||||
log.Printf("[ingest] job=%d street 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
|
log.Printf("[ingest] job=%d street 拉图 总耗时=%v %s", job.ID, timing.total.Round(time.Millisecond), timing)
|
||||||
if len(p.Images) > 0 && imgFailed {
|
if len(p.Images) > 0 && imgFailed {
|
||||||
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
|
||||||
@ -562,16 +584,16 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
Status: model.StatusPending,
|
Status: model.StatusPending,
|
||||||
}
|
}
|
||||||
rows := make([]model.StreetSnapImage, 0, len(fimgs))
|
rows := make([]model.StreetSnapImage, 0, len(fimgs))
|
||||||
|
// vecs 与 rows 下标一一对应(nil = 该张未产出向量);落库拿到图片 id 后写入 image_embeddings。
|
||||||
|
vecs := make([][]float32, 0, len(fimgs))
|
||||||
for i, fi := range fimgs {
|
for i, fi := range fimgs {
|
||||||
rows = append(rows, model.StreetSnapImage{
|
rows = append(rows, model.StreetSnapImage{
|
||||||
Image: fi.url,
|
Image: fi.url,
|
||||||
Name: fmt.Sprintf("Look %d", i+1),
|
Name: fmt.Sprintf("Look %d", i+1),
|
||||||
SortOrder: uint32(i + 1),
|
SortOrder: uint32(i + 1),
|
||||||
Phash: sqlNull(fi.phash),
|
ImageSize: fi.size,
|
||||||
IsDuplicate: fi.isDup,
|
|
||||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
|
||||||
ImageSize: fi.size,
|
|
||||||
})
|
})
|
||||||
|
vecs = append(vecs, fi.vec)
|
||||||
}
|
}
|
||||||
var saveErr error
|
var saveErr error
|
||||||
if action == ingestReuseRejected {
|
if action == ingestReuseRejected {
|
||||||
@ -583,19 +605,27 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
|
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 落库成功后写入语义向量(同走秀:GORM 批量 Create 回填自增主键,故 rows[i].ID 即新建图片 id)。
|
||||||
|
embedIDs := make([]uint32, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
embedIDs = append(embedIDs, rows[i].ID)
|
||||||
|
}
|
||||||
|
s.saveEmbeddings(ctx, dto.IngestKindStreet, embedIDs, vecs)
|
||||||
|
// 顺手把本专辑的「疑似同款」近邻算好落库(失败不影响入库)。
|
||||||
|
s.precomputeSimilar(ctx, dto.IngestKindStreet, embedIDs)
|
||||||
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
|
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
|
||||||
// prefix 为S4 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
|
// prefix 为S4 key 前缀(runway/ 或 street/)。
|
||||||
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
|
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
|
||||||
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(近重复标记)。
|
// 符合「单图失败=整任务失败」策略。
|
||||||
//
|
//
|
||||||
// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → phash → 上传」,再按下标顺序串行
|
// 实现:先用 concurrentFetch 并发完成全部图片的「下载 → 上传」,再按下标顺序串行
|
||||||
// 做去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。
|
// 做批次内去重与组装,保证 cover / 顺序 / 批次内去重与原串行实现一致。
|
||||||
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool, *fetchTiming) {
|
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []fetchedImage, []string, bool, *fetchTiming) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
timing := &fetchTiming{}
|
timing := &fetchTiming{}
|
||||||
cover := ""
|
cover := ""
|
||||||
@ -604,7 +634,7 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
|||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
failed := false
|
failed := false
|
||||||
|
|
||||||
// 并发段:下载 + phash + 上传(耗时大头)。
|
// 并发段:下载 + 上传(耗时大头)。
|
||||||
results := concurrentFetch(ctx, len(urls), func(c context.Context, i int) downloadResult {
|
results := concurrentFetch(ctx, len(urls), func(c context.Context, i int) downloadResult {
|
||||||
return s.downloadAndUpload(c, urls[i], prefix)
|
return s.downloadAndUpload(c, urls[i], prefix)
|
||||||
})
|
})
|
||||||
@ -612,8 +642,8 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
|||||||
// 串行段:按原始顺序累计耗时、做批次内去重、组装结果。
|
// 串行段:按原始顺序累计耗时、做批次内去重、组装结果。
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
timing.download += r.download
|
timing.download += r.download
|
||||||
timing.phash += r.phash
|
|
||||||
timing.upload += r.upload
|
timing.upload += r.upload
|
||||||
|
timing.embed += r.embed
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
failed = true
|
failed = true
|
||||||
continue
|
continue
|
||||||
@ -623,17 +653,14 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[r.sha1] = true
|
seen[r.sha1] = true
|
||||||
dupID, isDup := s.dedupImage(ctx, kind, r.phashBits, timing)
|
|
||||||
if cover == "" {
|
if cover == "" {
|
||||||
cover = r.url
|
cover = r.url
|
||||||
}
|
}
|
||||||
out = append(out, fetchedImage{
|
out = append(out, fetchedImage{
|
||||||
url: r.url,
|
url: r.url,
|
||||||
key: r.key,
|
key: r.key,
|
||||||
phash: r.phashBits,
|
size: r.size,
|
||||||
dupID: dupID,
|
vec: r.vec,
|
||||||
isDup: isDup,
|
|
||||||
size: r.size,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
timing.images = len(out)
|
timing.images = len(out)
|
||||||
@ -641,6 +668,41 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
|
|||||||
return cover, out, keys, failed, timing
|
return cover, out, keys, failed, timing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// precomputeSimilar 写完向量后顺手预计算「疑似同款」近邻并落库。
|
||||||
|
//
|
||||||
|
// 为什么放在这里:原先是在**打开图集页时**对每张图实时做向量近邻检索,一个 116 张图的
|
||||||
|
// 图集要发 116 次查询、每次约 20KB SQL;本机 PostgreSQL 跑在 WSL2 里、Windows 经 wslrelay
|
||||||
|
// 访问,该链路大 payload 吞吐实测约 0.47MB/s,合计 5 秒。挪到入库时算一次(每个图集只付一次),
|
||||||
|
// 页面就退化成一次小查询。
|
||||||
|
//
|
||||||
|
// 失败只记日志:预计算是增值数据,绝不能让「算不出同款」影响入库结果。
|
||||||
|
// 注意:这里统计的是本次新入库的图片;它们与**其他图集存量图**的配对由预计算内部双向写入,
|
||||||
|
// 因此对方图集的页面也能立刻看到提示,无需重算对方。
|
||||||
|
func (s *IngestService) precomputeSimilar(ctx context.Context, kind string, imageIDs []uint32) {
|
||||||
|
if s.similar == nil || len(imageIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
if err := s.similar.PrecomputeForGallery(ctx, kind, imageIDs); err != nil {
|
||||||
|
log.Printf("[ingest] 「疑似同款」预计算失败 kind=%s 图=%d err=%v(不影响入库)", kind, len(imageIDs), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[ingest] 「疑似同款」预计算耗时 kind=%s 图=%d 耗时=%v", kind, len(imageIDs), time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveEmbeddings 落库成功后写入一批图片的语义向量(仅后台「疑似同款」提示用)。
|
||||||
|
//
|
||||||
|
// 向量是增值数据、不是入库的必要条件:仓储未注入(未启用视觉服务)、无向量、
|
||||||
|
// 或写入失败,都只记日志并返回,绝不影响入库结果。ids[i] 与 vecs[i] 一一对应。
|
||||||
|
func (s *IngestService) saveEmbeddings(ctx context.Context, kind string, ids []uint32, vecs [][]float32) {
|
||||||
|
if s.embedRep == nil || len(ids) == 0 || len(vecs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.embedRep.BulkUpsertEmbedding(ctx, kind, ids, vecs); err != nil {
|
||||||
|
log.Printf("[ingest] 写向量失败 kind=%s 数量=%d err=%v(不影响入库)", kind, len(ids), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupUploads 删除一批本批次成功上传的对象(S4 + 本地兜底),用于任务失败回滚:
|
// cleanupUploads 删除一批本批次成功上传的对象(S4 + 本地兜底),用于任务失败回滚:
|
||||||
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
|
// 「单图失败=整任务失败」时,前面已成功上传的图若放任不管就成了孤儿,永远堆在存储里。
|
||||||
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
|
// 对两种存储都尝试删除(任一不存在即按幂等成功处理);删除失败仅告警,不阻断任务置失败。
|
||||||
@ -660,14 +722,12 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchedImage 是单张已下载图片的暂存结构,携带去重所需的指纹信息。
|
// fetchedImage 是单张已下载图片的暂存结构。
|
||||||
type fetchedImage struct {
|
type fetchedImage struct {
|
||||||
url string
|
url string
|
||||||
key string
|
key string
|
||||||
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
|
size int64 // 原图源文件字节数,随结果带回供落库写入 image_size
|
||||||
dupID uint32 // 命中近重复时的参考图 id
|
vec []float32 // FashionSigLIP 向量(nil = 未产出),供落库后写入 image_embeddings
|
||||||
isDup uint8 // 是否标记为近重复(供审核留痕)
|
|
||||||
size int64 // 原图源文件字节数,随结果带回供落库写入 image_size
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。
|
// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。
|
||||||
@ -675,90 +735,69 @@ const slowImageThreshold = 2 * time.Second
|
|||||||
|
|
||||||
// imageFetchConcurrency 单条入库任务内「下载 + 上传」的并发度。
|
// imageFetchConcurrency 单条入库任务内「下载 + 上传」的并发度。
|
||||||
//
|
//
|
||||||
// 实测结论(theimpression.com,59 张图):
|
// 默认 1(等效串行),可用环境变量 INGEST_IMAGE_CONCURRENCY(1~16)覆盖。
|
||||||
// - 并发 1:单张下载 ~3.7s,整批 59 张约 3m54s,任务成功。
|
|
||||||
// - 并发 5:单张下载涨到 ~23s(约 6 倍),5m24s 仅完成 12/59 张便因 30s 超时失败。
|
|
||||||
//
|
//
|
||||||
// 即源站对同 IP 的并发连接有强限流:并发不仅不提速,反而把吞吐从 ~0.27 张/秒
|
// 之所以默认 1:早期实测 theimpression.com(59 张图)显示源站对同 IP 并发有强限流——
|
||||||
// 打到 ~0.04 张/秒并直接拖垮任务。因此默认取 1(等效串行)。
|
// 并发 1 时单张 ~3.7s / 整批 3m54s;并发 5 时单张涨到 ~23s,5m24s 只完成 12/59 张
|
||||||
// 换到不限流的源站时,可把这个值调大(机制已就绪),但务必先做小批量实测。
|
// 就因超时失败,吞吐从 ~0.27 张/秒掉到 ~0.04 张/秒。结论:并发会把任务拖垮,故取 1。
|
||||||
const imageFetchConcurrency = 1
|
//
|
||||||
|
// 但该结论**只对 theimpression 成立,不能外推到所有源站**。2026-10-13 对 assets.vogue.com
|
||||||
|
// 的冷图(未命中 CDN 边缘)实测:串行 1582ms/张,8 并发降到 661ms/张(2.4x),并发是有收益的。
|
||||||
|
// 冷图慢是因为 CDN 要回源,属延迟受限而非带宽受限——正好是并发能掩盖的那一类。
|
||||||
|
//
|
||||||
|
// 因此把它做成可配置项:换源站时先小批量实测再调(本文件顶部注释记录了两组实测数据)。
|
||||||
|
var imageFetchConcurrency = func() int {
|
||||||
|
v := strings.TrimSpace(os.Getenv("INGEST_IMAGE_CONCURRENCY"))
|
||||||
|
if v == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 || n > 16 {
|
||||||
|
log.Printf("[ingest] INGEST_IMAGE_CONCURRENCY=%q 非法(应为 1~16 的整数),回退并发 1", v)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}()
|
||||||
|
|
||||||
// fetchTiming 汇总一次图集拉取各阶段的累计耗时,用于定位「慢在哪一步」。
|
// fetchTiming 汇总一次图集拉取各阶段的累计耗时,用于定位「慢在哪一步」。
|
||||||
// 下载 / 上传是网络 IO,phash 是 CPU(解码 + 哈希),近重复是 DB(HNSW 检索)——
|
// 下载与上传是网络 IO,embed 是视觉服务调用(HTTP + 模型推理),分开计时才能对症下药。
|
||||||
// 三类瓶颈成因不同,分开计时才能对症下药。
|
|
||||||
type fetchTiming struct {
|
type fetchTiming struct {
|
||||||
images int // 成功入行的图片数
|
images int // 成功入行的图片数
|
||||||
total time.Duration // 整个拉取过程的墙钟耗时
|
total time.Duration // 整个拉取过程的墙钟耗时
|
||||||
download time.Duration // 各张 HTTP 下载耗时之和
|
download time.Duration // 各张 HTTP 下载耗时之和
|
||||||
phash time.Duration // 各张 dHash 指纹计算耗时之和
|
|
||||||
upload time.Duration // 各张上传存储耗时之和
|
upload time.Duration // 各张上传存储耗时之和
|
||||||
dedup time.Duration // 近重复查询累计
|
embed time.Duration // 各张视觉服务编码耗时之和
|
||||||
}
|
}
|
||||||
|
|
||||||
// String 输出各阶段耗时(毫秒精度),一条日志看清瓶颈。
|
// String 输出各阶段耗时(毫秒精度),一条日志看清瓶颈。
|
||||||
// 注意:下载 / phash / 上传是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
|
// 注意:下载 / 上传 / embed 是「各张图耗时之和」,并发执行下其总和会大于墙钟总耗时,
|
||||||
// 因此三者与「总耗时」的比值不再等于时间占比——它们用于横向对比哪个阶段最重。
|
// 因此三者与「总耗时」的比值不再等于时间占比——它们用于横向对比哪个阶段最重。
|
||||||
func (t *fetchTiming) String() string {
|
func (t *fetchTiming) String() string {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("图片=%d 并发=%d 下载=%v phash=%v 上传=%v 近重复=%v(下载/phash/上传为各张累计值)",
|
return fmt.Sprintf("图片=%d 并发=%d 下载=%v 上传=%v embed=%v(各张累计值)",
|
||||||
t.images,
|
t.images,
|
||||||
imageFetchConcurrency,
|
imageFetchConcurrency,
|
||||||
t.download.Round(time.Millisecond),
|
t.download.Round(time.Millisecond),
|
||||||
t.phash.Round(time.Millisecond),
|
|
||||||
t.upload.Round(time.Millisecond),
|
t.upload.Round(time.Millisecond),
|
||||||
t.dedup.Round(time.Millisecond),
|
t.embed.Round(time.Millisecond),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqlNull 把 phash 字符串转成可空向量字段:空串 → NULL(不参与近邻检索)。
|
// downloadResult 是单张图「下载 → 上传」的结果,供并发执行后按下标串行组装。
|
||||||
func sqlNull(ph string) sql.NullString {
|
|
||||||
return sql.NullString{String: ph, Valid: ph != ""}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dedupImage 判断单张图是否近重复(dHash 汉明距离 ≤ 阈值):命中则仍入库,但标记 is_duplicate + dup_of。
|
|
||||||
func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string, timing *fetchTiming) (dupID uint32, isDup uint8) {
|
|
||||||
// repo 未注入(如离线单测)时跳过去重,不阻断主流程。生产环境 repo 必不为空。
|
|
||||||
if s.repo == nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
var tables []string
|
|
||||||
switch kind {
|
|
||||||
case dto.IngestKindRunway:
|
|
||||||
tables = []string{"brand_runway_images"}
|
|
||||||
case dto.IngestKindStreet:
|
|
||||||
tables = []string{"street_snap_images"}
|
|
||||||
default:
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
if phashBits != "" {
|
|
||||||
start := time.Now()
|
|
||||||
id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold)
|
|
||||||
if timing != nil {
|
|
||||||
timing.dedup += time.Since(start)
|
|
||||||
}
|
|
||||||
if err == nil && found {
|
|
||||||
return id, 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadResult 是单张图「下载 → phash → 上传」的结果,供并发执行后按下标串行组装。
|
|
||||||
// 各阶段耗时随结果一并带回、由调用方在串行段汇总(而非直接累加到共享 timing),
|
// 各阶段耗时随结果一并带回、由调用方在串行段汇总(而非直接累加到共享 timing),
|
||||||
// 这样并发路径上无需加锁即可保证计时准确。
|
// 这样并发路径上无需加锁即可保证计时准确。
|
||||||
type downloadResult struct {
|
type downloadResult struct {
|
||||||
url string // 存储访问地址(失败为空)
|
url string // 存储访问地址(失败为空)
|
||||||
key string // 对象 key(内容寻址)
|
key string // 对象 key(内容寻址)
|
||||||
sha1 string // 内容 sha1,用于批次内去重
|
sha1 string // 内容 sha1,用于批次内去重
|
||||||
phashBits string // dHash 向量串,空串表示无法解码
|
download time.Duration // 本张下载耗时
|
||||||
download time.Duration // 本张下载耗时
|
upload time.Duration // 本张上传耗时
|
||||||
phash time.Duration // 本张指纹计算耗时
|
size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度)
|
||||||
upload time.Duration // 本张上传耗时
|
vec []float32 // FashionSigLIP 向量(nil = 未产出:服务未启用/不可用/失败)
|
||||||
size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度)
|
embed time.Duration // 本张向量编码耗时
|
||||||
err error // 下载或上传失败原因(非空即该张失败)
|
err error // 下载或上传失败原因(非空即该张失败)
|
||||||
}
|
}
|
||||||
|
|
||||||
// concurrentFetch 以 imageFetchConcurrency 路并发执行 fn(ctx, i)(i ∈ [0,n)),返回长度 n、
|
// concurrentFetch 以 imageFetchConcurrency 路并发执行 fn(ctx, i)(i ∈ [0,n)),返回长度 n、
|
||||||
@ -836,10 +875,19 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
|
|||||||
res.key = fmt.Sprintf("%s/%s%s", prefix, res.sha1, ext)
|
res.key = fmt.Sprintf("%s/%s%s", prefix, res.sha1, ext)
|
||||||
ct := resp.Header.Get("Content-Type")
|
ct := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
// ② dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
|
// ② 视觉服务编码(可选):产出后台「疑似同款」用的语义向量。
|
||||||
phStart := time.Now()
|
// 未启用 / 不可用 / 超时 / 维度不符一律留空 vec —— 绝不阻断入库,也不在此重试
|
||||||
res.phashBits = phash.ToVectorBits(phash.Of(data))
|
// (重试会拖慢整批;熔断由客户端统一处理)。
|
||||||
res.phash = time.Since(phStart)
|
if s.vision != nil && s.vision.Enabled() {
|
||||||
|
embedStart := time.Now()
|
||||||
|
vecs, _, eerr := s.vision.Embed(ctx, [][]byte{data})
|
||||||
|
res.embed = time.Since(embedStart)
|
||||||
|
if eerr == nil && len(vecs) > 0 {
|
||||||
|
res.vec = vecs[0]
|
||||||
|
} else if eerr != nil {
|
||||||
|
log.Printf("[ingest] embed 失败 url=%s err=%v(不写向量,继续入库)", u, eerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ③ 上传(主存储 S4,失败兜底本地)
|
// ③ 上传(主存储 S4,失败兜底本地)
|
||||||
upStart := time.Now()
|
upStart := time.Now()
|
||||||
@ -858,10 +906,11 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
|
|||||||
res.upload = time.Since(upStart)
|
res.upload = time.Since(upStart)
|
||||||
|
|
||||||
// 单图异常慢(合计超阈值)单独告警:便于从几十上百张图里一眼锁定是哪张、卡在哪一段。
|
// 单图异常慢(合计超阈值)单独告警:便于从几十上百张图里一眼锁定是哪张、卡在哪一段。
|
||||||
if total := res.download + res.phash + res.upload; total > slowImageThreshold {
|
if total := res.download + res.embed + res.upload; total > slowImageThreshold {
|
||||||
log.Printf("[ingest] 慢图 url=%s 合计=%v 下载=%v phash=%v 上传=%v",
|
log.Printf("[ingest] 慢图 url=%s 合计=%v 下载=%v embed=%v 上传=%v",
|
||||||
u, total.Round(time.Millisecond),
|
u, total.Round(time.Millisecond),
|
||||||
res.download.Round(time.Millisecond), res.phash.Round(time.Millisecond), res.upload.Round(time.Millisecond))
|
res.download.Round(time.Millisecond), res.embed.Round(time.Millisecond),
|
||||||
|
res.upload.Round(time.Millisecond))
|
||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
|
|||||||
t.Fatalf("预置文件失败: %v", err)
|
t.Fatalf("预置文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil)
|
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "", nil, nil, nil)
|
||||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||||
svc.processMediaCleanup(context.Background(), job)
|
svc.processMediaCleanup(context.Background(), job)
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
|||||||
del := &countingDeleter{LocalUploader: up}
|
del := &countingDeleter{LocalUploader: up}
|
||||||
|
|
||||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil)
|
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "", nil, nil, nil)
|
||||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||||
svc.processMediaCleanup(context.Background(), job)
|
svc.processMediaCleanup(context.Background(), job)
|
||||||
|
|
||||||
|
|||||||
351
internal/service/manual_article_service.go
Normal file
351
internal/service/manual_article_service.go
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/season"
|
||||||
|
"fashionapi/internal/pkg/storage"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
"fashionapi/internal/vision"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManualImage 一张待上传的图片(后台表单读入的原始字节)。
|
||||||
|
type ManualImage struct {
|
||||||
|
Filename string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualArticleInput 后台「手工新建文章」的输入:元数据 + 图片字节。
|
||||||
|
type ManualArticleInput struct {
|
||||||
|
Kind string // runway | street
|
||||||
|
|
||||||
|
// 走秀
|
||||||
|
BrandName string // 品牌英文名,精确匹配(不区分大小写)
|
||||||
|
TitleEn string
|
||||||
|
TitleCn string
|
||||||
|
Year uint16
|
||||||
|
Season string // spring | fall | resort | pre_fall
|
||||||
|
CollectionType string // rtw | menswear | couture | resort | pre_fall
|
||||||
|
|
||||||
|
// 街拍
|
||||||
|
City string
|
||||||
|
|
||||||
|
Images []ManualImage
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
manualKindRunway = "runway"
|
||||||
|
manualKindStreet = "street"
|
||||||
|
manualMaxImageBytes = 10 << 20 // 单张 10MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManualArticleService 后台手工建稿:上传图片到存储 + 写正式表(status=pending)。
|
||||||
|
//
|
||||||
|
// 与爬虫入库的唯一区别是「图片来源」:那边拿 URL 由 worker 下载,这里直接拿字节。
|
||||||
|
// 因此复用同一套存储层 Uploader(sha1 内容寻址)与同一组入库仓储写入方法,
|
||||||
|
// 保证两条路径产出的对象 key / 行结构完全一致——后台不需要为它们维护第二套口径。
|
||||||
|
type ManualArticleService struct {
|
||||||
|
ingest repository.IngestRepository
|
||||||
|
brands repository.BrandRepository
|
||||||
|
uploader storage.Uploader
|
||||||
|
local *storage.LocalUploader // S4 上传失败时的本地兜底(可为 nil)
|
||||||
|
vision *vision.Client // 可为 nil:未启用视觉服务时跳过向量
|
||||||
|
embedRep repository.EmbeddingRepository // 可为 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManualArticleService 创建手工建稿服务。
|
||||||
|
func NewManualArticleService(ingest repository.IngestRepository, brands repository.BrandRepository, uploader storage.Uploader, local *storage.LocalUploader, vis *vision.Client, embedRep repository.EmbeddingRepository) *ManualArticleService {
|
||||||
|
return &ManualArticleService{ingest: ingest, brands: brands, uploader: uploader, local: local, vision: vis, embedRep: embedRep}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 建稿:校验 → 上传图片(sha1 内容寻址、同内容自动去重)→ 写正式表 → 补向量。
|
||||||
|
// 返回新建记录的数字主键。
|
||||||
|
func (s *ManualArticleService) Create(ctx context.Context, in ManualArticleInput) (uint32, error) {
|
||||||
|
if len(in.Images) == 0 {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "至少上传 1 张图片")
|
||||||
|
}
|
||||||
|
switch in.Kind {
|
||||||
|
case manualKindRunway:
|
||||||
|
return s.createRunway(ctx, in)
|
||||||
|
case manualKindStreet:
|
||||||
|
return s.createStreet(ctx, in)
|
||||||
|
default:
|
||||||
|
return 0, NewError(http.StatusBadRequest, "类型无效(仅支持走秀 / 街拍)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ManualArticleService) createRunway(ctx context.Context, in ManualArticleInput) (uint32, error) {
|
||||||
|
titleEn := strings.TrimSpace(in.TitleEn)
|
||||||
|
if titleEn == "" {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "走秀标题(英文)必填")
|
||||||
|
}
|
||||||
|
if in.Year == 0 || strings.TrimSpace(in.Season) == "" || strings.TrimSpace(in.CollectionType) == "" {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "年份、季节、系列均必填")
|
||||||
|
}
|
||||||
|
brand, err := s.findBrand(ctx, in.BrandName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
seasonCode := season.Derive(in.Year, in.CollectionType, in.Season)
|
||||||
|
if seasonCode == "" {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "无法推导季节码,请检查年份")
|
||||||
|
}
|
||||||
|
// 实体键预检:与库里的唯一索引同口径,先给出人话提示,避免把 23505 甩给运营。
|
||||||
|
if _, _, found, deleted, e := s.ingest.RunwayEntityState(ctx, brand.ID, seasonCode, in.CollectionType); e != nil {
|
||||||
|
return 0, internalErr(e.Error())
|
||||||
|
} else if found && !deleted {
|
||||||
|
return 0, NewError(http.StatusConflict, "该品牌在同一季节/系列下已有图集,请直接编辑那一条,不要重复新建")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded, err := s.uploadAll(ctx, manualKindRunway, in.Images)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
rw := &model.BrandRunway{
|
||||||
|
JobID: 0, // 手工建稿没有入库任务;该列 NOT NULL,用 0 表示「无任务」
|
||||||
|
BrandID: brand.ID,
|
||||||
|
TitleEn: titleEn,
|
||||||
|
TitleCn: strings.TrimSpace(in.TitleCn),
|
||||||
|
Year: in.Year,
|
||||||
|
Season: in.Season,
|
||||||
|
CollectionType: in.CollectionType,
|
||||||
|
SeasonCode: seasonCode,
|
||||||
|
Cover: uploaded[0].key,
|
||||||
|
ImageCount: uint16(len(uploaded)),
|
||||||
|
Status: model.StatusPending,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
imgs := make([]model.BrandRunwayImage, 0, len(uploaded))
|
||||||
|
for i, u := range uploaded {
|
||||||
|
imgs = append(imgs, model.BrandRunwayImage{
|
||||||
|
Image: u.key,
|
||||||
|
Name: fmt.Sprintf("Look %d", i+1),
|
||||||
|
SortOrder: uint32(i + 1),
|
||||||
|
IsDetail: 0,
|
||||||
|
ImageSize: u.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
id, err := s.ingest.CreateRunwayWithImages(ctx, rw, imgs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, s.writeErr(err)
|
||||||
|
}
|
||||||
|
s.embedUploaded(ctx, manualKindRunway, runwayImageIDs(imgs), uploaded)
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ManualArticleService) createStreet(ctx context.Context, in ManualArticleInput) (uint32, error) {
|
||||||
|
title := strings.TrimSpace(in.TitleEn)
|
||||||
|
titleCn := strings.TrimSpace(in.TitleCn)
|
||||||
|
if title == "" {
|
||||||
|
title = titleCn
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "街拍标题必填")
|
||||||
|
}
|
||||||
|
city := strings.TrimSpace(in.City)
|
||||||
|
if city == "" {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "城市必填")
|
||||||
|
}
|
||||||
|
if in.Year == 0 {
|
||||||
|
return 0, NewError(http.StatusBadRequest, "年份必填")
|
||||||
|
}
|
||||||
|
if _, _, found, deleted, e := s.ingest.StreetSnapEntityState(ctx, city, in.Year, title); e != nil {
|
||||||
|
return 0, internalErr(e.Error())
|
||||||
|
} else if found && !deleted {
|
||||||
|
return 0, NewError(http.StatusConflict, "同一城市、年份、标题下已有街拍,请直接编辑那一条,不要重复新建")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploaded, err := s.uploadAll(ctx, manualKindStreet, in.Images)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
snap := &model.StreetSnap{
|
||||||
|
JobID: 0,
|
||||||
|
Title: title,
|
||||||
|
TitleCn: titleCn,
|
||||||
|
City: city,
|
||||||
|
Year: in.Year,
|
||||||
|
Cover: uploaded[0].key,
|
||||||
|
ImageCount: uint16(len(uploaded)),
|
||||||
|
Status: model.StatusPending,
|
||||||
|
// source / source_url 不设:模型未映射这两列,由库默认值兜底(爬虫入库同样不设)。
|
||||||
|
// 唯一索引 uq_ss_source 只对非空 source_url 生效,因此手工建稿天然不会与来源去重冲突。
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
imgs := make([]model.StreetSnapImage, 0, len(uploaded))
|
||||||
|
for i, u := range uploaded {
|
||||||
|
imgs = append(imgs, model.StreetSnapImage{
|
||||||
|
Image: u.key,
|
||||||
|
Name: fmt.Sprintf("Look %d", i+1),
|
||||||
|
SortOrder: uint32(i + 1),
|
||||||
|
ImageSize: u.size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
id, err := s.ingest.CreateStreetSnapWithImages(ctx, snap, imgs)
|
||||||
|
if err != nil {
|
||||||
|
return 0, s.writeErr(err)
|
||||||
|
}
|
||||||
|
s.embedUploaded(ctx, manualKindStreet, streetImageIDs(imgs), uploaded)
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadedManualImage 一张已上传成功的图片:内容寻址 key + 原始字节数 + 原始字节。
|
||||||
|
// data 保留下来只为随后补向量,避免再向存储回读一次。
|
||||||
|
type uploadedManualImage struct {
|
||||||
|
key string
|
||||||
|
size int64
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAll 逐张校验 → 内容寻址 key → 上传(S4 失败回退本地)→ 同内容去重。
|
||||||
|
//
|
||||||
|
// 类型用 http.DetectContentType 按真实字节判断,不看扩展名(可伪造);
|
||||||
|
// key 用 sha1,与爬虫入库完全同一口径,因此「同一张图」无论来自哪条路径都落在同一个对象上。
|
||||||
|
func (s *ManualArticleService) uploadAll(ctx context.Context, prefix string, images []ManualImage) ([]uploadedManualImage, error) {
|
||||||
|
out := make([]uploadedManualImage, 0, len(images))
|
||||||
|
seen := make(map[string]struct{}, len(images))
|
||||||
|
for _, img := range images {
|
||||||
|
if len(img.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(img.Data) > manualMaxImageBytes {
|
||||||
|
return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 超过 10MB", img.Filename))
|
||||||
|
}
|
||||||
|
ct := http.DetectContentType(img.Data)
|
||||||
|
ext, ok := manualImageExt(ct)
|
||||||
|
if !ok {
|
||||||
|
return nil, NewError(http.StatusBadRequest, fmt.Sprintf("图片 %q 不是 JPG/PNG/WEBP(实际识别为 %s)", img.Filename, ct))
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(img.Data)
|
||||||
|
key := prefix + "/" + hex.EncodeToString(sum[:]) + ext
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue // 同一次提交里的同内容图片只留一份
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
if _, err := s.uploader.Upload(ctx, key, img.Data, ct); err != nil {
|
||||||
|
if s.local == nil {
|
||||||
|
return nil, internalErr("图片上传失败: " + err.Error())
|
||||||
|
}
|
||||||
|
if _, lerr := s.local.Upload(ctx, key, img.Data, ct); lerr != nil {
|
||||||
|
return nil, internalErr("图片上传失败(本地兜底也失败): " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, uploadedManualImage{key: key, size: int64(len(img.Data)), data: img.Data})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, NewError(http.StatusBadRequest, "没有可用的图片(全部为空或重复)")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// manualImageExt 把真实 content-type 映射为扩展名;不支持的格式返回 ok=false。
|
||||||
|
func manualImageExt(contentType string) (string, bool) {
|
||||||
|
switch contentType {
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg", true
|
||||||
|
case "image/png":
|
||||||
|
return ".png", true
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBrand 按英文名精确匹配(不区分大小写、忽略首尾空格)。
|
||||||
|
//
|
||||||
|
// 用 AdminList 的关键词查询而不是新增仓储方法:关键词路径本身就会跳过「必须有图集」
|
||||||
|
// 的过滤,因此能命中导入的 2 万+ 无图集品牌。
|
||||||
|
func (s *ManualArticleService) findBrand(ctx context.Context, name string) (*model.Brand, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, NewError(http.StatusBadRequest, "品牌名必填")
|
||||||
|
}
|
||||||
|
items, _, err := s.brands.ListAdmin(ctx, dto.BrandQuery{Keyword: name, Size: 50}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(items[i].NameEn), name) {
|
||||||
|
return &items[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, NewError(http.StatusBadRequest, "找不到品牌「"+name+"」,请先在品牌管理里新建(英文名需完全一致)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeErr 把唯一键冲突翻译成人话;其它错误按内部错误处理。
|
||||||
|
// 预检之外仍有并发窗口,DB 层的兜底错误不该原样甩给运营。
|
||||||
|
func (s *ManualArticleService) writeErr(err error) error {
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "uq_br_entity") || strings.Contains(msg, "uq_ss_entity") ||
|
||||||
|
strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key") {
|
||||||
|
return NewError(http.StatusConflict, "该实体已存在未删除的记录(走秀=品牌+季节+系列;街拍=城市+年份+标题)")
|
||||||
|
}
|
||||||
|
return internalErr(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedUploaded 给刚写入的图片补向量,供后台「疑似同款」使用。
|
||||||
|
//
|
||||||
|
// 任何一步不可用(未注入视觉服务 / 仓储为 nil / 熔断中 / 批量调用失败 / 图片 id 未回填)
|
||||||
|
// 都静默跳过:建稿成功与否绝不能取决于向量产出。
|
||||||
|
//
|
||||||
|
// 只处理「仓储回填了主键」的图片(id == 0 视为未回填),并且只在「返回的向量数与输入一一对应」
|
||||||
|
// 时才写入——有失败项时映射关系不再可靠,宁可少写向量,也不写错位的向量。
|
||||||
|
func (s *ManualArticleService) embedUploaded(ctx context.Context, kind string, imageIDs []uint32, uploaded []uploadedManualImage) {
|
||||||
|
if s.vision == nil || s.embedRep == nil || !s.vision.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch := make([][]byte, 0, len(uploaded))
|
||||||
|
slot := make([]uint32, 0, len(uploaded))
|
||||||
|
for i, u := range uploaded {
|
||||||
|
if i >= len(imageIDs) || imageIDs[i] == 0 || len(u.data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch = append(batch, u.data)
|
||||||
|
slot = append(slot, imageIDs[i])
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vecs, idxs, err := s.vision.Embed(ctx, batch)
|
||||||
|
if err != nil || len(vecs) != len(batch) || len(idxs) != len(batch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range batch {
|
||||||
|
if len(vecs[i]) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = s.embedRep.UpsertEmbedding(ctx, kind, slot[i], vecs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageIDsOf 取出仓储回填到图片行上的主键(未回填时为 0,调用方据此跳过)。
|
||||||
|
func runwayImageIDs(imgs []model.BrandRunwayImage) []uint32 {
|
||||||
|
ids := make([]uint32, len(imgs))
|
||||||
|
for i := range imgs {
|
||||||
|
ids[i] = imgs[i].ID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// streetImageIDs 同上,街拍版。
|
||||||
|
func streetImageIDs(imgs []model.StreetSnapImage) []uint32 {
|
||||||
|
ids := make([]uint32, len(imgs))
|
||||||
|
for i := range imgs {
|
||||||
|
ids[i] = imgs[i].ID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
136
internal/service/manual_article_service_test.go
Normal file
136
internal/service/manual_article_service_test.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubManualIngest 只实现建稿用到的两个方法(其余经嵌入接口转发,本测试不会调用)。
|
||||||
|
type stubManualIngest struct {
|
||||||
|
repository.IngestRepository
|
||||||
|
entityHit bool
|
||||||
|
created bool
|
||||||
|
gotRW *model.BrandRunway
|
||||||
|
gotImgs []model.BrandRunwayImage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubManualIngest) RunwayEntityState(_ context.Context, _ uint32, _, _ string) (uint32, string, bool, bool, error) {
|
||||||
|
return 0, "", s.entityHit, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubManualIngest) CreateRunwayWithImages(_ context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
|
s.created, s.gotRW, s.gotImgs = true, rw, imgs
|
||||||
|
return 99, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubManualBrands 提供品牌精确匹配所需的一行。
|
||||||
|
type stubManualBrands struct {
|
||||||
|
repository.BrandRepository
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubManualBrands) ListAdmin(_ context.Context, _ dto.BrandQuery, _ []uint32) ([]model.Brand, int64, error) {
|
||||||
|
if s.name == "" {
|
||||||
|
return []model.Brand{}, 0, nil
|
||||||
|
}
|
||||||
|
return []model.Brand{{ID: 7, NameEn: s.name}}, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubManualUploader 记录上传的 key,用于断言 sha1 内容寻址与去重。
|
||||||
|
type stubManualUploader struct{ keys []string }
|
||||||
|
|
||||||
|
func (s *stubManualUploader) Upload(_ context.Context, key string, _ []byte, _ string) (string, error) {
|
||||||
|
s.keys = append(s.keys, key)
|
||||||
|
return "https://cdn/" + key, nil
|
||||||
|
}
|
||||||
|
func (s *stubManualUploader) Enabled() bool { return true }
|
||||||
|
|
||||||
|
func manualRunwayInput(images []ManualImage) ManualArticleInput {
|
||||||
|
return ManualArticleInput{
|
||||||
|
Kind: manualKindRunway, BrandName: "Gucci", TitleEn: "Spring 2027 RTW",
|
||||||
|
Year: 2027, Season: "spring", CollectionType: "rtw",
|
||||||
|
Images: images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一张最小 JPEG 头,http.DetectContentType 会识别为 image/jpeg。
|
||||||
|
var fakeJPEG = append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("manual-test-payload")...)
|
||||||
|
|
||||||
|
// TestManualCreateRequiresImages 没有图片必须直接拒绝,且既不上传也不写库。
|
||||||
|
func TestManualCreateRequiresImages(t *testing.T) {
|
||||||
|
up := &stubManualUploader{}
|
||||||
|
ing := &stubManualIngest{}
|
||||||
|
svc := NewManualArticleService(ing, &stubManualBrands{name: "Gucci"}, up, nil, nil, nil)
|
||||||
|
|
||||||
|
if _, err := svc.Create(context.Background(), manualRunwayInput(nil)); err == nil {
|
||||||
|
t.Fatal("没有图片应报错")
|
||||||
|
}
|
||||||
|
if len(up.keys) != 0 || ing.created {
|
||||||
|
t.Fatalf("校验失败不应上传/写库,实际 uploads=%d created=%v", len(up.keys), ing.created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManualCreateBrandNotFound 品牌名匹配不到时必须报错且不写库。
|
||||||
|
func TestManualCreateBrandNotFound(t *testing.T) {
|
||||||
|
up := &stubManualUploader{}
|
||||||
|
ing := &stubManualIngest{}
|
||||||
|
svc := NewManualArticleService(ing, &stubManualBrands{name: "SomeOtherBrand"}, up, nil, nil, nil)
|
||||||
|
|
||||||
|
in := manualRunwayInput([]ManualImage{{Filename: "a.jpg", Data: fakeJPEG}})
|
||||||
|
if _, err := svc.Create(context.Background(), in); err == nil {
|
||||||
|
t.Fatal("品牌不存在应报错")
|
||||||
|
}
|
||||||
|
if ing.created || len(up.keys) != 0 {
|
||||||
|
t.Fatalf("品牌不存在不应写库/上传,实际 created=%v uploads=%d", ing.created, len(up.keys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManualCreateRunwayUploadsAndInserts 正常提交:sha1 内容寻址、同内容去重、
|
||||||
|
// 封面取第一张、image_count 与实际行数一致。
|
||||||
|
func TestManualCreateRunwayUploadsAndInserts(t *testing.T) {
|
||||||
|
up := &stubManualUploader{}
|
||||||
|
ing := &stubManualIngest{}
|
||||||
|
svc := NewManualArticleService(ing, &stubManualBrands{name: "gucci"}, up, nil, nil, nil)
|
||||||
|
|
||||||
|
// 两张同内容(应去重成 1 张)+ 一张不同内容 → 共 2 张
|
||||||
|
other := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte("another-payload")...)
|
||||||
|
in := manualRunwayInput([]ManualImage{
|
||||||
|
{Filename: "a.jpg", Data: fakeJPEG},
|
||||||
|
{Filename: "a-copy.jpg", Data: fakeJPEG},
|
||||||
|
{Filename: "b.jpg", Data: other},
|
||||||
|
})
|
||||||
|
id, err := svc.Create(context.Background(), in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("正常提交不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if id != 99 {
|
||||||
|
t.Fatalf("应返回仓储给的主键,实际 %d", id)
|
||||||
|
}
|
||||||
|
if len(up.keys) != 2 {
|
||||||
|
t.Fatalf("同内容图片应去重为 2 张上传,实际 %d: %v", len(up.keys), up.keys)
|
||||||
|
}
|
||||||
|
for _, k := range up.keys {
|
||||||
|
if len(k) < len("runway/")+40+len(".jpg") || k[:7] != "runway/" {
|
||||||
|
t.Fatalf("key 应为 runway/<sha1>.jpg,实际 %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ing.created {
|
||||||
|
t.Fatal("应写入正式表")
|
||||||
|
}
|
||||||
|
if len(ing.gotImgs) != 2 || ing.gotRW.ImageCount != 2 {
|
||||||
|
t.Fatalf("图片行数与 image_count 都应等于 2,实际 rows=%d count=%d", len(ing.gotImgs), ing.gotRW.ImageCount)
|
||||||
|
}
|
||||||
|
if ing.gotRW.Cover != up.keys[0] {
|
||||||
|
t.Fatalf("封面应取第一张,实际 cover=%q keys=%v", ing.gotRW.Cover, up.keys)
|
||||||
|
}
|
||||||
|
if ing.gotRW.Status != model.StatusPending {
|
||||||
|
t.Fatalf("新建记录必须是 pending(走审核),实际 %s", ing.gotRW.Status)
|
||||||
|
}
|
||||||
|
if ing.gotRW.BrandID != 7 {
|
||||||
|
t.Fatalf("品牌应按英文名匹配到 id=7(不区分大小写),实际 %d", ing.gotRW.BrandID)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
internal/service/similar_service.go
Normal file
189
internal/service/similar_service.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SimilarHint 是一条「疑似同款」提示:某张图片的一个近邻。
|
||||||
|
//
|
||||||
|
// 带上近邻的图片 key / 名称 / 所属图集标题,后台才能直接显示「和哪张图像」。
|
||||||
|
type SimilarHint struct {
|
||||||
|
ImageID uint32 // 近邻图片 id
|
||||||
|
OwnerID uint32 // 近邻所属走秀 / 街拍专辑 id(供前端跳转)
|
||||||
|
Similar float64 // 余弦相似度 0~1
|
||||||
|
Image string // 近邻图片存储 key(前端显示缩略图)
|
||||||
|
Name string // 近邻图片名(如 "Look 12")
|
||||||
|
OwnerTitle string // 近邻所属图集标题
|
||||||
|
}
|
||||||
|
|
||||||
|
// DupStat 是一个图集的「跨图集重复」概况,供文章列表直接标出哪些图集有重复。
|
||||||
|
type DupStat struct {
|
||||||
|
Total int // 该图集存活图片总数(实时统计,不用过期的 image_count 缓存列)
|
||||||
|
Images int // 其中有跨图集重复的图片数
|
||||||
|
FirstImageID uint32 // 其中 id 最小的一张,列表点进去可直接锚点定位
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarService 组装后台「疑似同款」提示。
|
||||||
|
//
|
||||||
|
// 定位(对应规格 §8/§9):**只提示、不隐藏、不计入重复率、不参与任何去重**。
|
||||||
|
//
|
||||||
|
// 分工:
|
||||||
|
// - PrecomputeForGallery 在**入库时**(或 dbtool similarbackfill 回填时)算好近邻并落库;
|
||||||
|
// - HintsForImages 只读结果表,供页面渲染。
|
||||||
|
//
|
||||||
|
// 为什么必须预计算:原实现在**打开图集页时**对每张图实时检索,一个 116 张图的图集要发
|
||||||
|
// 116 次查询、每次约 20KB SQL(768 维向量文本)。本机 PostgreSQL 跑在 WSL2 里、Windows 经
|
||||||
|
// wslrelay 访问,该链路大 payload 吞吐实测约 0.47MB/s —— 服务端执行单条只要 0.45ms,
|
||||||
|
// 客户端往返却要 45ms,合计 116×45ms ≈ 5 秒。挪到入库时后页面只读一次小查询。
|
||||||
|
type SimilarService struct {
|
||||||
|
embedRep repository.EmbeddingRepository
|
||||||
|
threshold float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSimilarService 构造 SimilarService。threshold 为余弦相似度阈值。
|
||||||
|
// embedRep 可为 nil(视觉服务未启用),此时所有查询返回空结果。
|
||||||
|
func NewSimilarService(embedRep repository.EmbeddingRepository, threshold float64) *SimilarService {
|
||||||
|
return &SimilarService{embedRep: embedRep, threshold: threshold}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled 报告是否可用(仓储已注入)。
|
||||||
|
func (s *SimilarService) Enabled() bool {
|
||||||
|
return s != nil && s.embedRep != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threshold 返回当前的「疑似同款」相似度阈值,供页面展示。
|
||||||
|
func (s *SimilarService) Threshold() float64 {
|
||||||
|
if s == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// HintsForImages 读取一个图集全部图片的「疑似同款」提示(只读结果表,不计算)。
|
||||||
|
//
|
||||||
|
// 返回 map[图片 id] -> 近邻提示列表(按相似度降序、每张最多 similarNeighborLimit 条)。
|
||||||
|
// **不返回 error**:提示是增值信息,读取失败只记日志并返回空,
|
||||||
|
// 绝不能让「查不到同款」影响后台页面渲染。
|
||||||
|
func (s *SimilarService) HintsForImages(ctx context.Context, kind string, imageIDs []uint32) map[uint32][]SimilarHint {
|
||||||
|
if !s.Enabled() || len(imageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pairs, err := s.embedRep.ListSimilarPairs(ctx, kind, imageIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[similar] 读近邻失败 kind=%s 数量=%d err=%v(页面不显示同款提示)", kind, len(imageIDs), err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[uint32][]SimilarHint, len(pairs))
|
||||||
|
for selfID, ns := range pairs {
|
||||||
|
if len(ns) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.Slice(ns, func(i, j int) bool { return ns[i].Similar > ns[j].Similar })
|
||||||
|
if len(ns) > similarNeighborLimit {
|
||||||
|
ns = ns[:similarNeighborLimit]
|
||||||
|
}
|
||||||
|
hints := make([]SimilarHint, 0, len(ns))
|
||||||
|
for _, n := range ns {
|
||||||
|
hints = append(hints, SimilarHint{
|
||||||
|
ImageID: n.OtherID,
|
||||||
|
OwnerID: n.OwnerID,
|
||||||
|
Similar: n.Similar,
|
||||||
|
Image: n.Image,
|
||||||
|
Name: n.Name,
|
||||||
|
OwnerTitle: n.OwnerTitle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out[selfID] = hints
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DupStats 汇总 runway / street 两类图集的跨图集重复情况(kind -> 图集 id -> 概况)。
|
||||||
|
//
|
||||||
|
// 供文章列表直接标出「哪些图集有重复」——否则只能一个个点进图集才能发现。
|
||||||
|
//
|
||||||
|
// **不返回 error**:与提示同理,汇总失败只记日志并跳过该类,
|
||||||
|
// 绝不能让列表页因为查不到重复而渲染失败。
|
||||||
|
func (s *SimilarService) DupStats(ctx context.Context) map[string]map[uint32]DupStat {
|
||||||
|
if !s.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]map[uint32]DupStat, 2)
|
||||||
|
for _, kind := range []string{"runway", "street"} {
|
||||||
|
raw, err := s.embedRep.DupStatsByOwner(ctx, kind)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[similar] 汇总跨图集重复失败 kind=%s err=%v(列表不显示重复列)", kind, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := make(map[uint32]DupStat, len(raw))
|
||||||
|
for ownerID, st := range raw {
|
||||||
|
m[ownerID] = DupStat{Total: st.Total, Images: st.Images, FirstImageID: st.FirstImageID}
|
||||||
|
}
|
||||||
|
out[kind] = m
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrecomputeForGallery 计算并落库一个图集全部图片的跨图集近邻。
|
||||||
|
//
|
||||||
|
// imageIDs 须为该图集的全部图片 id:它同时充当「同图集排除集」——需求是
|
||||||
|
// 「本图集的图与其他图集有没有重复」,同图集内所有照片共享背景/灯光/模特/造型,
|
||||||
|
// 相似度天然偏高(实测同场中位 0.69、最高 0.91),参与比对只会淹没真正的跨图集重复。
|
||||||
|
//
|
||||||
|
// 结果**双向**写入(A→B 与 B→A):这样打开任一侧所在的图集都能看到提示。
|
||||||
|
//
|
||||||
|
// 说明:单张查询失败只记日志并跳过,整体不中断——预计算是增值数据,
|
||||||
|
// 绝不能让「算不出同款」影响入库或回填。
|
||||||
|
func (s *SimilarService) PrecomputeForGallery(ctx context.Context, kind string, imageIDs []uint32) error {
|
||||||
|
if !s.Enabled() || len(imageIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
vecByImage, err := s.embedRep.ListEmbeddings(ctx, kind, imageIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("取向量: %w", err)
|
||||||
|
}
|
||||||
|
if len(vecByImage) == 0 {
|
||||||
|
return nil // 这批图还没有向量(视觉服务未启用),无需预计算
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]repository.SimilarPair, 0, len(vecByImage)*similarNeighborLimit*2)
|
||||||
|
done := 0
|
||||||
|
for imageID, vec := range vecByImage {
|
||||||
|
if len(vec) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
neighbors, ferr := s.embedRep.FindSimilarImages(
|
||||||
|
ctx, kind, imageID, imageIDs, vec, s.threshold, similarNeighborLimit)
|
||||||
|
if ferr != nil {
|
||||||
|
log.Printf("[similar] 预计算近邻失败 kind=%s 图片=%d err=%v", kind, imageID, ferr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
done++
|
||||||
|
for _, n := range neighbors {
|
||||||
|
rows = append(rows,
|
||||||
|
repository.SimilarPair{SelfID: imageID, OtherID: n.ImageID, Similar: n.Similar},
|
||||||
|
repository.SimilarPair{SelfID: n.ImageID, OtherID: imageID, Similar: n.Similar},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.embedRep.ReplaceSimilarPairs(ctx, kind, imageIDs, rows); err != nil {
|
||||||
|
return fmt.Errorf("写入近邻: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[similar] 预计算完成 kind=%s 图=%d 已算=%d 对=%d 阈值=%.2f",
|
||||||
|
kind, len(imageIDs), done, len(rows), s.threshold)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarNeighborLimit 每张图最多展示的「疑似同款」条数:只作人工参考,多了是噪音。
|
||||||
|
const similarNeighborLimit = 5
|
||||||
243
internal/service/similar_service_test.go
Normal file
243
internal/service/similar_service_test.go
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeEmbeddingRepo 是 EmbeddingRepository 的可控替身,记录被调用的参数。
|
||||||
|
type fakeEmbeddingRepo struct {
|
||||||
|
vecs map[uint32][]float32
|
||||||
|
neighbors map[uint32][]repository.SimilarImage
|
||||||
|
pairs map[uint32][]repository.SimilarNeighbor
|
||||||
|
dupStats map[string]map[uint32]repository.DupStat
|
||||||
|
listErr error
|
||||||
|
|
||||||
|
gotKind string
|
||||||
|
gotQueryID uint32
|
||||||
|
gotExclude []uint32
|
||||||
|
gotMinSim float64
|
||||||
|
gotLimit int
|
||||||
|
|
||||||
|
replacedSelfIDs []uint32
|
||||||
|
replacedRows []repository.SimilarPair
|
||||||
|
replaceErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) UpsertEmbedding(context.Context, string, uint32, []float32) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) BulkUpsertEmbedding(context.Context, string, []uint32, [][]float32) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) ListEmbeddings(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]float32, error) {
|
||||||
|
if f.listErr != nil {
|
||||||
|
return nil, f.listErr
|
||||||
|
}
|
||||||
|
out := map[uint32][]float32{}
|
||||||
|
for _, id := range imageIDs {
|
||||||
|
if v, ok := f.vecs[id]; ok {
|
||||||
|
out[id] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) FindSimilarImages(_ context.Context, kind string, imageID uint32, excludeIDs []uint32, _ []float32, minSim float64, limit int) ([]repository.SimilarImage, error) {
|
||||||
|
f.gotKind = kind
|
||||||
|
f.gotQueryID = imageID
|
||||||
|
f.gotExclude = excludeIDs
|
||||||
|
f.gotMinSim = minSim
|
||||||
|
f.gotLimit = limit
|
||||||
|
return f.neighbors[imageID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) ReplaceSimilarPairs(_ context.Context, _ string, selfIDs []uint32, rows []repository.SimilarPair) error {
|
||||||
|
f.replacedSelfIDs = selfIDs
|
||||||
|
f.replacedRows = rows
|
||||||
|
return f.replaceErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) ListSimilarPairs(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]repository.SimilarNeighbor, error) {
|
||||||
|
if f.listErr != nil {
|
||||||
|
return nil, f.listErr
|
||||||
|
}
|
||||||
|
out := map[uint32][]repository.SimilarNeighbor{}
|
||||||
|
for _, id := range imageIDs {
|
||||||
|
if ns, ok := f.pairs[id]; ok {
|
||||||
|
out[id] = ns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEmbeddingRepo) DupStatsByOwner(_ context.Context, kind string) (map[uint32]repository.DupStat, error) {
|
||||||
|
if f.listErr != nil {
|
||||||
|
return nil, f.listErr
|
||||||
|
}
|
||||||
|
return f.dupStats[kind], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSimilarDisabledReturnsNothing 未启用(仓储为 nil)时必须安全返回空结果,不 panic。
|
||||||
|
func TestSimilarDisabledReturnsNothing(t *testing.T) {
|
||||||
|
svc := NewSimilarService(nil, 0.93)
|
||||||
|
if svc.Enabled() {
|
||||||
|
t.Fatal("未注入仓储时应为未启用")
|
||||||
|
}
|
||||||
|
if got := svc.HintsForImages(context.Background(), "runway", []uint32{1, 2}); got != nil {
|
||||||
|
t.Fatalf("未启用应返回 nil,得到 %v", got)
|
||||||
|
}
|
||||||
|
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
|
||||||
|
t.Fatalf("未启用时预计算应直接返回 nil,得到 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHintsReadsStoredPairs 页面渲染只读结果表:验证分组、降序、每张上限,以及字段透传。
|
||||||
|
func TestHintsReadsStoredPairs(t *testing.T) {
|
||||||
|
nb := func(id uint32, sim float64) repository.SimilarNeighbor {
|
||||||
|
return repository.SimilarNeighbor{
|
||||||
|
OtherID: id, OwnerID: id * 10, Similar: sim,
|
||||||
|
Image: "runway/x.jpg", Name: "Look 1", OwnerTitle: "Fall 2019 Menswear",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := &fakeEmbeddingRepo{pairs: map[uint32][]repository.SimilarNeighbor{
|
||||||
|
// 故意乱序且超过上限(6 条),应被降序 + 截到 5 条。
|
||||||
|
7: {nb(71, 0.95), nb(72, 0.99), nb(73, 0.91), nb(74, 0.98), nb(75, 0.92), nb(76, 0.90)},
|
||||||
|
}}
|
||||||
|
svc := NewSimilarService(repo, 0.93)
|
||||||
|
|
||||||
|
got := svc.HintsForImages(context.Background(), "runway", []uint32{7, 8})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("应只有图片 7 有提示,得到 %d 项", len(got))
|
||||||
|
}
|
||||||
|
hints := got[7]
|
||||||
|
if len(hints) != 5 {
|
||||||
|
t.Fatalf("每张最多 5 条提示,得到 %d", len(hints))
|
||||||
|
}
|
||||||
|
for i := 1; i < len(hints); i++ {
|
||||||
|
if hints[i].Similar > hints[i-1].Similar {
|
||||||
|
t.Fatalf("提示应按相似度降序,实际 %+v", hints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hints[0].ImageID != 72 || hints[0].Similar != 0.99 {
|
||||||
|
t.Fatalf("第一条应为相似度最高的 72,实际 %+v", hints[0])
|
||||||
|
}
|
||||||
|
if hints[0].Image != "runway/x.jpg" || hints[0].Name != "Look 1" || hints[0].OwnerTitle != "Fall 2019 Menswear" {
|
||||||
|
t.Fatalf("近邻描述字段未透传: %+v", hints[0])
|
||||||
|
}
|
||||||
|
if hints[0].OwnerID != 720 {
|
||||||
|
t.Fatalf("近邻所属图集 id 未透传: %+v", hints[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHintsReadErrorDegradesQuietly 读结果表失败绝不能让页面渲染失败:返回空、不 panic。
|
||||||
|
func TestHintsReadErrorDegradesQuietly(t *testing.T) {
|
||||||
|
repo := &fakeEmbeddingRepo{listErr: errors.New("db down")}
|
||||||
|
svc := NewSimilarService(repo, 0.93)
|
||||||
|
if got := svc.HintsForImages(context.Background(), "street", []uint32{1}); got != nil {
|
||||||
|
t.Fatalf("读取失败应返回 nil,得到 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrecomputeWritesBidirectionalPairs 预计算:逐张查近邻,并把结果**双向**落库;
|
||||||
|
// 同时验证阈值、排除集(整个图集)与上限都透传给仓储。
|
||||||
|
func TestPrecomputeWritesBidirectionalPairs(t *testing.T) {
|
||||||
|
repo := &fakeEmbeddingRepo{
|
||||||
|
vecs: map[uint32][]float32{1: {0.1, 0.2}, 2: {0.3, 0.4}},
|
||||||
|
neighbors: map[uint32][]repository.SimilarImage{
|
||||||
|
1: {{ImageID: 9, OwnerID: 100, Similar: 0.97}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewSimilarService(repo, 0.93)
|
||||||
|
|
||||||
|
// 图集含图片 1、2;其中只有 1 有近邻 9(属于别的图集)。
|
||||||
|
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
|
||||||
|
t.Fatalf("预计算失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if repo.gotMinSim != 0.93 {
|
||||||
|
t.Fatalf("阈值应透传给仓储,实际 %v", repo.gotMinSim)
|
||||||
|
}
|
||||||
|
if repo.gotLimit != 5 {
|
||||||
|
t.Fatalf("每张最多取 5 条近邻,实际 %d", repo.gotLimit)
|
||||||
|
}
|
||||||
|
// 排除集必须是整个图集:同图集内不比(同场共享背景/灯光/造型,相似度天然偏高)。
|
||||||
|
if len(repo.gotExclude) != 2 || repo.gotExclude[0] != 1 || repo.gotExclude[1] != 2 {
|
||||||
|
t.Fatalf("同图集排除集应为整个图集的图片 id,实际 %v", repo.gotExclude)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双向:1→9 与 9→1 各一行。
|
||||||
|
if len(repo.replacedRows) != 2 {
|
||||||
|
t.Fatalf("应写入双向共 2 行,实际 %+v", repo.replacedRows)
|
||||||
|
}
|
||||||
|
forward, backward := false, false
|
||||||
|
for _, r := range repo.replacedRows {
|
||||||
|
switch {
|
||||||
|
case r.SelfID == 1 && r.OtherID == 9:
|
||||||
|
forward = true
|
||||||
|
case r.SelfID == 9 && r.OtherID == 1:
|
||||||
|
backward = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !forward || !backward {
|
||||||
|
t.Fatalf("必须双向写入(A→B 与 B→A),实际 %+v", repo.replacedRows)
|
||||||
|
}
|
||||||
|
// 替换范围是本图集的图片 id(先删后插用)。
|
||||||
|
if len(repo.replacedSelfIDs) != 2 {
|
||||||
|
t.Fatalf("替换范围应为整个图集的图片 id,实际 %v", repo.replacedSelfIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPrecomputeNoVectorsSkips 图集还没有向量(视觉服务未启用)时不做任何计算与写入。
|
||||||
|
func TestPrecomputeNoVectorsSkips(t *testing.T) {
|
||||||
|
repo := &fakeEmbeddingRepo{vecs: map[uint32][]float32{}}
|
||||||
|
svc := NewSimilarService(repo, 0.93)
|
||||||
|
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
|
||||||
|
t.Fatalf("无向量时应静默返回,实际 %v", err)
|
||||||
|
}
|
||||||
|
if repo.replacedRows != nil || repo.replacedSelfIDs != nil {
|
||||||
|
t.Fatalf("无向量时不应写库,实际 rows=%v selfIDs=%v", repo.replacedRows, repo.replacedSelfIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDupStatsByKind 列表页要用的汇总:按 kind 分组返回图集的重复概况与总图数。
|
||||||
|
func TestDupStatsByKind(t *testing.T) {
|
||||||
|
repo := &fakeEmbeddingRepo{dupStats: map[string]map[uint32]repository.DupStat{
|
||||||
|
"runway": {7: {Total: 120, Images: 3, FirstImageID: 55}},
|
||||||
|
}}
|
||||||
|
svc := NewSimilarService(repo, 0.93)
|
||||||
|
|
||||||
|
got := svc.DupStats(context.Background())
|
||||||
|
st, ok := got["runway"][7]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("应返回 runway 图集 7 的概况,实际 %+v", got)
|
||||||
|
}
|
||||||
|
if st.Total != 120 || st.Images != 3 || st.FirstImageID != 55 {
|
||||||
|
t.Fatalf("概况字段不正确: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDupStatsErrorDegradesQuietly 汇总失败只能记日志:列表页必须照常渲染。
|
||||||
|
func TestDupStatsErrorDegradesQuietly(t *testing.T) {
|
||||||
|
repo := &fakeEmbeddingRepo{listErr: errors.New("db down")}
|
||||||
|
svc := NewSimilarService(repo, 0.93)
|
||||||
|
if got := svc.DupStats(context.Background()); len(got) != 0 {
|
||||||
|
t.Fatalf("汇总失败应返回空,得到 %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDupStatsDisabled 未启用时直接返回 nil,不触发任何查询。
|
||||||
|
func TestDupStatsDisabled(t *testing.T) {
|
||||||
|
svc := NewSimilarService(nil, 0.93)
|
||||||
|
if got := svc.DupStats(context.Background()); got != nil {
|
||||||
|
t.Fatalf("未启用应返回 nil,得到 %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,8 +27,6 @@ type StreetSnapService interface {
|
|||||||
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
||||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||||
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释),供编辑页标记重复图。
|
|
||||||
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
|
||||||
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// DeleteImage 软删单张街拍图片。
|
// DeleteImage 软删单张街拍图片。
|
||||||
@ -338,11 +336,6 @@ func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.S
|
|||||||
return snap, orderStreetImagesForEdit(imgs), nil
|
return snap, orderStreetImagesForEdit(imgs), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释)。
|
|
||||||
func (s *streetSnapService) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
|
||||||
return s.repo.GetStreetSnapDupSources(ctx, imageIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
|
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
|
||||||
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
|
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
|
||||||
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
|
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
|
||||||
@ -395,16 +388,6 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
|
|||||||
if key != "" {
|
if key != "" {
|
||||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||||
}
|
}
|
||||||
// 删图后重算本专辑去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
|
||||||
if snapID, err := s.repo.GetSnapImageRunwayID(ctx, imageID); err == nil && snapID != 0 {
|
|
||||||
if err := s.repo.ReresolveSnapDedup(ctx, snapID); err != nil {
|
|
||||||
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 跨文章清理:本图若是其它街拍近重复的「源」,那些街拍的 dup_of 需重算/清除。
|
|
||||||
if err := s.repo.ReresolveSnapDupOfSources(ctx, []uint32{imageID}); err != nil {
|
|
||||||
log.Printf("[warn] 跨文章重算街拍图 %d 去重失败(重复标记可能陈旧): %v", imageID, err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,11 +412,6 @@ func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted u
|
|||||||
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
// PurgeGallery 下架街拍并异步清理S4孤儿图:逻辑同 articleService.PurgeGallery,
|
||||||
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
// 仅仓储调用换成街拍的 SoftDeleteSnapImages / SetSnapDeleted。
|
||||||
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
// 下架前先取存活图片 id:删整篇后,其它街拍中 dup_of 指向本篇图片的标记需跨文章重算/清除。
|
|
||||||
delIDs, err := s.repo.GetSnapLiveImageIDs(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
keys, err := s.repo.SoftDeleteSnapImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -446,11 +424,5 @@ func (s *streetSnapService) PurgeGallery(ctx context.Context, id uint32) error {
|
|||||||
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
log.Printf("[warn] 下架街拍 %d 入队清理S4失败(文件将暂留): %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 跨文章去重清理:删除整篇后,其它街拍中 dup_of 指向本篇图片的标记重算/清除。
|
|
||||||
if len(delIDs) > 0 {
|
|
||||||
if err := s.repo.ReresolveSnapDupOfSources(ctx, delIDs); err != nil {
|
|
||||||
log.Printf("[warn] 跨文章重算街拍 %d 去重失败(重复标记可能陈旧): %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
177
internal/vision/client.go
Normal file
177
internal/vision/client.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
// Package vision 调用独立部署的 FashionSigLIP 视觉服务,把图片编码成归一化向量。
|
||||||
|
//
|
||||||
|
// 设计要点(对应规格 docs/superpowers/specs/2026-10-12-fashionclip-siglip-vision-service-design.md §6/§11):
|
||||||
|
// - 服务不可用、超时、维度不符一律返回 ErrUnavailable,调用方据此「不写向量」而非中断入库;
|
||||||
|
// - 连续失败达到阈值后熔断冷却,避免每张图都卡在超时上;
|
||||||
|
// - 熔断期与禁用态下不发起任何 HTTP 请求。
|
||||||
|
//
|
||||||
|
// 该客户端只服务于「疑似同款」提示,**不参与任何自动去重**。
|
||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxBatch 单次请求的最大图片数,须与服务端限制保持一致。
|
||||||
|
const MaxBatch = 32
|
||||||
|
|
||||||
|
// cooldown 熔断后的冷却时长。
|
||||||
|
const cooldown = 5 * time.Minute
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrUnavailable 表示服务不可用:未启用、熔断中、网络错误、超时、响应异常或维度不符。
|
||||||
|
ErrUnavailable = errors.New("vision service unavailable")
|
||||||
|
// ErrBatchTooLarge 表示单批图片数超过 MaxBatch。
|
||||||
|
ErrBatchTooLarge = errors.New("vision batch too large")
|
||||||
|
)
|
||||||
|
|
||||||
|
// embedResponse 是服务端 POST /embed 的响应结构。
|
||||||
|
type embedResponse struct {
|
||||||
|
Dim int `json:"dim"`
|
||||||
|
Vectors [][]float32 `json:"vectors"`
|
||||||
|
Indexes []int `json:"indexes"`
|
||||||
|
Skipped []int `json:"skipped"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// embedRequest 是 POST /embed 的请求结构。
|
||||||
|
type embedRequest struct {
|
||||||
|
Images []string `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client 是视觉服务客户端。并发安全。
|
||||||
|
type Client struct {
|
||||||
|
endpoint string
|
||||||
|
dim int
|
||||||
|
failLimit int
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
fails int
|
||||||
|
cooldownEnd time.Time
|
||||||
|
disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 构造客户端。timeout 与 failLimit 非正时取默认值(5s / 3 次)。
|
||||||
|
func New(endpoint string, dim int, timeout time.Duration, failLimit int) *Client {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if failLimit <= 0 {
|
||||||
|
failLimit = 3
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
endpoint: endpoint,
|
||||||
|
dim: dim,
|
||||||
|
failLimit: failLimit,
|
||||||
|
http: &http.Client{Timeout: timeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable 关闭客户端:此后 Embed 直接返回 ErrUnavailable 且不发起请求。
|
||||||
|
// 用于配置未启用视觉服务时走零开销路径。
|
||||||
|
func (c *Client) Disable() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.disabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled 报告客户端当前是否可发起请求(未禁用且不在熔断冷却期)。
|
||||||
|
func (c *Client) Enabled() bool { return c.Open() }
|
||||||
|
|
||||||
|
// Open 报告是否可以发起请求(未禁用且不在熔断冷却期)。
|
||||||
|
func (c *Client) Open() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.canRequestLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) canRequestLocked() bool {
|
||||||
|
if c.disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().After(c.cooldownEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail 记录一次失败;达到阈值则进入熔断冷却并清零计数。
|
||||||
|
func (c *Client) fail() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.fails++
|
||||||
|
if c.fails >= c.failLimit {
|
||||||
|
c.cooldownEnd = time.Now().Add(cooldown)
|
||||||
|
c.fails = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok 记录一次成功,清零连续失败计数。
|
||||||
|
func (c *Client) ok() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.fails = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed 把图片字节批量编码为归一化向量,返回的 skipped 是无法解码的图片下标。
|
||||||
|
//
|
||||||
|
// 任何故障(未启用 / 熔断 / 网络 / 超时 / 响应异常 / 维度不符)都返回 ErrUnavailable,
|
||||||
|
// 调用方应据此跳过写向量,而不是让入库失败。
|
||||||
|
// 空输入直接返回 nil, nil, nil;超过 MaxBatch 返回 ErrBatchTooLarge。
|
||||||
|
func (c *Client) Embed(ctx context.Context, imgs [][]byte) ([][]float32, []int, error) {
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
if len(imgs) > MaxBatch {
|
||||||
|
return nil, nil, ErrBatchTooLarge
|
||||||
|
}
|
||||||
|
if !c.Open() {
|
||||||
|
return nil, nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := embedRequest{Images: make([]string, len(imgs))}
|
||||||
|
for i, b := range imgs {
|
||||||
|
payload.Images[i] = base64.StdEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/embed", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.fail()
|
||||||
|
return nil, nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.fail()
|
||||||
|
return nil, nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
var out embedResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
c.fail()
|
||||||
|
return nil, nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
if out.Dim != c.dim {
|
||||||
|
// 维度不符属于配置与模型不一致:必须报错并拒写,防止污染向量列。
|
||||||
|
c.fail()
|
||||||
|
return nil, nil, fmt.Errorf("%w: dim=%d want=%d", ErrUnavailable, out.Dim, c.dim)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok()
|
||||||
|
return out.Vectors, out.Skipped, nil
|
||||||
|
}
|
||||||
104
internal/vision/client_integration_test.go
Normal file
104
internal/vision/client_integration_test.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:打真实运行的视觉服务,验证 Go 客户端与服务端的接口契约。
|
||||||
|
//
|
||||||
|
// 运行前先起服务(本地开发用项目 .venv,走 GPU):
|
||||||
|
//
|
||||||
|
// cd D:\project\fashion-siglip
|
||||||
|
// .venv\Scripts\python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||||||
|
//
|
||||||
|
// 然后:
|
||||||
|
//
|
||||||
|
// go test -tags integration ./internal/vision/ -run TestClientAgainstRealService -v
|
||||||
|
//
|
||||||
|
// 服务未启动时本测试 **跳过**(不算失败),以免阻塞没有该服务的环境。
|
||||||
|
// 可用 VISION_ENDPOINT 环境变量指向其它地址。
|
||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientAgainstRealService(t *testing.T) {
|
||||||
|
endpoint := os.Getenv("VISION_ENDPOINT")
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = "http://127.0.0.1:8900"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 探活:不通就跳过(本测试依赖外部进程,不应让 CI 变红)。
|
||||||
|
hc := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := hc.Get(endpoint + "/health")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("视觉服务不可达(%s),跳过集成测试:%v", endpoint, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var health struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Dim int `json:"dim"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
|
||||||
|
t.Fatalf("解析 /health 失败: %v", err)
|
||||||
|
}
|
||||||
|
if !health.Ready {
|
||||||
|
t.Skipf("视觉服务未就绪(模型仍在加载或加载失败),跳过:%+v", health)
|
||||||
|
}
|
||||||
|
t.Logf("服务就绪:dim=%d device=%s", health.Dim, health.Device)
|
||||||
|
|
||||||
|
// 2) 用一张程序生成的 PNG 验证完整契约:base64 请求 → 服务解码 → 768 维归一化向量。
|
||||||
|
img := generateTestPNG(t)
|
||||||
|
|
||||||
|
c := New(endpoint, health.Dim, 60*time.Second, 3)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
vecs, skipped, err := c.Embed(ctx, [][]byte{img})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed 失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Fatalf("合法 PNG 不应被跳过,skipped=%v", skipped)
|
||||||
|
}
|
||||||
|
if len(vecs) != 1 {
|
||||||
|
t.Fatalf("应返回 1 个向量,得到 %d", len(vecs))
|
||||||
|
}
|
||||||
|
if got := len(vecs[0]); got != health.Dim {
|
||||||
|
t.Fatalf("向量维度应为 %d,得到 %d", health.Dim, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端承诺已 L2 归一化:模长≈1 是「点积即余弦相似度」的前提。
|
||||||
|
var sumSq float64
|
||||||
|
for _, f := range vecs[0] {
|
||||||
|
sumSq += float64(f) * float64(f)
|
||||||
|
}
|
||||||
|
norm := sumSq
|
||||||
|
if norm < 0.99 || norm > 1.01 {
|
||||||
|
t.Fatalf("向量应已 L2 归一化(模长²≈1),实际 %f", norm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestPNG 生成一张 64x64 的纯色 PNG(不引入外部测试资源)。
|
||||||
|
func generateTestPNG(t *testing.T) []byte {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||||
|
for x := 0; x < 64; x++ {
|
||||||
|
for y := 0; y < 64; y++ {
|
||||||
|
img.Set(x, y, color.RGBA{R: 200, G: 60, B: 120, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatalf("生成测试 PNG 失败: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
164
internal/vision/client_test.go
Normal file
164
internal/vision/client_test.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package vision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEmbedReturnsVectorsAndSkipsBroken 验证正常路径:批量编码返回向量,
|
||||||
|
// 服务端跳过的图片下标原样带回,向量维度与配置一致。
|
||||||
|
func TestEmbedReturnsVectorsAndSkipsBroken(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/embed" {
|
||||||
|
t.Errorf("路径不对: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"dim": 768,
|
||||||
|
"vectors": [][]float32{make([]float32, 768), make([]float32, 768)},
|
||||||
|
"indexes": []int{0, 2},
|
||||||
|
"skipped": []int{1},
|
||||||
|
"device": "cpu",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, 768, 2*time.Second, 3)
|
||||||
|
vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("a"), []byte("b"), []byte("c")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Embed 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if len(vecs) != 2 {
|
||||||
|
t.Fatalf("应有 2 个向量,得到 %d", len(vecs))
|
||||||
|
}
|
||||||
|
if len(skipped) != 1 || skipped[0] != 1 {
|
||||||
|
t.Fatalf("skipped 应为 [1],得到 %v", skipped)
|
||||||
|
}
|
||||||
|
if len(vecs[0]) != 768 {
|
||||||
|
t.Fatalf("向量维度应为 768,得到 %d", len(vecs[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedDisabledSkipsCall 验证禁用后完全不发起 HTTP 请求(视觉服务未启用时的零开销路径)。
|
||||||
|
func TestEmbedDisabledSkipsCall(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, 768, time.Second, 3)
|
||||||
|
c.Disable()
|
||||||
|
vecs, skipped, err := c.Embed(context.Background(), [][]byte{[]byte("x")})
|
||||||
|
if err != ErrUnavailable {
|
||||||
|
t.Fatalf("禁用时应返回 ErrUnavailable,实际 %v", err)
|
||||||
|
}
|
||||||
|
if vecs != nil || skipped != nil {
|
||||||
|
t.Fatalf("禁用时应返回 nil,实际 %v %v", vecs, skipped)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("禁用时不应发起 HTTP 请求")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedDimensionMismatchIsError 验证维度不符必须报错:
|
||||||
|
// 若放过,会把错误维度的向量写进 image_embeddings 造成脏数据。
|
||||||
|
func TestEmbedDimensionMismatchIsError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"dim": 512, "vectors": [][]float32{make([]float32, 512)}, "indexes": []int{0}, "skipped": []int{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, 768, time.Second, 3)
|
||||||
|
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||||||
|
t.Fatal("维度不符应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedTripBreakerThenCooldownSkips 验证熔断:连续失败达阈值后进入冷却,
|
||||||
|
// 冷却期内直接返回错误且不再发起请求(避免每张图都卡在超时上)。
|
||||||
|
func TestEmbedTripBreakerThenCooldownSkips(t *testing.T) {
|
||||||
|
hits := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits++
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, 768, time.Second, 3)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||||||
|
t.Fatalf("第 %d 次调用应报错", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Open() {
|
||||||
|
t.Fatal("熔断后 Open 应为 false")
|
||||||
|
}
|
||||||
|
before := hits
|
||||||
|
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("x")}); err == nil {
|
||||||
|
t.Fatalf("熔断期应直接返回错误: %v", err)
|
||||||
|
}
|
||||||
|
if hits != before {
|
||||||
|
t.Fatalf("熔断期间不应再发起请求,hits 从 %d 变为 %d", before, hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedSendsBase64Payload 验证请求体格式(base64 编码的图片数组),
|
||||||
|
// 这是与服务端约定的契约,写错会导致服务端解码失败。
|
||||||
|
func TestEmbedSendsBase64Payload(t *testing.T) {
|
||||||
|
var got struct {
|
||||||
|
Images []string `json:"images"`
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"dim": 768, "vectors": [][]float32{make([]float32, 768)}, "indexes": []int{0}, "skipped": []int{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, 768, time.Second, 3)
|
||||||
|
if _, _, err := c.Embed(context.Background(), [][]byte{[]byte("hello")}); err != nil {
|
||||||
|
t.Fatalf("Embed 返回错误: %v", err)
|
||||||
|
}
|
||||||
|
want := base64.StdEncoding.EncodeToString([]byte("hello"))
|
||||||
|
if len(got.Images) != 1 || got.Images[0] != want {
|
||||||
|
t.Fatalf("请求体 base64 不正确: %v", got.Images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedEmptyInputIsNoop 验证空输入不发起请求、不报错。
|
||||||
|
func TestEmbedEmptyInputIsNoop(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := New(srv.URL, 768, time.Second, 3)
|
||||||
|
vecs, skipped, err := c.Embed(context.Background(), nil)
|
||||||
|
if err != nil || vecs != nil || skipped != nil {
|
||||||
|
t.Fatalf("空输入应返回 nil,nil,nil,实际 %v %v %v", vecs, skipped, err)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("空输入不应发起请求")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmbedBatchTooLarge 验证单批上限,超限直接拒绝(避免把一张大图撑爆服务端)。
|
||||||
|
func TestEmbedBatchTooLarge(t *testing.T) {
|
||||||
|
c := New("http://127.0.0.1:1", 768, time.Second, 3)
|
||||||
|
imgs := make([][]byte, MaxBatch+1)
|
||||||
|
for i := range imgs {
|
||||||
|
imgs[i] = []byte("x")
|
||||||
|
}
|
||||||
|
if _, _, err := c.Embed(context.Background(), imgs); err != ErrBatchTooLarge {
|
||||||
|
t.Fatalf("超上限应返回 ErrBatchTooLarge,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user