update
This commit is contained in:
@ -57,6 +57,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@ -106,6 +107,10 @@ 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 "purge-rejected":
|
||||||
|
runPurgeRejected(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@ -120,6 +125,12 @@ 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>]
|
||||||
|
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
||||||
|
dbtool purge-rejected [-config <yml>]
|
||||||
|
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
|
||||||
|
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
|
||||||
|
按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||||
@ -281,6 +292,164 @@ 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 的重算效果之一,
|
||||||
|
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||||
|
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 连带索引 / 约束 / 归属该表的序列)。
|
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
|
||||||
//
|
//
|
||||||
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
||||||
|
|||||||
363
cmd/diagdup/main.go
Normal file
363
cmd/diagdup/main.go
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -143,7 +143,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy)
|
||||||
// 图集服务:注入 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)
|
||||||
|
|||||||
@ -85,6 +85,11 @@ client_sign:
|
|||||||
ingest:
|
ingest:
|
||||||
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
||||||
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
||||||
|
# 本机下载代理(仅本地开发用,不提交):worker 下载图片(如 assets.vogue.com)经此代理访问源站。
|
||||||
|
# 支持 http:// 或 socks5://,例如 socks5://127.0.0.1:51831 或 http://127.0.0.1:58591。
|
||||||
|
# 留空直连;环境变量 INGEST_DOWNLOAD_PROXY 可覆盖本值(二者均只作用于「下载」,不影响 S4 上传与数据库)。
|
||||||
|
# 实际值建议写 configs/config.local.yml(已被 .gitignore 忽略),避免进入共享配置。
|
||||||
|
download_proxy: "" # env: INGEST_DOWNLOAD_PROXY
|
||||||
|
|
||||||
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
|
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
|
||||||
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)
|
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)
|
||||||
|
|||||||
@ -4,8 +4,11 @@
|
|||||||
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
||||||
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
||||||
--
|
--
|
||||||
-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致,
|
-- 部分索引(WHERE is_deleted = 0):仅约束活着行,保证「同一实体至多一行活着」。
|
||||||
-- 软删的旧实体键不再占用,允许同实体重新入库。
|
-- 注:入库服务层 RunwayEntityState / StreetSnapEntityState 现已**不再**以 is_deleted = 0 过滤,
|
||||||
|
-- 软删实体也会被查到并由 worker 跳过重爬(「删除即永久」)。本索引仍保持部分索引是为了
|
||||||
|
-- 兼容存量「软删行同键共存」的数据、避免建全量唯一索引时因历史重复而失败;结构性唯一性靠它兜底,
|
||||||
|
-- 行为上的「删除即永久」由服务层查重保证。
|
||||||
|
|
||||||
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
7
db/migrations/2026-09-26-01-drop-reject-reason.sql
Normal file
7
db/migrations/2026-09-26-01-drop-reject-reason.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 移除「驳回理由」:审核流程不再要求/存储理由,删除 reject_reason 列。
|
||||||
|
-- 视图 public_brand_runways / public_street_snaps 本就显式排除该列,无需改动。
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
DROP COLUMN IF EXISTS reject_reason;
|
||||||
|
|
||||||
|
ALTER TABLE street_snaps
|
||||||
|
DROP COLUMN IF EXISTS reject_reason;
|
||||||
12
db/migrations/2026-09-27-01-add-runway-published-at.sql
Normal file
12
db/migrations/2026-09-27-01-add-runway-published-at.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- 新增走秀发布日期列 published_at:从 Vogue 详情页 application/ld+json 的 datePublished 抽取,
|
||||||
|
-- 以 Unix 时间戳(秒,bigint)存储,0 表示未抽取/未知,与 created_at / updated_at 的约定一致。
|
||||||
|
--
|
||||||
|
-- 用途:
|
||||||
|
-- 1) 作为走秀的权威发布时间存下来(替代 created_at/updated_at 这类入库时间)。
|
||||||
|
-- 2) 标题缺年份(year=0、season_code 为空)的秀,用发布日期的年份补全 year / season_code,
|
||||||
|
-- 使这些秀重新可被「按年份/季节筛选」与热门排序(按 year + season_code)命中。
|
||||||
|
--
|
||||||
|
-- 回填为一次性操作:重跑 spider vogue 时,已存在实体走跳过分支按主键 id 写回 published_at
|
||||||
|
-- (及缺失年份),不重下图片。
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
ADD COLUMN IF NOT EXISTS published_at bigint NOT NULL DEFAULT 0;
|
||||||
13559
db_dump.sql
13559
db_dump.sql
File diff suppressed because one or more lines are too long
@ -119,6 +119,10 @@ type IngestConfig struct {
|
|||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
||||||
TTLSeconds int `yaml:"ttl_seconds"`
|
TTLSeconds int `yaml:"ttl_seconds"`
|
||||||
|
// DownloadProxy 本机下载代理(仅本地开发用):worker 下载图片经此代理访问源站,
|
||||||
|
// 支持 http:// 或 socks5://(如 socks5://127.0.0.1:51831、http://127.0.0.1:58591)。
|
||||||
|
// 仅作用于图片下载客户端,不影响 S4 上传与数据库连接;留空直连。env: INGEST_DOWNLOAD_PROXY
|
||||||
|
DownloadProxy string `yaml:"download_proxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
@ -214,6 +218,7 @@ func defaultConfig() *Config {
|
|||||||
Ingest: IngestConfig{
|
Ingest: IngestConfig{
|
||||||
Secret: "",
|
Secret: "",
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
|
DownloadProxy: "",
|
||||||
},
|
},
|
||||||
S4: S4Config{
|
S4: S4Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
@ -268,11 +273,33 @@ func Load(explicit string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.loadedFrom = loadedFrom
|
cfg.loadedFrom = loadedFrom
|
||||||
|
|
||||||
|
// 本机私有覆盖(config.local.yml,已被 .gitignore 忽略):叠加在 config.yml 之上,
|
||||||
|
// 用于放本机相关、不应进版本库的设置(如本地代理 download_proxy)。环境变量仍拥有最高优先级。
|
||||||
|
overlayLocalConfig(cfg)
|
||||||
|
|
||||||
cfg.applyEnv()
|
cfg.applyEnv()
|
||||||
cfg.normalize()
|
cfg.normalize()
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overlayLocalConfig 把本机私有覆盖文件 config.local.yml(若存在)叠加到配置上。
|
||||||
|
// 仅用于本机相关、不应进版本库的设置(如本地代理 download_proxy);文件不存在则静默跳过。
|
||||||
|
// 与 config.yml 同样走 goccy/go-yaml 反序列化,仅覆盖文件中出现的键;环境变量仍在 applyEnv 中拥有最高优先级。
|
||||||
|
func overlayLocalConfig(cfg *Config) {
|
||||||
|
for _, p := range []string{
|
||||||
|
"configs/config.local.yml",
|
||||||
|
"config.local.yml",
|
||||||
|
filepath.Join("..", "..", "configs", "config.local.yml"),
|
||||||
|
} {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = yaml.Unmarshal(data, cfg) // 解析失败静默跳过:本地覆盖属尽力而为,不应阻断启动
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。
|
// LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。
|
||||||
func (c *Config) LoadedFrom() string { return c.loadedFrom }
|
func (c *Config) LoadedFrom() string { return c.loadedFrom }
|
||||||
|
|
||||||
@ -310,6 +337,7 @@ func (c *Config) applyEnv() {
|
|||||||
|
|
||||||
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
||||||
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
||||||
|
envStr("INGEST_DOWNLOAD_PROXY", &c.Ingest.DownloadProxy)
|
||||||
|
|
||||||
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
|
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
|
||||||
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
|
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
|
||||||
|
|||||||
10
internal/dto/dup_source.go
Normal file
10
internal/dto/dup_source.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DupSource 编辑页反查的「重复来源」上下文:某张被标记为重复的图所指向的源图,
|
||||||
|
// 及其所属走秀/街拍(对外统称"文章")的基本信息,供编辑员核对重复归属。
|
||||||
|
type DupSource struct {
|
||||||
|
Image string // 源图存储值(S4 key / 相对路径 / 外链),由模板经 imgURL 拼成可访问 URL
|
||||||
|
RecordID uint32 // 源图所属走秀/街拍的数字主键
|
||||||
|
Title string // 源图所属记录的标题(英文优先,回落中文)
|
||||||
|
Brand string // 源图所属走秀的品牌名(街拍无品牌关联,为空)
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ type RunwayIngest struct {
|
|||||||
Season string `json:"season"` // spring / fall
|
Season string `json:"season"` // spring / fall
|
||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
City string `json:"city"` // 地区/城市(street 用)
|
City string `json:"city"` // 地区/城市(street 用)
|
||||||
|
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
|
||||||
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
||||||
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
||||||
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
||||||
|
|||||||
@ -501,3 +501,22 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
.row { flex-direction: column; gap: 0; }
|
.row { flex-direction: column; gap: 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; }
|
||||||
|
.cell .dup-src > summary { cursor: pointer; color: #6b21a8; user-select: none; outline: none; }
|
||||||
|
.cell .dup-src-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #faf5ff;
|
||||||
|
border: 1px solid #e9d5ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cell .dup-src-body img { width: 56px; height: 84px; object-fit: cover; border-radius: 4px; flex: 0 0 auto; background: #eee; }
|
||||||
|
.cell .dup-title { margin-bottom: 2px; color: #333; }
|
||||||
|
.cell .dup-link { color: #6b21a8; font-weight: 600; text-decoration: none; }
|
||||||
|
.cell .dup-link:hover { text-decoration: underline; }
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
|
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
|
||||||
// 两个入口页面:
|
// 两个入口页面:
|
||||||
// - 文章管理混合列表(/admin/articles):批量通过/驳回
|
// - 文章管理混合列表(/admin/articles):批量通过
|
||||||
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
|
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@ -25,7 +25,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 文章管理列表:批量通过 / 驳回 ──────────────────────────────────────
|
// ── 文章管理列表:批量通过 ──────────────────────────────────────
|
||||||
function initArticleList() {
|
function initArticleList() {
|
||||||
var selAll = document.getElementById('sel-all');
|
var selAll = document.getElementById('sel-all');
|
||||||
var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); };
|
var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); };
|
||||||
@ -58,7 +58,7 @@
|
|||||||
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
|
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
|
||||||
});
|
});
|
||||||
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
|
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
|
||||||
if (!confirm('确认' + (action === 'approve' ? '通过' : '驳回') + '选中的 ' + items.length + ' 条?')) return;
|
if (!confirm('确认通过选中的 ' + items.length + ' 条?')) return;
|
||||||
fetch('/admin/api/articles/batch', {
|
fetch('/admin/api/articles/batch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -71,9 +71,7 @@
|
|||||||
}).catch(function (e) { alert('请求失败:' + e); });
|
}).catch(function (e) { alert('请求失败:' + e); });
|
||||||
}
|
}
|
||||||
var approve = document.getElementById('batch-approve');
|
var approve = document.getElementById('batch-approve');
|
||||||
var reject = document.getElementById('batch-reject');
|
|
||||||
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
|
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
|
||||||
if (reject) reject.addEventListener('click', function () { postBatch('reject'); });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
|
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
||||||
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
||||||
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
||||||
// 同时表单式路由(/admin/articles/... 下的 approve/reject/attach/detach)原样保留:关掉 JS 后台依然可用。
|
// 同时表单式路由(/admin/articles/... 下的 approve/attach/detach)原样保留:关掉 JS 后台依然可用。
|
||||||
//
|
//
|
||||||
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
||||||
package handler
|
package handler
|
||||||
@ -26,13 +26,12 @@ type reviewBatchItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type reviewBatchReq struct {
|
type reviewBatchReq struct {
|
||||||
// Action: approve | reject
|
// Action: approve
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Reason string `json:"reason"`
|
|
||||||
Items []reviewBatchItem `json:"items"`
|
Items []reviewBatchItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArticleBatch 文章管理批量通过 / 驳回(替代原审核批量接口)。
|
// ArticleBatch 文章管理批量通过(替代原审核批量接口)。
|
||||||
//
|
//
|
||||||
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
||||||
// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17}
|
// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17}
|
||||||
@ -44,8 +43,8 @@ func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Action != "approve" && req.Action != "reject" {
|
if req.Action != "approve" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve 或 reject"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
@ -56,11 +55,6 @@ func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Action == "reject" && req.Reason == "" {
|
|
||||||
// 驳回理由必填:留痕是审核流程的意义所在。
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "驳回必须填写理由"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
registered := h.registeredKinds(ctx)
|
registered := h.registeredKinds(ctx)
|
||||||
@ -77,8 +71,6 @@ func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
|
|||||||
var err error
|
var err error
|
||||||
if req.Action == "approve" {
|
if req.Action == "approve" {
|
||||||
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
||||||
} else {
|
|
||||||
err = h.review.Reject(ctx, item.Kind, item.ID, reviewer, req.Reason)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
||||||
|
|||||||
@ -311,7 +311,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
|||||||
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
|
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
|
||||||
|
|
||||||
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
|
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
|
||||||
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 驳回 / 编辑 / 下架都在这里发起。
|
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 编辑 / 删除(下架)都在这里发起。
|
||||||
func (h *BackstageHandler) Articles(c *gin.Context) {
|
func (h *BackstageHandler) Articles(c *gin.Context) {
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
kind := c.Query("kind")
|
kind := c.Query("kind")
|
||||||
@ -354,7 +354,7 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 状态 tab 角标(空串=全部)。
|
// 状态 tab 角标(空串=全部)。
|
||||||
tabCounts := map[string]int64{}
|
tabCounts := map[string]int64{}
|
||||||
for _, s := range []string{"", "pending", "published", "rejected"} {
|
for _, s := range []string{"", "pending", "published"} {
|
||||||
n, e := h.adminRepo.CountMixed(c.Request.Context(), s, kind)
|
n, e := h.adminRepo.CountMixed(c.Request.Context(), s, kind)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
tabCounts[s] = n
|
tabCounts[s] = n
|
||||||
@ -424,23 +424,6 @@ func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
|
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArticleReject 驳回:记录状态与理由(理由用于审核留痕)。
|
|
||||||
func (h *BackstageHandler) ArticleReject(c *gin.Context) {
|
|
||||||
kind := c.Param("kind")
|
|
||||||
id := uint32(atoiDefault(c.Param("id"), 0))
|
|
||||||
if id == 0 {
|
|
||||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reason := c.PostForm("reason")
|
|
||||||
username, _ := c.Get(middleware.ContextUsername)
|
|
||||||
if err := h.review.Reject(c.Request.Context(), kind, id, usernameString(username), reason); err != nil {
|
|
||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
|
// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
|
||||||
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
|
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
|
||||||
kind := c.Param("kind")
|
kind := c.Param("kind")
|
||||||
@ -661,6 +644,8 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
"isDetail": 0,
|
"isDetail": 0,
|
||||||
"role": "主图",
|
"role": "主图",
|
||||||
"DetCount": len(detailsByParent[m.ID]),
|
"DetCount": len(detailsByParent[m.ID]),
|
||||||
|
"isDuplicate": int(m.IsDuplicate),
|
||||||
|
"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{
|
||||||
@ -670,6 +655,8 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
"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),
|
||||||
|
"dupOf": d.DupOf,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -687,6 +674,8 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
"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),
|
||||||
|
"dupOf": d.DupOf,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -694,6 +683,7 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
if n := len(imgs); n > 0 {
|
if n := len(imgs); n > 0 {
|
||||||
dupPct = float64(dupCount) / float64(n) * 100
|
dupPct = float64(dupCount) / float64(n) * 100
|
||||||
}
|
}
|
||||||
|
dupSources := h.buildDupSources(c.Request.Context(), "runway", cells)
|
||||||
renderPage(c, http.StatusOK, "runway-edit", gin.H{
|
renderPage(c, http.StatusOK, "runway-edit", gin.H{
|
||||||
"UID": raw,
|
"UID": raw,
|
||||||
"Kind": "runway",
|
"Kind": "runway",
|
||||||
@ -706,6 +696,7 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
|
|||||||
"IsDown": rw.IsDeleted == 1,
|
"IsDown": rw.IsDeleted == 1,
|
||||||
"DupCount": dupCount,
|
"DupCount": dupCount,
|
||||||
"DupPct": dupPct,
|
"DupPct": dupPct,
|
||||||
|
"DupSources": dupSources,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -838,6 +829,8 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
"isDetail": 0,
|
"isDetail": 0,
|
||||||
"role": "主图",
|
"role": "主图",
|
||||||
"DetCount": len(detailsByParent[m.ID]),
|
"DetCount": len(detailsByParent[m.ID]),
|
||||||
|
"isDuplicate": int(m.IsDuplicate),
|
||||||
|
"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{
|
||||||
@ -847,6 +840,8 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
"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),
|
||||||
|
"dupOf": d.DupOf,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -864,6 +859,8 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
"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),
|
||||||
|
"dupOf": d.DupOf,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -871,6 +868,7 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
if n := len(imgs); n > 0 {
|
if n := len(imgs); n > 0 {
|
||||||
dupPct = float64(dupCount) / float64(n) * 100
|
dupPct = float64(dupCount) / float64(n) * 100
|
||||||
}
|
}
|
||||||
|
dupSources := h.buildDupSources(c.Request.Context(), "street", cells)
|
||||||
renderPage(c, http.StatusOK, "snap-edit", gin.H{
|
renderPage(c, http.StatusOK, "snap-edit", gin.H{
|
||||||
"UID": raw,
|
"UID": raw,
|
||||||
"Kind": "street",
|
"Kind": "street",
|
||||||
@ -882,9 +880,66 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
|
|||||||
"IsDown": snap.IsDeleted == 1,
|
"IsDown": snap.IsDeleted == 1,
|
||||||
"DupCount": dupCount,
|
"DupCount": dupCount,
|
||||||
"DupPct": dupPct,
|
"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")
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
{{template "bar" .}}
|
{{template "bar" .}}
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<h1>文章管理</h1>
|
<h1>文章管理</h1>
|
||||||
<p class="muted">走秀与街拍合并管理:通过 / 驳回 / 编辑 / 下架都在此处发起。待审核优先排在前面。</p>
|
<p class="muted">走秀与街拍合并管理:通过 / 编辑 / 删除(下架,清空 S4)都在此处发起。待审核优先排在前面。</p>
|
||||||
<div class="kinds">
|
<div class="kinds">
|
||||||
<a href="/admin/articles?status={{.Status}}" class="{{if eq .Kind ""}}on{{end}}">全部类型</a>
|
<a href="/admin/articles?status={{.Status}}" class="{{if eq .Kind ""}}on{{end}}">全部类型</a>
|
||||||
<a href="/admin/articles?status={{.Status}}&kind=runway" class="{{if eq .Kind "runway"}}on{{end}}">走秀</a>
|
<a href="/admin/articles?status={{.Status}}&kind=runway" class="{{if eq .Kind "runway"}}on{{end}}">走秀</a>
|
||||||
@ -15,7 +15,6 @@
|
|||||||
<a href="/admin/articles?status=&kind={{.Kind}}" class="{{if eq .Status ""}}on{{end}}">全部{{if gt (index .TabCounts "") 0}}({{index .TabCounts ""}}){{end}}</a>
|
<a href="/admin/articles?status=&kind={{.Kind}}" class="{{if eq .Status ""}}on{{end}}">全部{{if gt (index .TabCounts "") 0}}({{index .TabCounts ""}}){{end}}</a>
|
||||||
<a href="/admin/articles?status=pending&kind={{.Kind}}" class="{{if eq .Status "pending"}}on{{end}}">待审核{{if gt (index .TabCounts "pending") 0}}({{index .TabCounts "pending"}}){{end}}</a>
|
<a href="/admin/articles?status=pending&kind={{.Kind}}" class="{{if eq .Status "pending"}}on{{end}}">待审核{{if gt (index .TabCounts "pending") 0}}({{index .TabCounts "pending"}}){{end}}</a>
|
||||||
<a href="/admin/articles?status=published&kind={{.Kind}}" class="{{if eq .Status "published"}}on{{end}}">已发布{{if gt (index .TabCounts "published") 0}}({{index .TabCounts "published"}}){{end}}</a>
|
<a href="/admin/articles?status=published&kind={{.Kind}}" class="{{if eq .Status "published"}}on{{end}}">已发布{{if gt (index .TabCounts "published") 0}}({{index .TabCounts "published"}}){{end}}</a>
|
||||||
<a href="/admin/articles?status=rejected&kind={{.Kind}}" class="{{if eq .Status "rejected"}}on{{end}}">已驳回{{if gt (index .TabCounts "rejected") 0}}({{index .TabCounts "rejected"}}){{end}}</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@ -38,8 +37,7 @@
|
|||||||
<div class="ops">
|
<div class="ops">
|
||||||
{{if eq .Kind "runway"}}<a class="view" href="/admin/runways/{{.UID}}/edit">编辑</a>{{else}}<a class="view" href="/admin/street-snaps/{{.UID}}/edit">编辑</a>{{end}}
|
{{if eq .Kind "runway"}}<a class="view" href="/admin/runways/{{.UID}}/edit">编辑</a>{{else}}<a class="view" href="/admin/street-snaps/{{.UID}}/edit">编辑</a>{{end}}
|
||||||
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/approve"><button type="submit" class="op op-primary">通过</button></form>{{end}}
|
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/approve"><button type="submit" class="op op-primary">通过</button></form>{{end}}
|
||||||
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/reject"><button type="submit" class="op op-danger">驳回</button></form>{{end}}
|
{{if eq .Kind "runway"}}<form class="inline" method="post" action="/admin/runways/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认删除(清空 S4)?')">删除</button></form>{{else}}<form class="inline" method="post" action="/admin/street-snaps/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认删除(清空 S4)?')">删除</button></form>{{end}}
|
||||||
{{if eq .Kind "runway"}}<form class="inline" method="post" action="/admin/runways/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认下架(对外不可见)?')">下架</button></form>{{else}}<form class="inline" method="post" action="/admin/street-snaps/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认下架(对外不可见)?')">下架</button></form>{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>{{end}}
|
</tr>{{end}}
|
||||||
@ -52,7 +50,6 @@
|
|||||||
<span class="muted" id="sel-count">已选 0 条</span>
|
<span class="muted" id="sel-count">已选 0 条</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button type="button" id="batch-approve">批量通过</button>
|
<button type="button" id="batch-approve">批量通过</button>
|
||||||
<button type="button" id="batch-reject" class="reject">批量驳回…</button>
|
|
||||||
<button type="button" id="batch-clear" class="ghost">清空选择</button>
|
<button type="button" id="batch-clear" class="ghost">清空选择</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
|
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
|
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
|
||||||
<a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 驳回 · 编辑 · 下架</div></a>
|
<a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 编辑 · 删除(下架)</div></a>
|
||||||
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
|
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
|
||||||
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
|
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,12 +18,11 @@
|
|||||||
{{if eq .Runway.Status "pending"}}
|
{{if eq .Runway.Status "pending"}}
|
||||||
<div class="review-actions">
|
<div class="review-actions">
|
||||||
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||||
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
||||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,12 +16,11 @@
|
|||||||
{{if eq .Snap.Status "pending"}}
|
{{if eq .Snap.Status "pending"}}
|
||||||
<div class="review-actions">
|
<div class="review-actions">
|
||||||
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||||
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
||||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -52,13 +52,16 @@
|
|||||||
{{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}}
|
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}{{$dupSources := .DupSources}}
|
||||||
<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}}" 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><img src="{{imgURL .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>
|
||||||
|
{{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>
|
||||||
|
|||||||
@ -22,12 +22,12 @@ type BrandRunway struct {
|
|||||||
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
||||||
Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null
|
Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null
|
||||||
SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25
|
SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25
|
||||||
|
PublishedAt uint32 `gorm:"column:published_at" json:"published_at"` // 发布时间 Unix 时间戳(秒);0=未抽取。Vogue datePublished;标题缺年份时用于矫正 year
|
||||||
|
|
||||||
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
|
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
|
||||||
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||||
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||||
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
|
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
|
||||||
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名。
|
// TableName 指定表名。
|
||||||
|
|||||||
@ -21,7 +21,6 @@ type StreetSnap struct {
|
|||||||
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||||
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
|
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
|
||||||
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
|
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
|
||||||
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
|
|
||||||
|
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@ 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"
|
||||||
)
|
)
|
||||||
@ -37,6 +39,13 @@ 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
|
||||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||||
@ -389,6 +398,128 @@ 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
|||||||
@ -13,11 +13,14 @@ 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"
|
||||||
)
|
)
|
||||||
@ -91,6 +94,85 @@ 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()
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
//go:build integration
|
//go:build integration
|
||||||
|
|
||||||
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
|
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。
|
||||||
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
|
//
|
||||||
|
// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」;
|
||||||
|
// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体,
|
||||||
|
// 从而达成「删除即永久」。本测试只验证索引这一层的行为。
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -35,14 +38,15 @@ func TestRunwayEntityKeyUnique(t *testing.T) {
|
|||||||
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
|
// 软删首行后,部分索引(WHERE is_deleted = 0)不再占用该键,DB 层仍允许再插入。
|
||||||
|
// 「删除即永久」由入库服务层(RunwayEntityState 命中 isDeleted 跳过重爬)保证,而非本索引。
|
||||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
||||||
t.Fatalf("软删失败: %v", err)
|
t.Fatalf("软删失败: %v", err)
|
||||||
}
|
}
|
||||||
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
|
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
|
||||||
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
|
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("软删后应可再插入同实体键: %v", err)
|
t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
|
||||||
@ -137,14 +141,14 @@ func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
|
|||||||
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
|
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
|
||||||
|
|
||||||
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
|
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
|
||||||
if _, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
|
if _, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
} else if found {
|
} else if found {
|
||||||
t.Fatalf("不同 title 不应命中既有行,实际命中")
|
t.Fatalf("不同 title 不应命中既有行,实际命中")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
|
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
|
||||||
hitID, status, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
|
hitID, status, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
}
|
}
|
||||||
@ -166,7 +170,7 @@ func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
|
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
|
||||||
|
|
||||||
nid, _, nfound, err := repo.StreetSnapEntityState(ctx, city, year, "")
|
nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,7 @@ func TestApproveKeepsImageRowIDs(t *testing.T) {
|
|||||||
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ func TestApproveKeepsSnapImageRowIDs(t *testing.T) {
|
|||||||
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,19 +35,30 @@ type IngestRepository interface {
|
|||||||
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
||||||
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
||||||
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
||||||
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
|
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,
|
||||||
// worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
|
// 返回其状态与是否已被软删(isDeleted)。worker 据此分四支:
|
||||||
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
|
// - 命中 pending/published → 直接放弃本次入库(ingestSkip)
|
||||||
|
// - 命中 rejected → 复用该行重审(ingestReuseRejected)
|
||||||
|
// - 命中但已软删 → 跳过重爬(删除即永久,不再因重爬复活)
|
||||||
|
// - 未命中 → 新建(ingestCreate)
|
||||||
|
// 注意:本查询不再以 is_deleted = 0 为过滤条件(旧版口径),否则软删行会被当成「未命中」
|
||||||
|
// 而重新入库,违背「删除即永久」。优先取活着行(ORDER BY is_deleted ASC, id DESC)。
|
||||||
|
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, isDeleted bool, err error)
|
||||||
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
|
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
|
||||||
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
|
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
|
||||||
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
||||||
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
||||||
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
||||||
|
// BackfillRunwayPublishedAt 临时回填:给已存在走秀补 published_at,并在标题缺年份时
|
||||||
|
// 用发布日期年份补全 year / season_code(不重下图片)。回填为一次性操作,按主键 id 更新。
|
||||||
|
// publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
|
||||||
|
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
|
||||||
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
||||||
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
||||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
|
||||||
|
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
|
||||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||||
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
|
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, isDeleted bool, err error)
|
||||||
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
|
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
|
||||||
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 街拍记录(事务内完成)。
|
||||||
@ -248,24 +259,29 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
|
|||||||
|
|
||||||
// ---- 入库直写正式表(单表发布模型)----
|
// ---- 入库直写正式表(单表发布模型)----
|
||||||
|
|
||||||
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
|
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行。
|
||||||
|
// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。
|
||||||
|
// 优先取活着行(is_deleted ASC),保证「活着行 + 软删行同键共存」的存量数据下也返回活行。
|
||||||
|
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
||||||
var row struct {
|
var row struct {
|
||||||
ID uint32 `gorm:"column:id"`
|
ID uint32 `gorm:"column:id"`
|
||||||
Status string `gorm:"column:status"`
|
Status string `gorm:"column:status"`
|
||||||
|
IsDeleted uint8 `gorm:"column:is_deleted"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Select("id, status").
|
Select("id, status, is_deleted").
|
||||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
|
Where("brand_id = ? AND season_code = ? AND collection_type = ?", brandID, seasonCode, collectionType).
|
||||||
|
Order("is_deleted ASC, id DESC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", false, err
|
return 0, "", false, false, err
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return 0, "", false, nil
|
return 0, "", false, false, nil
|
||||||
}
|
}
|
||||||
return row.ID, row.Status, true, nil
|
return row.ID, row.Status, true, row.IsDeleted == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
@ -372,32 +388,72 @@ func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runway
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
// BackfillRunwayPublishedAt 临时回填(一次性):按主键 id 给已存在走秀补 published_at;
|
||||||
|
// 并在标题缺年份(year=0)时,用发布日期年份补全 year / season_code。
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// - published_at 总是写入(按 id,幂等,不会触犯唯一索引)。
|
||||||
|
// - year / season_code 仅在「当前 year=0」且「目标 season_code 不与同品牌其它行冲突」时补全,
|
||||||
|
// 避免触犯 (brand_id, season_code, collection_type) 唯一索引;冲突时只补 published_at,留待人工处理。
|
||||||
|
// - 不重下图片。
|
||||||
|
// - publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
|
||||||
|
func (r *ingestRepository) BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", runwayID).
|
||||||
|
Updates(map[string]any{"published_at": publishedAt, "updated_at": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if year == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.BrandRunway{}).
|
||||||
|
Where("id = ? AND year = 0 AND is_deleted = 0 "+
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
|
||||||
|
"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)",
|
||||||
|
targetSeasonCode).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"year": year,
|
||||||
|
"season_code": targetSeasonCode,
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
|
||||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
||||||
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
|
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
|
||||||
// 进而插入一行注定被索引拒绝的记录。
|
// 进而插入一行注定被索引拒绝的记录。
|
||||||
//
|
//
|
||||||
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
|
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
|
||||||
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
|
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
|
||||||
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
|
//
|
||||||
|
// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。
|
||||||
|
// 与 RunwayEntityState 同源,优先取活着行(ORDER BY is_deleted ASC, id DESC)。
|
||||||
|
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, bool, error) {
|
||||||
title = NormalizeStreetTitle(title)
|
title = NormalizeStreetTitle(title)
|
||||||
var row struct {
|
var row struct {
|
||||||
ID uint32 `gorm:"column:id"`
|
ID uint32 `gorm:"column:id"`
|
||||||
Status string `gorm:"column:status"`
|
Status string `gorm:"column:status"`
|
||||||
|
IsDeleted uint8 `gorm:"column:is_deleted"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
Select("id, status").
|
Select("id, status, is_deleted").
|
||||||
Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
|
Where("city = ? AND year = ? AND COALESCE(title, '') = ?", city, year, title).
|
||||||
|
Order("is_deleted ASC, id DESC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", false, err
|
return 0, "", false, false, err
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return 0, "", false, nil
|
return 0, "", false, false, nil
|
||||||
}
|
}
|
||||||
return row.ID, row.Status, true, nil
|
return row.ID, row.Status, true, row.IsDeleted == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
|
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
|
||||||
|
|||||||
@ -33,7 +33,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
const season = "SS97"
|
const season = "SS97"
|
||||||
_, _, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
_, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunwayEntityState 出错: %v", err)
|
t.Fatalf("RunwayEntityState 出错: %v", err)
|
||||||
}
|
}
|
||||||
@ -58,7 +58,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
|
|||||||
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
|
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
gotID, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
gotID, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunwayEntityState 出错: %v", err)
|
t.Fatalf("RunwayEntityState 出错: %v", err)
|
||||||
}
|
}
|
||||||
@ -90,11 +90,11 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
|
|||||||
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
|
||||||
})
|
})
|
||||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
|
||||||
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin", "reject_reason": "图片缺失"}).Error; err != nil {
|
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin"}).Error; err != nil {
|
||||||
t.Fatalf("置为已驳回失败: %v", err)
|
t.Fatalf("置为已驳回失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
_, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
if err != nil || !found || status != model.StatusRejected {
|
if err != nil || !found || status != model.StatusRejected {
|
||||||
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
|
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
|
||||||
}
|
}
|
||||||
@ -115,8 +115,8 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
|
|||||||
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
|
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
|
||||||
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
|
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
|
||||||
}
|
}
|
||||||
if rw.Reviewer != "" || rw.RejectReason != "" {
|
if rw.Reviewer != "" {
|
||||||
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason)
|
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
var alive []model.BrandRunwayImage
|
var alive []model.BrandRunwayImage
|
||||||
|
|||||||
@ -24,8 +24,8 @@ type ReviewRepository interface {
|
|||||||
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
||||||
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
||||||
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。
|
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
|
||||||
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
|
||||||
|
|
||||||
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
||||||
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
||||||
@ -33,7 +33,7 @@ type ReviewRepository interface {
|
|||||||
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
||||||
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
|
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
|
||||||
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
|
||||||
|
|
||||||
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
|
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
|
||||||
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
||||||
@ -166,14 +166,13 @@ func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields m
|
|||||||
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
||||||
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
||||||
// 而不是悄悄在这个仓储方法里加限制。
|
// 而不是悄悄在这个仓储方法里加限制。
|
||||||
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
|
||||||
res := r.db.WithContext(ctx).
|
res := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"reviewer": reviewer,
|
"reviewer": reviewer,
|
||||||
"reject_reason": reason,
|
|
||||||
"updated_at": uint32(time.Now().Unix()),
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
})
|
})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
@ -272,14 +271,13 @@ func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fi
|
|||||||
|
|
||||||
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
||||||
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
||||||
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
|
||||||
res := r.db.WithContext(ctx).
|
res := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"reviewer": reviewer,
|
"reviewer": reviewer,
|
||||||
"reject_reason": reason,
|
|
||||||
"updated_at": uint32(time.Now().Unix()),
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
})
|
})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
|
|||||||
@ -36,14 +36,14 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
|||||||
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 1 {
|
if got := publicCount(); got != 1 {
|
||||||
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin", "图片不对"); err != nil {
|
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
|
||||||
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
|
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 0 {
|
if got := publicCount(); got != 0 {
|
||||||
@ -55,12 +55,12 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
|||||||
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||||
t.Fatalf("读回记录失败: %v", err)
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
}
|
}
|
||||||
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" || rw.RejectReason != "图片不对" {
|
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" {
|
||||||
t.Fatalf("审核态与痕迹应落库,实际 status=%s reviewer=%s reason=%s", rw.Status, rw.Reviewer, rw.RejectReason)
|
t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
|
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
|
||||||
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
|
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,21 +83,21 @@ func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
|||||||
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 1 {
|
if got := publicCount(); got != 1 {
|
||||||
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin", "重复"); err != nil {
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
|
||||||
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
|
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 0 {
|
if got := publicCount(); got != 0 {
|
||||||
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
|
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@ 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,6 +41,13 @@ 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
|
||||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||||
// SetSnapCover 更新街拍封面。
|
// SetSnapCover 更新街拍封面。
|
||||||
@ -333,6 +342,117 @@ 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
|||||||
@ -38,7 +38,7 @@ func TestStreetTitleNormalizationUnifiesEntity(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。
|
// 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。
|
||||||
hid, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
|
hid, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -89,11 +89,10 @@ 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)
|
||||||
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/驳回/下架的统一入口。
|
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。
|
||||||
admin.GET("/articles", opt.Backstage.Articles)
|
admin.GET("/articles", opt.Backstage.Articles)
|
||||||
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)
|
||||||
admin.POST("/articles/:kind/:id/reject", opt.Backstage.ArticleReject)
|
|
||||||
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
|
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
|
||||||
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
|
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
|
||||||
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)
|
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)
|
||||||
|
|||||||
@ -147,6 +147,9 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
type fakeStreet struct{}
|
type fakeStreet struct{}
|
||||||
|
|
||||||
@ -204,6 +207,9 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
||||||
type fakeAdminRepo struct{}
|
type fakeAdminRepo struct{}
|
||||||
@ -254,6 +260,9 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
||||||
|
|
||||||
@ -393,7 +402,6 @@ type fakeReview struct {
|
|||||||
service.ReviewService
|
service.ReviewService
|
||||||
attachOK bool
|
attachOK bool
|
||||||
approveErr error
|
approveErr error
|
||||||
rejectErr error
|
|
||||||
last struct {
|
last struct {
|
||||||
kind string
|
kind string
|
||||||
recordID uint32
|
recordID uint32
|
||||||
@ -407,7 +415,6 @@ type fakeReview struct {
|
|||||||
Action string
|
Action string
|
||||||
Kind string
|
Kind string
|
||||||
ID uint32
|
ID uint32
|
||||||
Reason string
|
|
||||||
}
|
}
|
||||||
view *service.RecordDetailView
|
view *service.RecordDetailView
|
||||||
}
|
}
|
||||||
@ -456,8 +463,7 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
|
|||||||
Action string
|
Action string
|
||||||
Kind string
|
Kind string
|
||||||
ID uint32
|
ID uint32
|
||||||
Reason string
|
}{"approve", kind, id})
|
||||||
}{"approve", kind, id, ""})
|
|
||||||
// last 供「表单式通过」用例断言(批量接口用 calls)。
|
// last 供「表单式通过」用例断言(批量接口用 calls)。
|
||||||
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
|
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
|
||||||
if f.approveErr != nil {
|
if f.approveErr != nil {
|
||||||
@ -466,16 +472,6 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReview) Reject(_ context.Context, kind string, id uint32, reviewer, reason string) error {
|
|
||||||
f.calls = append(f.calls, struct {
|
|
||||||
Action string
|
|
||||||
Kind string
|
|
||||||
ID uint32
|
|
||||||
Reason string
|
|
||||||
}{"reject", kind, id, reason})
|
|
||||||
return f.rejectErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := jwt.NewManager("test-secret", 168)
|
m := jwt.NewManager("test-secret", 168)
|
||||||
@ -697,26 +693,6 @@ func TestBackstageReviewBatchApprove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBackstageReviewBatchRejectNeedsReason 驳回必须带理由(留痕要求),否则 400 且不调用 service。
|
|
||||||
func TestBackstageReviewBatchRejectNeedsReason(t *testing.T) {
|
|
||||||
r, rev := newReviewRouter(t)
|
|
||||||
tok := reviewToken(t, r)
|
|
||||||
|
|
||||||
body := `{"action":"reject","items":[{"kind":"street","id":11}]}`
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.AddCookie(tok)
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Fatalf("缺理由应 400: %d %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if len(rev.calls) != 0 {
|
|
||||||
t.Fatalf("缺理由不应调用 service: %+v", rev.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
|
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
|
||||||
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
||||||
r, rev := newReviewRouter(t)
|
r, rev := newReviewRouter(t)
|
||||||
|
|||||||
@ -30,6 +30,8 @@ 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 软删单张走秀图片。
|
||||||
@ -237,6 +239,11 @@ 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 {
|
||||||
@ -285,6 +292,12 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,10 +19,14 @@ type captureIngestRepo struct {
|
|||||||
repository.IngestRepository
|
repository.IngestRepository
|
||||||
createdRW *model.BrandRunway
|
createdRW *model.BrandRunway
|
||||||
createdImages []model.BrandRunwayImage
|
createdImages []model.BrandRunwayImage
|
||||||
|
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
|
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
||||||
return 0, "", false, nil // 实体键未命中 → 新建分支
|
if c.deletedEntity {
|
||||||
|
return 1, model.StatusPending, true, true, nil // 命中但已软删
|
||||||
|
}
|
||||||
|
return 0, "", false, false, nil // 实体键未命中 → 新建分支
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
@ -120,3 +124,45 @@ func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) {
|
|||||||
repo.createdRW.ImageCount, len(p.Looks))
|
repo.createdRW.ImageCount, len(p.Looks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」:
|
||||||
|
// 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。
|
||||||
|
func TestProcessRunwaySkipsSoftDeletedEntity(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
local := storage.NewLocalUploader(dir, "/uploads")
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。
|
||||||
|
repo := &captureIngestRepo{deletedEntity: true}
|
||||||
|
s := &IngestService{
|
||||||
|
repo: repo,
|
||||||
|
brandRepo: fakeBrandRepo{},
|
||||||
|
uploader: local,
|
||||||
|
local: local,
|
||||||
|
httpClient: srv.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
p := dto.RunwayIngest{
|
||||||
|
Kind: dto.IngestKindRunway,
|
||||||
|
BrandUID: hashid.Encode(1),
|
||||||
|
TitleEn: "deleted-entity-test",
|
||||||
|
Year: 2027,
|
||||||
|
Season: "spring",
|
||||||
|
CollectionType: "rtw",
|
||||||
|
Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}},
|
||||||
|
}
|
||||||
|
job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl}
|
||||||
|
s.processRunway(context.Background(), job, p)
|
||||||
|
|
||||||
|
if repo.createdRW != nil {
|
||||||
|
t.Fatal("已软删的实体不应被重新写入正式表")
|
||||||
|
}
|
||||||
|
if len(repo.createdImages) != 0 {
|
||||||
|
t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
@ -44,7 +45,28 @@ type IngestService struct {
|
|||||||
// 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) *IngestService {
|
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string) *IngestService {
|
||||||
|
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
||||||
|
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
||||||
|
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
||||||
|
dlTransport := &http.Transport{
|
||||||
|
MaxIdleConns: imageFetchConcurrency * 4,
|
||||||
|
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
if downloadProxy != "" {
|
||||||
|
if pu, perr := url.Parse(downloadProxy); perr == nil {
|
||||||
|
dlTransport.Proxy = http.ProxyURL(pu)
|
||||||
|
log.Printf("[ingest] 图片下载走代理: %s", downloadProxy)
|
||||||
|
} else {
|
||||||
|
log.Printf("[ingest] download_proxy 非法,已忽略并回退 ProxyFromEnvironment: %q", downloadProxy)
|
||||||
|
dlTransport.Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dlTransport.Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
return &IngestService{
|
return &IngestService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
brandRepo: brandRepo,
|
brandRepo: brandRepo,
|
||||||
@ -58,13 +80,7 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
|||||||
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
|
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
|
||||||
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
|
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: dlTransport,
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
MaxIdleConns: imageFetchConcurrency * 4,
|
|
||||||
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -243,8 +259,14 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) 实体键查重并决定分支(单表模型)
|
// 2) 实体键查重并决定分支(单表模型)
|
||||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
|
||||||
reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
// 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。
|
||||||
|
effYear := p.Year
|
||||||
|
if effYear == 0 && p.PublishedAt != 0 {
|
||||||
|
effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year())
|
||||||
|
}
|
||||||
|
seasonCode := season.Derive(effYear, p.CollectionType, p.Season)
|
||||||
|
reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
||||||
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
||||||
@ -253,9 +275,38 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exist && p.Year == 0 {
|
||||||
|
// 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次,
|
||||||
|
// 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。
|
||||||
|
var rid uint32
|
||||||
|
rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType)
|
||||||
|
if err != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reuseID = rid
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
if action == ingestSkip {
|
if action == ingestSkip {
|
||||||
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过", job.ID, existStatus)
|
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus)
|
||||||
|
// 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。
|
||||||
|
if p.PublishedAt != 0 {
|
||||||
|
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -311,10 +362,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
TitleCn: p.TitleCn,
|
TitleCn: p.TitleCn,
|
||||||
DescriptionEn: p.DescriptionEn,
|
DescriptionEn: p.DescriptionEn,
|
||||||
DescriptionCn: p.DescriptionCn,
|
DescriptionCn: p.DescriptionCn,
|
||||||
Year: p.Year,
|
Year: effYear,
|
||||||
Season: p.Season,
|
Season: p.Season,
|
||||||
CollectionType: p.CollectionType,
|
CollectionType: p.CollectionType,
|
||||||
SeasonCode: seasonCode,
|
SeasonCode: seasonCode,
|
||||||
|
PublishedAt: p.PublishedAt,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
ImageCount: imageCount,
|
ImageCount: imageCount,
|
||||||
Status: model.StatusPending,
|
Status: model.StatusPending,
|
||||||
@ -445,12 +497,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
||||||
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
|
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
|
||||||
title := repository.NormalizeStreetTitle(p.TitleEn)
|
title := repository.NormalizeStreetTitle(p.TitleEn)
|
||||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
reuseID, existStatus, exist, isDeleted, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
if action == ingestSkip {
|
if action == ingestSkip {
|
||||||
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||||
|
|||||||
@ -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)
|
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "")
|
||||||
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)
|
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "")
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,7 @@ type KindTab struct {
|
|||||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
||||||
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
||||||
//
|
//
|
||||||
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||||||
type ReviewModule interface {
|
type ReviewModule interface {
|
||||||
Kind() string
|
Kind() string
|
||||||
Label() string
|
Label() string
|
||||||
@ -110,18 +110,20 @@ type ReviewModule interface {
|
|||||||
// Approve 把正式记录置为 published;返回记录主键。
|
// Approve 把正式记录置为 published;返回记录主键。
|
||||||
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
||||||
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
||||||
Reject(ctx context.Context, id uint32, reviewer, reason string) error
|
|
||||||
CountPending(ctx context.Context) (int64, error)
|
CountPending(ctx context.Context) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
|
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
|
||||||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||||||
|
//
|
||||||
|
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
|
||||||
|
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
|
||||||
|
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
|
||||||
type ReviewService interface {
|
type ReviewService interface {
|
||||||
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
||||||
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
||||||
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||||||
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
|
||||||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||||
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||||
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||||
@ -234,14 +236,6 @@ func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, rev
|
|||||||
return m.Approve(ctx, id, reviewer)
|
return m.Approve(ctx, id, reviewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error {
|
|
||||||
m, ok := s.module(kind)
|
|
||||||
if !ok {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
return m.Reject(ctx, id, reviewer, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||||
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
||||||
var all int64
|
var all int64
|
||||||
@ -433,16 +427,12 @@ func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||||
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
|
||||||
return m.repo.SetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
||||||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
||||||
}
|
}
|
||||||
@ -566,16 +556,12 @@ func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||||
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
|
||||||
return m.repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
||||||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,8 @@ 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 软删单张街拍图片。
|
||||||
@ -334,6 +336,11 @@ 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 {
|
||||||
@ -386,6 +393,12 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2543
server_run.err
Normal file
2543
server_run.err
Normal file
File diff suppressed because it is too large
Load Diff
2480
server_run.out
Normal file
2480
server_run.out
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user