Compare commits

..

4 Commits

Author SHA1 Message Date
d15d2a4701 update 2026-09-28 10:52:50 +08:00
aaeab41d67 update 2026-09-25 19:59:06 +08:00
42f6316125 update 2026-09-25 11:31:52 +08:00
4f4c11d508 update 2026-09-25 11:29:17 +08:00
68 changed files with 25113 additions and 2061 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ admin_cookies.txt
# 日志
*.log
.superpowers

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

@ -94,6 +94,8 @@ func main() {
histRepo = repository.NewHistoryRepository(db)
ingestRepo = repository.NewIngestRepository(db)
reviewRepo = repository.NewReviewRepository(db)
// 文章管理混合列表(跨 brand_runways / street_snaps 的统一只读查询)
adminRepo = repository.NewAdminRepository(db)
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
mediaRepo = repository.NewMediaRepository(db)
@ -124,6 +126,9 @@ func main() {
reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel)
)
// 把图片 URL 拼装器交给后台模板:编辑/详情页用 imgURL 渲染图片,S4 模式下也能打开。
handler.SetImgComposer(imgComposer)
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
@ -138,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)
@ -156,7 +161,7 @@ func main() {
})
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc)
backstageHandler := handler.NewBackstageHandler(authSvc, brandSvc, articleSvc, snapSvc, reviewSvc, ingestSvc, adminRepo)
// 爬虫上报 handler(HMAC 验签入口)
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)

View File

@ -63,6 +63,7 @@ cors:
- X-Sign
- X-Sign-Ts
- X-Sign-Nonce
- X-Locale
max_age: 86400 # 预检请求缓存秒数
# 公开接口「前端 JS 签名」:给 /runway-looks(列表)与 /brands 两个批量接口加请求签名校验,
@ -84,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

@ -18,7 +18,11 @@ ALTER TABLE street_snaps
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '',
-- 街拍标题中文化:保留既有 title 作英文源,仅加 title_cn 供后台人工翻译、前端按 locale 取用。
-- 与 brand_runways 用 title_en/title_cn 不同:street_snaps 上有 uq_ss_entity(city, year, COALESCE(title,'')),
-- 改名成本过高;pickLocale 在中文为空时回落英文,无需数据回灌。
ADD COLUMN IF NOT EXISTS title_cn text NOT NULL DEFAULT '';
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
-- 2026-09-22-01b-publish-existing-rows.sql。
@ -31,14 +35,20 @@ CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status);
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。
-- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列
-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。
-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。
DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps;
CREATE VIEW public_brand_runways AS
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
SELECT
id, brand_id, title_en, title_cn, description_en, description_cn,
cover, image_count, year, collection_type, season, season_code,
status, is_deleted, created_at, updated_at
FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i
@ -46,7 +56,10 @@ CREATE VIEW public_brand_runway_images AS
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
CREATE VIEW public_street_snaps AS
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
SELECT
id, title, title_cn, year, city, cover, image_count,
status, is_deleted, created_at, updated_at
FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_street_snap_images AS
SELECT i.* FROM street_snap_images i

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,40 @@
-- 2026-09-24-01 走秀图片改用 parent_image_id 分组(与街拍同构),移除 look_index。
--
-- 背景:走秀图片原用 look_index 把「主图 + 细节图」归到同一 Look 桶(look_index 仅作为分组桶号,
-- 对产品无意义)。现统一为 parent_image_id(细节图直接指向所属主图的行 id),与 street_snap_images
-- 完全同构,后台可复用同一套「合并 / 拆出」逻辑。
--
-- 步骤:
-- 1) 给 brand_runway_images 加 parent_image_id 列(与街拍对齐,默认 0);
-- 2) 回填:每个 look 内 is_detail=0 的主图,其同 look 的 is_detail=1 细节图 parent_image_id 置为该主图 id;
-- 3) 删 look_index 列;
-- 4) 重建 public_brand_runway_images 视图(原 SELECT i.* 在删列后失效,重建即按新列集展开)。
--
-- 幂等:ADD COLUMN IF NOT EXISTS;视图 DROP VIEW IF EXISTS 后重建。
-- 应用后请重新导出结构:dbtool dump -clean -out db_dump.sql
ALTER TABLE brand_runway_images ADD COLUMN IF NOT EXISTS parent_image_id bigint NOT NULL DEFAULT 0;
-- 回填:对每个 runway,把每个 look 的 is_detail=1 细节图 parent_image_id 置为该 look 内 is_detail=0 主图的 id。
-- 孤儿细节图(同 look 无主图)保持 parent_image_id=0(COALESCE 兜底,避免 NOT NULL 报错)。
UPDATE brand_runway_images AS d
SET parent_image_id = COALESCE((
SELECT MIN(m.id)
FROM brand_runway_images AS m
WHERE m.runway_id = d.runway_id
AND m.look_index = d.look_index
AND m.is_detail = 0
AND m.is_deleted = 0
), 0)
WHERE d.is_detail = 1 AND d.is_deleted = 0 AND d.parent_image_id = 0;
-- 注意:必须**先**删视图,再删 look_index 列——视图 SELECT i.* 依赖 look_index,
-- 列还在时 DROP COLUMN 会报 "cannot drop column ... because other objects depend on it"。
DROP VIEW IF EXISTS public_brand_runway_images;
ALTER TABLE brand_runway_images DROP COLUMN IF EXISTS look_index;
CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i
JOIN brand_runways r ON r.id = i.runway_id
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;

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;

15390
db_dump.sql

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -254,5 +254,7 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题只做空白 / 大小写 / 措辞微调,入库查重会当作新实体,从而多出一行(内容重复)。目前无标题归一化。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题做**措辞**微调,入库查重会当作新实体,从而多出一行(内容重复)。
- ✅ 已处理(残留项 延后-4):纯**空白**差异(首尾空白、内部多空格 / 制表 / 换行)已通过 `repository.NormalizeStreetTitle`(空白折叠)在落库与查重两侧统一消除,不会再因空白微调产生重复行;措辞差异无法机械归一,仍按不同实体处理(符合预期)。
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
- 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。

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 兼容,爬虫入库图片上传目标)。
@ -214,6 +218,7 @@ func defaultConfig() *Config {
Ingest: IngestConfig{
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

@ -79,12 +79,10 @@ type PublicArticleImage struct {
Thumb string `json:"thumb,omitempty"`
Name string `json:"name"`
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
// Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。
// 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份
// 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。
// Detail 仅详情接口填充:该主图下的细节图子集,元素形状与 Images 递归同构。
// 前端直接读它即可,无需再维护一份分组映射表。列表接口不填(omitempty 后不出现)。
Detail []PublicArticleImage `json:"detail,omitempty"`
}

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

@ -290,7 +290,11 @@ tbody tr:last-child td { border-bottom: 0; }
display: flex;
flex-direction: column;
}
.cell.det { background: #f8faff; border-color: #c7d2fe; }
/* 细节图 / 副图:明显的靛蓝边框 + 淡蓝底,和主图一眼区分开 */
.cell.det { background: #eef2ff; border: 2px solid #818cf8; }
/* 副图默认隐藏,点对应主图上的「显示副图」才展开(逐组,不会一次全开) */
.cell.det { display: none; }
.cell.det.shown { display: flex; }
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
.cell .thumb {
@ -319,8 +323,18 @@ tbody tr:last-child td { border-bottom: 0; }
transition: opacity .15s;
}
.cell .thumb:hover::after { opacity: 1; }
.cell .role { display: block; padding: 6px 8px 0; font-size: 12px; color: #1a7f43; }
.cell.det .role { color: #3b4bb5; }
/* 角色标签统一做成胶囊徽标:主图绿、细节图靛蓝,颜色编码一眼可分 */
.cell .role {
align-self: flex-start;
margin: 6px 8px 0;
padding: 2px 9px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
color: #1a7f43;
background: #e6f7ec;
}
.cell.det .role { color: #fff; background: #4338ca; }
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
@ -339,6 +353,44 @@ tbody tr:last-child td { border-bottom: 0; }
cursor: pointer;
}
.btn:hover { border-color: #111; }
.toggle-det { background: #eef2ff; border: 1px solid #818cf8; color: #4338ca; }
/* ---------- 行内操作按钮组(列表行 / 审核操作) ---------- */
/* 把原先「编辑 · 通过 · 驳回 · 下架」那种用中点分隔的散落按钮,
收敛成一组紧凑胶囊按钮:编辑是描边链接,通过=绿、驳回=红、下架=琥珀描边。 */
.ops { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ops .inline { display: inline-flex; margin: 0; padding: 0; }
.ops .view {
display: inline-block;
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 6px;
text-decoration: none;
font-size: 13px;
color: #111;
background: #fff;
}
.ops .view:hover { border-color: #111; }
.op {
margin: 0;
padding: 5px 11px;
font-size: 13px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
font-family: inherit;
line-height: 1.4;
white-space: nowrap;
}
.op-primary { background: #1a7f43; border-color: #1a7f43; color: #fff; }
.op-danger { background: #c0392b; border-color: #c0392b; color: #fff; }
.op-warn { background: #fff; border-color: #e0a800; color: #8a6d00; }
.op-primary:hover, .op-danger:hover { opacity: .9; }
.op-warn:hover { background: #fff8e6; }
/* 审核操作区(编辑页底部的通过 / 驳回)同款胶囊按钮组 */
.review-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0; }
.review-actions .inline { display: inline-flex; margin: 0; padding: 0; }
.chk {
display: flex;
align-items: center;
@ -357,6 +409,25 @@ tbody tr:last-child td { border-bottom: 0; }
.chk:hover { border-color: #111; }
.chk input { margin: 0; }
/* ---------- 看大图弹层(<dialog>,initZoom 依赖 #zoom-modal / #zoom-img) ---------- */
#zoom-modal {
padding: 0;
border: 0;
background: transparent;
max-width: 94vw;
max-height: 94vh;
}
#zoom-modal::backdrop { background: rgba(0, 0, 0, .82); }
#zoom-img {
display: block;
max-width: 94vw;
max-height: 94vh;
width: auto;
height: auto;
object-fit: contain;
cursor: zoom-out;
}
/* ---------- 底部批量操作条 ---------- */
.batchbar {
position: sticky;
@ -430,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,402 +1,215 @@
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。
*
* 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底
* (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事:
* 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需;
* 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次);
* 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。
*
* 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。
*/
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
// 两个入口页面:
// - 文章管理混合列表(/admin/articles):批量通过
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
(function () {
'use strict';
var doc = document;
var $ = function (sel, root) { return (root || doc).querySelector(sel); };
var $$ = function (sel, root) { return Array.prototype.slice.call((root || doc).querySelectorAll(sel)); };
/* ---------------- 通用:提示与请求 ---------------- */
var toastTimer = null;
function toast(msg, ok) {
var el = doc.getElementById('toast');
if (!el) { if (!ok) window.alert(msg); return; }
el.textContent = msg;
el.className = 'flash ' + (ok === false ? 'err' : 'ok');
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.hidden = true; }, 4000);
// 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。
function apiOf(action) {
return action.replace('/admin/articles/', '/admin/api/articles/');
}
function postJSON(url, body) {
return fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
}).then(function (res) {
return res.json().catch(function () { return null; }).then(function (data) {
if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')'));
return data;
// ── 看大图弹层(所有页面通用)────────────────────────────────────────────
function initZoom() {
var modal = document.getElementById('zoom-modal');
if (!modal) return;
var img = document.getElementById('zoom-img');
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
btn.addEventListener('click', function () {
img.src = btn.querySelector('img').src;
modal.showModal();
});
});
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.close();
});
}
// 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...)
function apiURL(action) {
return String(action).replace('/admin/reviews/', '/admin/api/reviews/');
}
/* ---------------- 大图查看层 ---------------- */
var lb = null, lbList = [], lbIndex = 0;
function lightboxNodes() {
return $$('.cell .thumb img, .cell > img').filter(function (img) { return img.getAttribute('src'); });
}
function ensureLightbox() {
if (lb) return lb;
lb = doc.createElement('div');
lb.className = 'lightbox';
lb.hidden = true;
lb.innerHTML =
'<button class="lb-close" type="button" aria-label="关闭">✕</button>' +
'<button class="lb-prev" type="button" aria-label="上一张">‹</button>' +
'<button class="lb-next" type="button" aria-label="下一张">›</button>' +
'<img alt=""><div class="lb-cap"></div>';
doc.body.appendChild(lb);
lb.querySelector('.lb-close').addEventListener('click', closeLightbox);
lb.querySelector('.lb-prev').addEventListener('click', function () { stepLightbox(-1); });
lb.querySelector('.lb-next').addEventListener('click', function () { stepLightbox(1); });
lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); });
return lb;
}
function showLightbox() {
var box = ensureLightbox();
var img = lbList[lbIndex];
if (!img) return;
var box2 = box.querySelector('img');
box2.src = img.getAttribute('src');
box2.alt = img.getAttribute('alt') || '';
var cell = img.closest ? img.closest('.cell') : null;
var nm = cell ? $('.nm', cell) : null;
box.querySelector('.lb-cap').textContent =
(nm ? nm.textContent.trim() + ' · ' : '') + (lbIndex + 1) + ' / ' + lbList.length;
box.hidden = false;
doc.body.style.overflow = 'hidden';
}
function closeLightbox() {
if (lb) lb.hidden = true;
doc.body.style.overflow = '';
}
function stepLightbox(delta) {
if (!lbList.length) return;
lbIndex = (lbIndex + delta + lbList.length) % lbList.length;
showLightbox();
}
function initLightbox() {
var imgs = lightboxNodes();
if (!imgs.length) return;
imgs.forEach(function (img) {
var trigger = img.closest ? (img.closest('.thumb') || img) : img;
trigger.addEventListener('click', function (e) {
e.preventDefault();
lbList = lightboxNodes();
lbIndex = lbList.indexOf(img);
if (lbIndex < 0) lbIndex = 0;
showLightbox();
});
});
}
/* ---------------- 审核列表:多选 + 批量通过/驳回 ---------------- */
function initReviewList() {
var bar = doc.getElementById('batchbar');
var table = $('table');
if (!bar || !table) return;
var rows = $$('tbody tr', table).filter(function (r) { return r.dataset && r.dataset.id; });
if (!rows.length) return;
var selAll = doc.getElementById('sel-all');
var counter = doc.getElementById('sel-count');
function picked() {
return rows.filter(function (r) {
var c = $('input.row-pick', r);
return c && c.checked;
});
}
// ── 文章管理列表:批量通过 ──────────────────────────────────────
function initArticleList() {
var selAll = document.getElementById('sel-all');
var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); };
var bar = document.getElementById('batchbar');
var count = document.getElementById('sel-count');
function refresh() {
var n = picked().length;
bar.hidden = n === 0;
if (counter) counter.textContent = '已选 ' + n + ' 条';
rows.forEach(function (r) {
var c = $('input.row-pick', r);
r.classList.toggle('selected', !!(c && c.checked));
});
if (selAll) {
selAll.checked = n > 0 && n === rows.length;
selAll.indeterminate = n > 0 && n < rows.length;
var n = picks().filter(function (c) { return c.checked; }).length;
if (bar) bar.hidden = n === 0;
if (count) count.textContent = '已选 ' + n + ' 条';
}
if (selAll) selAll.addEventListener('change', function () {
picks().forEach(function (c) { c.checked = selAll.checked; });
refresh();
});
picks().forEach(function (c) { c.addEventListener('change', refresh); });
var clear = document.getElementById('batch-clear');
if (clear) clear.addEventListener('click', function () {
picks().forEach(function (c) { c.checked = false; });
if (selAll) selAll.checked = false;
refresh();
});
function postBatch(action) {
var items = picks().filter(function (c) {
return c.checked;
}).map(function (c) {
var tr = c.closest('tr');
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
});
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
if (!confirm('确认通过选中的 ' + items.length + ' 条?')) return;
fetch('/admin/api/articles/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, items: items })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.failed && d.failed.length) {
alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error);
}
location.reload();
}).catch(function (e) { alert('请求失败:' + e); });
}
var approve = document.getElementById('batch-approve');
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
}
table.addEventListener('change', function (e) {
if (e.target.classList.contains('row-pick')) refresh();
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
function initArticleDetail() {
var batchForm = document.getElementById('batch');
var stat = document.getElementById('img-stat');
var hint = document.getElementById('hint-dismiss');
if (hint) hint.addEventListener('click', function () {
var h = hint.closest('.hint'); if (h) h.style.display = 'none';
});
if (selAll) {
selAll.addEventListener('change', function () {
rows.forEach(function (r) {
var c = $('input.row-pick', r);
if (c) c.checked = selAll.checked;
});
refresh();
// 每个主图各自的「显示副图」开关:点一下只展开/收起该主图紧邻的副图(不会一次全开)。
var grid = document.querySelector('.grid');
if (grid) {
grid.addEventListener('click', function (e) {
var btn = e.target.closest('.toggle-det');
if (!btn) return;
var pid = btn.getAttribute('data-parent');
var dets = grid.querySelectorAll('.cell.det[data-parent="' + pid + '"]');
var expand = false;
dets.forEach(function (d) { if (!d.classList.contains('shown')) expand = true; });
dets.forEach(function (d) { d.classList.toggle('shown', expand); });
btn.textContent = expand
? ('隐藏副图(' + dets.length + ' 张)')
: ('显示副图(' + dets.length + ' 张)');
});
}
function submit(action, reason) {
var items = picked().map(function (r) {
return { kind: r.dataset.kind, id: Number(r.dataset.id) };
});
if (!items.length) return;
var btns = $$('#batchbar button');
btns.forEach(function (b) { b.disabled = true; });
postJSON('/admin/api/reviews/batch', { action: action, reason: reason || '', items: items })
.then(function (res) {
// 成功的行直接从表格移除;失败的行标红保留,便于逐条重试
var failed = {};
(res.failed || []).forEach(function (f) { failed[f.kind + '/' + f.id] = f.error; });
items.forEach(function (it) {
var row = rows.filter(function (r) {
return r.dataset.kind === it.kind && Number(r.dataset.id) === it.id;
})[0];
if (!row) return;
if (failed[it.kind + '/' + it.id]) {
row.classList.add('failed-row');
row.title = failed[it.kind + '/' + it.id];
return;
}
row.parentNode.removeChild(row);
rows = rows.filter(function (r) { return r !== row; });
});
var msg = (action === 'approve' ? '已通过 ' : '已驳回 ') + res.ok + ' 条';
if ((res.failed || []).length) msg += ',' + res.failed.length + ' 条失败';
toast(msg, !(res.failed || []).length);
refresh();
// 合并选中为一组(AJAX,成功后就地更新标签)
if (batchForm) batchForm.addEventListener('submit', function (e) {
e.preventDefault();
// 注意:主图 radio / 选入 checkbox 用 form="batch" 关联到批量表单,但 DOM 上并不在
// <form id="batch"> 内部(包在 .cell 里)。它们虽能随表单正确提交,却不在 form 子树中,
// 故必须用 document 级查询(而非 batchForm.querySelector)才能取到已勾选的项。
var main = document.querySelector('input[name="main"]:checked');
var picks = document.querySelectorAll('input[name="img"]:checked');
if (!main) { alert('请先在其中一张主图上点「主图」'); return; }
if (!picks.length) { alert('请勾选要合并的图片'); return; }
// 接口 ArticleAttachImagesAPI 收 JSON(main + imgs),不能发 FormData:
// multipart 会让 ShouldBindJSON 失败、返回「请求格式错误」。
var data = {
main: parseInt(main.value, 10),
imgs: Array.prototype.slice.call(picks).map(function (c) { return parseInt(c.value, 10); })
};
fetch(apiOf(batchForm.getAttribute('action')), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.catch(function (err) { toast(err.message, false); })
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
}
.then(function (r) { return r.json(); })
.then(function (d) {
applyImages(d.images, kindOf(batchForm.getAttribute('action')), main.value);
if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组';
clearSelection();
})
.catch(function (err) { alert('合并失败:' + err); });
});
var approveBtn = doc.getElementById('batch-approve');
var rejectBtn = doc.getElementById('batch-reject');
var clearBtn = doc.getElementById('batch-clear');
if (approveBtn) {
approveBtn.addEventListener('click', function () {
var n = picked().length;
if (n && window.confirm('通过所选 ' + n + ' 条草稿并发布到正式表?')) submit('approve');
// 单张拆出(AJAX)
document.querySelectorAll('form[data-only="detail"]').forEach(function (f) {
f.addEventListener('submit', function (e) {
e.preventDefault();
fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) })
.then(function (r) { return r.json(); })
.then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
.catch(function (err) { alert('拆出失败:' + err); });
});
}
if (rejectBtn) {
rejectBtn.addEventListener('click', function () {
var n = picked().length;
if (!n) return;
var reason = window.prompt('驳回所选 ' + n + ' 条的理由(必填,会记录到审核留痕)');
if (reason === null) return;
if (!reason.trim()) { toast('驳回理由不能为空', false); return; }
submit('reject', reason.trim());
});
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
rows.forEach(function (r) {
var c = $('input.row-pick', r);
if (c) c.checked = false;
});
refresh();
});
}
// 行内快捷通过(免进详情页)
$$('button[data-quick-approve]', table).forEach(function (btn) {
btn.addEventListener('click', function () {
var row = btn.closest('tr');
if (!row || !window.confirm('通过并发布这条草稿?')) return;
btn.disabled = true;
postJSON('/admin/api/reviews/batch', {
action: 'approve',
items: [{ kind: row.dataset.kind, id: Number(row.dataset.id) }],
}).then(function (res) {
if ((res.failed || []).length) {
toast(res.failed[0].error || '操作失败', false);
btn.disabled = false;
return;
}
toast('已通过', true);
row.parentNode.removeChild(row);
rows = rows.filter(function (r) { return r !== row; });
refresh();
}).catch(function (err) {
toast(err.message, false);
btn.disabled = false;
});
});
});
refresh();
// 从 /admin/articles/<kind>/... 形式的 action 取出实体类型(street / runway)。
function kindOf(action) {
var m = /^\/admin\/articles\/(\w+)\//.exec(action || '');
return m ? m[1] : 'street';
}
/* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */
function clearSelection() {
document.querySelectorAll('input[name="main"]:checked, input[name="img"]:checked').forEach(function (c) { c.checked = false; });
}
function initReviewDetail() {
var grid = $('.grid');
if (!grid) return;
var cells = $$('.cell', grid);
var attachForm = doc.getElementById('batch');
// 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。
function updateToggle(mainId, detailCount) {
var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]');
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
if (detailCount > 0) {
if (!btn && cell) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'toggle-det';
btn.setAttribute('data-parent', mainId);
var acts = cell.querySelector('.acts');
if (acts) acts.appendChild(btn);
}
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
var expanded = dets.length > 0 && dets[0].classList.contains('shown');
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + detailCount + ' 张)';
} else if (btn) {
btn.remove();
}
}
// 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆)
function applyGroups(res) {
var images = res.images || [];
var byId = {};
images.forEach(function (i) { byId[i.id] = i; });
cells.forEach(function (cell) {
var st = byId[Number(cell.dataset.imgId)];
if (!st) return;
cell.classList.toggle('det', st.isDetail === 1);
var role = $('.role', cell);
// 展开/收起某主图的全部副图,并同步按钮文案。
function setExpanded(mainId, expanded) {
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
dets.forEach(function (d) { d.classList.toggle('shown', expanded); });
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + dets.length + ' 张)';
}
// 用服务端返回的最新图片投影,重写网格里的角色标签、副图开关与统计。
// kind 决定细节图文案;focusMain 为合并操作的目标主图 id,合并后自动展开其副图。
function applyImages(images, kind, focusMain) {
if (!images) return;
images.forEach(function (im) {
var cell = document.querySelector('.cell[data-img-id="' + im.id + '"]');
if (!cell) return;
cell.classList.toggle('det', im.isDetail === 1);
if (im.isDetail === 1) {
cell.setAttribute('data-parent', im.parentId);
} else {
cell.removeAttribute('data-parent');
updateToggle(im.id, im.detailCount);
}
var role = cell.querySelector('.role');
if (role) {
role.textContent = st.isDetail === 1
? ('副图 → #' + st.parentId)
: ('主图' + (st.detailCount ? ' · ' + st.detailCount + ' 副图' : ''));
if (im.isDetail === 1) {
role.textContent = (kind === 'runway' ? '细节图' : '副图') + ' → #' + im.parentId;
} else {
role.textContent = '主图' + (im.detailCount ? (' · ' + im.detailCount + ' 副图') : '');
}
$$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; });
$$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; });
});
var stat = doc.getElementById('img-stat');
if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组';
}
// 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用)
function intercept(form, buildBody) {
form.addEventListener('submit', function (e) {
e.preventDefault();
var body = buildBody(form);
if (body === null) return; // 校验未过:交给原生提交去报错
postJSON(apiURL(form.getAttribute('action')), body)
.then(function (res) {
applyGroups(res);
cells.forEach(function (c) {
var chk = $('input[type="checkbox"]', c);
if (chk) chk.checked = false;
var radio = $('input[type="radio"]', c);
if (radio) radio.checked = false;
});
toast('已更新分组', true);
})
.catch(function (err) { toast(err.message, false); });
});
}
if (attachForm) {
intercept(attachForm, function (form) {
var main = $('input[type="radio"]:checked', grid);
var imgs = $$('input[type="checkbox"]:checked', grid).map(function (c) { return Number(c.value); });
if (!main || !imgs.length) { toast('请勾选图片,并在其中一张主图上点「主图」', false); return null; }
return { main: Number(main.value), imgs: imgs };
});
}
$$('form[action*="/detach"]', grid).forEach(function (form) {
intercept(form, function () { return {}; });
});
// ---- 键盘流(审核图片时手不离键盘) ----
var focusIndex = -1;
// scroll=false 用于初始化:只标记第一格,不要因为 scrollIntoView 把页面顶端的
// 字段表单(标题/年份/城市 + 通过按钮)滚出视野 —— 进页面必须先看到待填字段。
function setFocus(i, scroll) {
if (!cells.length) return;
focusIndex = Math.max(0, Math.min(cells.length - 1, i));
cells.forEach(function (c, j) { c.classList.toggle('focus', j === focusIndex); });
var el = cells[focusIndex];
if (scroll !== false && el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
}
function inField(t) {
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT');
}
doc.addEventListener('keydown', function (e) {
if (!lb.hidden && lb) {
if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { stepLightbox(-1); e.preventDefault(); }
else if (e.key === 'ArrowRight') { stepLightbox(1); e.preventDefault(); }
return;
}
if (inField(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
var cur = cells[focusIndex];
switch (e.key) {
case 'j': setFocus(focusIndex + 1); e.preventDefault(); break;
case 'k': setFocus(focusIndex - 1); e.preventDefault(); break;
case 'Enter':
if (cur) {
var t = $('.thumb', cur) || $('img', cur);
if (t) { t.click(); e.preventDefault(); }
}
break;
case 'x':
if (cur) {
var chk = $('input[type="checkbox"]', cur);
if (chk && !chk.disabled) { chk.checked = !chk.checked; e.preventDefault(); }
}
break;
case 'm':
if (cur) {
var radio = $('input[type="radio"]', cur);
if (radio) { radio.checked = true; e.preventDefault(); }
}
break;
case 'd':
if (cur) {
var df = $('form[action*="/detach"]', cur);
if (df) { df.requestSubmit ? df.requestSubmit() : df.submit(); e.preventDefault(); }
}
break;
case 'a':
// 通过 = 提交页面上那个「通过并发布」表单(复用当前输入框里的字段微调),故加一次确认
var af = $('form[action$="/approve"]');
if (af && window.confirm('通过并发布这条草稿?')) {
af.requestSubmit ? af.requestSubmit() : af.submit();
e.preventDefault();
}
break;
}
});
// 进页面先标记第一格(不滚动),键盘可直接用
if (cells.length) setFocus(0, false);
if (focusMain) setExpanded(focusMain, true);
}
/* ---------------- 启动 ---------------- */
function boot() {
// 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题)
doc.documentElement.setAttribute('data-admin-js', 'ready');
initLightbox();
initReviewList();
initReviewDetail();
var hint = doc.getElementById('hint-dismiss');
if (hint) {
hint.addEventListener('click', function () {
var box = hint.closest('.hint');
if (box) box.hidden = true;
document.addEventListener('DOMContentLoaded', function () {
initZoom();
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
if (document.getElementById('batch')) initArticleDetail();
});
}
}
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot);
else boot();
})();

View File

@ -3,7 +3,7 @@
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。
// 同时表单式路由(/admin/articles/... 下的 approve/attach/detach)原样保留:关掉 JS 后台依然可用。
//
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
package handler
@ -26,26 +26,25 @@ type reviewBatchItem struct {
}
type reviewBatchReq struct {
// Action: approve | reject
// Action: approve
Action string `json:"action"`
Reason string `json:"reason"`
Items []reviewBatchItem `json:"items"`
}
// ReviewBatch 批量通过 / 驳回审核记录。
// ArticleBatch 文章管理批量通过(替代原审核批量接口)。
//
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17}
//
// 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。
func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
var req reviewBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
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) ReviewBatch(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) ReviewBatch(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()})
@ -99,10 +91,10 @@ type reviewAttachReq struct {
Imgs []uint32 `json:"imgs"`
}
// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
// ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
// 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。
func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
@ -129,8 +121,8 @@ func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
h.respondRecordImages(c, kind, uint32(id))
}
// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
// ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。
func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)

View File

@ -1,6 +1,7 @@
package handler
import (
"context"
"fmt"
"net/http"
"strconv"
@ -8,7 +9,9 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/middleware"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/repository"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/service"
@ -30,11 +33,12 @@ type BackstageHandler struct {
street service.StreetSnapService
review service.ReviewService
ingest *service.IngestService
adminRepo repository.AdminRepository
}
// NewBackstageHandler 创建后台 handler。
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest}
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService, adminRepo repository.AdminRepository) *BackstageHandler {
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
}
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
@ -296,7 +300,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
}
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
@ -304,50 +308,65 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
}
// ── 审核记录 ───────────────────────────────────────────────────────────────────
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
func (h *BackstageHandler) ReviewList(c *gin.Context) {
kind := c.Param("kind")
if kind == "" {
kind = c.Query("kind")
}
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 编辑 / 删除(下架)都在这里发起。
func (h *BackstageHandler) Articles(c *gin.Context) {
status := c.Query("status")
kind := c.Query("kind")
if kind != "runway" && kind != "street" {
kind = "" // 非法的 kind 回落为「全部」
}
page := atoiDefault(c.Query("page"), 1)
if page < 1 {
page = 1
}
// 校验 kind 是否在注册表中;未知 kind 退回「全部」。
kinds := h.review.KindTabs(c.Request.Context())
registered := map[string]bool{}
for _, t := range kinds {
if t.Kind != "" {
registered[t.Kind] = true
}
}
if kind != "" && kind != "all" && !registered[kind] {
kind = ""
}
records, total, err := h.review.ListRecords(c.Request.Context(), kind, status, page, 20)
const size = 24
cards, total, err := h.adminRepo.ListMixed(c.Request.Context(), status, kind, page, size)
if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
// 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews
listBase := ""
if kind != "" && kind != "all" {
listBase = "/" + kind
// 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
dupStats := h.dupStatsByCards(c.Request.Context(), cards)
items := make([]gin.H, 0, len(cards))
for _, card := range cards {
var t byte
if card.Kind == "runway" {
t = hashid.TypeRunway
} else {
t = hashid.TypeSnap
}
p := pagination.Params{Page: page, PageSize: 20}
ds := dupStats[card.ID]
dupPct := 0.0
if ds.Total > 0 {
dupPct = float64(ds.Dup) / float64(ds.Total) * 100
}
items = append(items, gin.H{
"Kind": card.Kind,
"UID": hashid.EncodeWithType(card.ID, t),
"ID": card.ID,
"Title": card.Title,
"Subtitle": card.Subtitle,
"ImageCount": card.ImageCount,
"Status": card.Status,
"DupCount": ds.Dup,
"DupPct": dupPct,
})
}
// 状态 tab 角标(空串=全部)。
tabCounts := map[string]int64{}
for _, s := range []string{"", "pending", "published"} {
n, e := h.adminRepo.CountMixed(c.Request.Context(), s, kind)
if e == nil {
tabCounts[s] = n
}
}
p := pagination.Params{Page: page, PageSize: size}
meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "review-list", gin.H{
"Records": records,
"Total": total,
renderPage(c, http.StatusOK, "articles", gin.H{
"Items": items,
"Status": status,
"Kind": kind,
"Kinds": kinds,
"ListBase": listBase,
"TabCounts": tabCounts,
"Pager": pagination.Pager{
PrevURL: pagination.PageLink(c, meta.PrevPage),
NextURL: pagination.PageLink(c, meta.NextPage),
@ -358,96 +377,57 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
})
}
// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return
// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
out := make(map[uint32]repository.DupStat)
runwayIDs := make([]uint32, 0, len(cards))
streetIDs := make([]uint32, 0, len(cards))
for _, c := range cards {
if c.Kind == "runway" {
runwayIDs = append(runwayIDs, c.ID)
} else {
streetIDs = append(streetIDs, c.ID)
}
record, err := h.review.RecordDetail(c.Request.Context(), kind, uint32(id))
if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")})
return
}
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
brandName := ""
if record.BrandID > 0 {
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil {
brandName = b.NameEn
if brandName == "" {
brandName = b.NameCn
if len(runwayIDs) > 0 {
if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
for id, s := range m {
out[id] = s
}
}
}
renderPage(c, http.StatusOK, "review-detail", gin.H{
"Record": record,
"BrandName": brandName,
})
if len(streetIDs) > 0 {
if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
for id, s := range m {
out[id] = s
}
}
}
return out
}
// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
// 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。
func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return
}
fields := reviewFormFields(c)
if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
username, _ := c.Get(middleware.ContextUsername)
if _, err := h.review.Approve(c.Request.Context(), kind, uint32(id), usernameString(username)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewListURL(kind))
}
// ReviewReject 驳回:仅记录状态与理由。
func (h *BackstageHandler) ReviewReject(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return
}
reason := c.PostForm("reason")
username, _ := c.Get(middleware.ContextUsername)
if err := h.review.Reject(c.Request.Context(), kind, uint32(id), usernameString(username), reason); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewListURL(kind))
}
// ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
// 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。
func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)
if id == 0 || imgID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
if err := h.review.DeleteImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
username, _ := c.Get(middleware.ContextUsername)
if _, err := h.review.Approve(c.Request.Context(), kind, id, usernameString(username)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
}
// ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
// (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。)
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
@ -459,48 +439,50 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
imgIDs = append(imgIDs, uint32(v))
}
}
if len(imgIDs) == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片"})
if len(imgIDs) == 0 || mainID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片,并在其中一张主图上点选「主图」"})
return
}
if mainID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return
}
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
if err := h.review.AttachImages(c.Request.Context(), kind, id, mainID, imgIDs); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
c.Redirect(http.StatusFound, articleEditURL(kind, id))
}
// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
// ArticleDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ArticleDetachImageAPI)。
func (h *BackstageHandler) ArticleDetachImage(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)
if id == 0 || imgID == 0 {
id := uint32(atoiDefault(c.Param("id"), 0))
if id == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
imgID := atoiDefault(c.Param("img"), 0)
if imgID == 0 {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return
}
if err := h.review.DetachImage(c.Request.Context(), kind, id, uint32(imgID)); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
c.Redirect(http.StatusFound, articleEditURL(kind, id))
}
// reviewDetailURL 生成审核详情页跳转地址。
func reviewDetailURL(kind string, id uint32) string {
return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10)
}
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。
func reviewListURL(kind string) string {
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet {
return "/admin/reviews/" + kind
// articleEditURL 生成类型对应的编辑页地址(分组 / 通过 / 驳回操作完成后跳回编辑页)。
// 编辑页路由用 hashid 标识记录,故这里把数值主键编码回去。
func articleEditURL(kind string, id uint32) string {
var t byte
if kind == "runway" {
t = hashid.TypeRunway
} else {
t = hashid.TypeSnap
}
return "/admin/reviews"
if kind == "runway" {
return "/admin/runways/" + hashid.EncodeWithType(id, t) + "/edit"
}
return "/admin/street-snaps/" + hashid.EncodeWithType(id, t) + "/edit"
}
// ── 爬虫入库任务监控 ──────────────────────────────────────────────────────────
@ -637,12 +619,84 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
brandName = b.NameCn
}
}
cells := make([]gin.H, 0, len(imgs))
var dupCount int
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
detailsByParent := map[uint32][]model.BrandRunwayImage{}
var mains []model.BrandRunwayImage
for _, im := range imgs {
if im.IsDuplicate == 1 {
dupCount++
}
if im.IsDetail == 1 {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} else {
mains = append(mains, im)
}
}
mainIDs := map[uint32]bool{}
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]),
"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,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
// 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。
for pid, ds := range detailsByParent {
if mainIDs[pid] {
continue
}
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,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
dupPct := 0.0
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
dupSources := h.buildDupSources(c.Request.Context(), "runway", cells)
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,
"DupSources": dupSources,
})
}
@ -674,7 +728,7 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// RunwayImageDelete 软删单张走秀图片。
@ -693,7 +747,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// RunwaySetCover 把某张图片设为封面。
@ -713,7 +767,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。
@ -732,7 +786,7 @@ func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
}
// ── 街拍编辑 ─────────────────────────────────────────────────────────────────────
@ -750,14 +804,142 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return
}
cells := make([]gin.H, 0, len(imgs))
var dupCount int
// 按主图分组排放:每个主图紧跟它自己的副图,便于逐组展开(而不是一次性全展开)。
detailsByParent := map[uint32][]model.StreetSnapImage{}
var mains []model.StreetSnapImage
for _, im := range imgs {
if im.IsDuplicate == 1 {
dupCount++
}
if im.IsDetail == 1 {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
} else {
mains = append(mains, im)
}
}
mainIDs := map[uint32]bool{}
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]),
"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,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
// 孤立副图(父主图缺失)仍保留并默认显示,避免丢图。
for pid, ds := range detailsByParent {
if mainIDs[pid] {
continue
}
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,
"isDuplicate": int(d.IsDuplicate),
"dupOf": d.DupOf,
})
}
}
dupPct := 0.0
if n := len(imgs); n > 0 {
dupPct = float64(dupCount) / float64(n) * 100
}
dupSources := h.buildDupSources(c.Request.Context(), "street", cells)
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,
"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")
@ -774,6 +956,7 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
}
fields := map[string]any{
"title": c.PostForm("title"),
"title_cn": c.PostForm("title_cn"),
"city": c.PostForm("city"),
"year": year,
}
@ -781,7 +964,7 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// StreetSnapImageDelete 软删单张街拍图片。
@ -800,7 +983,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// StreetSnapSetCover 把某张图片设为封面。
@ -820,7 +1003,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// StreetSnapToggleDeleted 上下架。
@ -839,7 +1022,7 @@ func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
}
// atoiDefault 把字符串转 int,失败回落默认值。

View File

@ -24,20 +24,17 @@ import (
// (renderPage 会 500 "unknown backstage page: xxx")。
func TestBackstageTemplatesParsed(t *testing.T) {
want := []string{
"articles",
"brand-form",
"brands",
"dashboard",
"error",
"ingest-jobs",
"login",
"review-detail",
"review-list",
"runway-detail",
"runway-edit",
"runways",
"snap-edit",
"street-snap-detail",
"street-snaps",
"users",
}
got := PageNames()
@ -74,7 +71,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
"Images": []any{},
// 列表数据
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{},
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
"Article": &dto.PublicArticleDetail{UID: "abc123"},
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
@ -88,19 +85,20 @@ func TestBackstageTemplatesRender(t *testing.T) {
for _, page := range PageNames() {
for _, kind := range []string{"street", "runway"} {
if page != "review-detail" && kind == "runway" {
continue // 只有审核详情页按 kind 分流
}
name := page
if page == "review-detail" {
name = page + "/" + kind
}
name := page + "/" + kind
t.Run(name, func(t *testing.T) {
data := cloneData(base)
for k, v := range perPage[page] {
data[k] = v
}
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
data["Kind"] = kind
data["RecordID"] = uint32(7)
data["UID"] = "abc123"
data["Status"] = "pending"
data["Cells"] = []gin.H{}
data["BrandName"] = "Brand"
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

View File

@ -58,9 +58,13 @@ func queryBool(c *gin.Context, key string, def bool) bool {
return v == "1" || v == "true"
}
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
// parseLocale 解析请求语言,优先级:X-Locale 头 > ?locale=cn|en > Accept-Language > 默认 en。
// 与前端 i18n 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
// X-Locale 由前端统一经请求头透传「当前页面语言」,显式且不受浏览器语言设置干扰。
func parseLocale(c *gin.Context) string {
if l := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Locale"))); l == "cn" || l == "en" {
return l
}
if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" {
return l
}

View File

@ -64,7 +64,7 @@ func (h *SSGHandler) HotBrands(c *gin.Context) {
// GET /api/v1/ssg/street-snaps/popular
func (h *SSGHandler) StreetSnapPopular(c *gin.Context) {
const limit = 20
list, err := h.snaps.Popular(c.Request.Context(), limit)
list, err := h.snaps.Popular(c.Request.Context(), limit, parseLocale(c))
if err != nil {
fail(c, err)
return

View File

@ -82,12 +82,12 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
return
}
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
if err != nil {
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap {
if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10)); e2 == nil {
if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10), parseLocale(c)); e2 == nil {
detail, err = d2, nil
}
}

View File

@ -24,9 +24,23 @@ import (
"net/http"
"strings"
"fashionapi/internal/pkg/imgurl"
"github.com/gin-gonic/gin"
)
// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。
// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。
var imgComposer = imgurl.New("", "high", "thumb")
// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。
// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。
func SetImgComposer(c *imgurl.Composer) {
if c != nil {
imgComposer = c
}
}
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
var backstageFS embed.FS
@ -42,8 +56,10 @@ var assetFS fs.FS
func init() {
funcs := template.FuncMap{
// reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。
"reviewDetailURL": reviewDetailURL,
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
"imgURL": func(stored string) string { return imgComposer.Compose(stored) },
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
}
base, err := template.New("layout.html").Funcs(funcs).
@ -94,7 +110,7 @@ func PageNames() []string {
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
//
// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上
// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
func renderPage(c *gin.Context, status int, page string, data gin.H) {
tpl, ok := pageTemplates[page]

View File

@ -20,6 +20,7 @@
<body>
{{template "content" .}}
<div id="toast" class="flash ok" hidden></div>
<dialog id="zoom-modal"><img id="zoom-img" alt=""></dialog>
<script src="/admin/assets/admin.js" defer></script>
</body>
</html>

View File

@ -0,0 +1,56 @@
{{define "title"}}文章管理 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 文章管理{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>文章管理</h1>
<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>
<a href="/admin/articles?status={{.Status}}&kind=street" class="{{if eq .Kind "street"}}on{{end}}">街拍</a>
</div>
<div class="tabs">
<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>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>重复率</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
<td><code>{{.ID}}</code></td>
<td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td>
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>
<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 .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}}
</tbody>
</table>
</div>
{{template "pager" .Pager}}
<div class="batchbar" id="batchbar" hidden>
<span class="muted" id="sel-count">已选 0 条</span>
<span class="spacer"></span>
<button type="button" id="batch-approve">批量通过</button>
<button type="button" id="batch-clear" class="ghost">清空选择</button>
</div>
</div>
{{end}}

View File

@ -8,9 +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/runways"><div class="t">走秀档案</div><div class="d">按系列 / 季节 / 年份筛选</div></a>
<a class="card" href="/admin/street-snaps"><div class="t">街拍</div><div class="d">按城市 / 年份筛选</div></a>
<a class="card" href="/admin/reviews"><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

@ -1,56 +0,0 @@
{{define "title"}}草稿审核 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}}
{{define "barRight"}}<a href="{{reviewDetailURL .Record.Kind .Record.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main mid">
<p class="muted">类型 <b>{{.Record.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Record.Status}}</b></p>
{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
<form method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/approve">
{{range .Record.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
<button type="submit">通过并发布</button>
</form>
{{if eq .Record.Kind "street"}}
<div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div>
<h2>图片(<span id="img-stat">{{len .Record.Images}} 张 · {{len .Record.Groups}} 组</span>)</h2>
{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}}
<form id="batch" method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/images/attach"></form>
<div class="grid">
{{range .Record.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
<span class="nm">{{.Name}}</span>
<div class="acts">
{{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
{{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>
<div class="batchbar">
<button type="submit" form="batch">合并选中为一组</button>
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div>
{{else}}
<h2>图片管理({{len .Record.Images}} 张)</h2>
<div class="grid">
{{range .Record.Images}}<div class="cell" data-img-id="{{.ID}}">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="nm">{{.Name}}</span>
<div class="acts">
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>
{{end}}
</div>
{{end}}

View File

@ -1,50 +0,0 @@
{{define "title"}}待审草稿 · 管理后台{{end}}
{{define "barLabel"}}待审草稿{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>待审草稿(共 {{.Total}} 条)</h1>
<div class="kinds">
{{range .Kinds}}<a href="{{if eq .Kind ""}}/admin/reviews{{else}}/admin/reviews/{{.Kind}}{{end}}" class="{{if or (eq $.Kind .Kind) (eq $.Kind "all")}}on{{end}}">{{.Label}}{{if gt .Pending 0}}({{.Pending}}){{end}}</a>
{{end}}
</div>
<div class="tabs">
<a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a>
<a href="/admin/reviews{{.ListBase}}?status=pending" class="{{if eq .Status "pending"}}on{{end}}">待审核</a>
<a href="/admin/reviews{{.ListBase}}?status=published" class="{{if eq .Status "published"}}on{{end}}">已通过</a>
<a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a>
</div>
{{/* 多选 + 批量操作:勾选后底部出现操作条(admin.js 接管;无 JS 时单条「查看/审核」照常可用) */}}
<div class="table-wrap">
<table>
<thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Records}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td><code>{{.ID}}</code></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
<td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接发布">通过</button>{{end}}</td>
</tr>{{end}}
</tbody>
</table>
</div>
{{template "pager" .Pager}}
<div class="batchbar" id="batchbar" hidden>
<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>
{{end}}

View File

@ -1,13 +1,13 @@
{{define "title"}}走秀详情 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 走秀详情{{end}}
{{define "barRight"}}<a href="/admin/runways">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
<h1>{{.Article.Title}}</h1>
<div class="grid">
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{end}}
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
</div>
</div>
{{end}}

View File

@ -13,19 +13,16 @@
<button type="submit">保存字段</button>
</form>
<h2>图片管理({{len .Images}} 张)</h2>
<div class="grid">
{{range .Images}}<div class="cell">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<div class="acts">
<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>
</div>
</div>{{end}}
{{template "imageGrid" .}}
{{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>
</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

@ -1,39 +0,0 @@
{{define "title"}}走秀档案 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 走秀档案{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>走秀列表</h1>
<form class="toolbar" method="get" action="/admin/runways">
<select name="collection">
<option value="" {{if eq .Collection ""}}selected{{end}}>全部系列</option>
<option value="rtw" {{if eq .Collection "rtw"}}selected{{end}}>RTW 成衣</option>
<option value="menswear" {{if eq .Collection "menswear"}}selected{{end}}>男装</option>
<option value="couture" {{if eq .Collection "couture"}}selected{{end}}>高定</option>
<option value="resort" {{if eq .Collection "resort"}}selected{{end}}>度假</option>
<option value="pre_fall" {{if eq .Collection "pre_fall"}}selected{{end}}>早秋</option>
</select>
<select name="season">
<option value="" {{if eq .Season ""}}selected{{end}}>全部季节</option>
<option value="spring" {{if eq .Season "spring"}}selected{{end}}>春夏</option>
<option value="fall" {{if eq .Season "fall"}}selected{{end}}>秋冬</option>
</select>
<input name="year" placeholder="年份" value="{{if gt .Year 0}}{{.Year}}{{end}}" style="width:90px">
<select name="sort">
<option value="" {{if eq .Sort ""}}selected{{end}}>默认排序</option>
<option value="year_desc" {{if eq .Sort "year_desc"}}selected{{end}}>年份 ↓</option>
<option value="year_asc" {{if eq .Sort "year_asc"}}selected{{end}}>年份 ↑</option>
</select>
<button type="submit">筛选</button>
<a href="/admin/runways">清除</a>
</form>
<table>
<thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th>状态</th><th></th></tr></thead>
<tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -6,24 +6,21 @@
<div class="main">
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
<div><label>标题</label><input name="title" value="{{.Snap.Title}}"></div>
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
<button type="submit">保存字段</button>
</form>
<h2>图片管理({{len .Images}} 张)</h2>
<div class="grid">
{{range .Images}}<div class="cell">
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<div class="acts">
<form method="post" action="/admin/street-snaps/{{$.UID}}/cover"><input type="hidden" name="image" value="{{.Image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/street-snaps/{{$.UID}}/images/{{.ID}}/delete"><button type="submit" class="reject">删除</button></form>
</div>
</div>{{end}}
{{template "imageGrid" .}}
{{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>
</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

@ -1,13 +1,13 @@
{{define "title"}}街拍详情 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 街拍详情{{end}}
{{define "barRight"}}<a href="/admin/street-snaps">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<p class="muted">编码 {{.Snap.UID}}</p>
<h1>{{.Snap.Title}}</h1>
<div class="grid">
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{end}}
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
</div>
</div>
{{end}}

View File

@ -1,27 +0,0 @@
{{define "title"}}街拍 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 街拍{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>街拍列表</h1>
<form class="toolbar" method="get" action="/admin/street-snaps">
<input name="city" placeholder="城市" value="{{.City}}">
<input name="year" placeholder="年份" value="{{if gt .Year 0}}{{.Year}}{{end}}" style="width:90px">
<select name="sort">
<option value="" {{if eq .Sort ""}}selected{{end}}>默认(图最多)</option>
<option value="year_desc" {{if eq .Sort "year_desc"}}selected{{end}}>年份 ↓</option>
<option value="year_asc" {{if eq .Sort "year_asc"}}selected{{end}}>年份 ↑</option>
</select>
<button type="submit">筛选</button>
<a href="/admin/street-snaps">清除</a>
</form>
<table>
<thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th>状态</th><th></th></tr></thead>
<tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -16,9 +16,7 @@
<span class="bar-title">{{template "barLabel" .}}</span>
<nav class="bar-nav">
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
<a href="/admin/reviews" class="{{if or (eq .NavKey "review-list") (eq .NavKey "review-detail")}}on{{end}}">审核{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
<a href="/admin/runways" class="{{if or (eq .NavKey "runways") (eq .NavKey "runway-detail") (eq .NavKey "runway-edit")}}on{{end}}">走秀</a>
<a href="/admin/street-snaps" class="{{if or (eq .NavKey "street-snaps") (eq .NavKey "street-snap-detail") (eq .NavKey "snap-edit")}}on{{end}}">街拍</a>
<a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
@ -35,3 +33,50 @@
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>
{{end}}
{{/*
共享片段:街拍 / 走秀编辑页共用的「图片管理」块(网格 + 合并为一组 + 拆出)。
两个后台编辑页之前各写一份几乎相同的图片管理 HTML,现已抽到这里,改一处全局生效。
页面需要提供的字段:
.Kind "street" | "runway"(决定封面/删除路由前缀与主/副图文案)
.UID 记录的 hashid(用于 /admin/{street-snaps|runways}/<UID>/... 路由)
.RecordID 记录数字 id(用于 /admin/articles/<kind>/<id>/... 路由)
.Status 记录状态(pending 时显示待审角标)
.Cells []gin.H,每项 {id, image, name, isDetail, role}
*/}}
{{define "imageGrid"}}
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
<div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
{{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}}{{$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>
{{else}}<form method="post" action="/admin/street-snaps/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/street-snaps/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
{{end}}
{{if eq .isDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.id}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.id}}"> 选入</label>
{{if and (eq .isDetail 0) .DetCount}}<button type="button" class="toggle-det" data-parent="{{.id}}">显示副图({{.DetCount}} 张)</button>{{end}}
{{if eq .isDetail 1}}<form data-only="detail" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/{{.id}}/detach"><button type="submit">拆出</button></form>{{end}}
</div>
</div>{{end}}
</div>
<div class="batchbar">
<button type="submit" form="batch">合并选中为一组</button>
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div>
{{end}}

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

@ -13,8 +13,12 @@ type BrandRunwayImage struct {
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图
// 主副图分组(列由 db/migrations/2026-09-24-01-runway-parent-image-id.sql 添加):
// 与 street_snap_images 同构——is_detail=0 为主图(parent_image_id=0);
// =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
// 去重字段(近重复 + 语义 embedding 留痕):
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;

View File

@ -8,7 +8,8 @@ import "database/sql"
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
type StreetSnap struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
Title string `gorm:"column:title" json:"title"`
Title string `gorm:"column:title" json:"title"` // 英文标题(既有源,改动前唯一标题列)
TitleCn string `gorm:"column:title_cn" json:"title_cn"` // 中文标题:后台人工翻译;为空时前端/接口回落英文
Year uint16 `gorm:"column:year" json:"year"`
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
Cover string `gorm:"column:cover" json:"cover"`
@ -20,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

@ -22,7 +22,8 @@ import (
const Size = 64
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
const DefaultThreshold = 10
// 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。
const DefaultThreshold = 4
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。

View File

@ -0,0 +1,174 @@
//go:build integration
// 集成测试:文章管理混合列表必须跨 brand_runways / street_snaps 统一排序与过滤。
//
// 运行(库需先就绪,见 dedup_integration_test.go 顶部说明):
//
// go test -tags integration ./internal/repository/ -run TestAdminMixed -v
package repository
import (
"testing"
"gorm.io/gorm"
)
// seedMixedRecords 各插一条走秀(已发布)与街拍(待审),返回它们的内部 id。
// 用测试专用 city / season_code 隔离,避免与库里既有行撞唯一实体键。
func seedMixedRecords(t *testing.T, db *gorm.DB) (runwayID, streetID uint32) {
t.Helper()
if err := db.Raw(`
INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at)
VALUES (999999, 'AdminRepoTestRunway', 3, ?, 0, 2099, 'ZZZ', 'test', 1800000000)
RETURNING id`, "published").Scan(&runwayID).Error; err != nil {
t.Fatalf("插入走秀种子失败: %v", err)
}
if err := db.Raw(`
INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at)
VALUES ('TestCityZZZ', 'AdminRepoTestStreet', 2, ?, 0, 2099, 1800000000)
RETURNING id`, "pending").Scan(&streetID).Error; err != nil {
t.Fatalf("插入街拍种子失败: %v", err)
}
t.Cleanup(func() {
_ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error
_ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error
})
return runwayID, streetID
}
// TestAdminMixedListFilterAndOrder 验证:
// 1. 全部视图同时返回走秀与街拍;
// 2. 待审核视图只返回 pending 的街拍,不含已发布的走秀;
// 3. 排序为「pending 优先、其次 id 倒序」—— 任一 pending 都排在所有 published 之前。
func TestAdminMixedListFilterAndOrder(t *testing.T) {
db := testDB(t)
runwayID, streetID := seedMixedRecords(t, db)
repo := NewAdminRepository(db)
all, total, err := repo.ListMixed(t.Context(), "", "", 1, 100)
if err != nil {
t.Fatalf("ListMixed 全部失败: %v", err)
}
if total < 2 {
t.Fatalf("全部视图至少应含 2 条种子,实际 total=%d", total)
}
var sawRunway, sawStreet bool
for _, c := range all {
if c.Kind == "runway" && c.ID == runwayID {
sawRunway = true
}
if c.Kind == "street" && c.ID == streetID {
sawStreet = true
}
}
if !sawRunway || !sawStreet {
t.Fatalf("全部视图应同时含走秀与街拍: runway=%v street=%v", sawRunway, sawStreet)
}
// 待审核过滤:街拍(pending) 出现,走秀(published) 不出现。
pending, _, err := repo.ListMixed(t.Context(), "pending", "", 1, 100)
if err != nil {
t.Fatalf("ListMixed pending 失败: %v", err)
}
var pendingHasStreet, pendingHasRunway bool
for _, c := range pending {
if c.Kind == "street" && c.ID == streetID {
pendingHasStreet = true
}
if c.Kind == "runway" && c.ID == runwayID {
pendingHasRunway = true
}
}
if !pendingHasStreet {
t.Fatalf("待审核视图应含待审街拍")
}
if pendingHasRunway {
t.Fatalf("待审核视图不应含已发布走秀")
}
// 排序:任一 pending 必须排在所有 published 之前。
var maxPendingIdx, minPublishedIdx = -1, 1 << 30
for i, c := range all {
if c.Status == "pending" {
if i > maxPendingIdx {
maxPendingIdx = i
}
} else if c.Status == "published" {
if i < minPublishedIdx {
minPublishedIdx = i
}
}
}
if maxPendingIdx >= 0 && minPublishedIdx < 1<<30 && maxPendingIdx > minPublishedIdx {
t.Fatalf("排序错误:存在 published 排在 pending 之前 (pending@%d, published@%d)", maxPendingIdx, minPublishedIdx)
}
// CountMixed 与列表口径一致。
if n, err := repo.CountMixed(t.Context(), "pending", ""); err != nil || n < 1 {
t.Fatalf("CountMixed(pending) 应 >=1,实际 n=%d err=%v", n, err)
}
}
// TestAdminMixedKindFilter 验证 kind 筛选:只取走秀 / 只取街拍 / 全部。
func TestAdminMixedKindFilter(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewAdminRepository(db)
ctx := t.Context()
// 直接插入各一条,用独特 city / title 隔离唯一实体键。
var runwayID, streetID uint32
if err := db.Raw(`
INSERT INTO brand_runways (brand_id, title_en, image_count, status, is_deleted, year, season_code, collection_type, created_at)
VALUES (999999, 'KindFilterRunway', 1, 'published', 0, 2097, 'KFZ', 'test', 1800000000)
RETURNING id`,
).Scan(&runwayID).Error; err != nil {
t.Fatalf("插入走秀种子失败: %v", err)
}
if err := db.Raw(`
INSERT INTO street_snaps (city, title, image_count, status, is_deleted, year, created_at)
VALUES ('KindFilterCity', 'KindFilterStreet', 1, 'published', 0, 2097, 1800000000)
RETURNING id`,
).Scan(&streetID).Error; err != nil {
t.Fatalf("插入街拍种子失败: %v", err)
}
t.Cleanup(func() {
_ = db.Exec("DELETE FROM brand_runways WHERE id = ?", runwayID).Error
_ = db.Exec("DELETE FROM street_snaps WHERE id = ?", streetID).Error
})
runwayOnly, _, err := repo.ListMixed(ctx, "", "runway", 1, 50)
if err != nil {
t.Fatalf("kind=runway 失败: %v", err)
}
for _, c := range runwayOnly {
if c.Kind != "runway" {
t.Fatalf("kind=runway 不应含 %s", c.Kind)
}
}
if len(runwayOnly) == 0 {
t.Fatalf("kind=runway 至少应含 1 条")
}
streetOnly, _, err := repo.ListMixed(ctx, "", "street", 1, 50)
if err != nil {
t.Fatalf("kind=street 失败: %v", err)
}
for _, c := range streetOnly {
if c.Kind != "street" {
t.Fatalf("kind=street 不应含 %s", c.Kind)
}
}
if len(streetOnly) == 0 {
t.Fatalf("kind=street 至少应含 1 条")
}
all, _, err := repo.ListMixed(ctx, "", "", 1, 50)
if err != nil {
t.Fatalf("kind='' 失败: %v", err)
}
if len(all) != len(runwayOnly)+len(streetOnly) {
t.Fatalf("全部应等于两类型之和:all=%d runway=%d street=%d", len(all), len(runwayOnly), len(streetOnly))
}
}

View File

@ -0,0 +1,172 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
)
// AdminRecordCard 是「文章管理」混合列表的统一卡片:跨 brand_runways 与 street_snaps
// 两张表,字段取两者并集的最小可读子集(类型由 Kind 区分)。
//
// 为什么是 UNION 而非分别查两张表再在 Go 里拼:混合列表需要按(待审优先 + id 倒序)
// 做跨表统一排序与分页,分别查两表再分页会得到错误的边界;一条 UNION 查询在数据库侧
// 完成排序与 LIMIT/OFFSET,分页语义才正确,也避免把整张表拉进内存。
type AdminRecordCard struct {
Kind string // "runway" | "street"
ID uint32 // 内部数值主键(供通过/驳回/批量接口使用)
Title string
Subtitle string // 走秀=品牌名;街拍=城市
ImageCount int
Status string // pending / published / rejected
}
// DupStat 某记录的图片去重统计(仅统计 is_deleted=0 的存活行)。
type DupStat struct {
Total int // 存活图片总数(含主图与细节图)
Dup int // is_duplicate=1 的张数
}
// AdminRepository 文章管理页所需的跨表只读查询。
type AdminRepository interface {
// ListMixed 按状态 + 类型过滤(status 为空表示全部;kind 为空表示走秀+街拍,
// "runway"/"street" 则只取对应一类),跨表统一排序后分页返回。
// 排序:pending 优先,其次 id 倒序(最新待审排最前)。
ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error)
// CountMixed 统计某状态 + 类型下的记录总数(用于 tab 角标)。
CountMixed(ctx context.Context, status, kind string) (int64, error)
// DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。
// 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。
DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error)
}
type adminRepository struct {
db *gorm.DB
}
// NewAdminRepository 创建文章管理仓储。
func NewAdminRepository(db *gorm.DB) AdminRepository {
return &adminRepository{db: db}
}
// legWhere 单条腿(带表别名)的过滤条件:is_deleted=0 是后台可见性的硬约束,
// status 过滤可选。别名必须显式指定——两表 LEFT JOIN 时 is_deleted 会歧义。
func legWhere(alias, status string) (string, []interface{}) {
where := alias + ".is_deleted = 0"
args := []interface{}{}
if status != "" {
where += " AND " + alias + ".status = ?"
args = append(args, status)
}
return where, args
}
func (r *adminRepository) ListMixed(ctx context.Context, status, kind string, page, size int) ([]AdminRecordCard, int64, error) {
if page < 1 {
page = 1
}
if size < 1 {
size = 24
}
rw, rargs := legWhere("r", status)
sw, sargs := legWhere("s", status)
// kind 决定本次跨表 UNION 取哪几腿:""=两表都取,"street"=只取街拍,"runway"=只取走秀。
var legs []string
var args []interface{}
if kind != "street" {
legs = append(legs, `
SELECT 'runway' AS kind, r.id, COALESCE(r.title_en, '') AS title, COALESCE(b.name_en, '') AS subtitle,
CAST(r.image_count AS BIGINT) AS image_count, r.status
FROM brand_runways r
LEFT JOIN brands b ON b.id = r.brand_id
WHERE `+rw)
args = append(args, rargs...)
}
if kind != "runway" {
legs = append(legs, `
SELECT 'street' AS kind, s.id, COALESCE(s.title, '') AS title, COALESCE(s.city, '') AS subtitle,
CAST(s.image_count AS BIGINT) AS image_count, s.status
FROM street_snaps s
WHERE `+sw)
args = append(args, sargs...)
}
q := `
SELECT * FROM (` + strings.Join(legs, " UNION ALL ") + `) sub
ORDER BY (status = 'pending') DESC, id DESC
LIMIT ? OFFSET ?`
listArgs := append(append([]interface{}{}, args...), size, (page-1)*size)
var cards []AdminRecordCard
if err := r.db.WithContext(ctx).Raw(q, listArgs...).Scan(&cards).Error; err != nil {
return nil, 0, err
}
total, err := r.CountMixed(ctx, status, kind)
if err != nil {
return nil, 0, err
}
return cards, total, nil
}
// DupRateByRecords 批量统计若干记录的图片去重情况。
// kind=runway 查 brand_runway_images(外键 runway_id);kind=street 查 street_snap_images(外键 snap_id)。
// 只统计 is_deleted=0 的存活行;命中阈值为 phash.DefaultThreshold(当前 4)。
func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) {
out := make(map[uint32]DupStat)
if len(ids) == 0 {
return out, nil
}
var table, fk string
switch kind {
case "runway":
table, fk = "brand_runway_images", "runway_id"
case "street":
table, fk = "street_snap_images", "snap_id"
default:
return out, nil
}
type agg struct {
Owner uint32 `gorm:"column:owner"`
Total int `gorm:"column:total"`
Dup int `gorm:"column:dup"`
}
var rows []agg
if err := r.db.WithContext(ctx).
Table(table).
Select(fk + " AS owner, COUNT(*) AS total, COALESCE(SUM(CASE WHEN is_duplicate = 1 THEN 1 ELSE 0 END), 0) AS dup").
Where(fk+" IN ? AND is_deleted = 0", ids).
Group(fk).
Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
out[row.Owner] = DupStat{Total: row.Total, Dup: row.Dup}
}
return out, nil
}
func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) {
rw, rargs := legWhere("r", status)
sw, sargs := legWhere("s", status)
var legs []string
var args []interface{}
if kind != "street" {
legs = append(legs, `SELECT COUNT(*) AS c FROM brand_runways r WHERE `+rw)
args = append(args, rargs...)
}
if kind != "runway" {
legs = append(legs, `SELECT COUNT(*) AS c FROM street_snaps s WHERE `+sw)
args = append(args, sargs...)
}
q := `
SELECT COALESCE(SUM(c), 0) FROM (` + strings.Join(legs, " UNION ALL ") + `) t`
var total int64
if err := r.db.WithContext(ctx).Raw(q, args...).Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}

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 更新走秀封面(同时回到图片列表首张)。
@ -331,21 +340,49 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.BrandRunwayImage
err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
var rw model.BrandRunway
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil {
return err
}
if err := tx.Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return tx.Exec(`UPDATE brand_runways SET image_count =
if err := tx.Exec(`UPDATE brand_runways SET image_count =
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
WHERE id = ?`, img.RunwayID, img.RunwayID).Error
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil {
return err
}
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
if rw.Cover == img.Image {
cover := ""
var survivor struct {
Image string `gorm:"column:image"`
}
serr := tx.Raw(
"SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
img.RunwayID,
).Scan(&survivor).Error
if serr == nil {
cover = survivor.Image
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
return serr
}
if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID).
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
}
return nil
})
}
@ -361,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) {
@ -383,6 +542,13 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", runwayID).
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
return keys, nil
}

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()
@ -196,3 +278,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
}
}
// editorCover 读某记录的 cover 列(用于封面悬空防护断言)。
func editorCover(t *testing.T, db *gorm.DB, table, idCol string, id uint32) string {
t.Helper()
var row struct{ Cover string `gorm:"column:cover"` }
if err := db.Table(table).Select("cover").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
t.Fatalf("读 %s.cover 失败: %v", table, err)
}
return row.Cover
}
// TestEditorDeleteRunwayCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
func TestEditorDeleteRunwayCoverRepointsWhenDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2)
seedEditorRunwayImage(t, db, id, "keep", 1, 0)
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
t.Fatalf("删封面图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "keep.jpg" {
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
}
}
// TestEditorDeleteRunwayCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
func TestEditorDeleteRunwayCoverClearsWhenLastImage(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 1)
only := seedEditorRunwayImage(t, db, id, "only", 1, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteRunwayImage(ctx, only); err != nil {
t.Fatalf("删最后一张图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
}
}
// TestEditorPurgeRunwayGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
func TestEditorPurgeRunwayGalleryClearsCover(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2)
seedEditorRunwayImage(t, db, id, "a", 1, 0)
seedEditorRunwayImage(t, db, id, "b", 2, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if _, err := repo.SoftDeleteRunwayImages(ctx, id); err != nil {
t.Fatalf("下架删图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
}
}
// TestEditorDeleteSnapCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
func TestEditorDeleteSnapCoverRepointsWhenDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
seedEditorSnapImage(t, db, id, "keep", 1, 0, 0)
drop := seedEditorSnapImage(t, db, id, "drop", 2, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteSnapImage(ctx, drop); err != nil {
t.Fatalf("删封面图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "keep.jpg" {
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
}
}
// TestEditorDeleteSnapCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
func TestEditorDeleteSnapCoverClearsWhenLastImage(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 1)
only := seedEditorSnapImage(t, db, id, "only", 1, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteSnapImage(ctx, only); err != nil {
t.Fatalf("删最后一张图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
}
}
// TestEditorPurgeSnapGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
seedEditorSnapImage(t, db, id, "a", 1, 0, 0)
seedEditorSnapImage(t, db, id, "b", 2, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if _, err := repo.SoftDeleteSnapImages(ctx, id); err != nil {
t.Fatalf("下架删图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
}
}

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

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math"
"strings"
"time"
"fashionapi/internal/model"
@ -34,17 +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
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// 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)查正式表既有行,返回其状态与是否已被软删。
// 与 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 街拍记录(事务内完成)。
@ -245,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"`
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) {
@ -335,28 +354,117 @@ func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, r
})
}
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// RepairRunwayDetailParents 入库后按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
func (r *ingestRepository) RepairRunwayDetailParents(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
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}
// 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 的历史行)时误判未命中、
// 进而插入一行注定被索引拒绝的记录。
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
//
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
//
// 不再过滤 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"`
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 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
// 连续段折叠为单个空格。
//
// 为什么需要:实体键含 title((city, year, COALESCE(title, ''))),同一专题标题只做空白 / 大小写 / 措辞
// 微调会被判为新实体(设计规格 §12.4)。本函数消除「纯空白」这一类可机械归一的差异,避免重复行。
// 措辞差异无法机械归一,仍按不同实体处理(符合预期)。调用方在落库与查重两侧都必须用同一函数,
// 才能保证索引(COALESCE(title, ''))与查重口径逐字一致。
func NormalizeStreetTitle(title string) string {
return strings.Join(strings.Fields(title), " ")
}
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {

View File

@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType
}
func newRunwayImages(cover string) []model.BrandRunwayImage {
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}}
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}}
}
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
@ -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

@ -0,0 +1,66 @@
//go:build integration
// 集成测试:公开只读视图(public_brand_runways / public_street_snaps)不得暴露审核/溯源列
// (job_id / reviewer / reject_reason)。设计规格 I2:视图列集已显式枚举,刻意排除这些列,
// 使基表加列时敏感列不会无意泄露到公开读路径。
//
// 运行:go test -tags integration ./internal/repository/ -run TestPublicViewsExcludeSensitiveColumns -v
package repository
import (
"testing"
)
// TestPublicViewsExcludeSensitiveColumns 断言两个主表公开视图不含敏感列。
func TestPublicViewsExcludeSensitiveColumns(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
type v struct {
view, col string
}
cases := []v{}
for _, view := range []string{"public_brand_runways", "public_street_snaps"} {
for _, col := range []string{"job_id", "reviewer", "reject_reason"} {
cases = append(cases, v{view, col})
}
}
for _, c := range cases {
var cnt int64
if err := db.Raw(
`SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
c.view, c.col,
).Scan(&cnt).Error; err != nil {
t.Fatalf("查 information_schema 失败: %v", err)
}
if cnt != 0 {
t.Errorf("公开视图 %s 不应暴露敏感列 %s", c.view, c.col)
}
}
}
// TestPublicViewsKeepNeededColumns 断言公开视图仍含对外必需的列(回归保护,避免收窄时误删)。
func TestPublicViewsKeepNeededColumns(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
keep := map[string][]string{
"public_brand_runways": {"id", "brand_id", "title_en", "cover", "image_count", "year", "season_code", "status"},
"public_street_snaps": {"id", "title", "title_cn", "city", "cover", "image_count", "year", "status"},
}
for view, cols := range keep {
for _, col := range cols {
var cnt int64
if err := db.Raw(
`SELECT count(*) FROM information_schema.columns WHERE table_name = ? AND column_name = ?`,
view, col,
).Scan(&cnt).Error; err != nil {
t.Fatalf("查 information_schema 失败: %v", err)
}
if cnt != 1 {
t.Errorf("公开视图 %s 必须含列 %s(收窄时误删)", view, col)
}
}
}
}

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
@ -46,6 +46,11 @@ type ReviewRepository interface {
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
@ -161,14 +166,13 @@ 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()),
})
if res.Error != nil {
@ -267,14 +271,13 @@ 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()),
})
if res.Error != nil {
@ -412,3 +415,89 @@ func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, im
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
//
// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」
// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。
func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs {
if id != 0 && id != mainImgID {
ids = append(ids, id)
}
}
if mainImgID == 0 || len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
if len(ids) == 0 {
return nil // 集合里只有主图自身:无操作成功
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.BrandRunwayImage
if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID).
First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup
}
return err
}
now := uint32(time.Now().Unix())
// 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。
if err := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
// 再把被并入图自身标为副图并指向新主图。
return tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
// 同主图下其余明细图仍挂在该主图之下,不变量成立。
func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
// 供入库后调用,使新入库的走秀自动成组,无需人工合并。
func (r *reviewRepository) RepairRunwayDetailParents(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
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}

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 更新街拍封面。
@ -224,6 +233,7 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
var snapEditable = map[string]bool{
"title": true,
"title_cn": true,
"year": true,
"city": true,
"cover": true,
@ -274,21 +284,49 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.StreetSnapImage
err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error
err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
var snap model.StreetSnap
if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil {
return err
}
if err := tx.Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return tx.Exec(`UPDATE street_snaps SET image_count =
if err := tx.Exec(`UPDATE street_snaps SET image_count =
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
WHERE id = ?`, img.SnapID, img.SnapID).Error
WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil {
return err
}
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
if snap.Cover == img.Image {
cover := ""
var survivor struct {
Image string `gorm:"column:image"`
}
serr := tx.Raw(
"SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
img.SnapID,
).Scan(&survivor).Error
if serr == nil {
cover = survivor.Image
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
return serr
}
if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID).
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
}
return nil
})
}
@ -304,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) {
@ -326,6 +475,13 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", snapID).
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
return keys, nil
}

View File

@ -0,0 +1,48 @@
//go:build integration
package repository
import (
"context"
"testing"
"fashionapi/internal/model"
)
// TestStreetTitleNormalizationUnifiesEntity 验证街拍实体键标题归一化:
// 落库标题带多余空白(模拟爬虫原始上报)经 NormalizeStreetTitle 折叠后写入,
// 查重侧用未归一化的原始标题(多空格)也应命中同一实体,不会被判成新实体。
//
// 设计规格 §12.4:纯空白微调的同一专题若被当作新实体,会多出重复行。
func TestStreetTitleNormalizationUnifiesEntity(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
city, year := "NormCity", uint16(2099)
// 落库口径:与 processStreet 一致,先归一化再写。
stored := NormalizeStreetTitle("Norm Show 2026")
id, err := repo.CreateStreetSnapWithImages(ctx, &model.StreetSnap{
JobID: 1, Title: stored, Year: year, City: city, Status: model.StatusPending,
}, []model.StreetSnapImage{{Image: "norm.jpg", SortOrder: 1}})
if err != nil {
t.Fatalf("插入归一化标题失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id)
db.Exec("DELETE FROM street_snaps WHERE id = ?", id)
})
// 查重口径:用未归一化的原始标题(多空格)→ 必须命中已落库行。
hid, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "Norm Show 2026")
if err != nil {
t.Fatalf("查重出错: %v", err)
}
if !found || hid != id {
t.Fatalf("归一化后应命中同一实体 id=%d,实际 found=%v hid=%d", id, found, hid)
}
}

View File

@ -77,11 +77,11 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
admin.Use(opt.Backstage.NavStats())
{
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands)
@ -89,31 +89,28 @@ 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("/runways", opt.Backstage.Runways)
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。
admin.GET("/articles", opt.Backstage.Articles)
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
// 街拍主副图分组(零 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)
// 分类型编辑/详情/删图/设封面/上下架:从文章管理列表跳入,按类型落到各自的编辑页。
// 列表本身已合并到 /admin/articles,这里只保留编辑链路,避免重复维护两套列表。
admin.GET("/runways/:id", opt.Backstage.RunwayDetail)
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
admin.GET("/street-snaps", opt.Backstage.StreetSnaps)
admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail)
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核记录:爬虫入库待人工审核 → 通过置为已发布 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -13,7 +13,9 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/handler"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/repository"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
@ -145,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{}
@ -164,12 +169,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return nil, service.ErrStreetSnapNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street(待审)",
@ -178,11 +183,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr
},
}, nil
}
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (f *fakeStreet) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
return nil, nil
}
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil
imgs := []model.StreetSnapImage{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
}
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street", Status: "pending"}, imgs, nil
}
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil
@ -197,6 +207,62 @@ 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{}
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
all := []repository.AdminRecordCard{
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
{Kind: "street", ID: 2, Title: "Paris Fashion Week Street", Subtitle: "Paris", ImageCount: 8, Status: "pending"},
}
if kind != "" {
kept := all[:0]
for _, c := range all {
if c.Kind == kind {
kept = append(kept, c)
}
}
all = kept
}
if status != "" {
kept := all[:0]
for _, c := range all {
if c.Status == status {
kept = append(kept, c)
}
}
all = kept
}
return all, int64(len(all)), nil
}
func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int64, error) {
if kind == "runway" {
if status == "" || status == "published" {
return 1, nil
}
return 0, nil
}
if kind == "street" {
if status == "" || status == "pending" {
return 1, nil
}
return 0, nil
}
if status == "" {
return 2, nil
}
if status == "pending" || status == "published" {
return 1, 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
}
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
@ -204,7 +270,7 @@ func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
@ -281,13 +347,17 @@ func TestBackstage(t *testing.T) {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
// 6) 文章管理混合列表渲染(走秀 + 街拍合并)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
body := w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") {
t.Fatalf("文章管理列表异常: %d %s", w.Code, body)
}
if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") {
t.Fatalf("文章管理列表应含状态 tab: %s", body)
}
// 7) 后台走秀详情渲染图集。
@ -301,13 +371,14 @@ func TestBackstage(t *testing.T) {
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
// 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles?status=pending", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
body = w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") {
t.Fatalf("待审核筛选异常: %d %s", w.Code, body)
}
// 9) 后台街拍详情渲染图集。
@ -331,7 +402,6 @@ type fakeReview struct {
service.ReviewService
attachOK bool
approveErr error
rejectErr error
last struct {
kind string
recordID uint32
@ -345,7 +415,6 @@ type fakeReview struct {
Action string
Kind string
ID uint32
Reason string
}
view *service.RecordDetailView
}
@ -394,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 {
@ -404,23 +472,13 @@ 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)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
rev := &fakeReview{attachOK: true}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
return r, rev
}
@ -444,14 +502,14 @@ func TestBackstageStreetAttachRedirect(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
@ -465,7 +523,7 @@ func TestBackstageStreetAttachNeedsMain(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/attach", strings.NewReader("img=2&img=3"))
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -483,13 +541,13 @@ func TestBackstageStreetDetach(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/images/2/detach", nil)
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/2/detach", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("拆出应 302,实际 %d", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "/admin/reviews/street/7") {
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "detach" || rev.last.imgID != 2 {
@ -499,33 +557,23 @@ func TestBackstageStreetDetach(t *testing.T) {
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
func TestBackstageStreetRenderGroups(t *testing.T) {
hashid.Init("test-salt")
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
rev.view = &service.RecordDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.RecordImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
},
Groups: []service.ImageGroup{
{Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
},
}
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", nil)
req := httptest.NewRequest(http.MethodGet, "/admin/street-snaps/"+hashid.EncodeWithType(7, hashid.TypeSnap)+"/edit", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("详情应 200,实际 %d %s", w.Code, w.Body.String())
t.Fatalf("街拍编辑页应 200,实际 %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a")
}
// 副图徽标显示所属主图(沿链归并后的实际主图)。
// 副图徽标显示所属主图。
if !strings.Contains(body, "副图 → #1") {
t.Fatalf("副图应显示所属主图")
}
@ -552,7 +600,7 @@ func TestBackstageApprovePublishes(t *testing.T) {
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/reviews/street/7/approve", nil)
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
@ -605,7 +653,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
}
// 导航已在共享 partial 里,各模块入口都应在
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
if !strings.Contains(body, ">"+label) {
t.Fatalf("导航应包含入口 %q", label)
}
@ -619,7 +667,7 @@ func TestBackstageReviewBatchApprove(t *testing.T) {
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
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)
@ -645,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/reviews/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)
@ -672,7 +700,7 @@ func TestBackstageReviewBatchPartialFailure(t *testing.T) {
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/reviews/batch", strings.NewReader(body))
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)

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 软删单张走秀图片。
@ -194,25 +196,22 @@ func (s *articleService) detailErr(err error) error {
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
mains := make([]model.BrandRunwayImage, 0, len(imgs))
byLook := make(map[uint32][]model.BrandRunwayImage)
detailsByParent := make(map[uint32][]model.BrandRunwayImage)
for _, im := range imgs {
if im.IsDetail == 0 {
if im.IsDetail == 0 || im.ParentImageID == 0 {
mains = append(mains, im)
} else {
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
pubMains := s.toPublicImages(mains)
for i := range pubMains {
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
// 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
pubMains[i].Detail = s.toPublicImages(kids)
delete(byLook, pubMains[i].LookIndex)
}
}
@ -231,7 +230,50 @@ func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRun
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
return s.articles.GetForEdit(ctx, id)
rw, imgs, err := s.articles.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 与街拍编辑页同构:把每张主图(is_detail=0)紧跟其细节图(同 parent_image_id、is_detail=1)重排,
// 避免细节图被甩到几百张图的最末尾、看起来像「没显示」。孤儿细节图(无主图)追加末尾。
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 {
mains := make([]model.BrandRunwayImage, 0, len(imgs))
mainSeen := make(map[uint32]bool, len(imgs))
for _, im := range imgs {
isMain := im.IsDetail == 0 || im.ParentImageID == 0
if isMain && !mainSeen[im.ID] {
mainSeen[im.ID] = true
mains = append(mains, im)
}
}
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
out := make([]model.BrandRunwayImage, 0, len(imgs))
for _, main := range mains {
out = append(out, main)
out = append(out, detailsByParent[main.ID]...)
}
// 孤儿细节图(parent 缺失):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID == 0 && !mainSeen[im.ID] {
out = append(out, im)
}
}
return out
}
func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
@ -250,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
}
@ -306,7 +354,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
})
}
return out
@ -325,7 +372,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
Image: s.img.ComposeThumb(im.Image),
Name: im.Name,
IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
})
}
return out

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) {
@ -37,6 +41,11 @@ func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables [
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
return nil
}
// fakeBrandRepo 品牌校验恒通过。
type fakeBrandRepo struct{ repository.BrandRepository }
@ -115,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,
@ -58,13 +80,7 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
// 超时须显著大于单张图的正常下载耗时(实测 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,
},
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
}
@ -286,7 +337,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
LookIndex: uint32(i + 1),
IsDetail: 0,
Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup,
@ -312,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,
@ -333,13 +384,21 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
return
}
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
runwayID := rw.ID
if runwayID == 0 {
runwayID = reuseID
}
if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil {
log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr)
}
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
}
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
@ -408,7 +467,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
Image: r.url,
Name: name,
SortOrder: uint32(order),
LookIndex: uint32(task.lookIdx),
IsDetail: isDetail,
Phash: sqlNull(r.phashBits),
IsDuplicate: isDup,
@ -435,14 +493,22 @@ func countMainImages(images []model.BrandRunwayImage) uint16 {
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 实体键查重并决定分支(单表模型,同 runway)。
// 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致,
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn)
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
title := repository.NormalizeStreetTitle(p.TitleEn)
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)
@ -465,7 +531,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
writeStart := time.Now()
snap := &model.StreetSnap{
JobID: job.ID,
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
Year: p.Year,
City: p.City,
Cover: cover,
@ -695,7 +761,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
}
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
type lookImageTask struct {
url string
lookIdx int // 1-based look 序号

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

@ -28,8 +28,8 @@ type RecordCard struct {
// RecordImageRef 记录图片引用(统一结构)。
//
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
type RecordImageRef struct {
ID uint32 `json:"id"`
Image string `json:"image"`
@ -70,8 +70,9 @@ type RecordDetailView struct {
Images []RecordImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
Groups []ImageGroup `json:"groups,omitempty"`
}
@ -99,7 +100,7 @@ type KindTab struct {
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
// 3. 在此文件实现本接口并注册到 NewReviewService。
//
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
type ReviewModule interface {
Kind() string
Label() string
@ -109,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)。
@ -233,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
@ -286,20 +281,28 @@ func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID,
}
func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup
}
if len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
switch kind {
case dto.IngestKindStreet:
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
case dto.IngestKindRunway:
return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs))
default:
return ErrInvalidImageGroup
}
}
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
if kind != dto.IngestKindStreet {
switch kind {
case dto.IngestKindStreet:
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
case dto.IngestKindRunway:
return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID))
default:
return ErrInvalidImageGroup
}
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
}
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
@ -367,27 +370,69 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
},
}
// 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0
// 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。
// 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。
mains := make([]model.BrandRunwayImage, 0, len(imgs))
mainSeen := make(map[uint32]bool, len(imgs))
for _, im := range imgs {
v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
isMain := im.IsDetail == 0 || im.ParentImageID == 0
if isMain && !mainSeen[im.ID] {
mainSeen[im.ID] = true
mains = append(mains, im)
}
}
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
detailCount := make(map[uint32]int, len(detailsByParent))
for p, ds := range detailsByParent {
detailCount[p] = len(ds)
}
for _, main := range mains {
ref := RecordImageRef{
ID: main.ID,
Image: m.img.Compose(main.Image),
Name: main.Name,
SortOrder: main.SortOrder,
IsDetail: 0,
ParentImageID: 0,
DetailCount: detailCount[main.ID],
}
v.Images = append(v.Images, ref)
grp := ImageGroup{Image: ref}
for _, d := range detailsByParent[main.ID] {
dref := RecordImageRef{
ID: d.ID,
Image: m.img.Compose(d.Image),
Name: d.Name,
SortOrder: d.SortOrder,
IsDetail: 1,
ParentImageID: d.ParentImageID,
}
grp.Details = append(grp.Details, dref)
v.Images = append(v.Images, dref)
}
v.Groups = append(v.Groups, grp)
}
return v, nil
}
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateRecord(ctx, id, fields)
}
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{})
}
@ -511,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

@ -17,6 +17,7 @@ type fakeGroupRepo struct {
images []model.StreetSnapImage
detail *model.StreetSnap
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error
}
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin
return nil
}
func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error {
if f.attachRunwayFn != nil {
return f.attachRunwayFn(runwayID, mainID, imgIDs)
}
return nil
}
func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error {
return nil
}
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
func newGroupService(f *fakeGroupRepo) ReviewService {
@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
}
}
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("未知实体并入应返回 ErrInvalidImageGroup,实际 %v", err)
}
}
// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。
func TestRunwayGroupAttachDelegates(t *testing.T) {
var got struct {
runwayID uint32
mainID uint32
imgIDs []uint32
}
repo := &fakeGroupRepo{attachRunwayFn: func(runwayID, mainID uint32, imgIDs []uint32) error {
got.runwayID, got.mainID, got.imgIDs = runwayID, mainID, imgIDs
return nil
}}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 9, 4, []uint32{5, 6}); err != nil {
t.Fatalf("Runway AttachImages 失败: %v", err)
}
if got.runwayID != 9 || got.mainID != 4 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: runwayID=%d mainID=%d imgIDs=%v", got.runwayID, got.mainID, got.imgIDs)
}
}

View File

@ -12,7 +12,7 @@ import (
//
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A)
//
// 期望 images 只有 2 张主图:A(Detail=[B,E], LookIndex=1)、C(Detail=[D], LookIndex=2)。
// 期望 images 只有 2 张主图:A(Detail=[B,E])、C(Detail=[D])。
func TestBuildSnapImages(t *testing.T) {
s := &streetSnapService{img: imgurl.New("", "", "")}
imgs := []model.StreetSnapImage{
@ -29,21 +29,21 @@ func TestBuildSnapImages(t *testing.T) {
}
a := out[0]
if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 {
t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex)
if a.Name != "A" || a.IsDetail != 0 {
t.Fatalf("第 1 张应为 A 主图,实际 name=%s is_detail=%d", a.Name, a.IsDetail)
}
if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" {
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
}
for _, d := range a.Detail {
if d.IsDetail != 1 || d.LookIndex != 1 {
t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex)
if d.IsDetail != 1 {
t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail)
}
}
c := out[1]
if c.Name != "C" || c.LookIndex != 2 {
t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex)
if c.Name != "C" {
t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name)
}
if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))

View File

@ -0,0 +1,96 @@
package service
import (
"context"
"testing"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/imgurl"
"fashionapi/internal/repository"
)
// fakeSnapRepo 只实现测试路径用到的仓储方法;其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
type fakeSnapRepo struct {
repository.StreetSnapRepository
findByID *model.StreetSnap
listImages []model.StreetSnapImage
rows []model.StreetSnap
}
func (f *fakeSnapRepo) FindByID(_ context.Context, _ string) (*model.StreetSnap, error) {
return f.findByID, nil
}
func (f *fakeSnapRepo) ListImages(_ context.Context, _ string) ([]model.StreetSnapImage, error) {
return f.listImages, nil
}
func (f *fakeSnapRepo) List(_ context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
return f.rows, int64(len(f.rows)), nil
}
var _ repository.StreetSnapRepository = (*fakeSnapRepo)(nil)
func newSnapService(f *fakeSnapRepo) StreetSnapService {
return NewStreetSnapService(f, nil, imgurl.New("", "", ""), nil, nil)
}
// TestStreetSnapDetailLocalizesTitle 详情按 locale 返回中/英标题。
func TestStreetSnapDetailLocalizesTitle(t *testing.T) {
repo := &fakeSnapRepo{
findByID: &model.StreetSnap{ID: 1, Title: "London Street Style", TitleCn: "伦敦街拍", Cover: "c.jpg"},
listImages: []model.StreetSnapImage{{ID: 1, Image: "a.jpg"}},
}
svc := newSnapService(repo)
cn, err := svc.Detail(context.Background(), "1", "cn")
if err != nil {
t.Fatalf("Detail cn err: %v", err)
}
if cn.Title != "伦敦街拍" {
t.Errorf("cn 标题应为中文,实际 %q", cn.Title)
}
en, err := svc.Detail(context.Background(), "1", "en")
if err != nil {
t.Fatalf("Detail en err: %v", err)
}
if en.Title != "London Street Style" {
t.Errorf("en 标题应为英文,实际 %q", en.Title)
}
}
// TestStreetSnapDetailFallsBackToEn 中文为空时回落英文。
func TestStreetSnapDetailFallsBackToEn(t *testing.T) {
repo := &fakeSnapRepo{findByID: &model.StreetSnap{ID: 1, Title: "Only English", TitleCn: ""}}
svc := newSnapService(repo)
cn, err := svc.Detail(context.Background(), "1", "cn")
if err != nil {
t.Fatalf("Detail cn err: %v", err)
}
if cn.Title != "Only English" {
t.Errorf("中文为空应回落英文,实际 %q", cn.Title)
}
}
// TestStreetSnapListLocalizesTitle 列表按 locale 取标题,中文为空回落英文。
func TestStreetSnapListLocalizesTitle(t *testing.T) {
repo := &fakeSnapRepo{rows: []model.StreetSnap{
{ID: 1, Title: "EN A", TitleCn: "中 A"},
{ID: 2, Title: "EN B", TitleCn: ""},
}}
svc := newSnapService(repo)
got, _, err := svc.List(context.Background(), dto.StreetSnapQuery{Locale: "cn", Images: 0})
if err != nil {
t.Fatalf("List err: %v", err)
}
if len(got) != 2 {
t.Fatalf("期望 2 条,实际 %d", len(got))
}
if got[0].Title != "中 A" || got[1].Title != "EN B" {
t.Errorf("列表本地化错误: %#v", got)
}
}

View File

@ -5,6 +5,7 @@ import (
"errors"
"log"
"net/http"
"sort"
"fashionapi/internal/dto"
"fashionapi/internal/model"
@ -19,13 +20,15 @@ type StreetSnapService interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
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 软删单张街拍图片。
@ -70,7 +73,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
for _, r := range rows {
ids = append(ids, r.ID)
}
list := s.toPublicSnaps(rows)
list := s.toPublicSnaps(rows, q.Locale)
if q.Images > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
@ -91,17 +94,17 @@ func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicSnaps(rows), total, nil
return s.toPublicSnaps(rows, q.Locale), total, nil
}
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap {
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap {
list := make([]dto.PublicStreetSnap, 0, len(rows))
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.ComposeThumb(r.Cover),
ImageCount: r.ImageCount,
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
@ -149,7 +152,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, s.detailErr(err)
@ -158,14 +161,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs), nil
return s.buildSnapDetail(snap, imgs, locale), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByIDBase(ctx, id)
if err != nil {
return nil, s.detailErr(err)
@ -174,7 +177,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs), nil
return s.buildSnapDetail(snap, imgs, locale), nil
}
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
@ -186,10 +189,10 @@ func (s *streetSnapService) detailErr(err error) error {
}
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage) *dto.PublicStreetSnapDetail {
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage, locale string) *dto.PublicStreetSnapDetail {
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title,
Title: pickLocale(snap.Title, snap.TitleCn, locale),
Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs),
}
@ -198,7 +201,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
if len(imgs) == 0 {
return nil
@ -237,12 +240,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
continue
}
pub := s.toPublicSnapImage(im)
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
index[im.ID] = len(out)
out = append(out, pub)
}
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
for _, im := range imgs {
root := resolveRoot(im.ID)
if root == im.ID {
@ -254,14 +257,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
}
pub := s.toPublicSnapImage(im)
pub.IsDetail = 1
pub.LookIndex = out[gi].LookIndex
out[gi].Detail = append(out[gi].Detail, pub)
}
return out
}
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
rows, err := s.repo.Popular(ctx, limit)
if err != nil {
return nil, internalErr(err.Error())
@ -270,7 +273,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
ImageCount: r.ImageCount,
})
@ -323,7 +326,55 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return s.repo.GetForEdit(ctx, id)
snap, imgs, err := s.repo.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 编辑页按「主图 → 紧邻其副图」重排:副图的 sort_order 通常排在全部主图之后,
// 若原样平铺,副图会被甩到几百张图的最末尾,看起来像「副图没显示」。
// 重排后主图与其副图相邻,分组一眼可见;孤儿副图(父不在本记录)追加末尾,不丢图。
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 {
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
detailsByParent := make(map[uint32][]model.StreetSnapImage)
var mains []model.StreetSnapImage
for i := range imgs {
im := &imgs[i]
byID[im.ID] = im
if im.IsDetail == 0 {
mains = append(mains, *im)
} else {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], *im)
}
}
out := make([]model.StreetSnapImage, 0, len(imgs))
emitted := make(map[uint32]bool, len(imgs))
for _, m := range mains {
out = append(out, m)
emitted[m.ID] = true
ds := detailsByParent[m.ID]
sort.Slice(ds, func(i, j int) bool { return ds[i].SortOrder < ds[j].SortOrder })
for _, d := range ds {
out = append(out, d)
emitted[d.ID] = true
}
}
// 未被上述主图认领的副图(父缺失 / 父本身是副图):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && !emitted[im.ID] {
out = append(out, im)
}
}
return out
}
func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
@ -342,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