This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

View File

@ -70,6 +70,9 @@ import (
"github.com/jackc/pgx/v5/stdlib"
"fashionapi/internal/config"
"fashionapi/internal/database"
"fashionapi/internal/dto"
"fashionapi/internal/repository"
"fashionapi/internal/service"
)
@ -109,14 +112,14 @@ func main() {
runDump(os.Args[2:])
case "import":
runImport(os.Args[2:])
case "fixdup":
runFixDup(os.Args[2:])
case "filltitlecn":
runFillTitleCn(os.Args[2:])
case "purge-rejected":
runPurgeRejected(os.Args[2:])
case "migrate":
runMigrate(os.Args[2:])
case "similarbackfill":
runSimilarBackfill(os.Args[2:])
default:
usage()
os.Exit(2)
@ -131,9 +134,6 @@ func usage() {
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
dbtool import [-config <yml>] [-in <file>] [-clean]
把 dump 出来的 SQL 文件灌入目标库
dbtool fixdup [-config <yml>] [-dry-run]
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
-dry-run 只统计/列出将受影响的行,不实际改写(推荐先跑一次确认)
dbtool filltitlecn [-config <yml>] [-dry-run]
历史回填中文标题:扫描 brand_runways / street_snaps 里 title_cn 为空且英文标题
非空的存活记录,用与入库同源的规则(记忆优先、规则兜底)补全;规则无法完整翻译
@ -147,6 +147,10 @@ func usage() {
每个文件执行成功后记入 schema_migrations,重跑已记录的会跳过;库内「已存在」
视为已应用(也记入记录表后跳过),因此历史已手动 psql 过的迁移重跑不会报错中断。
-in 指定单个文件时,只执行该文件。本子命令替代 psql,免装客户端即可落库。
dbtool similarbackfill [-config <yml>] [-kind runway|street] [-limit N] [-dry-run]
给存量图集回填「疑似同款」近邻(image_similar_pairs 表)。近邻改为「入库时预计算」
后,此前入库的图集在结果表里没有数据、后台看不到提示,本命令按图集重算一遍
(可重复执行,同一图集先删后插)。-dry-run 只列出将处理的图集与图片数。
说明:
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
@ -159,6 +163,97 @@ func usage() {
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 并探活。
func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
cfg, err := config.Load(cfgPath)
@ -308,86 +403,6 @@ func runImport(args []string) {
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)」且未下架的记录转删除:
// 级联软删其全部图片、记录自身置 is_deleted=1(后台 legWhere 强制 is_deleted=0,记录即从列表消失),
// 并把图片 key 作为 media_cleanup 任务入队,由运行中的 worker 按引用计数真删 S4 孤儿文件。
@ -432,7 +447,10 @@ func runFillTitleCn(args []string) {
if qerr != nil {
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
for rows.Next() {
var id uint32