update
This commit is contained in:
@ -57,6 +57,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
@ -106,6 +107,10 @@ func main() {
|
||||
runDump(os.Args[2:])
|
||||
case "import":
|
||||
runImport(os.Args[2:])
|
||||
case "fixdup":
|
||||
runFixDup(os.Args[2:])
|
||||
case "purge-rejected":
|
||||
runPurgeRejected(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
@ -120,6 +125,12 @@ func usage() {
|
||||
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||
把 dump 出来的 SQL 文件灌入目标库
|
||||
dbtool fixdup [-config <yml>]
|
||||
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
||||
dbtool purge-rejected [-config <yml>]
|
||||
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
|
||||
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
|
||||
按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理)
|
||||
|
||||
说明:
|
||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||
@ -281,6 +292,164 @@ func runImport(args []string) {
|
||||
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
||||
}
|
||||
|
||||
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
||||
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
||||
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
||||
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||
func runFixDup(args []string) {
|
||||
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||
_ = 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"} {
|
||||
q := fmt.Sprintf(`
|
||||
UPDATE public.%[1]s
|
||||
SET is_duplicate = 0, dup_of = ''
|
||||
WHERE is_duplicate = 1
|
||||
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
|
||||
SELECT CAST(id AS text) FROM public.%[1]s WHERE is_deleted = 0));`, qi(t))
|
||||
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 孤儿文件。
|
||||
//
|
||||
// 与后台点「删除(清空 S4)」完全等价(后者走 articleService/streetSnapService.SetDeleted →
|
||||
// PurgeGallery),但本命令一次性全局处理历史遗留的 rejected 数据,无需逐条手动操作。
|
||||
func runPurgeRejected(args []string) {
|
||||
fs := flag.NewFlagSet("purge-rejected", flag.ExitOnError)
|
||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
db, _, err := openDB(*cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("✗ %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 两种实体:记录主表 / 图片明细表 / 图片表外键列。
|
||||
kinds := []struct {
|
||||
recTable, imgTable, fk string
|
||||
}{
|
||||
{"brand_runways", "brand_runway_images", "runway_id"},
|
||||
{"street_snaps", "street_snap_images", "snap_id"},
|
||||
}
|
||||
|
||||
now := uint32(time.Now().Unix())
|
||||
totalRecs, totalKeys := 0, 0
|
||||
var allKeys []string
|
||||
|
||||
for _, k := range kinds {
|
||||
ids, err := selectUint32s(db, fmt.Sprintf(
|
||||
"SELECT id FROM public.%s WHERE status = 'rejected' AND is_deleted = 0", qi(k.recTable)))
|
||||
if err != nil {
|
||||
log.Fatalf("✗ 查询 %s 失败: %v", k.recTable, err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
log.Printf("→ %s: 无 rejected 记录", k.recTable)
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
keys, err := selectStrings(db, fmt.Sprintf(
|
||||
"SELECT image FROM public.%s WHERE %s = $1 AND is_deleted = 0", qi(k.imgTable), k.fk), id)
|
||||
if err != nil {
|
||||
log.Fatalf("✗ 查询 %s 图片失败: %v", k.imgTable, err)
|
||||
}
|
||||
if _, err := db.Exec(fmt.Sprintf(
|
||||
"UPDATE public.%s SET is_deleted = 1, updated_at = $1 WHERE %s = $2 AND is_deleted = 0",
|
||||
qi(k.imgTable), k.fk), now, id); err != nil {
|
||||
log.Fatalf("✗ 软删 %s 图片失败: %v", k.imgTable, err)
|
||||
}
|
||||
if _, err := db.Exec(fmt.Sprintf(
|
||||
"UPDATE public.%s SET is_deleted = 1, updated_at = $1 WHERE id = $2",
|
||||
qi(k.recTable)), now, id); err != nil {
|
||||
log.Fatalf("✗ 置 %s 下架失败: %v", k.recTable, err)
|
||||
}
|
||||
totalRecs++
|
||||
allKeys = append(allKeys, keys...)
|
||||
totalKeys += len(keys)
|
||||
log.Printf("✓ %s #%d: 软删图片 %d 张", k.recTable, id, len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
// 把 key 分批入队 media_cleanup(与 EnqueueMediaCleanup 同格式),worker 按引用计数清 S4。
|
||||
if len(allKeys) > 0 {
|
||||
const batch = 500
|
||||
for start := 0; start < len(allKeys); start += batch {
|
||||
end := start + batch
|
||||
if end > len(allKeys) {
|
||||
end = len(allKeys)
|
||||
}
|
||||
payload, err := json.Marshal(map[string][]string{"keys": allKeys[start:end]})
|
||||
if err != nil {
|
||||
log.Fatalf("✗ 构造 payload 失败: %v", err)
|
||||
}
|
||||
if err := insertMediaCleanup(db, now, string(payload)); err != nil {
|
||||
log.Fatalf("✗ 入队 media_cleanup 失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("✓ 完成:转删除记录 %d 条、图片 %d 张,已入队 media_cleanup(运行中 worker 将按引用计数清理 S4)", totalRecs, totalKeys)
|
||||
}
|
||||
|
||||
// selectUint32s 执行返回单列 uint32 的查询。
|
||||
func selectUint32s(db *sql.DB, q string, args ...any) ([]uint32, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []uint32
|
||||
for rows.Next() {
|
||||
var v uint32
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// selectStrings 执行返回单列 string 的查询。
|
||||
func selectStrings(db *sql.DB, q string, args ...any) ([]string, error) {
|
||||
rows, err := db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// insertMediaCleanup 写入一条「清理S4孤儿图」任务(与 repository.EnqueueMediaCleanup 同格式)。
|
||||
// payload 为 {"keys":[...]},由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。
|
||||
func insertMediaCleanup(db *sql.DB, now uint32, payload string) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO public.ingest_jobs (kind, payload, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)",
|
||||
"media_cleanup", payload, "pending", now, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
|
||||
//
|
||||
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
||||
|
||||
Reference in New Issue
Block a user