This commit is contained in:
toom1996
2026-09-28 10:52:50 +08:00
parent aaeab41d67
commit d15d2a4701
42 changed files with 19884 additions and 337 deletions

View File

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

363
cmd/diagdup/main.go Normal file
View 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))
}
}
}
}
}

View File

@ -143,7 +143,7 @@ func main() {
}
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy)
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)

View File

@ -85,6 +85,11 @@ client_sign:
ingest:
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
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 兼容,爬虫入库图片上传目标)
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)

View File

@ -4,8 +4,11 @@
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
--
-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致,
-- 软删的旧实体键不再占用,允许同实体重新入库。
-- 部分索引(WHERE is_deleted = 0):仅约束活着行,保证「同一实体至多一行活着」。
-- 注:入库服务层 RunwayEntityState / StreetSnapEntityState 现已**不再**以 is_deleted = 0 过滤,
-- 软删实体也会被查到并由 worker 跳过重爬(「删除即永久」)。本索引仍保持部分索引是为了
-- 兼容存量「软删行同键共存」的数据、避免建全量唯一索引时因历史重复而失败;结构性唯一性靠它兜底,
-- 行为上的「删除即永久」由服务层查重保证。
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
DO $$

View 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;

View 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

File diff suppressed because one or more lines are too long

View File

@ -119,6 +119,10 @@ type IngestConfig struct {
Secret string `yaml:"secret"`
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
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 兼容,爬虫入库图片上传目标)。
@ -212,8 +216,9 @@ func defaultConfig() *Config {
TTLSeconds: 30,
},
Ingest: IngestConfig{
Secret: "",
TTLSeconds: 300,
Secret: "",
TTLSeconds: 300,
DownloadProxy: "",
},
S4: S4Config{
Enabled: false,
@ -268,11 +273,33 @@ func Load(explicit string) (*Config, error) {
}
cfg.loadedFrom = loadedFrom
// 本机私有覆盖(config.local.yml,已被 .gitignore 忽略):叠加在 config.yml 之上,
// 用于放本机相关、不应进版本库的设置(如本地代理 download_proxy)。环境变量仍拥有最高优先级。
overlayLocalConfig(cfg)
cfg.applyEnv()
cfg.normalize()
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 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。
func (c *Config) LoadedFrom() string { return c.loadedFrom }
@ -310,6 +337,7 @@ func (c *Config) applyEnv() {
envStr("INGEST_SECRET", &c.Ingest.Secret)
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
envStr("INGEST_DOWNLOAD_PROXY", &c.Ingest.DownloadProxy)
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。

View File

@ -0,0 +1,10 @@
package dto
// DupSource 编辑页反查的「重复来源」上下文:某张被标记为重复的图所指向的源图,
// 及其所属走秀/街拍(对外统称"文章")的基本信息,供编辑员核对重复归属。
type DupSource struct {
Image string // 源图存储值(S4 key / 相对路径 / 外链),由模板经 imgURL 拼成可访问 URL
RecordID uint32 // 源图所属走秀/街拍的数字主键
Title string // 源图所属记录的标题(英文优先,回落中文)
Brand string // 源图所属走秀的品牌名(街拍无品牌关联,为空)
}

View File

@ -28,6 +28,7 @@ type RunwayIngest struct {
Season string `json:"season"` // spring / fall
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
City string `json:"city"` // 地区/城市(street 用)
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。

View File

@ -501,3 +501,22 @@ tbody tr:last-child td { border-bottom: 0; }
.row { flex-direction: column; gap: 0; }
.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; }

View File

@ -1,6 +1,6 @@
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
// 两个入口页面:
// - 文章管理混合列表(/admin/articles):批量通过/驳回
// - 文章管理混合列表(/admin/articles):批量通过
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
(function () {
@ -25,7 +25,7 @@
});
}
// ── 文章管理列表:批量通过 / 驳回 ──────────────────────────────────────
// ── 文章管理列表:批量通过 ──────────────────────────────────────
function initArticleList() {
var selAll = document.getElementById('sel-all');
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) };
});
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
if (!confirm('确认' + (action === 'approve' ? '通过' : '驳回') + '选中的 ' + items.length + ' 条?')) return;
if (!confirm('确认通过选中的 ' + items.length + ' 条?')) return;
fetch('/admin/api/articles/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -71,9 +71,7 @@
}).catch(function (e) { alert('请求失败:' + e); });
}
var approve = document.getElementById('batch-approve');
var reject = document.getElementById('batch-reject');
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
if (reject) reject.addEventListener('click', function () { postBatch('reject'); });
}
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────

View File

@ -3,7 +3,7 @@
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
// 同时表单式路由(/admin/articles/... 下的 approve/reject/attach/detach)原样保留:关掉 JS 后台依然可用。
// 同时表单式路由(/admin/articles/... 下的 approve/attach/detach)原样保留:关掉 JS 后台依然可用。
//
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
package handler
@ -26,13 +26,12 @@ type reviewBatchItem struct {
}
type reviewBatchReq struct {
// Action: approve | reject
// Action: approve
Action string `json:"action"`
Reason string `json:"reason"`
Items []reviewBatchItem `json:"items"`
}
// ArticleBatch 文章管理批量通过 / 驳回(替代原审核批量接口)。
// ArticleBatch 文章管理批量通过(替代原审核批量接口)。
//
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
// 响应: {"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": "请求格式错误"})
return
}
if req.Action != "approve" && req.Action != "reject" {
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve 或 reject"})
if req.Action != "approve" {
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve"})
return
}
if len(req.Items) == 0 {
@ -56,11 +55,6 @@ func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
return
}
if req.Action == "reject" && req.Reason == "" {
// 驳回理由必填:留痕是审核流程的意义所在。
c.JSON(http.StatusBadRequest, gin.H{"error": "驳回必须填写理由"})
return
}
ctx := c.Request.Context()
registered := h.registeredKinds(ctx)
@ -77,8 +71,6 @@ func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
var err error
if req.Action == "approve" {
_, 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 {
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})

View File

@ -311,7 +311,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 驳回 / 编辑 / 下架都在这里发起。
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 编辑 / 删除(下架)都在这里发起。
func (h *BackstageHandler) Articles(c *gin.Context) {
status := c.Query("status")
kind := c.Query("kind")
@ -354,7 +354,7 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
}
// 状态 tab 角标(空串=全部)。
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)
if e == nil {
tabCounts[s] = n
@ -424,23 +424,6 @@ func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
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)。
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
kind := c.Param("kind")
@ -655,21 +638,25 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
for _, m := range mains {
mainIDs[m.ID] = true
cells = append(cells, gin.H{
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"isDuplicate": int(m.IsDuplicate),
"dupOf": m.DupOf,
})
for _, d := range detailsByParent[m.ID] {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
@ -680,13 +667,15 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
}
for _, d := range ds {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("细节图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
@ -694,18 +683,20 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
dupSources := h.buildDupSources(c.Request.Context(), "runway", cells)
renderPage(c, http.StatusOK, "runway-edit", gin.H{
"UID": raw,
"Kind": "runway",
"RecordID": rw.ID,
"Status": rw.Status,
"Runway": rw,
"Images": imgs,
"Cells": cells,
"BrandName": brandName,
"IsDown": rw.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
"UID": raw,
"Kind": "runway",
"RecordID": rw.ID,
"Status": rw.Status,
"Runway": rw,
"Images": imgs,
"Cells": cells,
"BrandName": brandName,
"IsDown": rw.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
"DupSources": dupSources,
})
}
@ -832,21 +823,25 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
for _, m := range mains {
mainIDs[m.ID] = true
cells = append(cells, gin.H{
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"id": m.ID,
"image": m.Image,
"name": m.Name,
"isDetail": 0,
"role": "主图",
"DetCount": len(detailsByParent[m.ID]),
"isDuplicate": int(m.IsDuplicate),
"dupOf": m.DupOf,
})
for _, d := range detailsByParent[m.ID] {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
@ -857,13 +852,15 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
}
for _, d := range ds {
cells = append(cells, gin.H{
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"id": d.ID,
"image": d.Image,
"name": d.Name,
"isDetail": 1,
"role": fmt.Sprintf("副图 → #%d", d.ParentImageID),
"parent": d.ParentImageID,
"orphan": true,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
@ -871,20 +868,78 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
dupSources := h.buildDupSources(c.Request.Context(), "street", cells)
renderPage(c, http.StatusOK, "snap-edit", gin.H{
"UID": raw,
"Kind": "street",
"RecordID": snap.ID,
"Status": snap.Status,
"Snap": snap,
"Images": imgs,
"Cells": cells,
"IsDown": snap.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
"UID": raw,
"Kind": "street",
"RecordID": snap.ID,
"Status": snap.Status,
"Snap": snap,
"Images": imgs,
"Cells": cells,
"IsDown": snap.IsDeleted == 1,
"DupCount": dupCount,
"DupPct": dupPct,
"DupSources": dupSources,
})
}
// buildDupSources 反查重复源上下文,返回以源图 id(dup_of 字符串)为键的模板数据。
// 仅对有重复标记(isDuplicate==1 且 dup_of 非空)的图做批量反查,避免逐图 N+1。
func (h *BackstageHandler) buildDupSources(ctx context.Context, kind string, cells []gin.H) map[string]gin.H {
out := map[string]gin.H{}
seen := map[uint32]bool{}
ids := make([]uint32, 0)
for _, c := range cells {
if v, ok := c["isDuplicate"].(int); !ok || v != 1 {
continue
}
s, ok := c["dupOf"].(string)
if !ok || s == "" {
continue
}
id, err := strconv.ParseUint(s, 10, 32)
if err != nil {
continue
}
u := uint32(id)
if seen[u] {
continue
}
seen[u] = true
ids = append(ids, u)
}
if len(ids) == 0 {
return out
}
var (
srcs map[uint32]dto.DupSource
err error
)
if kind == "street" {
srcs, err = h.street.GetStreetSnapDupSources(ctx, ids)
} else {
srcs, err = h.article.GetRunwayDupSources(ctx, ids)
}
if err != nil {
return out
}
typ := byte(hashid.TypeRunway)
if kind == "street" {
typ = byte(hashid.TypeSnap)
}
for id, s := range srcs {
out[strconv.FormatUint(uint64(id), 10)] = gin.H{
"srcImage": s.Image,
"srcTitle": s.Title,
"srcBrand": s.Brand,
"srcUID": hashid.EncodeWithType(s.RecordID, typ),
"srcKind": kind,
}
}
return out
}
// StreetSnapUpdate 应用街拍字段编辑。
func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
raw := c.Param("id")

View File

@ -5,7 +5,7 @@
{{template "bar" .}}
<div class="main">
<h1>文章管理</h1>
<p class="muted">走秀与街拍合并管理:通过 / 驳回 / 编辑 / 下架都在此处发起。待审核优先排在前面。</p>
<p class="muted">走秀与街拍合并管理:通过 / 编辑 / 删除(下架,清空 S4)都在此处发起。待审核优先排在前面。</p>
<div class="kinds">
<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>
@ -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=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=rejected&kind={{.Kind}}" class="{{if eq .Status "rejected"}}on{{end}}">已驳回{{if gt (index .TabCounts "rejected") 0}}({{index .TabCounts "rejected"}}){{end}}</a>
</div>
<div class="table-wrap">
@ -38,8 +37,7 @@
<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 .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('确认下架(对外不可见)?')">下架</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}}
{{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}}
</div>
</td>
</tr>{{end}}
@ -52,7 +50,6 @@
<span class="muted" id="sel-count">已选 0 条</span>
<span class="spacer"></span>
<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>
</div>
</div>

View File

@ -8,7 +8,7 @@
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
<div class="cards">
<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/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
</div>

View File

@ -18,12 +18,11 @@
{{if eq .Runway.Status "pending"}}
<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}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
</div>
{{end}}
<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>
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
</div>

View File

@ -16,12 +16,11 @@
{{if eq .Snap.Status "pending"}}
<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}}/reject"><input name="reason" placeholder="驳回理由(可选)"><button type="submit" class="op op-danger">驳回</button></form>
</div>
{{end}}
<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>
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
</div>

View File

@ -52,13 +52,16 @@
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</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>
<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}}>
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
<span class="role">{{.role}}</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">
{{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>

View File

@ -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
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
PublishedAt uint32 `gorm:"column:published_at" json:"published_at"` // 发布时间 Unix 时间戳(秒);0=未抽取。Vogue datePublished;标题缺年份时用于矫正 year
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
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(重跑溯源)
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 指定表名。

View File

@ -21,7 +21,6 @@ type StreetSnap struct {
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"`
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"`
}

View File

@ -3,11 +3,13 @@ package repository
import (
"context"
"errors"
"math"
"strconv"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -37,6 +39,13 @@ type ArticleRepository interface {
DeleteRunwayImage(ctx context.Context, imageID uint32) error
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
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(ctx context.Context, runwayID uint32) ([]string, error)
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
@ -389,6 +398,128 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
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 软删某走秀的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {

View File

@ -13,11 +13,14 @@ package repository
import (
"context"
"database/sql"
"fmt"
"strconv"
"testing"
"time"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -91,6 +94,85 @@ func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string,
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 由测试常量指定,无注入面)。
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
t.Helper()

View File

@ -1,7 +1,10 @@
//go:build integration
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。
//
// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」;
// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体,
// 从而达成「删除即永久」。本测试只验证索引这一层的行为。
package repository
import (
@ -35,14 +38,15 @@ func TestRunwayEntityKeyUnique(t *testing.T) {
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 {
t.Fatalf("软删失败: %v", err)
}
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
if err != nil {
t.Fatalf("软删后应可再插入同实体键: %v", err)
t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err)
}
t.Cleanup(func() {
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) })
// ① 同城同年**不同 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)
} else if found {
t.Fatalf("不同 title 不应命中既有行,实际命中")
}
// ② 同城同年**同 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 {
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) })
nid, _, nfound, err := repo.StreetSnapEntityState(ctx, city, year, "")
nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "")
if err != nil {
t.Fatalf("查重出错: %v", err)
}

View File

@ -72,7 +72,7 @@ func TestApproveKeepsImageRowIDs(t *testing.T) {
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)
}
@ -122,7 +122,7 @@ func TestApproveKeepsSnapImageRowIDs(t *testing.T) {
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)
}

View File

@ -35,19 +35,30 @@ type IngestRepository interface {
MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
// worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,
// 返回其状态与是否已被软删(isDeleted)。worker 据此分四支:
// - 命中 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(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
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(ctx context.Context, runwayID uint32) error
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
// 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(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
// 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 {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
IsDeleted uint8 `gorm:"column:is_deleted"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id, status").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
Select("id, status, is_deleted").
Where("brand_id = ? AND season_code = ? AND collection_type = ?", brandID, seasonCode, collectionType).
Order("is_deleted ASC, id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, "", false, err
return 0, "", false, false, err
}
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) {
@ -372,32 +388,72 @@ func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runway
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,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
// 进而插入一行注定被索引拒绝的记录。
//
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §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)
var row struct {
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
IsDeleted uint8 `gorm:"column:is_deleted"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id, status").
Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
Select("id, status, is_deleted").
Where("city = ? AND year = ? AND COALESCE(title, '') = ?", city, year, title).
Order("is_deleted ASC, id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, "", false, err
return 0, "", false, false, err
}
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 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)

View File

@ -33,7 +33,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
ctx := context.Background()
const season = "SS97"
_, _, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
_, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
@ -58,7 +58,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
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 {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
@ -90,11 +90,11 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
db.Exec("DELETE FROM brand_runways 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)
}
_, 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 {
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" {
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
}
if rw.Reviewer != "" || rw.RejectReason != "" {
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason)
if rw.Reviewer != "" {
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer)
}
var alive []model.BrandRunwayImage

View File

@ -24,8 +24,8 @@ type ReviewRepository interface {
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
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)
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
// 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(ctx context.Context, recordID, imgID uint32) error
@ -166,15 +166,14 @@ func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields m
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 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).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error
@ -272,15 +271,14 @@ func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fi
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 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).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error

View File

@ -36,14 +36,14 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
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)
}
if got := publicCount(); got != 1 {
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)
}
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 {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" || rw.RejectReason != "图片不对" {
t.Fatalf("审核态与痕迹应落库,实际 status=%s reviewer=%s reason=%s", rw.Status, rw.Reviewer, rw.RejectReason)
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" {
t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer)
}
// 不存在的 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)
}
}
@ -83,21 +83,21 @@ func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
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)
}
if got := publicCount(); got != 1 {
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)
}
if got := publicCount(); got != 0 {
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)
}
}

View File

@ -3,11 +3,13 @@ package repository
import (
"context"
"errors"
"math"
"strconv"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -39,6 +41,13 @@ type StreetSnapRepository interface {
DeleteSnapImage(ctx context.Context, imageID uint32) error
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
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(ctx context.Context, snapID uint32) ([]string, error)
// SetSnapCover 更新街拍封面。
@ -333,6 +342,117 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
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 软删某街拍的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {

View File

@ -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 {
t.Fatalf("查重出错: %v", err)
}

View File

@ -89,11 +89,10 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/brands", opt.Backstage.BrandCreate)
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/驳回/下架的统一入口。
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。
admin.GET("/articles", opt.Backstage.Articles)
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
admin.POST("/articles/:kind/:id/reject", opt.Backstage.ArticleReject)
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)

View File

@ -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 {
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{}
@ -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 {
return nil
}
func (f *fakeStreet) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
return map[uint32]dto.DupSource{}, nil
}
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
type fakeAdminRepo struct{}
@ -254,6 +260,9 @@ func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int6
}
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
attachOK bool
approveErr error
rejectErr error
last struct {
kind string
recordID uint32
@ -407,7 +415,6 @@ type fakeReview struct {
Action string
Kind string
ID uint32
Reason string
}
view *service.RecordDetailView
}
@ -456,8 +463,7 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
Action string
Kind string
ID uint32
Reason string
}{"approve", kind, id, ""})
}{"approve", kind, id})
// last 供「表单式通过」用例断言(批量接口用 calls)。
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
if f.approveErr != nil {
@ -466,16 +472,6 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
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) {
t.Helper()
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 未注册类型只计失败,其余照常处理(不整体回滚)。
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
r, rev := newReviewRouter(t)

View File

@ -30,6 +30,8 @@ type ArticleService interface {
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
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(ctx context.Context, id uint32, fields map[string]any) error
// DeleteImage 软删单张走秀图片。
@ -237,6 +239,11 @@ func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.Bran
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 重排为「主图后紧跟其细节图」的扁平顺序。
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
@ -285,6 +292,12 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
if 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
}

View File

@ -19,10 +19,14 @@ type captureIngestRepo struct {
repository.IngestRepository
createdRW *model.BrandRunway
createdImages []model.BrandRunwayImage
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
}
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
return 0, "", false, nil // 实体键未命中 → 新建分支
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
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) {
@ -120,3 +124,45 @@ func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) {
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))
}
}

View File

@ -10,6 +10,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"path"
"strconv"
"sync"
@ -44,7 +45,28 @@ type IngestService struct {
// uploader: 主上传器(S4或本地)
// del: 删除器(S4或本地),用于 media_cleanup 真删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{
repo: repo,
brandRepo: brandRepo,
@ -57,14 +79,8 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
httpClient: &http.Client{
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
Timeout: 60 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: imageFetchConcurrency * 4,
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
Timeout: 60 * time.Second,
Transport: dlTransport,
},
}
}
@ -243,8 +259,14 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
}
// 2) 实体键查重并决定分支(单表模型)
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
// 避免这些秀在「按年份/季节筛选」与热门排序(按 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 {
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
//(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())
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)
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)
return
}
@ -311,10 +362,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
TitleCn: p.TitleCn,
DescriptionEn: p.DescriptionEn,
DescriptionCn: p.DescriptionCn,
Year: p.Year,
Year: effYear,
Season: p.Season,
CollectionType: p.CollectionType,
SeasonCode: seasonCode,
PublishedAt: p.PublishedAt,
Cover: cover,
ImageCount: imageCount,
Status: model.StatusPending,
@ -445,12 +497,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
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 {
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
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)
if action == ingestSkip {
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)

View File

@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
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"]}`}
svc.processMediaCleanup(context.Background(), job)
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
del := &countingDeleter{LocalUploader: up}
// 引用计数恒为 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"]}`}
svc.processMediaCleanup(context.Background(), job)

View File

@ -100,7 +100,7 @@ type KindTab struct {
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
// 3. 在此文件实现本接口并注册到 NewReviewService。
//
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
type ReviewModule interface {
Kind() string
Label() string
@ -110,18 +110,20 @@ type ReviewModule interface {
// Approve 把正式记录置为 published;返回记录主键。
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
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)
}
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
//
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
type ReviewService interface {
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, 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)
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
@ -234,14 +236,6 @@ func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, rev
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 {
tabs := []KindTab{{Kind: "", Label: "全部"}}
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) {
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 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) {
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) {
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 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) {
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
}

View File

@ -27,6 +27,8 @@ type StreetSnapService interface {
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
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(ctx context.Context, id uint32, fields map[string]any) error
// DeleteImage 软删单张街拍图片。
@ -334,6 +336,11 @@ func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.S
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 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
@ -386,6 +393,12 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
if 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
}

2543
server_run.err Normal file

File diff suppressed because it is too large Load Diff

2480
server_run.out Normal file

File diff suppressed because it is too large Load Diff