Compare commits
4 Commits
f04b5707cb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d15d2a4701 | |||
| aaeab41d67 | |||
| 42f6316125 | |||
| 4f4c11d508 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -39,4 +39,5 @@ admin_cookies.txt
|
|||||||
/vendor/
|
/vendor/
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
*.log
|
*.log
|
||||||
|
.superpowers
|
||||||
@ -57,6 +57,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@ -106,6 +107,10 @@ func main() {
|
|||||||
runDump(os.Args[2:])
|
runDump(os.Args[2:])
|
||||||
case "import":
|
case "import":
|
||||||
runImport(os.Args[2:])
|
runImport(os.Args[2:])
|
||||||
|
case "fixdup":
|
||||||
|
runFixDup(os.Args[2:])
|
||||||
|
case "purge-rejected":
|
||||||
|
runPurgeRejected(os.Args[2:])
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@ -120,6 +125,12 @@ func usage() {
|
|||||||
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||||
dbtool import [-config <yml>] [-in <file>] [-clean]
|
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||||
把 dump 出来的 SQL 文件灌入目标库
|
把 dump 出来的 SQL 文件灌入目标库
|
||||||
|
dbtool fixdup [-config <yml>]
|
||||||
|
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
|
||||||
|
dbtool purge-rejected [-config <yml>]
|
||||||
|
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
|
||||||
|
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
|
||||||
|
按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理)
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||||
@ -281,6 +292,164 @@ func runImport(args []string) {
|
|||||||
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
|
||||||
|
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
|
||||||
|
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
|
||||||
|
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
|
||||||
|
func runFixDup(args []string) {
|
||||||
|
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
db, _, err := openDB(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
for _, t := range []string{"brand_runway_images", "street_snap_images"} {
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
UPDATE public.%[1]s
|
||||||
|
SET is_duplicate = 0, dup_of = ''
|
||||||
|
WHERE is_duplicate = 1
|
||||||
|
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
|
||||||
|
SELECT CAST(id AS text) FROM public.%[1]s WHERE is_deleted = 0));`, qi(t))
|
||||||
|
res, err := db.Exec(q)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 清理 %s 失败: %v", t, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
log.Printf("✓ %s: 清理悬空重复标记 %d 行", t, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPurgeRejected 把现存「已驳回(rejected)」且未下架的记录转删除:
|
||||||
|
// 级联软删其全部图片、记录自身置 is_deleted=1(后台 legWhere 强制 is_deleted=0,记录即从列表消失),
|
||||||
|
// 并把图片 key 作为 media_cleanup 任务入队,由运行中的 worker 按引用计数真删 S4 孤儿文件。
|
||||||
|
//
|
||||||
|
// 与后台点「删除(清空 S4)」完全等价(后者走 articleService/streetSnapService.SetDeleted →
|
||||||
|
// PurgeGallery),但本命令一次性全局处理历史遗留的 rejected 数据,无需逐条手动操作。
|
||||||
|
func runPurgeRejected(args []string) {
|
||||||
|
fs := flag.NewFlagSet("purge-rejected", flag.ExitOnError)
|
||||||
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
db, _, err := openDB(*cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// 两种实体:记录主表 / 图片明细表 / 图片表外键列。
|
||||||
|
kinds := []struct {
|
||||||
|
recTable, imgTable, fk string
|
||||||
|
}{
|
||||||
|
{"brand_runways", "brand_runway_images", "runway_id"},
|
||||||
|
{"street_snaps", "street_snap_images", "snap_id"},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
totalRecs, totalKeys := 0, 0
|
||||||
|
var allKeys []string
|
||||||
|
|
||||||
|
for _, k := range kinds {
|
||||||
|
ids, err := selectUint32s(db, fmt.Sprintf(
|
||||||
|
"SELECT id FROM public.%s WHERE status = 'rejected' AND is_deleted = 0", qi(k.recTable)))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 查询 %s 失败: %v", k.recTable, err)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
log.Printf("→ %s: 无 rejected 记录", k.recTable)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
keys, err := selectStrings(db, fmt.Sprintf(
|
||||||
|
"SELECT image FROM public.%s WHERE %s = $1 AND is_deleted = 0", qi(k.imgTable), k.fk), id)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 查询 %s 图片失败: %v", k.imgTable, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(fmt.Sprintf(
|
||||||
|
"UPDATE public.%s SET is_deleted = 1, updated_at = $1 WHERE %s = $2 AND is_deleted = 0",
|
||||||
|
qi(k.imgTable), k.fk), now, id); err != nil {
|
||||||
|
log.Fatalf("✗ 软删 %s 图片失败: %v", k.imgTable, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(fmt.Sprintf(
|
||||||
|
"UPDATE public.%s SET is_deleted = 1, updated_at = $1 WHERE id = $2",
|
||||||
|
qi(k.recTable)), now, id); err != nil {
|
||||||
|
log.Fatalf("✗ 置 %s 下架失败: %v", k.recTable, err)
|
||||||
|
}
|
||||||
|
totalRecs++
|
||||||
|
allKeys = append(allKeys, keys...)
|
||||||
|
totalKeys += len(keys)
|
||||||
|
log.Printf("✓ %s #%d: 软删图片 %d 张", k.recTable, id, len(keys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把 key 分批入队 media_cleanup(与 EnqueueMediaCleanup 同格式),worker 按引用计数清 S4。
|
||||||
|
if len(allKeys) > 0 {
|
||||||
|
const batch = 500
|
||||||
|
for start := 0; start < len(allKeys); start += batch {
|
||||||
|
end := start + batch
|
||||||
|
if end > len(allKeys) {
|
||||||
|
end = len(allKeys)
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(map[string][]string{"keys": allKeys[start:end]})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 构造 payload 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := insertMediaCleanup(db, now, string(payload)); err != nil {
|
||||||
|
log.Fatalf("✗ 入队 media_cleanup 失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("✓ 完成:转删除记录 %d 条、图片 %d 张,已入队 media_cleanup(运行中 worker 将按引用计数清理 S4)", totalRecs, totalKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectUint32s 执行返回单列 uint32 的查询。
|
||||||
|
func selectUint32s(db *sql.DB, q string, args ...any) ([]uint32, error) {
|
||||||
|
rows, err := db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []uint32
|
||||||
|
for rows.Next() {
|
||||||
|
var v uint32
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectStrings 执行返回单列 string 的查询。
|
||||||
|
func selectStrings(db *sql.DB, q string, args ...any) ([]string, error) {
|
||||||
|
rows, err := db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertMediaCleanup 写入一条「清理S4孤儿图」任务(与 repository.EnqueueMediaCleanup 同格式)。
|
||||||
|
// payload 为 {"keys":[...]},由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。
|
||||||
|
func insertMediaCleanup(db *sql.DB, now uint32, payload string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
"INSERT INTO public.ingest_jobs (kind, payload, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
"media_cleanup", payload, "pending", now, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
|
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
|
||||||
//
|
//
|
||||||
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
||||||
|
|||||||
363
cmd/diagdup/main.go
Normal file
363
cmd/diagdup/main.go
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/pkg/phash"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dsn = "host=127.0.0.1 port=5432 user=fashion password=fashion_dev_2026 dbname=fashion sslmode=disable"
|
||||||
|
base = "https://toomstudio.s3.bitiful.net/"
|
||||||
|
)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
ID int64
|
||||||
|
Image string
|
||||||
|
Phash sql.NullString
|
||||||
|
IsDuplicate int16
|
||||||
|
DupOf string
|
||||||
|
RunwayID int64
|
||||||
|
BrandID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryRow(db *sql.DB, like string) *row {
|
||||||
|
r := &row{}
|
||||||
|
q := `SELECT id, image, phash, is_duplicate, dup_of, runway_id, brand_id
|
||||||
|
FROM brand_runway_images WHERE image LIKE $1 ORDER BY id LIMIT 1`
|
||||||
|
err := db.QueryRow(q, like).Scan(&r.ID, &r.Image, &r.Phash, &r.IsDuplicate, &r.DupOf, &r.RunwayID, &r.BrandID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 查询 %s 失败: %v\n", like, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// vectorToUint64 把 "[0,1,...,1]" 转回 uint64(与 phash.ToVectorBits 互逆)。
|
||||||
|
func vectorToUint64(s string) uint64 {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.TrimPrefix(s, "[")
|
||||||
|
s = strings.TrimSuffix(s, "]")
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
var h uint64
|
||||||
|
for i, p := range parts {
|
||||||
|
if i >= 64 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p) == "1" {
|
||||||
|
h |= 1 << uint(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func hamming(a, b uint64) int {
|
||||||
|
c := 0
|
||||||
|
x := a ^ b
|
||||||
|
for x != 0 {
|
||||||
|
x &= x - 1
|
||||||
|
c++
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchImage 下载原图,返回解码后的 image 与原始字节。
|
||||||
|
func fetchImage(key string) (image.Image, []byte, bool) {
|
||||||
|
url := base + strings.TrimPrefix(key, "/")
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 下载 %s 失败: %v\n", url, err)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
fmt.Printf(" 下载 %s 状态 %d\n", url, resp.StatusCode)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 读 %s 失败: %v\n", url, err)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 解码 %s 失败: %v\n", url, err)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return img, data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// aHash 平均哈希:缩到 8x8 灰度,与整图均值比大小,得 64-bit。比 dHash 更关注整体明暗分布。
|
||||||
|
func aHash(img image.Image) uint64 {
|
||||||
|
const n = 8
|
||||||
|
gray := make([][]float64, n)
|
||||||
|
for i := range gray {
|
||||||
|
gray[i] = make([]float64, n)
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
var total float64
|
||||||
|
for y := 0; y < n; y++ {
|
||||||
|
for x := 0; x < n; x++ {
|
||||||
|
sx := b.Min.X + (x*b.Dx())/n + b.Dx()/(2*n)
|
||||||
|
sy := b.Min.Y + (y*b.Dy())/n + b.Dy()/(2*n)
|
||||||
|
r, g, bl, _ := img.At(sx, sy).RGBA()
|
||||||
|
l := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
||||||
|
gray[y][x] = l
|
||||||
|
total += l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mean := total / float64(n*n)
|
||||||
|
var h uint64
|
||||||
|
i := 0
|
||||||
|
for y := 0; y < n; y++ {
|
||||||
|
for x := 0; x < n; x++ {
|
||||||
|
if gray[y][x] > mean {
|
||||||
|
h |= 1 << uint(i)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// pHash DCT 感知哈希:缩到 32x32 灰度 → 2D DCT → 取左上 8x8 低频系数 → 与中位数比大小得 64-bit。
|
||||||
|
func pHash(img image.Image) uint64 {
|
||||||
|
const N = 32
|
||||||
|
g := [N][N]float64{}
|
||||||
|
b := img.Bounds()
|
||||||
|
for cy := 0; cy < N; cy++ {
|
||||||
|
for cx := 0; cx < N; cx++ {
|
||||||
|
x0 := b.Min.X + cx*b.Dx()/N
|
||||||
|
x1 := b.Min.X + (cx+1)*b.Dx()/N
|
||||||
|
y0 := b.Min.Y + cy*b.Dy()/N
|
||||||
|
y1 := b.Min.Y + (cy+1)*b.Dy()/N
|
||||||
|
if x1 <= x0 {
|
||||||
|
x1 = x0 + 1
|
||||||
|
}
|
||||||
|
if y1 <= y0 {
|
||||||
|
y1 = y0 + 1
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
var n int
|
||||||
|
for y := y0; y < y1 && y < b.Max.Y; y++ {
|
||||||
|
for x := x0; x < x1 && x < b.Max.X; x++ {
|
||||||
|
r, gg, bl, _ := img.At(x, y).RGBA()
|
||||||
|
sum += 0.299*float64(r>>8) + 0.587*float64(gg>>8) + 0.114*float64(bl>>8)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
g[cy][cx] = sum / float64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 行 DCT-II
|
||||||
|
var rows [N][N]float64
|
||||||
|
for y := 0; y < N; y++ {
|
||||||
|
for u := 0; u < N; u++ {
|
||||||
|
var s float64
|
||||||
|
for x := 0; x < N; x++ {
|
||||||
|
s += g[y][x] * math.Cos(math.Pi*float64(u)*(float64(x)+0.5)/float64(N))
|
||||||
|
}
|
||||||
|
rows[y][u] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 列 DCT-II
|
||||||
|
var dct [N][N]float64
|
||||||
|
for x := 0; x < N; x++ {
|
||||||
|
for v := 0; v < N; v++ {
|
||||||
|
var s float64
|
||||||
|
for y := 0; y < N; y++ {
|
||||||
|
s += rows[y][x] * math.Cos(math.Pi*float64(v)*(float64(y)+0.5)/float64(N))
|
||||||
|
}
|
||||||
|
dct[v][x] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 取左上 8x8 低频,与中位数比大小
|
||||||
|
var coeffs [64]float64
|
||||||
|
i := 0
|
||||||
|
for v := 0; v < 8; v++ {
|
||||||
|
for u := 0; u < 8; u++ {
|
||||||
|
coeffs[i] = dct[v][u]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sorted := append([]float64{}, coeffs[:]...)
|
||||||
|
for a := 0; a < len(sorted); a++ {
|
||||||
|
for c := a + 1; c < len(sorted); c++ {
|
||||||
|
if sorted[c] < sorted[a] {
|
||||||
|
sorted[a], sorted[c] = sorted[c], sorted[a]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
median := sorted[len(sorted)/2]
|
||||||
|
var h uint64
|
||||||
|
for k := 0; k < 64; k++ {
|
||||||
|
if coeffs[k] > median {
|
||||||
|
h |= 1 << uint(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// luminanceStats 算亮度均值/标准差,并用 phash.Of 复算指纹。
|
||||||
|
func luminanceStats(img image.Image, data []byte) (mean, std float64, recomputed uint64, ok bool) {
|
||||||
|
recomputed = phash.Of(data)
|
||||||
|
b := img.Bounds()
|
||||||
|
var sum, sum2 float64
|
||||||
|
var n int
|
||||||
|
step := 1
|
||||||
|
if b.Dx() > 300 || b.Dy() > 300 {
|
||||||
|
step = int(math.Max(1, float64(b.Dx())/300))
|
||||||
|
}
|
||||||
|
for y := b.Min.Y; y < b.Max.Y; y += step {
|
||||||
|
for x := b.Min.X; x < b.Max.X; x += step {
|
||||||
|
r, g, bl, _ := img.At(x, y).RGBA()
|
||||||
|
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
|
||||||
|
sum += lum
|
||||||
|
sum2 += lum * lum
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mean = sum / float64(n)
|
||||||
|
variance := sum2/float64(n) - mean*mean
|
||||||
|
if variance < 0 {
|
||||||
|
variance = 0
|
||||||
|
}
|
||||||
|
std = math.Sqrt(variance)
|
||||||
|
ok = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func dumpBits(h uint64) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
if (h>>uint(i))&1 == 1 {
|
||||||
|
sb.WriteByte('1')
|
||||||
|
} else {
|
||||||
|
sb.WriteByte('0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("open db:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
k1 := "6837152087e6b9849368860811920d98d6b986dc"
|
||||||
|
k2 := "53f47b14a8799e682864fb7ffc1f942caaeeb56b"
|
||||||
|
if len(os.Args) >= 3 {
|
||||||
|
k1, k2 = os.Args[1], os.Args[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
r1 := queryRow(db, "%"+k1+"%")
|
||||||
|
r2 := queryRow(db, "%"+k2+"%")
|
||||||
|
if r1 == nil || r2 == nil {
|
||||||
|
fmt.Println("未能取到两行,退出")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("==== 行 1 ====")
|
||||||
|
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
|
||||||
|
r1.ID, r1.Image, r1.IsDuplicate, r1.DupOf, r1.RunwayID, r1.BrandID)
|
||||||
|
fmt.Println("==== 行 2 ====")
|
||||||
|
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
|
||||||
|
r2.ID, r2.Image, r2.IsDuplicate, r2.DupOf, r2.RunwayID, r2.BrandID)
|
||||||
|
|
||||||
|
var h1, h2 uint64
|
||||||
|
if r1.Phash.Valid {
|
||||||
|
h1 = vectorToUint64(r1.Phash.String)
|
||||||
|
fmt.Printf("phash1=%s\nbits1=%s\n", r1.Phash.String, dumpBits(h1))
|
||||||
|
} else {
|
||||||
|
fmt.Println("phash1=NULL")
|
||||||
|
}
|
||||||
|
if r2.Phash.Valid {
|
||||||
|
h2 = vectorToUint64(r2.Phash.String)
|
||||||
|
fmt.Printf("phash2=%s\nbits2=%s\n", r2.Phash.String, dumpBits(h2))
|
||||||
|
} else {
|
||||||
|
fmt.Println("phash2=NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r1.Phash.Valid && r2.Phash.Valid {
|
||||||
|
ham := hamming(h1, h2)
|
||||||
|
fmt.Printf("\n>>> 两张图之间的真实汉明距离 = %d (系统阈值 DefaultThreshold=4)\n", ham)
|
||||||
|
if ham <= 4 {
|
||||||
|
fmt.Println(">>> 系统会把这两者判为近重复(≤4)。")
|
||||||
|
} else {
|
||||||
|
fmt.Println(">>> 这两张彼此并不在 ≤4 内;若被标重,必是各自 dup_of 指向了不同的第三者。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 亮度标准差 + 指纹复算(验证“平淡图哈希塌缩”假说)+ aHash 区分度
|
||||||
|
fmt.Println("\n==== 像素核验(原图,无 style)====")
|
||||||
|
var imgs [2]image.Image
|
||||||
|
var ahs [2]uint64
|
||||||
|
var phs [2]uint64
|
||||||
|
for i, r := range []*row{r1, r2} {
|
||||||
|
img, data, ok := fetchImage(r.Image)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imgs[i] = img
|
||||||
|
ahs[i] = aHash(img)
|
||||||
|
phs[i] = pHash(img)
|
||||||
|
mean, std, recomputed, ok2 := luminanceStats(img, data)
|
||||||
|
if !ok2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("image=%s\n 亮度均值=%.1f 标准差=%.1f (标准差越低越“平”,哈希越易塌缩)\n",
|
||||||
|
r.Image, mean, std)
|
||||||
|
stored := vectorToUint64(r.Phash.String)
|
||||||
|
fmt.Printf(" 复算phash与库存phash一致=%v 复算bits=%s\n", recomputed == stored, dumpBits(recomputed))
|
||||||
|
fmt.Printf(" aHash bits=%s\n", dumpBits(ahs[i]))
|
||||||
|
fmt.Printf(" pHash bits=%s\n", dumpBits(phs[i]))
|
||||||
|
}
|
||||||
|
if imgs[0] != nil && imgs[1] != nil {
|
||||||
|
dh := 0
|
||||||
|
if r1.Phash.Valid && r2.Phash.Valid {
|
||||||
|
dh = hamming(vectorToUint64(r1.Phash.String), vectorToUint64(r2.Phash.String))
|
||||||
|
}
|
||||||
|
fmt.Printf("\n>>> dHash 汉明距离 = %d(库存判定,≤4 判重)\n", dh)
|
||||||
|
fmt.Printf(">>> aHash 汉明距离 = %d\n", hamming(ahs[0], ahs[1]))
|
||||||
|
fmt.Printf(">>> pHash 汉明距离 = %d (pHash 常规判重阈值约 10~15,远超 dHash 的 4)\n", hamming(phs[0], phs[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若某行 dup_of 指向第三者,把它也拉出来看
|
||||||
|
for _, r := range []*row{r1, r2} {
|
||||||
|
if r.DupOf != "" && r.DupOf != "0" {
|
||||||
|
tgt := &row{}
|
||||||
|
err := db.QueryRow(`SELECT id, image, phash, runway_id, brand_id FROM brand_runway_images WHERE id=$1`, r.DupOf).
|
||||||
|
Scan(&tgt.ID, &tgt.Image, &tgt.Phash, &tgt.RunwayID, &tgt.BrandID)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf("\n>>> 行 %d 的 dup_of=%s 指向:\n id=%d image=%s runway_id=%d brand_id=%d\n",
|
||||||
|
r.ID, r.DupOf, tgt.ID, tgt.Image, tgt.RunwayID, tgt.BrandID)
|
||||||
|
if tgt.Phash.Valid {
|
||||||
|
th := vectorToUint64(tgt.Phash.String)
|
||||||
|
fmt.Printf(" 与该目标汉明距离=%d\n", hamming(vectorToUint64(r.Phash.String), th))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -94,6 +94,8 @@ func main() {
|
|||||||
histRepo = repository.NewHistoryRepository(db)
|
histRepo = repository.NewHistoryRepository(db)
|
||||||
ingestRepo = repository.NewIngestRepository(db)
|
ingestRepo = repository.NewIngestRepository(db)
|
||||||
reviewRepo = repository.NewReviewRepository(db)
|
reviewRepo = repository.NewReviewRepository(db)
|
||||||
|
// 文章管理混合列表(跨 brand_runways / street_snaps 的统一只读查询)
|
||||||
|
adminRepo = repository.NewAdminRepository(db)
|
||||||
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
|
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
|
||||||
mediaRepo = repository.NewMediaRepository(db)
|
mediaRepo = repository.NewMediaRepository(db)
|
||||||
|
|
||||||
@ -124,6 +126,9 @@ func main() {
|
|||||||
reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel)
|
reviewSvc = service.NewReviewService(reviewRepo, mediaRepo, imgComposer, imgDel)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 把图片 URL 拼装器交给后台模板:编辑/详情页用 imgURL 渲染图片,S4 模式下也能打开。
|
||||||
|
handler.SetImgComposer(imgComposer)
|
||||||
|
|
||||||
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
|
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
|
||||||
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
|
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
|
||||||
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
|
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
|
||||||
@ -138,7 +143,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp, cfg.Ingest.DownloadProxy)
|
||||||
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
||||||
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
@ -156,7 +161,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
|
// 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 验签入口)
|
// 爬虫上报 handler(HMAC 验签入口)
|
||||||
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
ingestHandler := handler.NewIngestHandler(ingestSvc, brandSvc)
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,7 @@ cors:
|
|||||||
- X-Sign
|
- X-Sign
|
||||||
- X-Sign-Ts
|
- X-Sign-Ts
|
||||||
- X-Sign-Nonce
|
- X-Sign-Nonce
|
||||||
|
- X-Locale
|
||||||
max_age: 86400 # 预检请求缓存秒数
|
max_age: 86400 # 预检请求缓存秒数
|
||||||
|
|
||||||
# 公开接口「前端 JS 签名」:给 /runway-looks(列表)与 /brands 两个批量接口加请求签名校验,
|
# 公开接口「前端 JS 签名」:给 /runway-looks(列表)与 /brands 两个批量接口加请求签名校验,
|
||||||
@ -84,6 +85,11 @@ client_sign:
|
|||||||
ingest:
|
ingest:
|
||||||
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
||||||
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
||||||
|
# 本机下载代理(仅本地开发用,不提交):worker 下载图片(如 assets.vogue.com)经此代理访问源站。
|
||||||
|
# 支持 http:// 或 socks5://,例如 socks5://127.0.0.1:51831 或 http://127.0.0.1:58591。
|
||||||
|
# 留空直连;环境变量 INGEST_DOWNLOAD_PROXY 可覆盖本值(二者均只作用于「下载」,不影响 S4 上传与数据库)。
|
||||||
|
# 实际值建议写 configs/config.local.yml(已被 .gitignore 忽略),避免进入共享配置。
|
||||||
|
download_proxy: "" # env: INGEST_DOWNLOAD_PROXY
|
||||||
|
|
||||||
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
|
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
|
||||||
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)
|
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)
|
||||||
|
|||||||
@ -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 status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
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 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) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
|
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
|
||||||
-- 2026-09-22-01b-publish-existing-rows.sql。
|
-- 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);
|
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
|
||||||
|
|
||||||
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
|
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
|
||||||
-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。
|
-- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列
|
||||||
|
-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。
|
||||||
|
-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。
|
||||||
DROP VIEW IF EXISTS public_brand_runway_images;
|
DROP VIEW IF EXISTS public_brand_runway_images;
|
||||||
DROP VIEW IF EXISTS public_brand_runways;
|
DROP VIEW IF EXISTS public_brand_runways;
|
||||||
DROP VIEW IF EXISTS public_street_snap_images;
|
DROP VIEW IF EXISTS public_street_snap_images;
|
||||||
DROP VIEW IF EXISTS public_street_snaps;
|
DROP VIEW IF EXISTS public_street_snaps;
|
||||||
|
|
||||||
CREATE VIEW public_brand_runways AS
|
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
|
CREATE VIEW public_brand_runway_images AS
|
||||||
SELECT i.* FROM brand_runway_images i
|
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;
|
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
|
||||||
|
|
||||||
CREATE VIEW public_street_snaps AS
|
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
|
CREATE VIEW public_street_snap_images AS
|
||||||
SELECT i.* FROM street_snap_images i
|
SELECT i.* FROM street_snap_images i
|
||||||
|
|||||||
@ -4,8 +4,11 @@
|
|||||||
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
||||||
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
||||||
--
|
--
|
||||||
-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致,
|
-- 部分索引(WHERE is_deleted = 0):仅约束活着行,保证「同一实体至多一行活着」。
|
||||||
-- 软删的旧实体键不再占用,允许同实体重新入库。
|
-- 注:入库服务层 RunwayEntityState / StreetSnapEntityState 现已**不再**以 is_deleted = 0 过滤,
|
||||||
|
-- 软删实体也会被查到并由 worker 跳过重爬(「删除即永久」)。本索引仍保持部分索引是为了
|
||||||
|
-- 兼容存量「软删行同键共存」的数据、避免建全量唯一索引时因历史重复而失败;结构性唯一性靠它兜底,
|
||||||
|
-- 行为上的「删除即永久」由服务层查重保证。
|
||||||
|
|
||||||
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
40
db/migrations/2026-09-24-01-runway-parent-image-id.sql
Normal file
40
db/migrations/2026-09-24-01-runway-parent-image-id.sql
Normal 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;
|
||||||
7
db/migrations/2026-09-26-01-drop-reject-reason.sql
Normal file
7
db/migrations/2026-09-26-01-drop-reject-reason.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- 移除「驳回理由」:审核流程不再要求/存储理由,删除 reject_reason 列。
|
||||||
|
-- 视图 public_brand_runways / public_street_snaps 本就显式排除该列,无需改动。
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
DROP COLUMN IF EXISTS reject_reason;
|
||||||
|
|
||||||
|
ALTER TABLE street_snaps
|
||||||
|
DROP COLUMN IF EXISTS reject_reason;
|
||||||
12
db/migrations/2026-09-27-01-add-runway-published-at.sql
Normal file
12
db/migrations/2026-09-27-01-add-runway-published-at.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- 新增走秀发布日期列 published_at:从 Vogue 详情页 application/ld+json 的 datePublished 抽取,
|
||||||
|
-- 以 Unix 时间戳(秒,bigint)存储,0 表示未抽取/未知,与 created_at / updated_at 的约定一致。
|
||||||
|
--
|
||||||
|
-- 用途:
|
||||||
|
-- 1) 作为走秀的权威发布时间存下来(替代 created_at/updated_at 这类入库时间)。
|
||||||
|
-- 2) 标题缺年份(year=0、season_code 为空)的秀,用发布日期的年份补全 year / season_code,
|
||||||
|
-- 使这些秀重新可被「按年份/季节筛选」与热门排序(按 year + season_code)命中。
|
||||||
|
--
|
||||||
|
-- 回填为一次性操作:重跑 spider vogue 时,已存在实体走跳过分支按主键 id 写回 published_at
|
||||||
|
-- (及缺失年份),不重下图片。
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
ADD COLUMN IF NOT EXISTS published_at bigint NOT NULL DEFAULT 0;
|
||||||
15390
db_dump.sql
15390
db_dump.sql
File diff suppressed because one or more lines are too long
2341
docs/superpowers/plans/2026-09-22-single-table-publish.md
Normal file
2341
docs/superpowers/plans/2026-09-22-single-table-publish.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -254,5 +254,7 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
|||||||
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
|
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
|
||||||
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
|
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
|
||||||
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
|
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. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
|
||||||
|
- 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。
|
||||||
|
|||||||
@ -119,6 +119,10 @@ type IngestConfig struct {
|
|||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
||||||
TTLSeconds int `yaml:"ttl_seconds"`
|
TTLSeconds int `yaml:"ttl_seconds"`
|
||||||
|
// DownloadProxy 本机下载代理(仅本地开发用):worker 下载图片经此代理访问源站,
|
||||||
|
// 支持 http:// 或 socks5://(如 socks5://127.0.0.1:51831、http://127.0.0.1:58591)。
|
||||||
|
// 仅作用于图片下载客户端,不影响 S4 上传与数据库连接;留空直连。env: INGEST_DOWNLOAD_PROXY
|
||||||
|
DownloadProxy string `yaml:"download_proxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
@ -212,8 +216,9 @@ func defaultConfig() *Config {
|
|||||||
TTLSeconds: 30,
|
TTLSeconds: 30,
|
||||||
},
|
},
|
||||||
Ingest: IngestConfig{
|
Ingest: IngestConfig{
|
||||||
Secret: "",
|
Secret: "",
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
|
DownloadProxy: "",
|
||||||
},
|
},
|
||||||
S4: S4Config{
|
S4: S4Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
@ -268,11 +273,33 @@ func Load(explicit string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.loadedFrom = loadedFrom
|
cfg.loadedFrom = loadedFrom
|
||||||
|
|
||||||
|
// 本机私有覆盖(config.local.yml,已被 .gitignore 忽略):叠加在 config.yml 之上,
|
||||||
|
// 用于放本机相关、不应进版本库的设置(如本地代理 download_proxy)。环境变量仍拥有最高优先级。
|
||||||
|
overlayLocalConfig(cfg)
|
||||||
|
|
||||||
cfg.applyEnv()
|
cfg.applyEnv()
|
||||||
cfg.normalize()
|
cfg.normalize()
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overlayLocalConfig 把本机私有覆盖文件 config.local.yml(若存在)叠加到配置上。
|
||||||
|
// 仅用于本机相关、不应进版本库的设置(如本地代理 download_proxy);文件不存在则静默跳过。
|
||||||
|
// 与 config.yml 同样走 goccy/go-yaml 反序列化,仅覆盖文件中出现的键;环境变量仍在 applyEnv 中拥有最高优先级。
|
||||||
|
func overlayLocalConfig(cfg *Config) {
|
||||||
|
for _, p := range []string{
|
||||||
|
"configs/config.local.yml",
|
||||||
|
"config.local.yml",
|
||||||
|
filepath.Join("..", "..", "configs", "config.local.yml"),
|
||||||
|
} {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = yaml.Unmarshal(data, cfg) // 解析失败静默跳过:本地覆盖属尽力而为,不应阻断启动
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。
|
// LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。
|
||||||
func (c *Config) LoadedFrom() string { return c.loadedFrom }
|
func (c *Config) LoadedFrom() string { return c.loadedFrom }
|
||||||
|
|
||||||
@ -310,6 +337,7 @@ func (c *Config) applyEnv() {
|
|||||||
|
|
||||||
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
||||||
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
||||||
|
envStr("INGEST_DOWNLOAD_PROXY", &c.Ingest.DownloadProxy)
|
||||||
|
|
||||||
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
|
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
|
||||||
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
|
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
|
||||||
|
|||||||
@ -79,12 +79,10 @@ type PublicArticleImage struct {
|
|||||||
Thumb string `json:"thumb,omitempty"`
|
Thumb string `json:"thumb,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
|
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
|
||||||
LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值
|
|
||||||
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
|
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
|
||||||
|
|
||||||
// Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。
|
// Detail 仅详情接口填充:该主图下的细节图子集,元素形状与 Images 递归同构。
|
||||||
// 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份
|
// 前端直接读它即可,无需再维护一份分组映射表。列表接口不填(omitempty 后不出现)。
|
||||||
// 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。
|
|
||||||
Detail []PublicArticleImage `json:"detail,omitempty"`
|
Detail []PublicArticleImage `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
internal/dto/dup_source.go
Normal file
10
internal/dto/dup_source.go
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DupSource 编辑页反查的「重复来源」上下文:某张被标记为重复的图所指向的源图,
|
||||||
|
// 及其所属走秀/街拍(对外统称"文章")的基本信息,供编辑员核对重复归属。
|
||||||
|
type DupSource struct {
|
||||||
|
Image string // 源图存储值(S4 key / 相对路径 / 外链),由模板经 imgURL 拼成可访问 URL
|
||||||
|
RecordID uint32 // 源图所属走秀/街拍的数字主键
|
||||||
|
Title string // 源图所属记录的标题(英文优先,回落中文)
|
||||||
|
Brand string // 源图所属走秀的品牌名(街拍无品牌关联,为空)
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ type RunwayIngest struct {
|
|||||||
Season string `json:"season"` // spring / fall
|
Season string `json:"season"` // spring / fall
|
||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
City string `json:"city"` // 地区/城市(street 用)
|
City string `json:"city"` // 地区/城市(street 用)
|
||||||
|
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
|
||||||
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
||||||
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
||||||
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
||||||
|
|||||||
@ -290,7 +290,11 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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); }
|
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
|
||||||
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
||||||
.cell .thumb {
|
.cell .thumb {
|
||||||
@ -319,8 +323,18 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
transition: opacity .15s;
|
transition: opacity .15s;
|
||||||
}
|
}
|
||||||
.cell .thumb:hover::after { opacity: 1; }
|
.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 .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 { 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; }
|
.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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn:hover { border-color: #111; }
|
.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 {
|
.chk {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -357,6 +409,25 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
.chk:hover { border-color: #111; }
|
.chk:hover { border-color: #111; }
|
||||||
.chk input { margin: 0; }
|
.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 {
|
.batchbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@ -430,3 +501,22 @@ tbody tr:last-child td { border-bottom: 0; }
|
|||||||
.row { flex-direction: column; gap: 0; }
|
.row { flex-direction: column; gap: 0; }
|
||||||
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 编辑页重复图标记 ---------- */
|
||||||
|
.cell .badge.dup { margin-left: 8px; vertical-align: middle; }
|
||||||
|
.cell .dup-src { margin: 0 8px 6px; font-size: 12px; }
|
||||||
|
.cell .dup-src > summary { cursor: pointer; color: #6b21a8; user-select: none; outline: none; }
|
||||||
|
.cell .dup-src-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #faf5ff;
|
||||||
|
border: 1px solid #e9d5ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cell .dup-src-body img { width: 56px; height: 84px; object-fit: cover; border-radius: 4px; flex: 0 0 auto; background: #eee; }
|
||||||
|
.cell .dup-title { margin-bottom: 2px; color: #333; }
|
||||||
|
.cell .dup-link { color: #6b21a8; font-weight: 600; text-decoration: none; }
|
||||||
|
.cell .dup-link:hover { text-decoration: underline; }
|
||||||
|
|||||||
@ -1,402 +1,215 @@
|
|||||||
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。
|
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
|
||||||
*
|
// 两个入口页面:
|
||||||
* 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底
|
// - 文章管理混合列表(/admin/articles):批量通过
|
||||||
* (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事:
|
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
|
||||||
* 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需;
|
|
||||||
* 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次);
|
|
||||||
* 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。
|
|
||||||
*
|
|
||||||
* 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。
|
|
||||||
*/
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
// 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。
|
||||||
|
function apiOf(action) {
|
||||||
var doc = document;
|
return action.replace('/admin/articles/', '/admin/api/articles/');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function postJSON(url, body) {
|
// ── 看大图弹层(所有页面通用)────────────────────────────────────────────
|
||||||
return fetch(url, {
|
function initZoom() {
|
||||||
method: 'POST',
|
var modal = document.getElementById('zoom-modal');
|
||||||
credentials: 'same-origin',
|
if (!modal) return;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
var img = document.getElementById('zoom-img');
|
||||||
body: JSON.stringify(body || {}),
|
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
|
||||||
}).then(function (res) {
|
btn.addEventListener('click', function () {
|
||||||
return res.json().catch(function () { return null; }).then(function (data) {
|
img.src = btn.querySelector('img').src;
|
||||||
if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')'));
|
modal.showModal();
|
||||||
return data;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
|
if (e.target === modal) modal.close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...)
|
// ── 文章管理列表:批量通过 ──────────────────────────────────────
|
||||||
function apiURL(action) {
|
function initArticleList() {
|
||||||
return String(action).replace('/admin/reviews/', '/admin/api/reviews/');
|
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');
|
||||||
|
|
||||||
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 refresh() {
|
function refresh() {
|
||||||
var n = picked().length;
|
var n = picks().filter(function (c) { return c.checked; }).length;
|
||||||
bar.hidden = n === 0;
|
if (bar) bar.hidden = n === 0;
|
||||||
if (counter) counter.textContent = '已选 ' + n + ' 条';
|
if (count) count.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (selAll) selAll.addEventListener('change', function () {
|
||||||
table.addEventListener('change', function (e) {
|
picks().forEach(function (c) { c.checked = selAll.checked; });
|
||||||
if (e.target.classList.contains('row-pick')) refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
if (selAll) {
|
picks().forEach(function (c) { c.addEventListener('change', refresh); });
|
||||||
selAll.addEventListener('change', function () {
|
|
||||||
rows.forEach(function (r) {
|
|
||||||
var c = $('input.row-pick', r);
|
|
||||||
if (c) c.checked = selAll.checked;
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit(action, reason) {
|
var clear = document.getElementById('batch-clear');
|
||||||
var items = picked().map(function (r) {
|
if (clear) clear.addEventListener('click', function () {
|
||||||
return { kind: r.dataset.kind, id: Number(r.dataset.id) };
|
picks().forEach(function (c) { c.checked = false; });
|
||||||
});
|
if (selAll) selAll.checked = false;
|
||||||
if (!items.length) return;
|
refresh();
|
||||||
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();
|
|
||||||
})
|
|
||||||
.catch(function (err) { toast(err.message, false); })
|
|
||||||
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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();
|
function postBatch(action) {
|
||||||
}
|
var items = picks().filter(function (c) {
|
||||||
|
return c.checked;
|
||||||
/* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */
|
}).map(function (c) {
|
||||||
|
var tr = c.closest('tr');
|
||||||
function initReviewDetail() {
|
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
|
||||||
var grid = $('.grid');
|
});
|
||||||
if (!grid) return;
|
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
|
||||||
var cells = $$('.cell', grid);
|
if (!confirm('确认通过选中的 ' + items.length + ' 条?')) return;
|
||||||
var attachForm = doc.getElementById('batch');
|
fetch('/admin/api/articles/batch', {
|
||||||
|
method: 'POST',
|
||||||
// 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
function applyGroups(res) {
|
body: JSON.stringify({ action: action, items: items })
|
||||||
var images = res.images || [];
|
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
var byId = {};
|
if (d.failed && d.failed.length) {
|
||||||
images.forEach(function (i) { byId[i.id] = i; });
|
alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error);
|
||||||
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);
|
|
||||||
if (role) {
|
|
||||||
role.textContent = st.isDetail === 1
|
|
||||||
? ('副图 → #' + st.parentId)
|
|
||||||
: ('主图' + (st.detailCount ? ' · ' + st.detailCount + ' 副图' : ''));
|
|
||||||
}
|
}
|
||||||
$$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; });
|
location.reload();
|
||||||
$$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; });
|
}).catch(function (e) { alert('请求失败:' + e); });
|
||||||
|
}
|
||||||
|
var approve = document.getElementById('batch-approve');
|
||||||
|
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每个主图各自的「显示副图」开关:点一下只展开/收起该主图紧邻的副图(不会一次全开)。
|
||||||
|
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 + ' 张)');
|
||||||
});
|
});
|
||||||
var stat = doc.getElementById('img-stat');
|
|
||||||
if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用)
|
// 合并选中为一组(AJAX,成功后就地更新标签)
|
||||||
function intercept(form, buildBody) {
|
if (batchForm) batchForm.addEventListener('submit', function (e) {
|
||||||
form.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)
|
||||||
|
})
|
||||||
|
.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); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 单张拆出(AJAX)
|
||||||
|
document.querySelectorAll('form[data-only="detail"]').forEach(function (f) {
|
||||||
|
f.addEventListener('submit', function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var body = buildBody(form);
|
fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) })
|
||||||
if (body === null) return; // 校验未过:交给原生提交去报错
|
.then(function (r) { return r.json(); })
|
||||||
postJSON(apiURL(form.getAttribute('action')), body)
|
.then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
|
||||||
.then(function (res) {
|
.catch(function (err) { alert('拆出失败:' + err); });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 启动 ---------------- */
|
// 从 /admin/articles/<kind>/... 形式的 action 取出实体类型(street / runway)。
|
||||||
|
function kindOf(action) {
|
||||||
|
var m = /^\/admin\/articles\/(\w+)\//.exec(action || '');
|
||||||
|
return m ? m[1] : 'street';
|
||||||
|
}
|
||||||
|
|
||||||
function boot() {
|
function clearSelection() {
|
||||||
// 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题)
|
document.querySelectorAll('input[name="main"]:checked, input[name="img"]:checked').forEach(function (c) { c.checked = false; });
|
||||||
doc.documentElement.setAttribute('data-admin-js', 'ready');
|
}
|
||||||
initLightbox();
|
|
||||||
initReviewList();
|
// 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。
|
||||||
initReviewDetail();
|
function updateToggle(mainId, detailCount) {
|
||||||
var hint = doc.getElementById('hint-dismiss');
|
var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]');
|
||||||
if (hint) {
|
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
|
||||||
hint.addEventListener('click', function () {
|
if (detailCount > 0) {
|
||||||
var box = hint.closest('.hint');
|
if (!btn && cell) {
|
||||||
if (box) box.hidden = true;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot);
|
// 展开/收起某主图的全部副图,并同步按钮文案。
|
||||||
else boot();
|
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) {
|
||||||
|
if (im.isDetail === 1) {
|
||||||
|
role.textContent = (kind === 'runway' ? '细节图' : '副图') + ' → #' + im.parentId;
|
||||||
|
} else {
|
||||||
|
role.textContent = '主图' + (im.detailCount ? (' · ' + im.detailCount + ' 副图') : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (focusMain) setExpanded(focusMain, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initZoom();
|
||||||
|
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
|
||||||
|
if (document.getElementById('batch')) initArticleDetail();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
||||||
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
||||||
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
||||||
// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。
|
// 同时表单式路由(/admin/articles/... 下的 approve/attach/detach)原样保留:关掉 JS 后台依然可用。
|
||||||
//
|
//
|
||||||
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
||||||
package handler
|
package handler
|
||||||
@ -26,26 +26,25 @@ type reviewBatchItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type reviewBatchReq struct {
|
type reviewBatchReq struct {
|
||||||
// Action: approve | reject
|
// Action: approve
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Reason string `json:"reason"`
|
|
||||||
Items []reviewBatchItem `json:"items"`
|
Items []reviewBatchItem `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewBatch 批量通过 / 驳回审核记录。
|
// ArticleBatch 文章管理批量通过(替代原审核批量接口)。
|
||||||
//
|
//
|
||||||
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
// 请求体:{"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
|
var req reviewBatchReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Action != "approve" && req.Action != "reject" {
|
if req.Action != "approve" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve 或 reject"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
@ -56,11 +55,6 @@ func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Action == "reject" && req.Reason == "" {
|
|
||||||
// 驳回理由必填:留痕是审核流程的意义所在。
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "驳回必须填写理由"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
registered := h.registeredKinds(ctx)
|
registered := h.registeredKinds(ctx)
|
||||||
@ -77,8 +71,6 @@ func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
|
|||||||
var err error
|
var err error
|
||||||
if req.Action == "approve" {
|
if req.Action == "approve" {
|
||||||
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
||||||
} else {
|
|
||||||
err = h.review.Reject(ctx, item.Kind, item.ID, reviewer, req.Reason)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
||||||
@ -99,10 +91,10 @@ type reviewAttachReq struct {
|
|||||||
Imgs []uint32 `json:"imgs"`
|
Imgs []uint32 `json:"imgs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
|
// ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。
|
||||||
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
||||||
// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。
|
// 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。
|
||||||
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
|
func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) {
|
||||||
kind := c.Param("kind")
|
kind := c.Param("kind")
|
||||||
id := atoiDefault(c.Param("id"), 0)
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
@ -129,8 +121,8 @@ func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
|
|||||||
h.respondRecordImages(c, kind, uint32(id))
|
h.respondRecordImages(c, kind, uint32(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
|
// ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。
|
||||||
func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
|
func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) {
|
||||||
kind := c.Param("kind")
|
kind := c.Param("kind")
|
||||||
id := atoiDefault(c.Param("id"), 0)
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
imgID := atoiDefault(c.Param("img"), 0)
|
imgID := atoiDefault(c.Param("img"), 0)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -8,7 +9,9 @@ import (
|
|||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/middleware"
|
"fashionapi/internal/middleware"
|
||||||
|
"fashionapi/internal/model"
|
||||||
"fashionapi/internal/pkg/hashid"
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
"fashionapi/internal/pkg/pagination"
|
"fashionapi/internal/pkg/pagination"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
|
||||||
@ -24,17 +27,18 @@ const adminCookie = "admin_session"
|
|||||||
// 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权,
|
// 定位:朴素、实用、代码简洁的服务端渲染后台,复用现有 service 层读库与鉴权,
|
||||||
// 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。
|
// 不引入额外前端框架。会话用 access token 存 HttpOnly cookie,由 router 层中间件校验。
|
||||||
type BackstageHandler struct {
|
type BackstageHandler struct {
|
||||||
auth service.AuthService
|
auth service.AuthService
|
||||||
brand service.BrandService
|
brand service.BrandService
|
||||||
article service.ArticleService
|
article service.ArticleService
|
||||||
street service.StreetSnapService
|
street service.StreetSnapService
|
||||||
review service.ReviewService
|
review service.ReviewService
|
||||||
ingest *service.IngestService
|
ingest *service.IngestService
|
||||||
|
adminRepo repository.AdminRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBackstageHandler 创建后台 handler。
|
// NewBackstageHandler 创建后台 handler。
|
||||||
func NewBackstageHandler(auth service.AuthService, brand service.BrandService, article service.ArticleService, street service.StreetSnapService, review service.ReviewService, ingest *service.IngestService) *BackstageHandler {
|
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}
|
return &BackstageHandler{auth: auth, brand: brand, article: article, street: street, review: review, ingest: ingest, adminRepo: adminRepo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模板渲染机制见 templates.go:模板已抽成 templates/ 下的独立 .html 文件(go:embed),
|
// 模板渲染机制见 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})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
return
|
||||||
@ -304,50 +308,65 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
|
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 审核记录 ───────────────────────────────────────────────────────────────────
|
// ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)─────────────
|
||||||
|
|
||||||
// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
|
// Articles 文章管理混合列表:跨 brand_runways / street_snaps 统一展示,按状态 + 类型筛选。
|
||||||
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
|
// 这是「审核 / 走秀 / 街拍」三页合一后的唯一入口:通过 / 编辑 / 删除(下架)都在这里发起。
|
||||||
func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
func (h *BackstageHandler) Articles(c *gin.Context) {
|
||||||
kind := c.Param("kind")
|
|
||||||
if kind == "" {
|
|
||||||
kind = c.Query("kind")
|
|
||||||
}
|
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
|
kind := c.Query("kind")
|
||||||
|
if kind != "runway" && kind != "street" {
|
||||||
|
kind = "" // 非法的 kind 回落为「全部」
|
||||||
|
}
|
||||||
page := atoiDefault(c.Query("page"), 1)
|
page := atoiDefault(c.Query("page"), 1)
|
||||||
if page < 1 {
|
const size = 24
|
||||||
page = 1
|
cards, total, err := h.adminRepo.ListMixed(c.Request.Context(), status, kind, page, size)
|
||||||
}
|
|
||||||
// 校验 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 分页链接基址:带 kind 时追加 /{kind},否则裸 /admin/reviews
|
// 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
|
||||||
listBase := ""
|
dupStats := h.dupStatsByCards(c.Request.Context(), cards)
|
||||||
if kind != "" && kind != "all" {
|
items := make([]gin.H, 0, len(cards))
|
||||||
listBase = "/" + kind
|
for _, card := range cards {
|
||||||
|
var t byte
|
||||||
|
if card.Kind == "runway" {
|
||||||
|
t = hashid.TypeRunway
|
||||||
|
} else {
|
||||||
|
t = hashid.TypeSnap
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
p := pagination.Params{Page: page, PageSize: 20}
|
// 状态 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)
|
meta := p.NewMeta(total)
|
||||||
renderPage(c, http.StatusOK, "review-list", gin.H{
|
renderPage(c, http.StatusOK, "articles", gin.H{
|
||||||
"Records": records,
|
"Items": items,
|
||||||
"Total": total,
|
"Status": status,
|
||||||
"Status": status,
|
"Kind": kind,
|
||||||
"Kind": kind,
|
"TabCounts": tabCounts,
|
||||||
"Kinds": kinds,
|
|
||||||
"ListBase": listBase,
|
|
||||||
"Pager": pagination.Pager{
|
"Pager": pagination.Pager{
|
||||||
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
PrevURL: pagination.PageLink(c, meta.PrevPage),
|
||||||
NextURL: pagination.PageLink(c, meta.NextPage),
|
NextURL: pagination.PageLink(c, meta.NextPage),
|
||||||
@ -358,96 +377,57 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
|
// dupStatsByCards 按卡片类型批量取每篇图集的重复统计(is_duplicate=1 张数 / 总存活图片数)。
|
||||||
func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
|
// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无统计」(不阻断列表)。
|
||||||
kind := c.Param("kind")
|
func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]repository.DupStat {
|
||||||
id := atoiDefault(c.Param("id"), 0)
|
out := make(map[uint32]repository.DupStat)
|
||||||
if id == 0 {
|
runwayIDs := make([]uint32, 0, len(cards))
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
streetIDs := make([]uint32, 0, len(cards))
|
||||||
return
|
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 len(runwayIDs) > 0 {
|
||||||
if err != nil {
|
if m, err := h.adminRepo.DupRateByRecords(ctx, "runway", runwayIDs); err == nil {
|
||||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")})
|
for id, s := range m {
|
||||||
return
|
out[id] = s
|
||||||
}
|
|
||||||
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderPage(c, http.StatusOK, "review-detail", gin.H{
|
if len(streetIDs) > 0 {
|
||||||
"Record": record,
|
if m, err := h.adminRepo.DupRateByRecords(ctx, "street", streetIDs); err == nil {
|
||||||
"BrandName": brandName,
|
for id, s := range m {
|
||||||
})
|
out[id] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
|
// ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
|
||||||
func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
|
// 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。
|
||||||
|
func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
|
||||||
kind := c.Param("kind")
|
kind := c.Param("kind")
|
||||||
id := atoiDefault(c.Param("id"), 0)
|
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||||
if 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": "参数无效"})
|
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||||
return
|
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()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
|
c.Redirect(http.StatusFound, "/admin/articles?status=pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
|
// ArticleAttachImages 把勾选的图并入指定主图(街拍「合并选中为一组」的表单版;AJAX 版见 ArticleAttachImagesAPI)。
|
||||||
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
|
func (h *BackstageHandler) ArticleAttachImages(c *gin.Context) {
|
||||||
// (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。)
|
|
||||||
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
|
||||||
kind := c.Param("kind")
|
kind := c.Param("kind")
|
||||||
id := atoiDefault(c.Param("id"), 0)
|
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||||
return
|
return
|
||||||
@ -459,48 +439,50 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
|||||||
imgIDs = append(imgIDs, uint32(v))
|
imgIDs = append(imgIDs, uint32(v))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(imgIDs) == 0 {
|
if len(imgIDs) == 0 || mainID == 0 {
|
||||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片"})
|
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请先勾选要合并的图片,并在其中一张主图上点选「主图」"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mainID == 0 {
|
if err := h.review.AttachImages(c.Request.Context(), kind, id, mainID, imgIDs); err != nil {
|
||||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
|
|
||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
|
c.Redirect(http.StatusFound, articleEditURL(kind, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
|
// ArticleDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ArticleDetachImageAPI)。
|
||||||
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
|
func (h *BackstageHandler) ArticleDetachImage(c *gin.Context) {
|
||||||
kind := c.Param("kind")
|
kind := c.Param("kind")
|
||||||
id := atoiDefault(c.Param("id"), 0)
|
id := uint32(atoiDefault(c.Param("id"), 0))
|
||||||
imgID := atoiDefault(c.Param("img"), 0)
|
if id == 0 {
|
||||||
if id == 0 || imgID == 0 {
|
|
||||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||||
return
|
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()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
|
c.Redirect(http.StatusFound, articleEditURL(kind, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// reviewDetailURL 生成审核详情页跳转地址。
|
// articleEditURL 生成类型对应的编辑页地址(分组 / 通过 / 驳回操作完成后跳回编辑页)。
|
||||||
func reviewDetailURL(kind string, id uint32) string {
|
// 编辑页路由用 hashid 标识记录,故这里把数值主键编码回去。
|
||||||
return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10)
|
func articleEditURL(kind string, id uint32) string {
|
||||||
}
|
var t byte
|
||||||
|
if kind == "runway" {
|
||||||
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。
|
t = hashid.TypeRunway
|
||||||
func reviewListURL(kind string) string {
|
} else {
|
||||||
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet {
|
t = hashid.TypeSnap
|
||||||
return "/admin/reviews/" + kind
|
|
||||||
}
|
}
|
||||||
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
|
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{
|
renderPage(c, http.StatusOK, "runway-edit", gin.H{
|
||||||
"UID": raw,
|
"UID": raw,
|
||||||
"Runway": rw,
|
"Kind": "runway",
|
||||||
"Images": imgs,
|
"RecordID": rw.ID,
|
||||||
"BrandName": brandName,
|
"Status": rw.Status,
|
||||||
"IsDown": rw.IsDeleted == 1,
|
"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()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunwayImageDelete 软删单张走秀图片。
|
// RunwayImageDelete 软删单张走秀图片。
|
||||||
@ -693,7 +747,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunwaySetCover 把某张图片设为封面。
|
// RunwaySetCover 把某张图片设为封面。
|
||||||
@ -713,7 +767,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/runways/"+raw)
|
c.Redirect(http.StatusFound, "/admin/runways/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。
|
// 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()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
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})
|
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||||
return
|
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{
|
renderPage(c, http.StatusOK, "snap-edit", gin.H{
|
||||||
"UID": raw,
|
"UID": raw,
|
||||||
"Snap": snap,
|
"Kind": "street",
|
||||||
"Images": imgs,
|
"RecordID": snap.ID,
|
||||||
"IsDown": snap.IsDeleted == 1,
|
"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 应用街拍字段编辑。
|
// StreetSnapUpdate 应用街拍字段编辑。
|
||||||
func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
||||||
raw := c.Param("id")
|
raw := c.Param("id")
|
||||||
@ -773,15 +955,16 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fields := map[string]any{
|
fields := map[string]any{
|
||||||
"title": c.PostForm("title"),
|
"title": c.PostForm("title"),
|
||||||
"city": c.PostForm("city"),
|
"title_cn": c.PostForm("title_cn"),
|
||||||
"year": year,
|
"city": c.PostForm("city"),
|
||||||
|
"year": year,
|
||||||
}
|
}
|
||||||
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
|
if err := h.street.UpdateSnap(c.Request.Context(), numeric, fields); err != nil {
|
||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnapImageDelete 软删单张街拍图片。
|
// StreetSnapImageDelete 软删单张街拍图片。
|
||||||
@ -800,7 +983,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnapSetCover 把某张图片设为封面。
|
// StreetSnapSetCover 把某张图片设为封面。
|
||||||
@ -820,7 +1003,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnapToggleDeleted 上下架。
|
// StreetSnapToggleDeleted 上下架。
|
||||||
@ -839,7 +1022,7 @@ func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) {
|
|||||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw)
|
c.Redirect(http.StatusFound, "/admin/street-snaps/"+raw+"/edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// atoiDefault 把字符串转 int,失败回落默认值。
|
// atoiDefault 把字符串转 int,失败回落默认值。
|
||||||
|
|||||||
@ -24,20 +24,17 @@ import (
|
|||||||
// (renderPage 会 500 "unknown backstage page: xxx")。
|
// (renderPage 会 500 "unknown backstage page: xxx")。
|
||||||
func TestBackstageTemplatesParsed(t *testing.T) {
|
func TestBackstageTemplatesParsed(t *testing.T) {
|
||||||
want := []string{
|
want := []string{
|
||||||
|
"articles",
|
||||||
"brand-form",
|
"brand-form",
|
||||||
"brands",
|
"brands",
|
||||||
"dashboard",
|
"dashboard",
|
||||||
"error",
|
"error",
|
||||||
"ingest-jobs",
|
"ingest-jobs",
|
||||||
"login",
|
"login",
|
||||||
"review-detail",
|
|
||||||
"review-list",
|
|
||||||
"runway-detail",
|
"runway-detail",
|
||||||
"runway-edit",
|
"runway-edit",
|
||||||
"runways",
|
|
||||||
"snap-edit",
|
"snap-edit",
|
||||||
"street-snap-detail",
|
"street-snap-detail",
|
||||||
"street-snaps",
|
|
||||||
"users",
|
"users",
|
||||||
}
|
}
|
||||||
got := PageNames()
|
got := PageNames()
|
||||||
@ -74,7 +71,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
|||||||
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
|
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
|
||||||
"Images": []any{},
|
"Images": []any{},
|
||||||
// 列表数据
|
// 列表数据
|
||||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{},
|
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
|
||||||
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||||
"Article": &dto.PublicArticleDetail{UID: "abc123"},
|
"Article": &dto.PublicArticleDetail{UID: "abc123"},
|
||||||
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
|
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
|
||||||
@ -88,19 +85,20 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
|||||||
|
|
||||||
for _, page := range PageNames() {
|
for _, page := range PageNames() {
|
||||||
for _, kind := range []string{"street", "runway"} {
|
for _, kind := range []string{"street", "runway"} {
|
||||||
if page != "review-detail" && kind == "runway" {
|
name := page + "/" + kind
|
||||||
continue // 只有审核详情页按 kind 分流
|
|
||||||
}
|
|
||||||
name := page
|
|
||||||
if page == "review-detail" {
|
|
||||||
name = page + "/" + kind
|
|
||||||
}
|
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
data := cloneData(base)
|
data := cloneData(base)
|
||||||
for k, v := range perPage[page] {
|
for k, v := range perPage[page] {
|
||||||
data[k] = v
|
data[k] = v
|
||||||
}
|
}
|
||||||
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
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()
|
w := httptest.NewRecorder()
|
||||||
c, _ := gin.CreateTestContext(w)
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
|||||||
@ -58,9 +58,13 @@ func queryBool(c *gin.Context, key string, def bool) bool {
|
|||||||
return v == "1" || v == "true"
|
return v == "1" || v == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。
|
// parseLocale 解析请求语言,优先级:X-Locale 头 > ?locale=cn|en > Accept-Language > 默认 en。
|
||||||
// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
|
// 与前端 i18n 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。
|
||||||
|
// X-Locale 由前端统一经请求头透传「当前页面语言」,显式且不受浏览器语言设置干扰。
|
||||||
func parseLocale(c *gin.Context) string {
|
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" {
|
if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" {
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ func (h *SSGHandler) HotBrands(c *gin.Context) {
|
|||||||
// GET /api/v1/ssg/street-snaps/popular
|
// GET /api/v1/ssg/street-snaps/popular
|
||||||
func (h *SSGHandler) StreetSnapPopular(c *gin.Context) {
|
func (h *SSGHandler) StreetSnapPopular(c *gin.Context) {
|
||||||
const limit = 20
|
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 {
|
if err != nil {
|
||||||
fail(c, err)
|
fail(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -82,12 +82,12 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。
|
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。
|
||||||
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。
|
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。
|
||||||
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap {
|
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
|
detail, err = d2, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,9 +24,23 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/*
|
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
|
||||||
var backstageFS embed.FS
|
var backstageFS embed.FS
|
||||||
|
|
||||||
@ -42,8 +56,10 @@ var assetFS fs.FS
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
// reviewDetailURL 生成审核详情页地址(partials / 页面模板里都会用到)。
|
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
|
||||||
"reviewDetailURL": reviewDetailURL,
|
// 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).
|
base, err := template.New("layout.html").Funcs(funcs).
|
||||||
@ -94,7 +110,7 @@ func PageNames() []string {
|
|||||||
|
|
||||||
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
|
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
|
||||||
//
|
//
|
||||||
// page 取 pages/ 下的文件名(如 "review-list");data 为页面数据,renderPage 会补上
|
// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
|
||||||
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
|
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
|
||||||
func renderPage(c *gin.Context, status int, page string, data gin.H) {
|
func renderPage(c *gin.Context, status int, page string, data gin.H) {
|
||||||
tpl, ok := pageTemplates[page]
|
tpl, ok := pageTemplates[page]
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
<div id="toast" class="flash ok" hidden></div>
|
<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>
|
<script src="/admin/assets/admin.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
56
internal/handler/templates/pages/articles.html
Normal file
56
internal/handler/templates/pages/articles.html
Normal 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}}
|
||||||
@ -8,9 +8,7 @@
|
|||||||
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
|
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
|
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
|
||||||
<a class="card" href="/admin/runways"><div class="t">走秀档案</div><div class="d">按系列 / 季节 / 年份筛选</div></a>
|
<a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 编辑 · 删除(下架)</div></a>
|
||||||
<a class="card" href="/admin/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/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
|
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
|
||||||
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
|
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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}}
|
|
||||||
@ -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}}
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
{{define "title"}}走秀详情 · 管理后台{{end}}
|
{{define "title"}}走秀详情 · 管理后台{{end}}
|
||||||
{{define "barLabel"}}管理后台 · 走秀详情{{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"}}
|
{{define "content"}}
|
||||||
{{template "bar" .}}
|
{{template "bar" .}}
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
||||||
<h1>{{.Article.Title}}</h1>
|
<h1>{{.Article.Title}}</h1>
|
||||||
<div class="grid">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -13,19 +13,16 @@
|
|||||||
<button type="submit">保存字段</button>
|
<button type="submit">保存字段</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2>图片管理({{len .Images}} 张)</h2>
|
{{template "imageGrid" .}}
|
||||||
<div class="grid">
|
|
||||||
{{range .Images}}<div class="cell">
|
{{if eq .Runway.Status "pending"}}
|
||||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
<div class="review-actions">
|
||||||
<div class="acts">
|
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||||
<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}}
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
||||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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}}
|
|
||||||
@ -6,24 +6,21 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
|
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
|
||||||
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
|
<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>
|
<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>
|
<button type="submit">保存字段</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2>图片管理({{len .Images}} 张)</h2>
|
{{template "imageGrid" .}}
|
||||||
<div class="grid">
|
|
||||||
{{range .Images}}<div class="cell">
|
{{if eq .Snap.Status "pending"}}
|
||||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
<div class="review-actions">
|
||||||
<div class="acts">
|
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||||
<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}}
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
||||||
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">下架(对外不可见)</button>{{end}}
|
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
{{define "title"}}街拍详情 · 管理后台{{end}}
|
{{define "title"}}街拍详情 · 管理后台{{end}}
|
||||||
{{define "barLabel"}}管理后台 · 街拍详情{{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"}}
|
{{define "content"}}
|
||||||
{{template "bar" .}}
|
{{template "bar" .}}
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<p class="muted">编码 {{.Snap.UID}}</p>
|
<p class="muted">编码 {{.Snap.UID}}</p>
|
||||||
<h1>{{.Snap.Title}}</h1>
|
<h1>{{.Snap.Title}}</h1>
|
||||||
<div class="grid">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -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}}
|
|
||||||
@ -16,9 +16,7 @@
|
|||||||
<span class="bar-title">{{template "barLabel" .}}</span>
|
<span class="bar-title">{{template "barLabel" .}}</span>
|
||||||
<nav class="bar-nav">
|
<nav class="bar-nav">
|
||||||
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
|
<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/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/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/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{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/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
|
||||||
<a href="/admin/users" class="{{if eq .NavKey "users"}}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}}
|
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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}}
|
||||||
|
|||||||
@ -22,12 +22,12 @@ type BrandRunway struct {
|
|||||||
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
||||||
Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null
|
Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null
|
||||||
SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25
|
SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25
|
||||||
|
PublishedAt uint32 `gorm:"column:published_at" json:"published_at"` // 发布时间 Unix 时间戳(秒);0=未抽取。Vogue datePublished;标题缺年份时用于矫正 year
|
||||||
|
|
||||||
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
|
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
|
||||||
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||||
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||||
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
|
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
|
||||||
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名。
|
// TableName 指定表名。
|
||||||
|
|||||||
@ -13,8 +13,12 @@ type BrandRunwayImage struct {
|
|||||||
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"`
|
||||||
Name string `gorm:"column:name" json:"name"`
|
Name string `gorm:"column:name" json:"name"`
|
||||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
|
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=主图 1=细节图
|
||||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 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 留痕):
|
// 去重字段(近重复 + 语义 embedding 留痕):
|
||||||
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
|
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import "database/sql"
|
|||||||
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
|
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
|
||||||
type StreetSnap struct {
|
type StreetSnap struct {
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
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"`
|
Year uint16 `gorm:"column:year" json:"year"`
|
||||||
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
|
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
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"`
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||||
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
|
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
|
||||||
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
|
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
|
||||||
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
|
|
||||||
|
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,8 @@ import (
|
|||||||
const Size = 64
|
const Size = 64
|
||||||
|
|
||||||
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
|
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
|
||||||
const DefaultThreshold = 10
|
// 历史取值:10(明显过松,实测混入大量噪声)→ 4(用户指定,收紧判定)。
|
||||||
|
const DefaultThreshold = 4
|
||||||
|
|
||||||
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
|
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
|
||||||
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。
|
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。
|
||||||
|
|||||||
174
internal/repository/admin_mixed_integration_test.go
Normal file
174
internal/repository/admin_mixed_integration_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
172
internal/repository/admin_repository.go
Normal file
172
internal/repository/admin_repository.go
Normal 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
|
||||||
|
}
|
||||||
@ -3,11 +3,13 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/phash"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -37,6 +39,13 @@ type ArticleRepository interface {
|
|||||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||||
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
|
// GetRunwayDupSources 批量反查重复源:给定一组走秀图片 id(即 dup_of 指向的源图 id),
|
||||||
|
// 返回每张源图及其所属走秀(标题、品牌)的上下文,供编辑页标记重复图时展示来源。
|
||||||
|
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||||
|
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
|
||||||
|
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||||
|
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
|
||||||
|
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
|
||||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
|
||||||
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
|
||||||
@ -331,21 +340,49 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
|
|||||||
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var img model.BrandRunwayImage
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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{}).
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
Where("id = ? AND is_deleted = 0", imageID).
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
return err
|
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)
|
(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
|
return img.Image, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,
|
||||||
|
// 供编辑页删图后重算去重时定位所属画廊)。
|
||||||
|
func (r *articleRepository) GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
|
||||||
|
var img model.BrandRunwayImage
|
||||||
|
err := r.db.WithContext(ctx).Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return img.RunwayID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nearDupExceptRunway 在 brand_runway_images 内做全局近重复检索(is_deleted=0),
|
||||||
|
// 但排除自身与「同 runway 且 sort_order 在后的图」——后者防止重算时后入者反成源导致互指环。
|
||||||
|
// 命中返回源图 id,无则 0。
|
||||||
|
func nearDupExceptRunway(db *gorm.DB, phashBits string, threshold int, excludeID, runwayID, sortOrder uint32) (uint32, error) {
|
||||||
|
l2Limit := math.Sqrt(float64(threshold))
|
||||||
|
var row struct {
|
||||||
|
ID uint32 `gorm:"column:id"`
|
||||||
|
}
|
||||||
|
if err := db.Table("brand_runway_images").
|
||||||
|
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||||
|
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||||
|
Where("is_deleted = 0").
|
||||||
|
Where("id <> ?", excludeID).
|
||||||
|
Where("(runway_id <> ? OR sort_order < ?)", runwayID, sortOrder).
|
||||||
|
Order("dist ASC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&row).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return row.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReresolveRunwayDedup 删除图片后重解本画廊去重:先清空全部存活图的 is_duplicate/dup_of,
|
||||||
|
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
|
||||||
|
//
|
||||||
|
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;
|
||||||
|
// 删除「源图」后,剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」,且「查看重复来源」指向已删
|
||||||
|
// 源图(其 S4 文件可能已被清理)而打不开。重算后,被删源图不再作为参照,剩余近重复图重新确立
|
||||||
|
// 「先入者为准」的源/副本关系,使「重复」标记始终反映当前真实图片。
|
||||||
|
func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error {
|
||||||
|
var imgs []model.BrandRunwayImage
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&imgs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, im := range imgs {
|
||||||
|
if !im.Phash.Valid || im.Phash.String == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, runwayID, im.SortOrder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if src != 0 && src != im.ID {
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
|
||||||
|
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
|
||||||
|
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
|
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
||||||
|
if len(imageIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
type srcRow struct {
|
||||||
|
ImageID uint32
|
||||||
|
Image string
|
||||||
|
RunwayID uint32
|
||||||
|
TitleEn string
|
||||||
|
TitleCn string
|
||||||
|
BrandEn string
|
||||||
|
BrandCn string
|
||||||
|
}
|
||||||
|
var rows []srcRow
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("brand_runway_images bri").
|
||||||
|
Select("bri.id AS image_id, bri.image AS image, bri.runway_id AS runway_id, br.title_en AS title_en, br.title_cn AS title_cn, b.name_en AS brand_en, b.name_cn AS brand_cn").
|
||||||
|
Joins("JOIN brand_runways br ON br.id = bri.runway_id").
|
||||||
|
Joins("LEFT JOIN brands b ON b.id = br.brand_id").
|
||||||
|
Where("bri.id IN ?", imageIDs).
|
||||||
|
Where("bri.is_deleted = 0").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, rw := range rows {
|
||||||
|
title := rw.TitleEn
|
||||||
|
if title == "" {
|
||||||
|
title = rw.TitleCn
|
||||||
|
}
|
||||||
|
brand := rw.BrandEn
|
||||||
|
if brand == "" {
|
||||||
|
brand = rw.BrandCn
|
||||||
|
}
|
||||||
|
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.RunwayID, Title: title, Brand: brand}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
|
||||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||||
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
|
||||||
@ -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 {
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
return nil, err
|
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
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,14 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/phash"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -91,6 +94,85 @@ func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string,
|
|||||||
return img.ID
|
return img.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedEditorRunwayImagePhash 插入一张带 phash 的走秀图(isDetail 表示主/细节),返回行 id。
|
||||||
|
func seedEditorRunwayImagePhash(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8, phashBits string) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
var ph sql.NullString
|
||||||
|
if phashBits != "" {
|
||||||
|
ph = sql.NullString{String: phashBits, Valid: true}
|
||||||
|
}
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: order, IsDetail: isDetail, Phash: ph,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入带 phash 走秀图片失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorImageFlag 读某图片的 is_duplicate / dup_of(验证去重重算结果)。
|
||||||
|
func editorImageFlag(t *testing.T, db *gorm.DB, id uint32) (uint8, string) {
|
||||||
|
t.Helper()
|
||||||
|
var row struct {
|
||||||
|
IsDuplicate uint8 `gorm:"column:is_duplicate"`
|
||||||
|
DupOf string `gorm:"column:dup_of"`
|
||||||
|
}
|
||||||
|
if err := db.Table("brand_runway_images").Select("is_duplicate, dup_of").Where("id = ?", id).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatalf("读去重标记失败: %v", err)
|
||||||
|
}
|
||||||
|
return row.IsDuplicate, row.DupOf
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorReresolveRunwayDedupAfterSourceDeleted 模拟「删掉源图后」重算去重应消除悬空 dup_of:
|
||||||
|
// A 为源(被删),B、C 曾 dup_of=A;重算后 A 退出参照,B 成为新源(dup=0),C 重指 B(dup=1),
|
||||||
|
// 不再有任何图指向已删的 A —— 对应线上「删了源图后仍显示重复、源图查看重复来源打不开」。
|
||||||
|
func TestEditorReresolveRunwayDedupAfterSourceDeleted(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
id := seedEditorRunway(t, db, 3)
|
||||||
|
// A=全1 向量;B 翻转 1 位(距 A=1);C 翻转 2 位(距 A=2)—— 三者互为近重复。
|
||||||
|
allOne := phash.ToVectorBits(^uint64(0))
|
||||||
|
bVec := phash.ToVectorBits(^uint64(0) &^ (uint64(1) << 0))
|
||||||
|
cVec := phash.ToVectorBits(^uint64(0) &^ ((uint64(1) << 0) | (uint64(1) << 1)))
|
||||||
|
a := seedEditorRunwayImagePhash(t, db, id, "A", 1, 0, allOne)
|
||||||
|
b := seedEditorRunwayImagePhash(t, db, id, "B", 2, 0, bVec)
|
||||||
|
c := seedEditorRunwayImagePhash(t, db, id, "C", 3, 0, cVec)
|
||||||
|
|
||||||
|
// 预置入库去重后的陈旧态:B、C 都 dup_of=A。
|
||||||
|
if err := db.Model(&model.BrandRunwayImage{}).Where("id IN ?", []uint32{b, c}).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(a), 10)}).Error; err != nil {
|
||||||
|
t.Fatalf("预置陈旧去重标记失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删掉源图 A(软删,模拟编辑页删除)。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, a); err != nil {
|
||||||
|
t.Fatalf("删源图失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重算去重(编辑页删图会自动调用,这里直接验证仓储方法)。
|
||||||
|
if err := repo.ReresolveRunwayDedup(ctx, id); err != nil {
|
||||||
|
t.Fatalf("重算去重失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diB, ofB := editorImageFlag(t, db, b)
|
||||||
|
diC, ofC := editorImageFlag(t, db, c)
|
||||||
|
if diB != 0 {
|
||||||
|
t.Fatalf("B 应成为新源(is_duplicate=0),实际 %d", diB)
|
||||||
|
}
|
||||||
|
if ofB != "" {
|
||||||
|
t.Fatalf("B 的 dup_of 应为空,实际 %q", ofB)
|
||||||
|
}
|
||||||
|
if diC != 1 {
|
||||||
|
t.Fatalf("C 应标记为重复(is_duplicate=1),实际 %d", diC)
|
||||||
|
}
|
||||||
|
if ofC != strconv.FormatUint(uint64(b), 10) {
|
||||||
|
t.Fatalf("C 的 dup_of 应指向 B(%d),实际 %q", b, ofC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
||||||
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@ -196,3 +278,133 @@ func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
|
|||||||
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
//go:build integration
|
//go:build integration
|
||||||
|
|
||||||
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
|
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。
|
||||||
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
|
//
|
||||||
|
// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」;
|
||||||
|
// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体,
|
||||||
|
// 从而达成「删除即永久」。本测试只验证索引这一层的行为。
|
||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -35,14 +38,15 @@ func TestRunwayEntityKeyUnique(t *testing.T) {
|
|||||||
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
|
// 软删首行后,部分索引(WHERE is_deleted = 0)不再占用该键,DB 层仍允许再插入。
|
||||||
|
// 「删除即永久」由入库服务层(RunwayEntityState 命中 isDeleted 跳过重爬)保证,而非本索引。
|
||||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
||||||
t.Fatalf("软删失败: %v", err)
|
t.Fatalf("软删失败: %v", err)
|
||||||
}
|
}
|
||||||
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
|
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
|
||||||
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
|
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("软删后应可再插入同实体键: %v", err)
|
t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
|
||||||
@ -137,14 +141,14 @@ func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
|
|||||||
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
|
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
|
||||||
|
|
||||||
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
|
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
|
||||||
if _, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
|
if _, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
} else if found {
|
} else if found {
|
||||||
t.Fatalf("不同 title 不应命中既有行,实际命中")
|
t.Fatalf("不同 title 不应命中既有行,实际命中")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
|
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
|
||||||
hitID, status, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
|
hitID, status, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
}
|
}
|
||||||
@ -166,7 +170,7 @@ func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
|
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
|
||||||
|
|
||||||
nid, _, nfound, err := repo.StreetSnapEntityState(ctx, city, year, "")
|
nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("查重出错: %v", err)
|
t.Fatalf("查重出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,7 +72,7 @@ func TestApproveKeepsImageRowIDs(t *testing.T) {
|
|||||||
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ func TestApproveKeepsSnapImageRowIDs(t *testing.T) {
|
|||||||
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -34,17 +35,30 @@ type IngestRepository interface {
|
|||||||
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
||||||
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
||||||
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
||||||
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
|
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,
|
||||||
// worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
|
// 返回其状态与是否已被软删(isDeleted)。worker 据此分四支:
|
||||||
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
|
// - 命中 pending/published → 直接放弃本次入库(ingestSkip)
|
||||||
|
// - 命中 rejected → 复用该行重审(ingestReuseRejected)
|
||||||
|
// - 命中但已软删 → 跳过重爬(删除即永久,不再因重爬复活)
|
||||||
|
// - 未命中 → 新建(ingestCreate)
|
||||||
|
// 注意:本查询不再以 is_deleted = 0 为过滤条件(旧版口径),否则软删行会被当成「未命中」
|
||||||
|
// 而重新入库,违背「删除即永久」。优先取活着行(ORDER BY is_deleted ASC, id DESC)。
|
||||||
|
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, isDeleted bool, err error)
|
||||||
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
|
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
|
||||||
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
|
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
|
||||||
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
||||||
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
||||||
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
||||||
// 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 的表达式保持一致。
|
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||||
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
|
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, isDeleted bool, err error)
|
||||||
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
|
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
|
||||||
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
|
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
|
||||||
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
|
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
|
||||||
@ -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 {
|
var row struct {
|
||||||
ID uint32 `gorm:"column:id"`
|
ID uint32 `gorm:"column:id"`
|
||||||
Status string `gorm:"column:status"`
|
Status string `gorm:"column:status"`
|
||||||
|
IsDeleted uint8 `gorm:"column:is_deleted"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Select("id, status").
|
Select("id, status, is_deleted").
|
||||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
|
Where("brand_id = ? AND season_code = ? AND collection_type = ?", brandID, seasonCode, collectionType).
|
||||||
|
Order("is_deleted ASC, id DESC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", false, err
|
return 0, "", false, false, err
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return 0, "", false, nil
|
return 0, "", false, false, nil
|
||||||
}
|
}
|
||||||
return row.ID, row.Status, true, nil
|
return row.ID, row.Status, true, row.IsDeleted == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
@ -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 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
||||||
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 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 {
|
var row struct {
|
||||||
ID uint32 `gorm:"column:id"`
|
ID uint32 `gorm:"column:id"`
|
||||||
Status string `gorm:"column:status"`
|
Status string `gorm:"column:status"`
|
||||||
|
IsDeleted uint8 `gorm:"column:is_deleted"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
Select("id, status").
|
Select("id, status, is_deleted").
|
||||||
Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
|
Where("city = ? AND year = ? AND COALESCE(title, '') = ?", city, year, title).
|
||||||
|
Order("is_deleted ASC, id DESC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", false, err
|
return 0, "", false, false, err
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return 0, "", false, nil
|
return 0, "", false, false, nil
|
||||||
}
|
}
|
||||||
return row.ID, row.Status, true, nil
|
return row.ID, row.Status, true, row.IsDeleted == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
|
||||||
|
// 连续段折叠为单个空格。
|
||||||
|
//
|
||||||
|
// 为什么需要:实体键含 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) {
|
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
|
||||||
|
|||||||
@ -22,7 +22,7 @@ func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newRunwayImages(cover string) []model.BrandRunwayImage {
|
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,图片落到正式图片表。
|
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
|
||||||
@ -33,7 +33,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
const season = "SS97"
|
const season = "SS97"
|
||||||
_, _, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
_, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunwayEntityState 出错: %v", err)
|
t.Fatalf("RunwayEntityState 出错: %v", err)
|
||||||
}
|
}
|
||||||
@ -58,7 +58,7 @@ func TestIngestCreatesPendingRecord(t *testing.T) {
|
|||||||
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
|
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
gotID, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
gotID, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunwayEntityState 出错: %v", err)
|
t.Fatalf("RunwayEntityState 出错: %v", err)
|
||||||
}
|
}
|
||||||
@ -90,11 +90,11 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
|
|||||||
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
|
||||||
})
|
})
|
||||||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
|
||||||
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin", "reject_reason": "图片缺失"}).Error; err != nil {
|
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin"}).Error; err != nil {
|
||||||
t.Fatalf("置为已驳回失败: %v", err)
|
t.Fatalf("置为已驳回失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
_, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
if err != nil || !found || status != model.StatusRejected {
|
if err != nil || !found || status != model.StatusRejected {
|
||||||
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
|
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
|
||||||
}
|
}
|
||||||
@ -115,8 +115,8 @@ func TestIngestReusesRejectedRecord(t *testing.T) {
|
|||||||
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
|
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
|
||||||
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
|
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
|
||||||
}
|
}
|
||||||
if rw.Reviewer != "" || rw.RejectReason != "" {
|
if rw.Reviewer != "" {
|
||||||
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason)
|
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
var alive []model.BrandRunwayImage
|
var alive []model.BrandRunwayImage
|
||||||
|
|||||||
66
internal/repository/public_view_columns_integration_test.go
Normal file
66
internal/repository/public_view_columns_integration_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,8 +24,8 @@ type ReviewRepository interface {
|
|||||||
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
||||||
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
||||||
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。
|
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
|
||||||
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
|
||||||
|
|
||||||
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
||||||
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
||||||
@ -33,7 +33,7 @@ type ReviewRepository interface {
|
|||||||
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
||||||
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
|
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
|
||||||
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
|
||||||
|
|
||||||
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
|
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
|
||||||
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
||||||
@ -46,6 +46,11 @@ type ReviewRepository interface {
|
|||||||
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
||||||
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
||||||
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
|
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 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
||||||
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
||||||
@ -161,15 +166,14 @@ func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields m
|
|||||||
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
||||||
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
||||||
// 而不是悄悄在这个仓储方法里加限制。
|
// 而不是悄悄在这个仓储方法里加限制。
|
||||||
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
|
||||||
res := r.db.WithContext(ctx).
|
res := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"reviewer": reviewer,
|
"reviewer": reviewer,
|
||||||
"reject_reason": reason,
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
"updated_at": uint32(time.Now().Unix()),
|
|
||||||
})
|
})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
@ -267,15 +271,14 @@ func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fi
|
|||||||
|
|
||||||
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
||||||
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
||||||
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
|
||||||
res := r.db.WithContext(ctx).
|
res := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
"reviewer": reviewer,
|
"reviewer": reviewer,
|
||||||
"reject_reason": reason,
|
"updated_at": uint32(time.Now().Unix()),
|
||||||
"updated_at": uint32(time.Now().Unix()),
|
|
||||||
})
|
})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
@ -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())}).
|
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
|
||||||
Error
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -36,14 +36,14 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
|||||||
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 1 {
|
if got := publicCount(); got != 1 {
|
||||||
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin", "图片不对"); err != nil {
|
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
|
||||||
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
|
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 0 {
|
if got := publicCount(); got != 0 {
|
||||||
@ -55,12 +55,12 @@ func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
|||||||
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||||
t.Fatalf("读回记录失败: %v", err)
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
}
|
}
|
||||||
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" || rw.RejectReason != "图片不对" {
|
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" {
|
||||||
t.Fatalf("审核态与痕迹应落库,实际 status=%s reviewer=%s reason=%s", rw.Status, rw.Reviewer, rw.RejectReason)
|
t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
|
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
|
||||||
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
|
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,21 +83,21 @@ func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
|||||||
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin", ""); err != nil {
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 1 {
|
if got := publicCount(); got != 1 {
|
||||||
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin", "重复"); err != nil {
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
|
||||||
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
|
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
|
||||||
}
|
}
|
||||||
if got := publicCount(); got != 0 {
|
if got := publicCount(); got != 0 {
|
||||||
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin", ""); !errors.Is(err, ErrNotFound) {
|
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,11 +3,13 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/phash"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -39,6 +41,13 @@ type StreetSnapRepository interface {
|
|||||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||||
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||||
|
// GetStreetSnapDupSources 批量反查重复源:给定一组街拍图片 id(dup_of 指向的源图 id),
|
||||||
|
// 返回每张源图及其所属街拍的上下文,供编辑页标记重复图时展示来源。
|
||||||
|
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||||
|
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
|
||||||
|
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
|
||||||
|
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
|
||||||
|
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
|
||||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
|
||||||
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
|
||||||
// SetSnapCover 更新街拍封面。
|
// SetSnapCover 更新街拍封面。
|
||||||
@ -223,10 +232,11 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
|
|||||||
|
|
||||||
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
|
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
|
||||||
var snapEditable = map[string]bool{
|
var snapEditable = map[string]bool{
|
||||||
"title": true,
|
"title": true,
|
||||||
"year": true,
|
"title_cn": true,
|
||||||
"city": true,
|
"year": true,
|
||||||
"cover": true,
|
"city": true,
|
||||||
|
"cover": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
||||||
@ -274,21 +284,49 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
|
|||||||
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var img model.StreetSnapImage
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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{}).
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
Where("id = ? AND is_deleted = 0", imageID).
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
return err
|
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)
|
(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
|
return img.Image, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供编辑页删图后重算去重)。
|
||||||
|
func (r *streetSnapRepository) GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
|
||||||
|
var img model.StreetSnapImage
|
||||||
|
err := r.db.WithContext(ctx).Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return img.SnapID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nearDupExceptSnap 在 street_snap_images 内做全局近重复检索(is_deleted=0),但排除自身与
|
||||||
|
// 「同 snap 且 sort_order 在后的图」——防止重算时后入者反成源导致互指环。命中返回源图 id,无则 0。
|
||||||
|
func nearDupExceptSnap(db *gorm.DB, phashBits string, threshold int, excludeID, snapID, sortOrder uint32) (uint32, error) {
|
||||||
|
l2Limit := math.Sqrt(float64(threshold))
|
||||||
|
var row struct {
|
||||||
|
ID uint32 `gorm:"column:id"`
|
||||||
|
}
|
||||||
|
if err := db.Table("street_snap_images").
|
||||||
|
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||||
|
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||||
|
Where("is_deleted = 0").
|
||||||
|
Where("id <> ?", excludeID).
|
||||||
|
Where("(snap_id <> ? OR sort_order < ?)", snapID, sortOrder).
|
||||||
|
Order("dist ASC").
|
||||||
|
Limit(1).
|
||||||
|
Scan(&row).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return row.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReresolveSnapDedup 删除图片后重解本专辑去重:先清空全部存活图的 is_duplicate/dup_of,
|
||||||
|
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
|
||||||
|
//
|
||||||
|
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;删除「源图」
|
||||||
|
// 后剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」且「查看重复来源」指向已删源图而打不开。重算后
|
||||||
|
// 被删源图不再作为参照,剩余近重复图重新确立「先入者为准」的源/副本关系。
|
||||||
|
func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID uint32) error {
|
||||||
|
var imgs []model.StreetSnapImage
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&imgs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, im := range imgs {
|
||||||
|
if !im.Phash.Valid || im.Phash.String == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, snapID, im.SortOrder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if src != 0 && src != im.ID {
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", im.ID).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
|
||||||
|
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
|
out := make(map[uint32]dto.DupSource, len(imageIDs))
|
||||||
|
if len(imageIDs) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
type srcRow struct {
|
||||||
|
ImageID uint32
|
||||||
|
Image string
|
||||||
|
SnapID uint32
|
||||||
|
Title string
|
||||||
|
TitleCn string
|
||||||
|
}
|
||||||
|
var rows []srcRow
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("street_snap_images ssi").
|
||||||
|
Select("ssi.id AS image_id, ssi.image AS image, ssi.snap_id AS snap_id, ss.title AS title, ss.title_cn AS title_cn").
|
||||||
|
Joins("JOIN street_snaps ss ON ss.id = ssi.snap_id").
|
||||||
|
Where("ssi.id IN ?", imageIDs).
|
||||||
|
Where("ssi.is_deleted = 0").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, rw := range rows {
|
||||||
|
title := rw.Title
|
||||||
|
if title == "" {
|
||||||
|
title = rw.TitleCn
|
||||||
|
}
|
||||||
|
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.SnapID, Title: title}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
|
||||||
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
|
||||||
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
|
||||||
@ -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 {
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
return nil, err
|
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
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,11 +77,11 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
|
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
|
||||||
admin.Use(opt.Backstage.NavStats())
|
admin.Use(opt.Backstage.NavStats())
|
||||||
{
|
{
|
||||||
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||||
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
||||||
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
|
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
|
||||||
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
|
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
|
||||||
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
|
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
|
||||||
|
|
||||||
admin.GET("/", opt.Backstage.Dashboard)
|
admin.GET("/", opt.Backstage.Dashboard)
|
||||||
admin.GET("/brands", opt.Backstage.Brands)
|
admin.GET("/brands", opt.Backstage.Brands)
|
||||||
@ -89,31 +89,28 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
admin.POST("/brands", opt.Backstage.BrandCreate)
|
admin.POST("/brands", opt.Backstage.BrandCreate)
|
||||||
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
|
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
|
||||||
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
|
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
|
||||||
admin.GET("/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", opt.Backstage.RunwayDetail)
|
||||||
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
|
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
|
||||||
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
|
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
|
||||||
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
|
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
|
||||||
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
|
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
|
||||||
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
|
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", opt.Backstage.StreetSnapDetail)
|
||||||
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
|
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
|
||||||
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
|
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/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
|
||||||
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
|
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
|
||||||
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
|
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.GET("/ingest-jobs", opt.Backstage.IngestJobs)
|
||||||
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
|
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import (
|
|||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/handler"
|
"fashionapi/internal/handler"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/hashid"
|
||||||
"fashionapi/internal/pkg/jwt"
|
"fashionapi/internal/pkg/jwt"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
"fashionapi/internal/service"
|
"fashionapi/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeArticle) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
|
return map[uint32]dto.DupSource{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeStreet struct{}
|
type fakeStreet struct{}
|
||||||
|
|
||||||
@ -164,12 +169,12 @@ func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dt
|
|||||||
|
|
||||||
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
|
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
|
||||||
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
// 故固定返回 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
|
return nil, service.ErrStreetSnapNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
// 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{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
Title: "Paris Fashion Week Street(待审)",
|
Title: "Paris Fashion Week Street(待审)",
|
||||||
@ -178,11 +183,16 @@ func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStr
|
|||||||
},
|
},
|
||||||
}, nil
|
}, 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
|
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 {
|
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
return nil
|
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 {
|
func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeStreet) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
|
||||||
|
return map[uint32]dto.DupSource{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
|
||||||
|
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)
|
m := jwt.NewManager("test-secret", 168)
|
||||||
auth := &fakeAuth{jwt: m}
|
auth := &fakeAuth{jwt: m}
|
||||||
brand := &fakeBrand{}
|
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})
|
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||||
|
|
||||||
// 1) 登录页可访问
|
// 1) 登录页可访问
|
||||||
@ -281,13 +347,17 @@ func TestBackstage(t *testing.T) {
|
|||||||
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
|
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
|
// 6) 文章管理混合列表渲染(走秀 + 街拍合并)
|
||||||
w = httptest.NewRecorder()
|
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})
|
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
|
body := w.Body.String()
|
||||||
t.Fatalf("走秀列表异常: %d %s", w.Code, 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) 后台走秀详情渲染图集。
|
// 7) 后台走秀详情渲染图集。
|
||||||
@ -301,13 +371,14 @@ func TestBackstage(t *testing.T) {
|
|||||||
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8) 街拍列表渲染
|
// 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
|
||||||
w = httptest.NewRecorder()
|
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})
|
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
|
body = w.Body.String()
|
||||||
t.Fatalf("街拍列表异常: %d %s", w.Code, 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) 后台街拍详情渲染图集。
|
// 9) 后台街拍详情渲染图集。
|
||||||
@ -331,7 +402,6 @@ type fakeReview struct {
|
|||||||
service.ReviewService
|
service.ReviewService
|
||||||
attachOK bool
|
attachOK bool
|
||||||
approveErr error
|
approveErr error
|
||||||
rejectErr error
|
|
||||||
last struct {
|
last struct {
|
||||||
kind string
|
kind string
|
||||||
recordID uint32
|
recordID uint32
|
||||||
@ -345,7 +415,6 @@ type fakeReview struct {
|
|||||||
Action string
|
Action string
|
||||||
Kind string
|
Kind string
|
||||||
ID uint32
|
ID uint32
|
||||||
Reason string
|
|
||||||
}
|
}
|
||||||
view *service.RecordDetailView
|
view *service.RecordDetailView
|
||||||
}
|
}
|
||||||
@ -394,8 +463,7 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
|
|||||||
Action string
|
Action string
|
||||||
Kind string
|
Kind string
|
||||||
ID uint32
|
ID uint32
|
||||||
Reason string
|
}{"approve", kind, id})
|
||||||
}{"approve", kind, id, ""})
|
|
||||||
// last 供「表单式通过」用例断言(批量接口用 calls)。
|
// last 供「表单式通过」用例断言(批量接口用 calls)。
|
||||||
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
|
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
|
||||||
if f.approveErr != nil {
|
if f.approveErr != nil {
|
||||||
@ -404,23 +472,13 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReview) Reject(_ context.Context, kind string, id uint32, reviewer, reason string) error {
|
|
||||||
f.calls = append(f.calls, struct {
|
|
||||||
Action string
|
|
||||||
Kind string
|
|
||||||
ID uint32
|
|
||||||
Reason string
|
|
||||||
}{"reject", kind, id, reason})
|
|
||||||
return f.rejectErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := jwt.NewManager("test-secret", 168)
|
m := jwt.NewManager("test-secret", 168)
|
||||||
auth := &fakeAuth{jwt: m}
|
auth := &fakeAuth{jwt: m}
|
||||||
brand := &fakeBrand{}
|
brand := &fakeBrand{}
|
||||||
rev := &fakeReview{attachOK: true}
|
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})
|
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
|
||||||
return r, rev
|
return r, rev
|
||||||
}
|
}
|
||||||
@ -444,14 +502,14 @@ func TestBackstageStreetAttachRedirect(t *testing.T) {
|
|||||||
tok := reviewToken(t, r)
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(tok)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != http.StatusFound {
|
if w.Code != http.StatusFound {
|
||||||
t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String())
|
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"))
|
t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
|
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)
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(tok)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
@ -483,13 +541,13 @@ func TestBackstageStreetDetach(t *testing.T) {
|
|||||||
tok := reviewToken(t, r)
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != http.StatusFound {
|
if w.Code != http.StatusFound {
|
||||||
t.Fatalf("拆出应 302,实际 %d", w.Code)
|
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"))
|
t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
if rev.last.op != "detach" || rev.last.imgID != 2 {
|
if rev.last.op != "detach" || rev.last.imgID != 2 {
|
||||||
@ -499,33 +557,23 @@ func TestBackstageStreetDetach(t *testing.T) {
|
|||||||
|
|
||||||
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
|
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
|
||||||
func TestBackstageStreetRenderGroups(t *testing.T) {
|
func TestBackstageStreetRenderGroups(t *testing.T) {
|
||||||
|
hashid.Init("test-salt")
|
||||||
r, rev := newReviewRouter(t)
|
r, rev := newReviewRouter(t)
|
||||||
tok := reviewToken(t, r)
|
tok := reviewToken(t, r)
|
||||||
rev.view = &service.RecordDetailView{
|
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
|
||||||
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"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
if w.Code != http.StatusOK {
|
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()
|
body := w.Body.String()
|
||||||
if !strings.Contains(body, "detail-a") {
|
if !strings.Contains(body, "detail-a") {
|
||||||
t.Fatalf("应渲染副图 detail-a")
|
t.Fatalf("应渲染副图 detail-a")
|
||||||
}
|
}
|
||||||
// 副图徽标显示所属主图(沿链归并后的实际主图)。
|
// 副图徽标显示所属主图。
|
||||||
if !strings.Contains(body, "副图 → #1") {
|
if !strings.Contains(body, "副图 → #1") {
|
||||||
t.Fatalf("副图应显示所属主图")
|
t.Fatalf("副图应显示所属主图")
|
||||||
}
|
}
|
||||||
@ -552,7 +600,7 @@ func TestBackstageApprovePublishes(t *testing.T) {
|
|||||||
tok := reviewToken(t, r)
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
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.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(tok)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
@ -605,7 +653,7 @@ func TestBackstageNavStatsOnEveryPage(t *testing.T) {
|
|||||||
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
||||||
}
|
}
|
||||||
// 导航已在共享 partial 里,各模块入口都应在
|
// 导航已在共享 partial 里,各模块入口都应在
|
||||||
for _, label := range []string{"审核", "走秀", "街拍", "品牌", "入库任务", "用户"} {
|
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
|
||||||
if !strings.Contains(body, ">"+label) {
|
if !strings.Contains(body, ">"+label) {
|
||||||
t.Fatalf("导航应包含入口 %q", 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}]}`
|
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
|
||||||
w := httptest.NewRecorder()
|
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.Header.Set("Content-Type", "application/json")
|
||||||
req.AddCookie(tok)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
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 未注册类型只计失败,其余照常处理(不整体回滚)。
|
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
|
||||||
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
||||||
r, rev := newReviewRouter(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}]}`
|
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
|
||||||
w := httptest.NewRecorder()
|
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.Header.Set("Content-Type", "application/json")
|
||||||
req.AddCookie(tok)
|
req.AddCookie(tok)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|||||||
@ -30,6 +30,8 @@ type ArticleService interface {
|
|||||||
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||||
|
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释),供编辑页标记重复图。
|
||||||
|
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||||
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
|
||||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// DeleteImage 软删单张走秀图片。
|
// DeleteImage 软删单张走秀图片。
|
||||||
@ -194,25 +196,22 @@ func (s *articleService) detailErr(err error) error {
|
|||||||
|
|
||||||
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||||
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
||||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
|
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
|
||||||
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
|
||||||
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
|
||||||
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
mains := make([]model.BrandRunwayImage, 0, len(imgs))
|
||||||
byLook := make(map[uint32][]model.BrandRunwayImage)
|
detailsByParent := make(map[uint32][]model.BrandRunwayImage)
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
if im.IsDetail == 0 {
|
if im.IsDetail == 0 || im.ParentImageID == 0 {
|
||||||
mains = append(mains, im)
|
mains = append(mains, im)
|
||||||
} else {
|
} else {
|
||||||
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
|
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pubMains := s.toPublicImages(mains)
|
pubMains := s.toPublicImages(mains)
|
||||||
for i := range pubMains {
|
for i := range pubMains {
|
||||||
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批
|
// 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
|
||||||
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。
|
if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
|
||||||
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
|
|
||||||
pubMains[i].Detail = s.toPublicImages(kids)
|
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) {
|
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 {
|
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 != "" {
|
if key != "" {
|
||||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||||
}
|
}
|
||||||
|
// 删图后重算本画廊去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
||||||
|
if runwayID, err := s.articles.GetRunwayImageRunwayID(ctx, imageID); err == nil && runwayID != 0 {
|
||||||
|
if err := s.articles.ReresolveRunwayDedup(ctx, runwayID); err != nil {
|
||||||
|
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,7 +354,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
|
|||||||
Thumb: s.img.ComposeThumb(im.Image),
|
Thumb: s.img.ComposeThumb(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
IsDetail: im.IsDetail,
|
IsDetail: im.IsDetail,
|
||||||
LookIndex: im.LookIndex,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -325,7 +372,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
|
|||||||
Image: s.img.ComposeThumb(im.Image),
|
Image: s.img.ComposeThumb(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
IsDetail: im.IsDetail,
|
IsDetail: im.IsDetail,
|
||||||
LookIndex: im.LookIndex,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -19,10 +19,14 @@ type captureIngestRepo struct {
|
|||||||
repository.IngestRepository
|
repository.IngestRepository
|
||||||
createdRW *model.BrandRunway
|
createdRW *model.BrandRunway
|
||||||
createdImages []model.BrandRunwayImage
|
createdImages []model.BrandRunwayImage
|
||||||
|
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
|
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
||||||
return 0, "", false, nil // 实体键未命中 → 新建分支
|
if c.deletedEntity {
|
||||||
|
return 1, model.StatusPending, true, true, nil // 命中但已软删
|
||||||
|
}
|
||||||
|
return 0, "", false, false, nil // 实体键未命中 → 新建分支
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
@ -37,6 +41,11 @@ func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables [
|
|||||||
|
|
||||||
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
|
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 品牌校验恒通过。
|
// fakeBrandRepo 品牌校验恒通过。
|
||||||
type fakeBrandRepo struct{ repository.BrandRepository }
|
type fakeBrandRepo struct{ repository.BrandRepository }
|
||||||
|
|
||||||
@ -115,3 +124,45 @@ func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) {
|
|||||||
repo.createdRW.ImageCount, len(p.Looks))
|
repo.createdRW.ImageCount, len(p.Looks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」:
|
||||||
|
// 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。
|
||||||
|
func TestProcessRunwaySkipsSoftDeletedEntity(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
local := storage.NewLocalUploader(dir, "/uploads")
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。
|
||||||
|
repo := &captureIngestRepo{deletedEntity: true}
|
||||||
|
s := &IngestService{
|
||||||
|
repo: repo,
|
||||||
|
brandRepo: fakeBrandRepo{},
|
||||||
|
uploader: local,
|
||||||
|
local: local,
|
||||||
|
httpClient: srv.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
p := dto.RunwayIngest{
|
||||||
|
Kind: dto.IngestKindRunway,
|
||||||
|
BrandUID: hashid.Encode(1),
|
||||||
|
TitleEn: "deleted-entity-test",
|
||||||
|
Year: 2027,
|
||||||
|
Season: "spring",
|
||||||
|
CollectionType: "rtw",
|
||||||
|
Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}},
|
||||||
|
}
|
||||||
|
job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl}
|
||||||
|
s.processRunway(context.Background(), job, p)
|
||||||
|
|
||||||
|
if repo.createdRW != nil {
|
||||||
|
t.Fatal("已软删的实体不应被重新写入正式表")
|
||||||
|
}
|
||||||
|
if len(repo.createdImages) != 0 {
|
||||||
|
t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
@ -44,7 +45,28 @@ type IngestService struct {
|
|||||||
// uploader: 主上传器(S4或本地)
|
// uploader: 主上传器(S4或本地)
|
||||||
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
|
||||||
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
|
||||||
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService {
|
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string) *IngestService {
|
||||||
|
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
|
||||||
|
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
|
||||||
|
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
|
||||||
|
dlTransport := &http.Transport{
|
||||||
|
MaxIdleConns: imageFetchConcurrency * 4,
|
||||||
|
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
if downloadProxy != "" {
|
||||||
|
if pu, perr := url.Parse(downloadProxy); perr == nil {
|
||||||
|
dlTransport.Proxy = http.ProxyURL(pu)
|
||||||
|
log.Printf("[ingest] 图片下载走代理: %s", downloadProxy)
|
||||||
|
} else {
|
||||||
|
log.Printf("[ingest] download_proxy 非法,已忽略并回退 ProxyFromEnvironment: %q", downloadProxy)
|
||||||
|
dlTransport.Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dlTransport.Proxy = http.ProxyFromEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
return &IngestService{
|
return &IngestService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
brandRepo: brandRepo,
|
brandRepo: brandRepo,
|
||||||
@ -57,14 +79,8 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
|
|||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
|
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
|
||||||
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
|
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
Transport: &http.Transport{
|
Transport: dlTransport,
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
MaxIdleConns: imageFetchConcurrency * 4,
|
|
||||||
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -243,8 +259,14 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) 实体键查重并决定分支(单表模型)
|
// 2) 实体键查重并决定分支(单表模型)
|
||||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
|
||||||
reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
// 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。
|
||||||
|
effYear := p.Year
|
||||||
|
if effYear == 0 && p.PublishedAt != 0 {
|
||||||
|
effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year())
|
||||||
|
}
|
||||||
|
seasonCode := season.Derive(effYear, p.CollectionType, p.Season)
|
||||||
|
reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
||||||
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
||||||
@ -253,9 +275,38 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exist && p.Year == 0 {
|
||||||
|
// 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次,
|
||||||
|
// 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。
|
||||||
|
var rid uint32
|
||||||
|
rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType)
|
||||||
|
if err != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reuseID = rid
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
if action == ingestSkip {
|
if action == ingestSkip {
|
||||||
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过", job.ID, existStatus)
|
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus)
|
||||||
|
// 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。
|
||||||
|
if p.PublishedAt != 0 {
|
||||||
|
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -286,7 +337,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
Image: fi.url,
|
Image: fi.url,
|
||||||
Name: fmt.Sprintf("Look %d", i+1),
|
Name: fmt.Sprintf("Look %d", i+1),
|
||||||
SortOrder: uint32(i + 1),
|
SortOrder: uint32(i + 1),
|
||||||
LookIndex: uint32(i + 1),
|
|
||||||
IsDetail: 0,
|
IsDetail: 0,
|
||||||
Phash: sqlNull(fi.phash),
|
Phash: sqlNull(fi.phash),
|
||||||
IsDuplicate: fi.isDup,
|
IsDuplicate: fi.isDup,
|
||||||
@ -312,10 +362,11 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
TitleCn: p.TitleCn,
|
TitleCn: p.TitleCn,
|
||||||
DescriptionEn: p.DescriptionEn,
|
DescriptionEn: p.DescriptionEn,
|
||||||
DescriptionCn: p.DescriptionCn,
|
DescriptionCn: p.DescriptionCn,
|
||||||
Year: p.Year,
|
Year: effYear,
|
||||||
Season: p.Season,
|
Season: p.Season,
|
||||||
CollectionType: p.CollectionType,
|
CollectionType: p.CollectionType,
|
||||||
SeasonCode: seasonCode,
|
SeasonCode: seasonCode,
|
||||||
|
PublishedAt: p.PublishedAt,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
ImageCount: imageCount,
|
ImageCount: imageCount,
|
||||||
Status: model.StatusPending,
|
Status: model.StatusPending,
|
||||||
@ -333,13 +384,21 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
||||||
return
|
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",
|
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||||
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
|
||||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||||
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||||
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||||
@ -408,7 +467,6 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
Image: r.url,
|
Image: r.url,
|
||||||
Name: name,
|
Name: name,
|
||||||
SortOrder: uint32(order),
|
SortOrder: uint32(order),
|
||||||
LookIndex: uint32(task.lookIdx),
|
|
||||||
IsDetail: isDetail,
|
IsDetail: isDetail,
|
||||||
Phash: sqlNull(r.phashBits),
|
Phash: sqlNull(r.phashBits),
|
||||||
IsDuplicate: isDup,
|
IsDuplicate: isDup,
|
||||||
@ -435,14 +493,22 @@ func countMainImages(images []model.BrandRunwayImage) uint16 {
|
|||||||
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
|
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
|
||||||
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||||
// 1) 实体键查重并决定分支(单表模型,同 runway)。
|
// 1) 实体键查重并决定分支(单表模型,同 runway)。
|
||||||
// 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致,
|
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
|
||||||
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
// 同城同年的不同专题(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 {
|
if err != nil {
|
||||||
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
action := decideIngestAction(exist, existStatus)
|
action := decideIngestAction(exist, existStatus)
|
||||||
if action == ingestSkip {
|
if action == ingestSkip {
|
||||||
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||||
@ -465,7 +531,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
writeStart := time.Now()
|
writeStart := time.Now()
|
||||||
snap := &model.StreetSnap{
|
snap := &model.StreetSnap{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
|
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
|
||||||
Year: p.Year,
|
Year: p.Year,
|
||||||
City: p.City,
|
City: p.City,
|
||||||
Cover: cover,
|
Cover: cover,
|
||||||
@ -695,7 +761,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
|
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
|
||||||
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。
|
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
|
||||||
type lookImageTask struct {
|
type lookImageTask struct {
|
||||||
url string
|
url string
|
||||||
lookIdx int // 1-based look 序号
|
lookIdx int // 1-based look 序号
|
||||||
|
|||||||
@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
|
|||||||
t.Fatalf("预置文件失败: %v", err)
|
t.Fatalf("预置文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up)
|
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "")
|
||||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
|
||||||
svc.processMediaCleanup(context.Background(), job)
|
svc.processMediaCleanup(context.Background(), job)
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
|
|||||||
del := &countingDeleter{LocalUploader: up}
|
del := &countingDeleter{LocalUploader: up}
|
||||||
|
|
||||||
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
// 引用计数恒为 1(仍被其他图集使用),不应真删。
|
||||||
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up)
|
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "")
|
||||||
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
|
||||||
svc.processMediaCleanup(context.Background(), job)
|
svc.processMediaCleanup(context.Background(), job)
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,8 @@ type RecordCard struct {
|
|||||||
|
|
||||||
// RecordImageRef 记录图片引用(统一结构)。
|
// RecordImageRef 记录图片引用(统一结构)。
|
||||||
//
|
//
|
||||||
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
|
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
|
||||||
// DetailCount 让模板不用自己数副图。
|
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
|
||||||
type RecordImageRef struct {
|
type RecordImageRef struct {
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
@ -70,8 +70,9 @@ type RecordDetailView struct {
|
|||||||
Images []RecordImageRef `json:"images"`
|
Images []RecordImageRef `json:"images"`
|
||||||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||||||
Fields []EditField `json:"fields"`
|
Fields []EditField `json:"fields"`
|
||||||
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
|
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
|
||||||
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
|
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
|
||||||
|
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
|
||||||
Groups []ImageGroup `json:"groups,omitempty"`
|
Groups []ImageGroup `json:"groups,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +100,7 @@ type KindTab struct {
|
|||||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
||||||
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
||||||
//
|
//
|
||||||
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||||||
type ReviewModule interface {
|
type ReviewModule interface {
|
||||||
Kind() string
|
Kind() string
|
||||||
Label() string
|
Label() string
|
||||||
@ -109,18 +110,20 @@ type ReviewModule interface {
|
|||||||
// Approve 把正式记录置为 published;返回记录主键。
|
// Approve 把正式记录置为 published;返回记录主键。
|
||||||
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
||||||
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
||||||
Reject(ctx context.Context, id uint32, reviewer, reason string) error
|
|
||||||
CountPending(ctx context.Context) (int64, error)
|
CountPending(ctx context.Context) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
|
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
|
||||||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||||||
|
//
|
||||||
|
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
|
||||||
|
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
|
||||||
|
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
|
||||||
type ReviewService interface {
|
type ReviewService interface {
|
||||||
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
||||||
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
||||||
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||||||
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
|
||||||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||||
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||||
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||||
@ -233,14 +236,6 @@ func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, rev
|
|||||||
return m.Approve(ctx, id, reviewer)
|
return m.Approve(ctx, id, reviewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error {
|
|
||||||
m, ok := s.module(kind)
|
|
||||||
if !ok {
|
|
||||||
return repository.ErrNotFound
|
|
||||||
}
|
|
||||||
return m.Reject(ctx, id, reviewer, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||||
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
tabs := []KindTab{{Kind: "", Label: "全部"}}
|
||||||
var all int64
|
var all int64
|
||||||
@ -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 {
|
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 {
|
if len(imgIDs) == 0 {
|
||||||
return ErrInvalidImageGroup
|
return ErrInvalidImageGroup
|
||||||
}
|
}
|
||||||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
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 {
|
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 ErrInvalidImageGroup
|
||||||
}
|
}
|
||||||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
||||||
@ -367,27 +370,69 @@ func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDeta
|
|||||||
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
|
{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 {
|
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
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
return m.repo.UpdateRecord(ctx, id, fields)
|
return m.repo.UpdateRecord(ctx, id, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||||
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
|
||||||
return m.repo.SetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
||||||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
||||||
}
|
}
|
||||||
@ -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) {
|
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||||
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
|
||||||
return m.repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
||||||
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,9 +14,10 @@ import (
|
|||||||
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||||
type fakeGroupRepo struct {
|
type fakeGroupRepo struct {
|
||||||
repository.ReviewRepository
|
repository.ReviewRepository
|
||||||
images []model.StreetSnapImage
|
images []model.StreetSnapImage
|
||||||
detail *model.StreetSnap
|
detail *model.StreetSnap
|
||||||
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
|
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) {
|
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
|
||||||
@ -41,6 +42,17 @@ func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uin
|
|||||||
return nil
|
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)
|
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
|
||||||
|
|
||||||
func newGroupService(f *fakeGroupRepo) ReviewService {
|
func newGroupService(f *fakeGroupRepo) ReviewService {
|
||||||
@ -102,12 +114,32 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
|
// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
|
||||||
func TestStreetGroupNonStreetRejected(t *testing.T) {
|
func TestStreetGroupNonStreetRejected(t *testing.T) {
|
||||||
repo := &fakeGroupRepo{}
|
repo := &fakeGroupRepo{}
|
||||||
svc := newGroupService(repo)
|
svc := newGroupService(repo)
|
||||||
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||||
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
//
|
//
|
||||||
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A)
|
// 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) {
|
func TestBuildSnapImages(t *testing.T) {
|
||||||
s := &streetSnapService{img: imgurl.New("", "", "")}
|
s := &streetSnapService{img: imgurl.New("", "", "")}
|
||||||
imgs := []model.StreetSnapImage{
|
imgs := []model.StreetSnapImage{
|
||||||
@ -29,21 +29,21 @@ func TestBuildSnapImages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := out[0]
|
a := out[0]
|
||||||
if a.Name != "A" || a.IsDetail != 0 || a.LookIndex != 1 {
|
if a.Name != "A" || a.IsDetail != 0 {
|
||||||
t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex)
|
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" {
|
if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" {
|
||||||
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
|
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
|
||||||
}
|
}
|
||||||
for _, d := range a.Detail {
|
for _, d := range a.Detail {
|
||||||
if d.IsDetail != 1 || d.LookIndex != 1 {
|
if d.IsDetail != 1 {
|
||||||
t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex)
|
t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := out[1]
|
c := out[1]
|
||||||
if c.Name != "C" || c.LookIndex != 2 {
|
if c.Name != "C" {
|
||||||
t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex)
|
t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name)
|
||||||
}
|
}
|
||||||
if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
|
if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
|
||||||
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))
|
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))
|
||||||
|
|||||||
96
internal/service/street_snap_i18n_test.go
Normal file
96
internal/service/street_snap_i18n_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"fashionapi/internal/dto"
|
"fashionapi/internal/dto"
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -19,13 +20,15 @@ type StreetSnapService interface {
|
|||||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||||
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
||||||
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
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 后台详情:读基表,能渲染 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 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
|
||||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||||
|
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释),供编辑页标记重复图。
|
||||||
|
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
|
||||||
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
|
||||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// DeleteImage 软删单张街拍图片。
|
// DeleteImage 软删单张街拍图片。
|
||||||
@ -70,7 +73,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
ids = append(ids, r.ID)
|
ids = append(ids, r.ID)
|
||||||
}
|
}
|
||||||
list := s.toPublicSnaps(rows)
|
list := s.toPublicSnaps(rows, q.Locale)
|
||||||
|
|
||||||
if q.Images > 0 && len(list) > 0 {
|
if q.Images > 0 && len(list) > 0 {
|
||||||
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, internalErr(err.Error())
|
return nil, 0, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
return s.toPublicSnaps(rows), total, nil
|
return s.toPublicSnaps(rows, q.Locale), total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
||||||
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
|
// 公开 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))
|
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||||
Title: r.Title,
|
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||||
Cover: s.img.ComposeThumb(r.Cover),
|
Cover: s.img.ComposeThumb(r.Cover),
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
|
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
|
||||||
@ -149,7 +152,7 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
|||||||
|
|
||||||
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
||||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
// 图片统一走展示样式(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)
|
snap, err := s.repo.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, s.detailErr(err)
|
return nil, s.detailErr(err)
|
||||||
@ -158,14 +161,14 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
return s.buildSnapDetail(snap, imgs), nil
|
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||||
//
|
//
|
||||||
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||||
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
// 若后台详情也走公开视图,待审项会 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)
|
snap, err := s.repo.FindByIDBase(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, s.detailErr(err)
|
return nil, s.detailErr(err)
|
||||||
@ -174,7 +177,7 @@ func (s *streetSnapService) DetailAdmin(ctx context.Context, id string) (*dto.Pu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
return s.buildSnapDetail(snap, imgs), nil
|
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||||
@ -186,10 +189,10 @@ func (s *streetSnapService) detailErr(err error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
// 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{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
|
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
|
||||||
Title: snap.Title,
|
Title: pickLocale(snap.Title, snap.TitleCn, locale),
|
||||||
Cover: s.img.Compose(snap.Cover),
|
Cover: s.img.Compose(snap.Cover),
|
||||||
Images: s.buildSnapImages(imgs),
|
Images: s.buildSnapImages(imgs),
|
||||||
}
|
}
|
||||||
@ -198,7 +201,7 @@ func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model
|
|||||||
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
||||||
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
|
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
|
||||||
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
|
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
|
||||||
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
|
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
|
||||||
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
|
||||||
if len(imgs) == 0 {
|
if len(imgs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -237,12 +240,12 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pub := s.toPublicSnapImage(im)
|
pub := s.toPublicSnapImage(im)
|
||||||
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
|
|
||||||
index[im.ID] = len(out)
|
index[im.ID] = len(out)
|
||||||
out = append(out, pub)
|
out = append(out, pub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
|
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
root := resolveRoot(im.ID)
|
root := resolveRoot(im.ID)
|
||||||
if root == im.ID {
|
if root == im.ID {
|
||||||
@ -254,14 +257,14 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
|||||||
}
|
}
|
||||||
pub := s.toPublicSnapImage(im)
|
pub := s.toPublicSnapImage(im)
|
||||||
pub.IsDetail = 1
|
pub.IsDetail = 1
|
||||||
pub.LookIndex = out[gi].LookIndex
|
|
||||||
out[gi].Detail = append(out[gi].Detail, pub)
|
out[gi].Detail = append(out[gi].Detail, pub)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
// 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)
|
rows, err := s.repo.Popular(ctx, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
@ -270,7 +273,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
|||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||||
Title: r.Title,
|
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||||
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
|
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
|
||||||
ImageCount: r.ImageCount,
|
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) {
|
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 {
|
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 != "" {
|
if key != "" {
|
||||||
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
|
||||||
}
|
}
|
||||||
|
// 删图后重算本专辑去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
|
||||||
|
if snapID, err := s.repo.GetSnapImageRunwayID(ctx, imageID); err == nil && snapID != 0 {
|
||||||
|
if err := s.repo.ReresolveSnapDedup(ctx, snapID); err != nil {
|
||||||
|
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2543
server_run.err
Normal file
2543
server_run.err
Normal file
File diff suppressed because it is too large
Load Diff
2480
server_run.out
Normal file
2480
server_run.out
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user