Compare commits
26 Commits
d908036621
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d15d2a4701 | |||
| aaeab41d67 | |||
| 42f6316125 | |||
| 4f4c11d508 | |||
| f04b5707cb | |||
| be4db0bf96 | |||
| 3fc8b1f924 | |||
| 762e528334 | |||
| d68a57009c | |||
| c4bafc5b41 | |||
| 12d05f75e9 | |||
| ce4f8b786d | |||
| d8b3424663 | |||
| f9068f530b | |||
| 76626b8372 | |||
| e0ce81318d | |||
| 474e1c1a64 | |||
| 8f8b06def7 | |||
| dba615d636 | |||
| 4169ebe1ed | |||
| f5144038ab | |||
| 83823a8d49 | |||
| bfe890bd56 | |||
| 7a069fe293 | |||
| c3a6baa419 | |||
| c32347ed3c |
6
.gitignore
vendored
6
.gitignore
vendored
@ -20,6 +20,9 @@ gotest*.exe
|
|||||||
# 数据库备份
|
# 数据库备份
|
||||||
/db/backups/
|
/db/backups/
|
||||||
|
|
||||||
|
# dbtool 陈旧导出产物(权威 schema 是仓库根目录的 /db_dump.sql,勿混淆)
|
||||||
|
cmd/dbtool/db_dump.*
|
||||||
|
|
||||||
# 本地配置
|
# 本地配置
|
||||||
configs/config.local.yml
|
configs/config.local.yml
|
||||||
|
|
||||||
@ -36,4 +39,5 @@ admin_cookies.txt
|
|||||||
/vendor/
|
/vendor/
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
*.log
|
*.log
|
||||||
|
.superpowers
|
||||||
@ -81,7 +81,7 @@ internal/
|
|||||||
### 2.3 管理后台(8092)
|
### 2.3 管理后台(8092)
|
||||||
|
|
||||||
- `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。
|
- `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。
|
||||||
- `/admin/*`:品牌 / 走秀 / 街拍管理、草稿审核(通过晋升正式表 / 驳回)、入库任务监控与重试、用户管理(提级 VIP)。
|
- `/admin/*`:品牌 / 走秀 / 街拍管理、内容审核(单表 + `status`:通过置 `published`、驳回置 `rejected`)、入库任务监控与重试、用户管理(提级 VIP)。
|
||||||
- `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。
|
- `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -95,7 +95,7 @@ internal/
|
|||||||
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
|
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
|
||||||
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
|
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
|
||||||
- **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
|
- **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
|
||||||
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、写草稿表;后台人工审核通过后晋升正式表。失败按指数退避重试,单图失败整任务回滚。
|
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、**直写正式表**(`status=pending`,已无草稿表);后台人工审核只改状态(`published` / `rejected`),不重建图片;公开读走 `public_*` 只读视图,未发布内容结构性不可见。失败按指数退避重试,单图失败整任务回滚。
|
||||||
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
|
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -174,7 +174,7 @@ backend/
|
|||||||
│ └── dbdiag/ # 数据库诊断
|
│ └── dbdiag/ # 数据库诊断
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # 配置加载
|
│ ├── config/ # 配置加载
|
||||||
│ ├── model/ # 实体(brand / runway / street_snap / user / 草稿 / ingest)
|
│ ├── model/ # 实体(brand / runway / street_snap / user / ingest)
|
||||||
│ ├── database/ # 连接池(结构由 dbtool 导出的 SQL 维护,不做 DDL)
|
│ ├── database/ # 连接池(结构由 dbtool 导出的 SQL 维护,不做 DDL)
|
||||||
│ ├── repository/ # 数据访问接口 + GORM 实现
|
│ ├── repository/ # 数据访问接口 + GORM 实现
|
||||||
│ ├── service/ # 业务逻辑
|
│ ├── service/ # 业务逻辑
|
||||||
|
|||||||
37096
cmd/dbtool/db_dump.json
37096
cmd/dbtool/db_dump.json
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
|||||||
// Command dbtool 是后台管理用的数据库导出 / 导入脚本。
|
// Command dbtool 是后台管理用的数据库导出 / 导入脚本。
|
||||||
//
|
//
|
||||||
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 数据」导出为
|
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为
|
||||||
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
|
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
|
||||||
//
|
//
|
||||||
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
|
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
|
||||||
@ -23,9 +23,13 @@
|
|||||||
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
|
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
|
||||||
//
|
//
|
||||||
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
|
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
|
||||||
// 数据、序列当前值。
|
// 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。
|
||||||
//
|
//
|
||||||
// 视图 / 触发器 / 外键 / 注释 / 权限不导出(当前库中也不存在)。
|
// 触发器 / 外键 / 注释 / 权限不导出。
|
||||||
|
//
|
||||||
|
// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01),
|
||||||
|
// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图,
|
||||||
|
// 公开查询直接报 relation does not exist。
|
||||||
//
|
//
|
||||||
// 目标库为空还是已有数据:
|
// 目标库为空还是已有数据:
|
||||||
//
|
//
|
||||||
@ -53,10 +57,12 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -101,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)
|
||||||
@ -112,9 +122,15 @@ func usage() {
|
|||||||
|
|
||||||
用法:
|
用法:
|
||||||
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
|
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
|
||||||
导出「结构 + 主键 / 索引 + 数据」为单个纯 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 覆盖)。
|
||||||
@ -178,13 +194,17 @@ func runDump(args []string) {
|
|||||||
if len(tables) == 0 {
|
if len(tables) == 0 {
|
||||||
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
|
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
|
||||||
}
|
}
|
||||||
|
views, err := listViews(db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 列举视图失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
cols := make(map[string][]column, len(tables))
|
cols := make(map[string][]column, len(tables))
|
||||||
|
|
||||||
// ① 结构(-clean 时先输出清理段)
|
// ① 结构(-clean 时先输出清理段)
|
||||||
fmt.Fprintln(w, "-- ==================== 结构 ====================")
|
fmt.Fprintln(w, "-- ==================== 结构 ====================")
|
||||||
if *clean {
|
if *clean {
|
||||||
writeClean(w, tables)
|
writeClean(w, tables, views)
|
||||||
}
|
}
|
||||||
for _, t := range tables {
|
for _, t := range tables {
|
||||||
cs, err := listColumns(db, t)
|
cs, err := listColumns(db, t)
|
||||||
@ -194,7 +214,11 @@ func runDump(args []string) {
|
|||||||
cols[t] = cs
|
cols[t] = cs
|
||||||
writeCreateTable(w, t, cs)
|
writeCreateTable(w, t, cs)
|
||||||
}
|
}
|
||||||
log.Printf("→ 结构:%d 张表", len(tables))
|
// 视图放在全部基表之后建:视图可能引用基表,也可能引用另一个视图(listViews 已按依赖排序)。
|
||||||
|
for _, v := range views {
|
||||||
|
writeCreateView(w, v)
|
||||||
|
}
|
||||||
|
log.Printf("→ 结构:%d 张表、%d 个视图", len(tables), len(views))
|
||||||
|
|
||||||
// ② 数据
|
// ② 数据
|
||||||
fmt.Fprintln(w, "-- ==================== 数据 ====================")
|
fmt.Fprintln(w, "-- ==================== 数据 ====================")
|
||||||
@ -268,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 对象,不属于任何表,因此
|
||||||
@ -306,19 +488,175 @@ func execScript(ctx context.Context, db *sql.DB, script string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeClean 输出清理段:DROP 掉本次要重建的表,让脚本可以灌进「已有数据的库」。
|
// writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。
|
||||||
//
|
//
|
||||||
// 只 DROP 本次 dump 里出现的表(与 pg_dump --clean 口径一致)——目标库中多出来的表保持不动。
|
// 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。
|
||||||
// 用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
|
// 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。
|
||||||
|
// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
|
||||||
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
|
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
|
||||||
func writeClean(w *bufio.Writer, tables []string) {
|
func writeClean(w *bufio.Writer, tables []string, views []viewDef) {
|
||||||
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列表,目标库中这些表的既有数据将丢失")
|
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失")
|
||||||
|
for i := len(views) - 1; i >= 0; i-- {
|
||||||
|
fmt.Fprintf(w, "DROP VIEW IF EXISTS %s;\n", qname(views[i].Name))
|
||||||
|
}
|
||||||
for _, t := range tables {
|
for _, t := range tables {
|
||||||
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
|
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
|
||||||
}
|
}
|
||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewDef 单个视图的名字与定义(定义取自 pg_get_viewdef,已去掉前导空白)。
|
||||||
|
type viewDef struct {
|
||||||
|
Name string
|
||||||
|
Def string
|
||||||
|
}
|
||||||
|
|
||||||
|
// listViews 返回 public 下全部视图,按**依赖顺序**排列(被依赖者在前)。
|
||||||
|
//
|
||||||
|
// 视图可以依赖基表,也可以依赖另一个视图;重建时必须先建被依赖者,否则 CREATE VIEW 会报
|
||||||
|
// relation does not exist。顺序由 pg_depend 推出的「视图→视图」依赖做拓扑排序得到。
|
||||||
|
func listViews(db *sql.DB) ([]viewDef, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT c.relname, pg_get_viewdef(c.oid, true)
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relkind = 'v'
|
||||||
|
ORDER BY c.relname`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
defs := map[string]string{}
|
||||||
|
var names []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name, def string
|
||||||
|
if err := rows.Scan(&name, &def); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defs[name] = def
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deps, err := listViewDeps(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ordered, err := topoSortViews(names, deps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]viewDef, 0, len(ordered))
|
||||||
|
for _, name := range ordered {
|
||||||
|
// pg_get_viewdef 末尾自带分号,去掉后由 writeCreateView 统一补,避免出现 ";;"。
|
||||||
|
body := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(defs[name]), ";"))
|
||||||
|
out = append(out, viewDef{Name: name, Def: body})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listViewDeps 返回「视图 → 它直接引用的另一个视图」的依赖边(忽略对基表的引用)。
|
||||||
|
func listViewDeps(db *sql.DB) (map[string][]string, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT DISTINCT v.relname, d.relname
|
||||||
|
FROM pg_depend dep
|
||||||
|
JOIN pg_rewrite rw ON rw.oid = dep.objid
|
||||||
|
JOIN pg_class v ON v.oid = rw.ev_class
|
||||||
|
JOIN pg_class d ON d.oid = dep.refobjid
|
||||||
|
JOIN pg_namespace n ON n.oid = v.relnamespace
|
||||||
|
WHERE dep.classid = 'pg_rewrite'::regclass
|
||||||
|
AND dep.refclassid = 'pg_class'::regclass
|
||||||
|
AND dep.deptype = 'n'
|
||||||
|
AND v.relkind = 'v' AND d.relkind = 'v'
|
||||||
|
AND v.oid <> d.oid
|
||||||
|
AND n.nspname = 'public'`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
deps := map[string][]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v, d string
|
||||||
|
if err := rows.Scan(&v, &d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deps[v] = append(deps[v], d)
|
||||||
|
}
|
||||||
|
return deps, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// topoSortViews 对视图做拓扑排序:被依赖者在前。每步用名字排序兜底,保证导出可复现。
|
||||||
|
func topoSortViews(names []string, deps map[string][]string) ([]string, error) {
|
||||||
|
known := make(map[string]bool, len(names))
|
||||||
|
indeg := make(map[string]int, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
known[n] = true
|
||||||
|
indeg[n] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
adj := map[string][]string{}
|
||||||
|
for v, ds := range deps {
|
||||||
|
if !known[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range ds {
|
||||||
|
if !known[d] || d == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adj[d] = append(adj[d], v) // d 必须先建,建完 d 才轮到 v
|
||||||
|
indeg[v]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ready := make([]string, 0, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
if indeg[n] == 0 {
|
||||||
|
ready = append(ready, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ready)
|
||||||
|
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
for len(ready) > 0 {
|
||||||
|
n := ready[0]
|
||||||
|
ready = ready[1:]
|
||||||
|
out = append(out, n)
|
||||||
|
|
||||||
|
next := append([]string(nil), adj[n]...)
|
||||||
|
sort.Strings(next)
|
||||||
|
for _, m := range next {
|
||||||
|
indeg[m]--
|
||||||
|
if indeg[m] == 0 {
|
||||||
|
ready = append(ready, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ready)
|
||||||
|
}
|
||||||
|
if len(out) != len(names) {
|
||||||
|
return nil, fmt.Errorf("视图依赖存在环,无法排序(已完成 %d/%d)", len(out), len(names))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCreateView 输出一个视图的 CREATE OR REPLACE VIEW。
|
||||||
|
// 定义来自 pg_get_viewdef(形如「 SELECT ...」),去掉前导空白后接到 AS 之后即可。
|
||||||
|
//
|
||||||
|
// 用 OR REPLACE 而非裸 CREATE:非 -clean 产物里其余语句都幂等(表 IF NOT EXISTS、
|
||||||
|
// 约束/索引 DROP ... IF EXISTS、索引 IF NOT EXISTS),视图也应对齐,重复灌入不报
|
||||||
|
// relation already exists。
|
||||||
|
// 注意 OR REPLACE 不允许改变已有视图的列名 / 列序 / 类型 —— 视图列集被 SELECT * 冻结,
|
||||||
|
// 给基表加列必须同批重建视图(见 db/migrations/2026-09-22-01 的备注)。
|
||||||
|
func writeCreateView(w *bufio.Writer, v viewDef) {
|
||||||
|
fmt.Fprintf(w, "CREATE OR REPLACE VIEW %s AS\n%s;\n\n", qname(v.Name), v.Def)
|
||||||
|
}
|
||||||
|
|
||||||
// writeHeader 写文件头与几个会话设置。
|
// writeHeader 写文件头与几个会话设置。
|
||||||
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
|
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
|
||||||
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())
|
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())
|
||||||
|
|||||||
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 渲染时拼)
|
||||||
|
|||||||
67
db/migrations/2026-09-22-01-single-table-publish.sql
Normal file
67
db/migrations/2026-09-22-01-single-table-publish.sql
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。
|
||||||
|
--
|
||||||
|
-- 本文件只做两件事:加列 / 建视图。幂等,可重复执行。
|
||||||
|
-- 存量行置已发布不在本文件里(它是只能执行一次的数据变更,见 2026-09-22-01b-publish-existing-rows.sql)。
|
||||||
|
-- 数据搬迁由一次性脚本 scripts/migrate_single_table/main.go 完成。该脚本已随本次改造从工作树删除
|
||||||
|
--(它 import 已删除的草稿模型,无法在改造后的代码树上编译);取回方式与执行顺序见本目录 README.md。
|
||||||
|
-- 删草稿表见 2026-09-22-05-drop-draft-tables.sql。
|
||||||
|
|
||||||
|
-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed):
|
||||||
|
-- 任何漏赋值的行默认不可见,而不是意外对外发布。
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE street_snaps
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '',
|
||||||
|
-- 街拍标题中文化:保留既有 title 作英文源,仅加 title_cn 供后台人工翻译、前端按 locale 取用。
|
||||||
|
-- 与 brand_runways 用 title_en/title_cn 不同:street_snaps 上有 uq_ss_entity(city, year, COALESCE(title,'')),
|
||||||
|
-- 改名成本过高;pickLocale 在中文为空时回落英文,无需数据回灌。
|
||||||
|
ADD COLUMN IF NOT EXISTS title_cn text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
|
||||||
|
-- 2026-09-22-01b-publish-existing-rows.sql。
|
||||||
|
-- 为什么必须拆开:数据搬迁(scripts/migrate_single_table/main.go)会把 pending 草稿连原 created_at
|
||||||
|
-- 一起搬进正式表;若本文件含那条 UPDATE 且被重复执行(测试每次都会跑),
|
||||||
|
-- 这些待审内容会被误刷成已发布 —— 恰好是本设计要防的泄漏。
|
||||||
|
|
||||||
|
-- 3) 状态索引:审核列表与公开视图都按 status 过滤。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
|
||||||
|
|
||||||
|
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
|
||||||
|
-- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列
|
||||||
|
-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。
|
||||||
|
-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。
|
||||||
|
DROP VIEW IF EXISTS public_brand_runway_images;
|
||||||
|
DROP VIEW IF EXISTS public_brand_runways;
|
||||||
|
DROP VIEW IF EXISTS public_street_snap_images;
|
||||||
|
DROP VIEW IF EXISTS public_street_snaps;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runways AS
|
||||||
|
SELECT
|
||||||
|
id, brand_id, title_en, title_cn, description_en, description_cn,
|
||||||
|
cover, image_count, year, collection_type, season, season_code,
|
||||||
|
status, is_deleted, created_at, updated_at
|
||||||
|
FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runway_images AS
|
||||||
|
SELECT i.* FROM brand_runway_images i
|
||||||
|
JOIN brand_runways r ON r.id = i.runway_id
|
||||||
|
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snaps AS
|
||||||
|
SELECT
|
||||||
|
id, title, title_cn, year, city, cover, image_count,
|
||||||
|
status, is_deleted, created_at, updated_at
|
||||||
|
FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snap_images AS
|
||||||
|
SELECT i.* FROM street_snap_images i
|
||||||
|
JOIN street_snaps s ON s.id = i.snap_id
|
||||||
|
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
|
||||||
11
db/migrations/2026-09-22-01b-publish-existing-rows.sql
Normal file
11
db/migrations/2026-09-22-01b-publish-existing-rows.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- 一次性数据迁移:把存量正式行视为已发布。
|
||||||
|
--
|
||||||
|
-- ⚠️ 只执行一次;必须在部署本计划的新代码之前、且在一次性搬迁脚本
|
||||||
|
-- scripts/migrate_single_table/main.go 之前执行。
|
||||||
|
-- 该脚本已随本次改造从工作树删除(它 import 已删除的草稿模型);取回方式见本目录 README.md。
|
||||||
|
-- 为什么单独成文件而不放进可重复执行的 2026-09-22-01:
|
||||||
|
-- 数据搬迁会把 pending 草稿连同旧的 created_at 一起搬进正式表;
|
||||||
|
-- 若这条 UPDATE 可被重复执行(集成测试每次都会跑 01 号文件),
|
||||||
|
-- 这些待审内容会被误刷成 published —— 恰好是本设计要防的泄漏。
|
||||||
|
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
|
||||||
|
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
|
||||||
50
db/migrations/2026-09-22-03-entity-key-unique.sql
Normal file
50
db/migrations/2026-09-22-03-entity-key-unique.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。
|
||||||
|
--
|
||||||
|
-- 为什么必须有:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈)。
|
||||||
|
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
||||||
|
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
||||||
|
--
|
||||||
|
-- 部分索引(WHERE is_deleted = 0):仅约束活着行,保证「同一实体至多一行活着」。
|
||||||
|
-- 注:入库服务层 RunwayEntityState / StreetSnapEntityState 现已**不再**以 is_deleted = 0 过滤,
|
||||||
|
-- 软删实体也会被查到并由 worker 跳过重爬(「删除即永久」)。本索引仍保持部分索引是为了
|
||||||
|
-- 兼容存量「软删行同键共存」的数据、避免建全量唯一索引时因历史重复而失败;结构性唯一性靠它兜底,
|
||||||
|
-- 行为上的「删除即永久」由服务层查重保证。
|
||||||
|
|
||||||
|
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
||||||
|
DO $$
|
||||||
|
DECLARE dup int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO dup FROM (
|
||||||
|
SELECT brand_id, season_code, collection_type
|
||||||
|
FROM brand_runways WHERE is_deleted = 0
|
||||||
|
GROUP BY 1, 2, 3 HAVING count(*) > 1
|
||||||
|
) t;
|
||||||
|
IF dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'brand_runways 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 口径已随任务 3c 的实体键细化同步为 (city, year, title):否则本文件在「同城同年多专题」
|
||||||
|
-- 的合法数据上不再幂等(重复执行会误报重复而中止)。
|
||||||
|
SELECT count(*) INTO dup FROM (
|
||||||
|
SELECT city, year, COALESCE(title, '')
|
||||||
|
FROM street_snaps WHERE is_deleted = 0
|
||||||
|
GROUP BY 1, 2, 3 HAVING count(*) > 1
|
||||||
|
) t;
|
||||||
|
IF dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_entity
|
||||||
|
ON brand_runways (brand_id, season_code, collection_type) WHERE is_deleted = 0;
|
||||||
|
|
||||||
|
-- 索引表达式必须与上面的体检、以及 StreetSnapEntityState 的查重口径**逐字一致**:
|
||||||
|
-- (city, year, COALESCE(title, ''))。早期版本这里误建成 (city, year) —— 比体检更窄,
|
||||||
|
-- 于是体检放行「同城同年不同专题」的合法数据后,建索引才抛原生 23505
|
||||||
|
-- (could not create unique index ... duplicate key)。COALESCE(title, '') 的取舍见 2026-09-22-04。
|
||||||
|
--
|
||||||
|
-- ⚠️ CREATE UNIQUE INDEX IF NOT EXISTS 只检查**索引名**是否存在:若库里已有一个同名的
|
||||||
|
-- 旧 (city, year) 索引,本句会静默跳过、留下错误结构(这正是它一度掩盖问题的原因)。
|
||||||
|
-- 纠正这类存量库由 2026-09-22-04 承担;全新库 / 重复执行由本文件承载最终表达式。
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
|
||||||
|
ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0;
|
||||||
34
db/migrations/2026-09-22-04-street-entity-key-title.sql
Normal file
34
db/migrations/2026-09-22-04-street-entity-key-title.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
-- 街拍实体键细化:由 (city, year) 改为 (city, year, title)。
|
||||||
|
--
|
||||||
|
-- ⚠️ 本文件是**幂等兜底**,最终表达式的常态承载在 2026-09-22-03(它已直接建同一表达式索引)。
|
||||||
|
-- 之所以必须保留:03 的 `CREATE UNIQUE INDEX IF NOT EXISTS` 只按**索引名**判断存在性,
|
||||||
|
-- 遇到「同名但口径更窄的旧 (city, year) 索引」会静默跳过、留下错误结构 ——
|
||||||
|
-- 只有本文件的 `DROP INDEX IF EXISTS` + 重建能纠正这类存量库。
|
||||||
|
-- 全新库上重跑本文件也只是 DROP 后原样重建,幂等无害。
|
||||||
|
--
|
||||||
|
-- 背景:同城同年可能有多个专题(如 London 2027 Day 2 / Day 3),原键把它们判为同一实体,
|
||||||
|
-- 导致唯一索引不允许共存、且入库查重把第二个专题当成重复直接放弃。
|
||||||
|
--
|
||||||
|
-- COALESCE(title, '') 是刻意的:唯一索引中 NULL 互不冲突,若直接用 title,
|
||||||
|
-- 两条 title 为 NULL 的行会被判为不同实体而共存。入库始终会写 title,
|
||||||
|
-- 这里只是把「NULL 也要参与唯一性」这件事钉死。
|
||||||
|
|
||||||
|
-- 建索引前先体检(与 03 同一口径 (city, year, COALESCE(title, ''))):
|
||||||
|
-- 直接建索引只会抛原生 23505,这里先给出可读的报错原因。
|
||||||
|
DO $$
|
||||||
|
DECLARE dup int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO dup FROM (
|
||||||
|
SELECT city, year, COALESCE(title, '')
|
||||||
|
FROM street_snaps WHERE is_deleted = 0
|
||||||
|
GROUP BY 1, 2, 3 HAVING count(*) > 1
|
||||||
|
) t;
|
||||||
|
IF dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uq_ss_entity;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
|
||||||
|
ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0;
|
||||||
10
db/migrations/2026-09-22-05-drop-draft-tables.sql
Normal file
10
db/migrations/2026-09-22-05-drop-draft-tables.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- 单表发布模型收尾:删除 4 张草稿表。
|
||||||
|
--
|
||||||
|
-- ⚠️ 执行前置条件:一次性搬迁脚本 scripts/migrate_single_table/main.go(已随本次改造从工作树删除,
|
||||||
|
-- 取回方式见本目录 README.md)已跑完且校验通过 —— 否则草稿数据会丢失。
|
||||||
|
-- 本迁移不可逆:执行前请确认 db/backups 有可用备份。
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS brand_runway_draft_images;
|
||||||
|
DROP TABLE IF EXISTS brand_runway_drafts;
|
||||||
|
DROP TABLE IF EXISTS street_snap_draft_images;
|
||||||
|
DROP TABLE IF EXISTS street_snap_drafts;
|
||||||
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;
|
||||||
55
db/migrations/README.md
Normal file
55
db/migrations/README.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 数据库迁移
|
||||||
|
|
||||||
|
本目录的 `.sql` 是**手工一次性迁移**(服务启动不做 DDL,结构由 `dbtool` 导出的 `db_dump.sql` 维护)。
|
||||||
|
命名规则 `日期-序号-主题.sql`,**按文件名顺序执行**。
|
||||||
|
|
||||||
|
- 全新库:直接用 `db_dump.sql` 建库(见根目录 `README.md`「数据库」一节),**不需要**跑这些迁移。
|
||||||
|
- 存量库升级:按下文顺序执行;大多语句幂等,但**数据变更类不可重放**(见各步说明)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、2026-09-21 系列(街拍主副图 / 重复检测 / 来源幂等)
|
||||||
|
|
||||||
|
| 顺序 | 文件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `2026-09-21-01-street-main-detail.sql` | 草稿表与正式图片表加 `is_detail` / `parent_image_id`。全 `IF NOT EXISTS`,幂等。 |
|
||||||
|
| 2 | `2026-09-21-02-duplicate-review.sql` | 新建 `image_duplicates`,街拍正式表加 `source` / `source_url` 及部分唯一索引 `uq_ss_source`。幂等。 |
|
||||||
|
| 3 | `2026-09-21-03-ingest-source-idempotency.sql` | 两张**草稿表**加 `source` / `source_url` 及部分唯一索引。幂等。这些列会随 2026-09-22-05 删草稿表一并消失。 |
|
||||||
|
|
||||||
|
这三步属于更早的改造;对已经过它们的库重放无害。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、2026-09-22 单表发布(草稿表 → 单表 + `status` + 公开只读视图)
|
||||||
|
|
||||||
|
**执行顺序:`01 → 01b → 03 → 04 → 搬迁脚本 → 05`**(与文件名排序一致:`01`、`01b`、`03`、`04`、`05`;搬迁脚本是一段独立命令,夹在 `04` 与 `05` 之间执行)
|
||||||
|
|
||||||
|
| 顺序 | 对象 | 前提 / 幂等 / 不可逆点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `2026-09-22-01-single-table-publish.sql` | 给 `brand_runways` / `street_snaps` 加 `status` 等列、建状态索引、建 4 个 `public_*` 视图。**幂等**,可重复执行(集成测试每次都会跑它)。 |
|
||||||
|
| 2 | `2026-09-22-01b-publish-existing-rows.sql` | 把存量正式行置 `published`。**只能执行一次**;且必须在新代码部署**之前**、搬迁脚本**之前**执行。理由:搬迁会把 pending 草稿连同旧 `created_at` 一起搬进来,若这条 UPDATE 可重放,待审内容会被误刷成已发布(本设计要防的就是这个泄漏)。 |
|
||||||
|
| 3 | `2026-09-22-03-entity-key-unique.sql` | 实体键部分唯一索引 `uq_br_entity` / `uq_ss_entity`(街拍为最终表达式 `(city, year, COALESCE(title,''))`)。**幂等**;建索引前先体检,有重复就报可读错误而非原生 23505。 |
|
||||||
|
| 4 | `2026-09-22-04-street-entity-key-title.sql` | 街拍索引的**幂等兜底**:`DROP INDEX IF EXISTS uq_ss_entity` 后按最终表达式重建。常态由 03 承载;本文件专治「库里已有同名旧 `(city, year)` 索引、03 因 `IF NOT EXISTS` 静默跳过」的存量库。 |
|
||||||
|
| 5 | 搬迁脚本 `<见下>` | 把草稿表数据搬进正式表。**一次性**;先 `-dry-run` + 守恒校验,**校验不过绝不进入第 6 步**。 |
|
||||||
|
| 6 | `2026-09-22-05-drop-draft-tables.sql` | 删除 4 张草稿表。**不可逆**;前提是第 5 步校验通过,且 `db/backups/` 有可用备份。⚠️ 编号排在最后(`05`)是刻意的:按文件名顺序执行的运维者不会在搬迁之前误删草稿表。 |
|
||||||
|
|
||||||
|
### 搬迁脚本的取回方式
|
||||||
|
|
||||||
|
`scripts/migrate_single_table/main.go` 是一次性工具,已随本次改造从工作树删除(它 `import` 了同批删除的
|
||||||
|
草稿模型,无法在改造后的代码树上编译)。如需重放,从**它被删除前的最后一个提交**取回:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 取回脚本源码(c4bafc5 是删除提交 d68a570 的父提交,即脚本最后一次存在的版本)
|
||||||
|
git show c4bafc5:scripts/migrate_single_table/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:脚本依赖草稿模型,必须在该提交(或更早)的代码树上运行,**不能**直接放进当前树。
|
||||||
|
真正需要重放时,建议 `git worktree add ../old c4bafc5` 切出旧树再跑。
|
||||||
|
|
||||||
|
### 索引口径的一个坑
|
||||||
|
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS` 只按**索引名**判断存在性,不比对表达式。因此:
|
||||||
|
|
||||||
|
- 若库里已有同名但口径更窄的旧 `uq_ss_entity (city, year)`,03 会静默跳过、留下错误结构;
|
||||||
|
这时必须跑 04 纠正(04 的 `DROP` 正是为此)。
|
||||||
|
- 反过来,若只跑 03 而库里已存在正确的同名索引,跳过是无害的。
|
||||||
17172
db_dump.sql
Normal file
17172
db_dump.sql
Normal file
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
260
docs/superpowers/specs/2026-09-22-single-table-publish-design.md
Normal file
260
docs/superpowers/specs/2026-09-22-single-table-publish-design.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# 单表发布模型(取消草稿表)设计
|
||||||
|
|
||||||
|
- 日期:2026-09-22
|
||||||
|
- 替代关系:本设计**取代** `2026-09-20-ingest-idempotency-design.md` 中"草稿表持有 source/source_url 做来源幂等"的部分(该能力从未落到代码,列与索引为空转);并**吸收** `2026-09-21-street-main-detail-design.md` 的主副图能力(从草稿图片迁移到正式图片表,父引用不再需要重映射)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
现状是「草稿表 → 人工审核 → 晋升写入正式表」的两套表结构:
|
||||||
|
|
||||||
|
| 模块 | 草稿 | 正式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 走秀 / article | `brand_runway_drafts` + `brand_runway_draft_images` | `brand_runways` + `brand_runway_images` |
|
||||||
|
| 街拍 | `street_snap_drafts` + `street_snap_draft_images` | `street_snaps` + `street_snap_images` |
|
||||||
|
|
||||||
|
由此产生的复杂度:
|
||||||
|
|
||||||
|
1. **结构重复**:内容字段(标题/描述/年份/季节/cover/image_count)在两张表各写一份;正式图片表与草稿图片表字段几乎相同(`is_detail`/`look_index`/`phash`/`is_duplicate`/`dup_of` 是双份)。
|
||||||
|
2. **晋升即重建**:`SaveRunwayFromDraft` / `SaveStreetSnapFromDraft` 会软删正式图片再整批重建,**图片行 id 每次都变** —— 外层业已存在"重复发布打断单图收藏"的隐患(`favorites.target_uid` 存的是图片的 hashid)。
|
||||||
|
3. **父引用重映射**:街拍副图的 `parent_image_id` 存的是草稿表行 id,晋升时要两遍插入并改写为新正式行 id,父行缺失还要降级。
|
||||||
|
4. **多来源聚合**:`sibling` 图片聚合(按实体键把多个已通过草稿的图并成一条正式记录)只在晋升时发生,逻辑绕。
|
||||||
|
5. **死物**:`brand_runway_drafts.source/source_url`、`street_snaps.source/source_url`、`image_duplicates`、`image_embeddings` 全仓无代码读写。
|
||||||
|
|
||||||
|
核心判断:**草稿表承担的两件事(审核态、来源追溯)都只是一个字段,不值得一整张表。**
|
||||||
|
|
||||||
|
## 2. 目标 / 非目标
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 每个模块**只剩一张记录表 + 一张图片表**。
|
||||||
|
- 审核态由 `status` 字段承载,**审核阶段保留**(不是取消审核,是取消"另一张表")。
|
||||||
|
- 公开可见性从"每个公开查询各自记得过滤"升级为**结构性保证**(只读视图)。
|
||||||
|
- 图片行 id 从入库起**终身不变**(收藏不再被打断)。
|
||||||
|
- 删除晋升/聚合/父引用重映射的全部代码。
|
||||||
|
|
||||||
|
### 非目标(本次不做)
|
||||||
|
- 不改公开 API 的响应结构(`images` / `groups` / 列表字段全部保持)。
|
||||||
|
- 不改前台网站(独立前端仓库)。
|
||||||
|
- 不合并"审核页"与"正式编辑页"两个后台入口(后续可再收敛)。
|
||||||
|
- 不统一 `image_count` 口径(runway 计主图 / street 计全部图,现状保留,见 §12)。
|
||||||
|
- 不修 `favorites` 的唯一索引问题(见 §12)。
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 保留 / 删除的表
|
||||||
|
|
||||||
|
保留:`brand_runways`、`brand_runway_images`、`street_snaps`、`street_snap_images`、`brands`(品牌本就没有草稿表,不参与本次改造)。
|
||||||
|
|
||||||
|
删除:`brand_runway_drafts`、`brand_runway_draft_images`、`street_snap_drafts`、`street_snap_draft_images`。
|
||||||
|
|
||||||
|
### 3.2 新增列(从草稿表搬到正式表)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE street_snaps
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
```
|
||||||
|
|
||||||
|
**默认值刻意用 `pending`(fail-closed)**:任何新增/遗漏赋值的行都会默认"不可见",而不是默认"已发布"。
|
||||||
|
|
||||||
|
图片表无需改动:
|
||||||
|
- runway 的细节图归属用 `look_index`;
|
||||||
|
- 街拍的副图归属用 `is_detail` + `parent_image_id`,单表之后它天然指向**本表**行 id,**永远有效,不需要任何重映射**。
|
||||||
|
|
||||||
|
### 3.3 状态取值
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/model/content_status.go(新增)
|
||||||
|
const (
|
||||||
|
StatusPending = "pending" // 待审核(入库默认;公开不可见)
|
||||||
|
StatusPublished = "published" // 已发布(公开可见)
|
||||||
|
StatusRejected = "rejected" // 已驳回(公开不可见,留在库中备查)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
审核列表的「已通过」tab 取值由 `approved` 改为 `published`(模板 `review-list.html` 的 tab 链接同步改)。
|
||||||
|
|
||||||
|
## 4. 状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
ingest 入库
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐ 通过(写 reviewer,应用字段编辑) ┌───────────┐
|
||||||
|
│ pending │ ────────────────────────────────► │ published │
|
||||||
|
└─────────┘ └───────────┘
|
||||||
|
│ │
|
||||||
|
│ 驳回(写 reviewer + reject_reason) │ 驳回(下架)
|
||||||
|
▼ │
|
||||||
|
┌──────────┐ ◄──────────────────────────────────────┘
|
||||||
|
│ rejected │
|
||||||
|
└──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **通过**:`UPDATE ... SET status='published', reviewer=?` + 应用表单里的字段编辑。**不再触碰图片行**。
|
||||||
|
- **驳回**:`UPDATE ... SET status='rejected', reviewer=?, reject_reason=?`。行保留(备查)。
|
||||||
|
- **驳回后重爬**:`rejected → pending`(复用同一行,见 §5 第 1 条第 2 支)。驳回不是永久黑名单。
|
||||||
|
- 每条记录**只有一个实体键行**(runway: `brand_id+season_code+collection_type`;street: `city+year`),不存在"同实体的多条 pending 并存"。
|
||||||
|
|
||||||
|
## 5. 写入链路(ingest 直写正式表)
|
||||||
|
|
||||||
|
`internal/service/ingest_service.go` 的 `processRunway` / `processStreet`:
|
||||||
|
|
||||||
|
1. **实体键查重**(`RunwayIDByEntity` / `StreetSnapIDByEntity`,`WHERE 实体键 AND is_deleted=0`)。单表之后该条件会命中任意状态的行,因此必须**按命中行的状态分三支**:
|
||||||
|
- 命中 `pending` / `published` → 整任务 `MarkDone`,**直接放弃**(用户裁定)。语义:该实体已收录或正在审核,不重复入库。
|
||||||
|
- 命中 `rejected` → **复用该行**:覆盖内容字段(标题/描述/年份/季节/collection_type/season_code/cover)、软删其旧图片、写入本次抓取的图片、置回 `status='pending'` 并清空 `reviewer`/`reject_reason`。语义:驳回不是永久黑名单,重爬即重新送审。
|
||||||
|
> ⚠️ 这一支是**必需的修正**:查重条件不带 status,若不特判 `rejected`,一条被驳回(甚至误驳)的记录会**永久挡住重爬**;而现状(草稿表)不会——查重只查正式表,驳回的草稿不挡路。
|
||||||
|
- 未命中 → 新建 `pending` 记录。
|
||||||
|
"同一实体只有一行"因此始终成立。
|
||||||
|
2. **建记录**:`CreateRunwayDraft` → `CreateRunway`(写 `brand_runways`,`status='pending'`、`job_id`);`CreateStreetSnapDraft` → `CreateStreetSnap`(写 `street_snaps`)。
|
||||||
|
3. **建图片**:把图直接写 `brand_runway_images` / `street_snap_images`。
|
||||||
|
4. **图片去重**:`FindNearDuplicateImage` 的比对表从"草稿图 + 正式图"两张收敛为**一张正式图表**;`phash` / `is_duplicate` / `dup_of` 的写入位置不变。
|
||||||
|
|
||||||
|
失效的旧代码(删除):`CreateRunwayDraft`、`CreateStreetSnapDraft`、草稿相关的 `RunwayIDByEntity` 之外的草稿查询、`MarkDraftDone` 一类的草稿态更新。
|
||||||
|
|
||||||
|
## 6. 公开可见性:只读视图
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP VIEW IF EXISTS public_brand_runway_images;
|
||||||
|
DROP VIEW IF EXISTS public_brand_runways;
|
||||||
|
DROP VIEW IF EXISTS public_street_snap_images;
|
||||||
|
DROP VIEW IF EXISTS public_street_snaps;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runways AS
|
||||||
|
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snaps AS
|
||||||
|
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snap_images AS
|
||||||
|
SELECT i.* FROM street_snap_images i
|
||||||
|
JOIN street_snaps s ON s.id = i.snap_id
|
||||||
|
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
**分层规则(硬约束)**
|
||||||
|
|
||||||
|
| 层 | 读 | 写 |
|
||||||
|
|---|---|---|
|
||||||
|
| 公开(public API / SSG) | 只读 `public_*` 视图 | 从不写 |
|
||||||
|
| 后台 | 读基表 | 读写基表 |
|
||||||
|
|
||||||
|
公开侧需要改读视图的方法(现状均为裸 `is_deleted=0`):
|
||||||
|
|
||||||
|
- `article_repository.go`:`List`、`FindByID`、`ListImages`、`ImagesByRunwayIDs`
|
||||||
|
- `street_snap_repository.go`:`List`、`FindByID`、`ListImages`、`ImagesBySnapIDs`、`Popular`
|
||||||
|
- `brand_repository.go`:`List` 的 `hasArticlesSubQuery`("有档案品牌")、`FeaturedIDs`、`PopularWithCover`(这两个直接查 `brand_runways`,必须改读 `public_brand_runways`,否则**待审走秀会让品牌提前出现在前台**)
|
||||||
|
- `index_service.go` / `article_service.go` / `street_snap_service.go` / `brand_service.go` 的公开映射方法本身不过滤,只跟着仓储走。
|
||||||
|
|
||||||
|
后台侧不改(`ListAdmin`、`GetForEdit` 等继续读基表)。
|
||||||
|
|
||||||
|
**GORM 用法**:视图无主键/无关联,统一以 `.Table("public_brand_runways")` + `.Find(&[]model.BrandRunway{})` 形式查询;`Preload` 不可用(现有公开查询本就是显式 join/分步查询,不受影响)。
|
||||||
|
|
||||||
|
**维护规则**:视图用 `SELECT *` 冻结列集,因此**任何给这 4 张基表加列的迁移,必须同批重建视图**(`DROP VIEW` + `CREATE VIEW`,本文件 §3.2 的加列即需要)。
|
||||||
|
|
||||||
|
## 7. 后台
|
||||||
|
|
||||||
|
- **审核列表**:`review_repository` 的 `ListDrafts(kind, status)` 改为直接查基表 `status`;「全部」tab = 不过滤。
|
||||||
|
- **审核详情**:`DraftDetailView` 由记录行组装(逻辑不变,只是数据源从草稿表换成正式表)。街拍分组视图(`Groups`)继续用 `is_detail`/`parent_image_id` 组装,`resolveRoot` 的链式上浮与孤儿容错**保持不变**。
|
||||||
|
- **通过 / 驳回**:`Approve` 不再调用 `SaveXxxFromDraft`,改为「应用字段编辑 + 置 `status`」;`Reject` 置 `status='rejected'` + 理由。
|
||||||
|
- **图片操作**:`AttachStreetDraftImages`→`AttachImages`、`AttachPrevStreetDraftImage`、`DetachStreetDraftImage`、`SoftDeleteXxxDraftImage` 全部改为操作**正式图片表**(逻辑不变,事务与不变量保证不变)。
|
||||||
|
- **正式列表页**(`/admin/runways`、`/admin/street-snaps`):读基表,默认展示 `published`,加一列状态。
|
||||||
|
- **命名收敛**:`Draft*` 前缀统一改名(`DraftDetailView`→`RecordDetailView`、`DraftImageRef`→`RecordImageRef`、`DraftImageGroup`→`ImageGroup`、`ListDrafts`→`ListRecords` 等),模板数据键 `.Draft` → `.Record`(模板不受编译器保护,由 `backstage_handler_test.go` 的"逐页渲染"测试兜底:缺键会导致模板执行报错→500→测试红)。
|
||||||
|
|
||||||
|
## 8. 迁移
|
||||||
|
|
||||||
|
分三步,**顺序不可颠倒**。
|
||||||
|
|
||||||
|
### 第 1 步:DDL + 视图(`db/migrations/2026-09-22-01-single-table-publish.sql`)
|
||||||
|
1. §3.2 加列。
|
||||||
|
2. 建 §6 的 4 个视图(视图定义必须幂等:`DROP VIEW IF EXISTS` 后重建)。
|
||||||
|
|
||||||
|
**存量行置已发布拆成独立的一次性文件**(`db/migrations/2026-09-22-01b-publish-existing-rows.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
|
||||||
|
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么必须拆开:数据搬迁(第 2 步)会把 pending 草稿**连同旧的 `created_at`** 搬进正式表。若这条 UPDATE 留在可重复执行的迁移文件里(集成测试每次都会执行它),这些待审内容会被误刷成 `published` —— 恰好是本设计要防的泄漏。因此:结构 DDL 可重复执行,数据变更只执行一次,且**必须在第 2 步之前**执行。
|
||||||
|
|
||||||
|
> 本步必须在部署新代码**之前**应用(新代码开始写 `status`)。
|
||||||
|
|
||||||
|
### 第 2 步:数据搬迁(`scripts/migrate_single_table/main.go`,一次性命令,支持 `-dry-run`)
|
||||||
|
1. 建 id 映射:`map[draftID]newRecordID`、`map[draftImageID]newImageID`。
|
||||||
|
2. `pending` / `rejected` 草稿 → 插入正式表(保留 `status`/`job_id`/`reviewer`/`reject_reason`/`is_deleted`),草稿图片 → 插入正式图片表(保留 `look_index`/`is_detail`/`phash`/`is_duplicate`/`dup_of`/`sort_order`)。
|
||||||
|
3. **街拍副图**:第二遍把 `parent_image_id` 从"草稿图片 id"改写为"新正式图片 id"(用第 1 步的映射);映射缺失则降级 `is_detail=0`(与读侧容错一致)。
|
||||||
|
4. **校验(不通过则中止,不删表)**:
|
||||||
|
- 每个 `approved` 草稿都能在正式表找到对应实体行(否则说明有"只存在于草稿"的内容,需人工处理);
|
||||||
|
- 待审/驳回草稿的记录数与图片数在迁移前后守恒;
|
||||||
|
- `street_snap_images` 中不存在指向不存在行的 `parent_image_id`。
|
||||||
|
5. `-dry-run` 打印上述统计与差异,不写库。
|
||||||
|
|
||||||
|
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
|
||||||
|
|
||||||
|
### 第 3 步:删表(`db/migrations/2026-09-22-05-drop-draft-tables.sql`,校验通过后再执行)
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
||||||
|
street_snap_draft_images, street_snap_drafts;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 受影响文件(预估)
|
||||||
|
|
||||||
|
| 层 | 文件 |
|
||||||
|
|---|---|
|
||||||
|
| model | `runway_draft.go`(删)、`street_snap_draft.go`(删)、`runway.go`、`street_snap.go`(加列)、`content_status.go`(新) |
|
||||||
|
| repository | `ingest_repository.go`(建记录/查重)、`review_repository.go`(最大改动:列表/详情/状态/图片操作/删除晋升)、`article_repository.go`、`street_snap_repository.go`、`brand_repository.go`(改读视图) |
|
||||||
|
| service | `ingest_service.go`、`review_service.go`、`article_service.go`、`street_snap_service.go`、`brand_service.go`、`index_service.go` |
|
||||||
|
| handler | `backstage_handler.go`(去掉 `?main=` 之外基本不动)、`article_handler.go`/`street_snap_handler.go`/`brand_handler.go`/`ssg_handler.go`(应无需改动,公开过滤下沉到仓储) |
|
||||||
|
| templates | `review-list.html`(status 值)、`review-detail.html`(`.Draft`→`.Record`)、`runways.html`、`street-snaps.html`(状态列) |
|
||||||
|
| DDL / 脚本 | `db/migrations/2026-09-22-01-*.sql`、`db/migrations/2026-09-22-05-*.sql`、`scripts/migrate_single_table/main.go` |
|
||||||
|
| 测试 | `street_main_detail_integration_test.go`、`dedup_integration_test.go`、`ingest_repository_test.go`、`backstage_handler_test.go`、`router/backstage_test.go`、service 层单测 |
|
||||||
|
|
||||||
|
## 10. 测试策略
|
||||||
|
|
||||||
|
1. **公开可见性(关键,新建)**:插入一条 `status='pending'` 的记录 + 图片,断言
|
||||||
|
- 公开列表/详情/热门/SSG 全部**查不到**它;
|
||||||
|
- 置为 `published` 后能查到;
|
||||||
|
- 置回 `rejected` 后再次查不到。
|
||||||
|
这是"视图方案"真正要拿下的证据。
|
||||||
|
2. **实体键唯一**:同一实体键重复入库 → 只有一行,且第二次整任务被跳过。
|
||||||
|
3. **图片 id 稳定(新)**:通过 → 编辑字段 → 再通过,断言图片行 id 不变(旧实现会变)。
|
||||||
|
4. **街拍主副图不变量(现有集成测试改造)**:并入/并入上一张/拆出在正式图片表上仍满足"无副图的副图";父引用失效时按主图渲染。
|
||||||
|
5. **模板逐页渲染**:`backstage_handler_test.go` 现有"每个 page 渲染 200"继续兜住 `.Draft`→`.Record` 改名的遗漏。
|
||||||
|
6. **迁移脚本**:对拍迁移前后记录数/图片数;`-dry-run` 可重复执行。
|
||||||
|
|
||||||
|
## 11. 风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| 视图漏建/漏改,导致公开读到未审内容 | 视图是唯一公开入口 + §10.1 的专项测试;视图 DDL 与加列迁移同文件 |
|
||||||
|
| 加了列忘了重建视图(列集冻结) | §6 维护规则 + 迁移文件里 `DROP VIEW` 后重建 |
|
||||||
|
| 迁移把内容搬丢 | 第 2 步先 `-dry-run` + 三项守恒校验,**校验不过不执行第 3 步删表** |
|
||||||
|
| 存量 `approved` 草稿无对应正式行 | 校验会中止并列出,人工决定(补插或忽略) |
|
||||||
|
| 审核期间已发布专辑"消失" | 本设计下**不会发生**:重复入库直接放弃,不会把 published 行改回 pending |
|
||||||
|
|
||||||
|
**回滚**:第 1 步可逆(删列删视图),第 2 步之前数据零损失;第 3 步删表后不可逆(需从备份恢复,`db/backups/` 与 `db_dump.sql` 可用)。
|
||||||
|
|
||||||
|
## 12. 已知不一致(本次明确不处理,仅记录)
|
||||||
|
|
||||||
|
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
|
||||||
|
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
|
||||||
|
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
|
||||||
|
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题做**措辞**微调,入库查重会当作新实体,从而多出一行(内容重复)。
|
||||||
|
- ✅ 已处理(残留项 延后-4):纯**空白**差异(首尾空白、内部多空格 / 制表 / 换行)已通过 `repository.NormalizeStreetTitle`(空白折叠)在落库与查重两侧统一消除,不会再因空白微调产生重复行;措辞差异无法机械归一,仍按不同实体处理(符合预期)。
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,4 +130,7 @@ type AdminRunway struct {
|
|||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
||||||
ImageCount uint16 `json:"image_count"`
|
ImageCount uint16 `json:"image_count"`
|
||||||
|
// Status 审核态(pending / published / rejected),供后台列表展示状态列。
|
||||||
|
// 后台专用 DTO,不外泄到公开接口。
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
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(全部视为主图,无细节)。
|
||||||
|
|||||||
@ -58,6 +58,11 @@ type PublicStreetSnap struct {
|
|||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
|
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
|
||||||
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
||||||
|
|
||||||
|
// Status 审核态(pending / published / rejected)。
|
||||||
|
// 后台街拍列表复用本 DTO 渲染状态列,但公开列表接口**不返回**它:
|
||||||
|
// json:"-" 保证公开 API 响应结构与既有约定一致。
|
||||||
|
Status string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
|
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
|
||||||
|
|||||||
522
internal/handler/assets/admin.css
Normal file
522
internal/handler/assets/admin.css
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* 管理后台统一样式(合并原先散在 14 个内联模板里的 12 份重复 <style>)。
|
||||||
|
*
|
||||||
|
* 设计基调沿用原后台:黑白、直角感、系统字体、无外部依赖。
|
||||||
|
* 统一后只此一份,改样式不必再逐页改 14 处。
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* 1) 布局三件套 —— body(页面底色)/ .bar(顶部导航)/ .main(内容容器)
|
||||||
|
* 2) 组件化 —— .btn / .badge / .card / .cell / .pager / .flash 等按语义命名,
|
||||||
|
* 页面只写结构,不再写内联 style
|
||||||
|
* 3) 缩略图统一 2:3 —— 与站内图片素材比例一致(后端素材为 240×360 / 720×1080)
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
/* ---------- 基础 ---------- */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* 让 hidden 属性始终生效:下面给 .batchbar/.flash/.chk 设了 display,
|
||||||
|
作者样式会盖过浏览器默认的 [hidden]{display:none},故这里强制抬高。 */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #111;
|
||||||
|
background: #f4f4f5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 15px; margin: 26px 0 10px; color: #333; }
|
||||||
|
|
||||||
|
a { color: #111; }
|
||||||
|
a.view { text-decoration: underline; }
|
||||||
|
.muted { color: #777; }
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f6f6f7;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.break { word-break: break-all; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---------- 顶部导航 ---------- */
|
||||||
|
.bar {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.bar a { color: #fff; text-decoration: none; }
|
||||||
|
.bar-title { font-weight: 600; white-space: nowrap; }
|
||||||
|
.bar-nav { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.bar-nav a {
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .78;
|
||||||
|
transition: background .15s, opacity .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bar-nav a:hover { opacity: 1; background: rgba(255, 255, 255, .14); }
|
||||||
|
.bar-nav a.on { opacity: 1; background: rgba(255, 255, 255, .2); }
|
||||||
|
.bar-nav .sep { width: 1px; height: 14px; background: rgba(255, 255, 255, .25); margin: 0 6px; }
|
||||||
|
.bar-badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #111;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 内容容器 ---------- */
|
||||||
|
.main { max-width: 1100px; margin: 24px auto 60px; padding: 0 24px; }
|
||||||
|
.main.narrow { max-width: 620px; margin-top: 32px; }
|
||||||
|
.main.mid { max-width: 900px; }
|
||||||
|
|
||||||
|
/* 登录页固定宽卡片 */
|
||||||
|
.wrap {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 80px auto;
|
||||||
|
background: #fff;
|
||||||
|
padding: 28px 28px 32px;
|
||||||
|
border: 1px solid #e2e2e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.wrap h1 { margin-bottom: 18px; }
|
||||||
|
.wrap button { width: 100%; margin-top: 18px; }
|
||||||
|
|
||||||
|
/* ---------- 提示条 ---------- */
|
||||||
|
.flash {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 14px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.flash.ok { background: #e6f7ec; border: 1px solid #a9dcbd; color: #1a7f43; }
|
||||||
|
.flash.err, .err { color: #c0392b; }
|
||||||
|
.flash.err { background: #fdeaea; border: 1px solid #f0b7b3; }
|
||||||
|
/* 收起按钮(提示条 / 顶部提示里的小 ✕):必须是「无底无边」的小图标,
|
||||||
|
否则会继承下面 button 的黑底样式,变成一个突兀的黑方块。 */
|
||||||
|
.hint .x, .flash .x {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
/* hint 是 sticky(已定位),收起按钮绝对定位到右上角,不参与文字排版 */
|
||||||
|
.hint { padding-right: 34px; }
|
||||||
|
.hint .x { position: absolute; top: 6px; right: 8px; }
|
||||||
|
.hint {
|
||||||
|
position: sticky;
|
||||||
|
top: 52px;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #f5e08a;
|
||||||
|
color: #7a5b00;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 表单 ---------- */
|
||||||
|
label { display: block; margin: 14px 0 4px; color: #555; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
|
||||||
|
textarea { min-height: 72px; }
|
||||||
|
.row { display: flex; gap: 14px; }
|
||||||
|
.row > div { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #111;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
button:hover { opacity: .88; }
|
||||||
|
button.reject, button.danger { background: #c0392b; border-color: #c0392b; }
|
||||||
|
button.ghost { background: #fff; color: #111; border-color: #ccc; }
|
||||||
|
button.retry {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-color: #c0392b;
|
||||||
|
color: #c0392b;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
button.retry:hover { background: #c0392b; color: #fff; opacity: 1; }
|
||||||
|
button:disabled { opacity: .45; cursor: default; }
|
||||||
|
|
||||||
|
/* ---------- 工具栏 / 筛选 ---------- */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.toolbar input, .toolbar select { width: auto; padding: 8px 10px; }
|
||||||
|
.toolbar a { color: #555; text-decoration: none; font-size: 13px; }
|
||||||
|
.tabs a, .kinds a {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 8px 8px 0;
|
||||||
|
color: #111;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.kinds a { border-color: #ddd; }
|
||||||
|
.tabs a.on, .kinds a.on { background: #111; color: #fff; border-color: #111; }
|
||||||
|
.idx { line-height: 2.2; }
|
||||||
|
.idx a { margin-right: 8px; text-decoration: none; font-size: 13px; }
|
||||||
|
.idx a.on { font-weight: 700; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { background: #fafafa; color: #555; font-weight: 600; white-space: nowrap; }
|
||||||
|
tbody tr:hover { background: #fbfbfc; }
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- 徽标 ---------- */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.pending { background: #fff4e0; color: #a85b00; }
|
||||||
|
.badge.processing { background: #eef2ff; color: #3b4bb5; }
|
||||||
|
/* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
|
||||||
|
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
||||||
|
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
|
||||||
|
.badge.dup { background: #f3e8ff; color: #6b21a8; }
|
||||||
|
.badge.free { background: #eef2ff; color: #3b4bb5; }
|
||||||
|
.badge.vip { background: #fff4e0; color: #a85b00; }
|
||||||
|
.kind {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3b4bb5;
|
||||||
|
}
|
||||||
|
.kind.street { background: #eafaf0; color: #1a7f43; }
|
||||||
|
.down { display: inline-block; padding: 2px 8px; background: #fdeaea; color: #c0392b; border-radius: 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---------- 卡片(首页入口) ---------- */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e2e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111;
|
||||||
|
display: block;
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: #111; transform: translateY(-1px); }
|
||||||
|
.card .t { font-size: 15px; font-weight: 600; }
|
||||||
|
.card .d { color: #777; font-size: 13px; margin-top: 6px; }
|
||||||
|
.chips { margin-top: 10px; }
|
||||||
|
.chip { display: inline-block; background: #111; color: #fff; font-size: 12px; padding: 3px 9px; border-radius: 10px; margin: 0 6px 6px 0; }
|
||||||
|
|
||||||
|
/* ---------- 图片网格(审核 / 图集) ---------- */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
/* 细节图 / 副图:明显的靛蓝边框 + 淡蓝底,和主图一眼区分开 */
|
||||||
|
.cell.det { background: #eef2ff; border: 2px solid #818cf8; }
|
||||||
|
/* 副图默认隐藏,点对应主图上的「显示副图」才展开(逐组,不会一次全开) */
|
||||||
|
.cell.det { display: none; }
|
||||||
|
.cell.det.shown { display: flex; }
|
||||||
|
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
|
||||||
|
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
||||||
|
.cell .thumb {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f1f1f2;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.cell .thumb img, .cell > img { width: 100%; display: block; aspect-ratio: 2 / 3; object-fit: cover; }
|
||||||
|
.cell > img { cursor: zoom-in; }
|
||||||
|
.cell .thumb::after {
|
||||||
|
content: "点击看大图";
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, .62);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.cell .thumb:hover::after { opacity: 1; }
|
||||||
|
/* 角色标签统一做成胶囊徽标:主图绿、细节图靛蓝,颜色编码一眼可分 */
|
||||||
|
.cell .role {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 6px 8px 0;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a7f43;
|
||||||
|
background: #e6f7ec;
|
||||||
|
}
|
||||||
|
.cell.det .role { color: #fff; background: #4338ca; }
|
||||||
|
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
|
||||||
|
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
|
||||||
|
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: #111; }
|
||||||
|
.toggle-det { background: #eef2ff; border: 1px solid #818cf8; color: #4338ca; }
|
||||||
|
|
||||||
|
/* ---------- 行内操作按钮组(列表行 / 审核操作) ---------- */
|
||||||
|
/* 把原先「编辑 · 通过 · 驳回 · 下架」那种用中点分隔的散落按钮,
|
||||||
|
收敛成一组紧凑胶囊按钮:编辑是描边链接,通过=绿、驳回=红、下架=琥珀描边。 */
|
||||||
|
.ops { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
.ops .inline { display: inline-flex; margin: 0; padding: 0; }
|
||||||
|
.ops .view {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #111;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.ops .view:hover { border-color: #111; }
|
||||||
|
.op {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.op-primary { background: #1a7f43; border-color: #1a7f43; color: #fff; }
|
||||||
|
.op-danger { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||||
|
.op-warn { background: #fff; border-color: #e0a800; color: #8a6d00; }
|
||||||
|
.op-primary:hover, .op-danger:hover { opacity: .9; }
|
||||||
|
.op-warn:hover { background: #fff8e6; }
|
||||||
|
|
||||||
|
/* 审核操作区(编辑页底部的通过 / 驳回)同款胶囊按钮组 */
|
||||||
|
.review-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0; }
|
||||||
|
.review-actions .inline { display: inline-flex; margin: 0; padding: 0; }
|
||||||
|
.chk {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.chk:hover { border-color: #111; }
|
||||||
|
.chk input { margin: 0; }
|
||||||
|
|
||||||
|
/* ---------- 看大图弹层(<dialog>,initZoom 依赖 #zoom-modal / #zoom-img) ---------- */
|
||||||
|
#zoom-modal {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
max-width: 94vw;
|
||||||
|
max-height: 94vh;
|
||||||
|
}
|
||||||
|
#zoom-modal::backdrop { background: rgba(0, 0, 0, .82); }
|
||||||
|
#zoom-img {
|
||||||
|
display: block;
|
||||||
|
max-width: 94vw;
|
||||||
|
max-height: 94vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 底部批量操作条 ---------- */
|
||||||
|
.batchbar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, .06);
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
.batchbar button { margin: 0; }
|
||||||
|
.batchbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ---------- 分页 ---------- */
|
||||||
|
.pager { margin: 18px 0; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.pager a { padding: 7px 14px; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; background: #fff; }
|
||||||
|
.pager a:hover { border-color: #111; }
|
||||||
|
.pager a.off { color: #bbb; border-color: #eee; pointer-events: none; }
|
||||||
|
|
||||||
|
/* ---------- 大图查看层(admin.js 用) ---------- */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, .88);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.lightbox[hidden] { display: none; }
|
||||||
|
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
|
.lightbox .lb-close,
|
||||||
|
.lightbox .lb-prev,
|
||||||
|
.lightbox .lb-next {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
color: #111;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lightbox .lb-close { top: 18px; right: 18px; }
|
||||||
|
.lightbox .lb-prev { left: 18px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox .lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox .lb-cap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 窄屏 ---------- */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bar { padding: 10px 14px; }
|
||||||
|
.bar-title { font-size: 13px; }
|
||||||
|
.main { padding: 0 14px; margin-top: 16px; }
|
||||||
|
.row { flex-direction: column; gap: 0; }
|
||||||
|
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 编辑页重复图标记 ---------- */
|
||||||
|
.cell .badge.dup { margin-left: 8px; vertical-align: middle; }
|
||||||
|
.cell .dup-src { margin: 0 8px 6px; font-size: 12px; }
|
||||||
|
.cell .dup-src > summary { cursor: pointer; color: #6b21a8; user-select: none; outline: none; }
|
||||||
|
.cell .dup-src-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #faf5ff;
|
||||||
|
border: 1px solid #e9d5ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cell .dup-src-body img { width: 56px; height: 84px; object-fit: cover; border-radius: 4px; flex: 0 0 auto; background: #eee; }
|
||||||
|
.cell .dup-title { margin-bottom: 2px; color: #333; }
|
||||||
|
.cell .dup-link { color: #6b21a8; font-weight: 600; text-decoration: none; }
|
||||||
|
.cell .dup-link:hover { text-decoration: underline; }
|
||||||
215
internal/handler/assets/admin.js
Normal file
215
internal/handler/assets/admin.js
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
|
||||||
|
// 两个入口页面:
|
||||||
|
// - 文章管理混合列表(/admin/articles):批量通过
|
||||||
|
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。
|
||||||
|
function apiOf(action) {
|
||||||
|
return action.replace('/admin/articles/', '/admin/api/articles/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 看大图弹层(所有页面通用)────────────────────────────────────────────
|
||||||
|
function initZoom() {
|
||||||
|
var modal = document.getElementById('zoom-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
var img = document.getElementById('zoom-img');
|
||||||
|
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
img.src = btn.querySelector('img').src;
|
||||||
|
modal.showModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
|
if (e.target === modal) modal.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 文章管理列表:批量通过 ──────────────────────────────────────
|
||||||
|
function initArticleList() {
|
||||||
|
var selAll = document.getElementById('sel-all');
|
||||||
|
var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); };
|
||||||
|
var bar = document.getElementById('batchbar');
|
||||||
|
var count = document.getElementById('sel-count');
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var n = picks().filter(function (c) { return c.checked; }).length;
|
||||||
|
if (bar) bar.hidden = n === 0;
|
||||||
|
if (count) count.textContent = '已选 ' + n + ' 条';
|
||||||
|
}
|
||||||
|
if (selAll) selAll.addEventListener('change', function () {
|
||||||
|
picks().forEach(function (c) { c.checked = selAll.checked; });
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
picks().forEach(function (c) { c.addEventListener('change', refresh); });
|
||||||
|
|
||||||
|
var clear = document.getElementById('batch-clear');
|
||||||
|
if (clear) clear.addEventListener('click', function () {
|
||||||
|
picks().forEach(function (c) { c.checked = false; });
|
||||||
|
if (selAll) selAll.checked = false;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function postBatch(action) {
|
||||||
|
var items = picks().filter(function (c) {
|
||||||
|
return c.checked;
|
||||||
|
}).map(function (c) {
|
||||||
|
var tr = c.closest('tr');
|
||||||
|
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
|
||||||
|
});
|
||||||
|
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
|
||||||
|
if (!confirm('确认通过选中的 ' + items.length + ' 条?')) return;
|
||||||
|
fetch('/admin/api/articles/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: action, items: items })
|
||||||
|
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
if (d.failed && d.failed.length) {
|
||||||
|
alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error);
|
||||||
|
}
|
||||||
|
location.reload();
|
||||||
|
}).catch(function (e) { alert('请求失败:' + e); });
|
||||||
|
}
|
||||||
|
var approve = document.getElementById('batch-approve');
|
||||||
|
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
|
||||||
|
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 + ' 张)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并选中为一组(AJAX,成功后就地更新标签)
|
||||||
|
if (batchForm) batchForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
// 注意:主图 radio / 选入 checkbox 用 form="batch" 关联到批量表单,但 DOM 上并不在
|
||||||
|
// <form id="batch"> 内部(包在 .cell 里)。它们虽能随表单正确提交,却不在 form 子树中,
|
||||||
|
// 故必须用 document 级查询(而非 batchForm.querySelector)才能取到已勾选的项。
|
||||||
|
var main = document.querySelector('input[name="main"]:checked');
|
||||||
|
var picks = document.querySelectorAll('input[name="img"]:checked');
|
||||||
|
if (!main) { alert('请先在其中一张主图上点「主图」'); return; }
|
||||||
|
if (!picks.length) { alert('请勾选要合并的图片'); return; }
|
||||||
|
// 接口 ArticleAttachImagesAPI 收 JSON(main + imgs),不能发 FormData:
|
||||||
|
// multipart 会让 ShouldBindJSON 失败、返回「请求格式错误」。
|
||||||
|
var data = {
|
||||||
|
main: parseInt(main.value, 10),
|
||||||
|
imgs: Array.prototype.slice.call(picks).map(function (c) { return parseInt(c.value, 10); })
|
||||||
|
};
|
||||||
|
fetch(apiOf(batchForm.getAttribute('action')), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
|
||||||
|
.catch(function (err) { alert('拆出失败:' + err); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 /admin/articles/<kind>/... 形式的 action 取出实体类型(street / runway)。
|
||||||
|
function kindOf(action) {
|
||||||
|
var m = /^\/admin\/articles\/(\w+)\//.exec(action || '');
|
||||||
|
return m ? m[1] : 'street';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
document.querySelectorAll('input[name="main"]:checked, input[name="img"]:checked').forEach(function (c) { c.checked = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。
|
||||||
|
function updateToggle(mainId, detailCount) {
|
||||||
|
var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]');
|
||||||
|
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
|
||||||
|
if (detailCount > 0) {
|
||||||
|
if (!btn && cell) {
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'toggle-det';
|
||||||
|
btn.setAttribute('data-parent', mainId);
|
||||||
|
var acts = cell.querySelector('.acts');
|
||||||
|
if (acts) acts.appendChild(btn);
|
||||||
|
}
|
||||||
|
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
|
||||||
|
var expanded = dets.length > 0 && dets[0].classList.contains('shown');
|
||||||
|
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + detailCount + ' 张)';
|
||||||
|
} else if (btn) {
|
||||||
|
btn.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开/收起某主图的全部副图,并同步按钮文案。
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
175
internal/handler/backstage_api.go
Normal file
175
internal/handler/backstage_api.go
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
// 审核页的 AJAX 接口(返回 JSON,不整页跳转)。
|
||||||
|
//
|
||||||
|
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
||||||
|
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
||||||
|
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
||||||
|
// 同时表单式路由(/admin/articles/... 下的 approve/attach/detach)原样保留:关掉 JS 后台依然可用。
|
||||||
|
//
|
||||||
|
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fashionapi/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxReviewBatch 单次批量审核的条数上限,避免一次请求打到几百条。
|
||||||
|
const maxReviewBatch = 200
|
||||||
|
|
||||||
|
type reviewBatchItem struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewBatchReq struct {
|
||||||
|
// Action: approve
|
||||||
|
Action string `json:"action"`
|
||||||
|
Items []reviewBatchItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleBatch 文章管理批量通过(替代原审核批量接口)。
|
||||||
|
//
|
||||||
|
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
||||||
|
// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17}
|
||||||
|
//
|
||||||
|
// 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。
|
||||||
|
func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
|
||||||
|
var req reviewBatchReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action != "approve" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) > maxReviewBatch {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
registered := h.registeredKinds(ctx)
|
||||||
|
nameVal, _ := c.Get(middleware.ContextUsername)
|
||||||
|
reviewer := usernameString(nameVal)
|
||||||
|
|
||||||
|
okCount := 0
|
||||||
|
failed := make([]gin.H, 0)
|
||||||
|
for _, item := range req.Items {
|
||||||
|
if item.ID == 0 || !registered[item.Kind] {
|
||||||
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": "类型或 ID 无效"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if req.Action == "approve" {
|
||||||
|
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
okCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": okCount,
|
||||||
|
"failed": failed,
|
||||||
|
"pending": h.PendingReviews(ctx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewAttachReq struct {
|
||||||
|
Main uint32 `json:"main"`
|
||||||
|
Imgs []uint32 `json:"imgs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。
|
||||||
|
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
||||||
|
// 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。
|
||||||
|
func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
if id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req reviewAttachReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Main == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先指定哪张是主图"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Imgs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondRecordImages(c, kind, uint32(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。
|
||||||
|
func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
imgID := atoiDefault(c.Param("img"), 0)
|
||||||
|
if id == 0 || imgID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondRecordImages(c, kind, uint32(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondRecordImages 重新读取记录并把「图片分组状态」按前端需要的形状返回。
|
||||||
|
//
|
||||||
|
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
|
||||||
|
func (h *BackstageHandler) respondRecordImages(c *gin.Context, kind string, id uint32) {
|
||||||
|
view, err := h.review.RecordDetail(c.Request.Context(), kind, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
images := make([]gin.H, 0, len(view.Images))
|
||||||
|
for _, im := range view.Images {
|
||||||
|
images = append(images, gin.H{
|
||||||
|
"id": im.ID,
|
||||||
|
"isDetail": im.IsDetail,
|
||||||
|
"parentId": im.ParentImageID,
|
||||||
|
"detailCount": im.DetailCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"images": images,
|
||||||
|
"imageCount": view.ImageCount,
|
||||||
|
"groups": len(view.Groups),
|
||||||
|
"pending": h.PendingReviews(c.Request.Context()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredKinds 已注册的审核类型集合(与 ReviewList 的校验口径一致)。
|
||||||
|
func (h *BackstageHandler) registeredKinds(ctx context.Context) map[string]bool {
|
||||||
|
registered := map[string]bool{}
|
||||||
|
for _, tab := range h.review.KindTabs(ctx) {
|
||||||
|
if tab.Kind != "" {
|
||||||
|
registered[tab.Kind] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registered
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,131 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
// TestBackstageTemplatesParse 所有内嵌后台模板必须能解析。
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/pagination"
|
||||||
|
"fashionapi/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBackstageTemplatesParsed 后台页面模板必须能解析,且集合齐全。
|
||||||
//
|
//
|
||||||
// 模板是 Go 字符串常量,语法错误不会在编译期暴露——只会在用户打开页面时 500;
|
// 模板从「Go 字符串常量」改成 templates/ 下的独立文件后:
|
||||||
// 而审核页又有登录中间件挡着(curl 只能打到 302),冒烟测试覆盖不到,故用解析测试兜住。
|
// - 语法错误会在包 init(template.ParseFS)时直接 panic,go test 立刻暴露,
|
||||||
func TestBackstageTemplatesParse(t *testing.T) {
|
// 不再出现「编得过、点到那个页面才 500」;
|
||||||
for name, tpl := range map[string]string{
|
// - 本测试额外断言页面集合齐全,防止加了 handler 却忘了加模板文件
|
||||||
"login": tplLogin,
|
// (renderPage 会 500 "unknown backstage page: xxx")。
|
||||||
"reviewList": tplReviewList,
|
func TestBackstageTemplatesParsed(t *testing.T) {
|
||||||
"reviewDetail": tplReviewDetail,
|
want := []string{
|
||||||
} {
|
"articles",
|
||||||
t.Run(name, func(t *testing.T) {
|
"brand-form",
|
||||||
base, err := pagerTmpl.Clone()
|
"brands",
|
||||||
if err != nil {
|
"dashboard",
|
||||||
t.Fatalf("clone pager 模板失败: %v", err)
|
"error",
|
||||||
}
|
"ingest-jobs",
|
||||||
if _, err := base.Parse(tpl); err != nil {
|
"login",
|
||||||
t.Fatalf("模板 %s 解析失败: %v", name, err)
|
"runway-detail",
|
||||||
}
|
"runway-edit",
|
||||||
})
|
"snap-edit",
|
||||||
|
"street-snap-detail",
|
||||||
|
"users",
|
||||||
|
}
|
||||||
|
got := PageNames()
|
||||||
|
sort.Strings(got)
|
||||||
|
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("页面模板数量不一致:\n got %v\n want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("页面模板集合不一致:\n got %v\n want %v", got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBackstageTemplatesRender 每个页面都要能真正渲染出来(不只是能解析)。
|
||||||
|
//
|
||||||
|
// 解析通过 ≠ 渲染通过:{{template "barRight" .}}、{{template "pager" .Pager}} 这类引用
|
||||||
|
// 是**执行期**才解析的,某个页面漏了 barLabel / barRight 定义时,只有渲染那一步才会报错
|
||||||
|
// —— 而审核页又被登录中间件挡着(curl 只能打到 302),所以用这个测试兜住。
|
||||||
|
//
|
||||||
|
// 数据用一份「尽量齐全」的假数据;有分支的页面(审核详情按 kind 分流)分别渲染。
|
||||||
|
func TestBackstageTemplatesRender(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
base := gin.H{
|
||||||
|
"Username": "admin",
|
||||||
|
"Error": "",
|
||||||
|
"Total": 1,
|
||||||
|
"Pager": pagination.Pager{HasPrev: true, HasNext: true, PrevURL: "/p/1", NextURL: "/p/3", Label: "第 2 页"},
|
||||||
|
// 各筛选/表单页用到的零值字段
|
||||||
|
"Keyword": "", "Letter": "", "Status": "", "Kind": "", "ListBase": "",
|
||||||
|
"Collection": "", "Season": "", "Year": 2026, "City": "", "Sort": "",
|
||||||
|
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
|
||||||
|
"Images": []any{},
|
||||||
|
// 列表数据
|
||||||
|
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
|
||||||
|
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||||
|
"Article": &dto.PublicArticleDetail{UID: "abc123"},
|
||||||
|
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同名 key 在不同页面指向不同模型(历史遗留命名):按页覆盖,顺带把这份契约固化下来。
|
||||||
|
perPage := map[string]gin.H{
|
||||||
|
"snap-edit": {"Snap": &model.StreetSnap{}, "Images": []any{}},
|
||||||
|
"runway-edit": {"Runway": &model.BrandRunway{}, "Images": []any{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range PageNames() {
|
||||||
|
for _, kind := range []string{"street", "runway"} {
|
||||||
|
name := page + "/" + kind
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
data := cloneData(base)
|
||||||
|
for k, v := range perPage[page] {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
||||||
|
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
|
||||||
|
data["Kind"] = kind
|
||||||
|
data["RecordID"] = uint32(7)
|
||||||
|
data["UID"] = "abc123"
|
||||||
|
data["Status"] = "pending"
|
||||||
|
data["Cells"] = []gin.H{}
|
||||||
|
data["BrandName"] = "Brand"
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||||
|
|
||||||
|
renderPage(c, http.StatusOK, page, data)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("页面 %s 渲染失败(%d):%s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if body == "" {
|
||||||
|
t.Fatalf("页面 %s 渲染为空", name)
|
||||||
|
}
|
||||||
|
// 共享外壳必须挂上统一样式与渐进增强脚本(改回内联样式时这条会失败,是有意的)
|
||||||
|
if !strings.Contains(body, "/admin/assets/admin.css") || !strings.Contains(body, "/admin/assets/admin.js") {
|
||||||
|
t.Fatalf("页面 %s 未引用后台统一样式/脚本", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneData 复制一份页面数据:renderPage 会往里注入 NavKey / PendingReviews,不能跨子测试共享。
|
||||||
|
func cloneData(src gin.H) gin.H {
|
||||||
|
dst := make(gin.H, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] = v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
internal/handler/templates.go
Normal file
172
internal/handler/templates.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// 后台模板的加载与渲染。
|
||||||
|
//
|
||||||
|
// 为什么把模板从 Go 字符串常量搬到独立 .html 文件:
|
||||||
|
// 1. IDE 支持 —— .html 有语法高亮、补全、格式化、Emmet;Go 反引号字符串里没有;
|
||||||
|
// 2. 可维护 —— 改样式 / 加字段只动一个小文件,diff 干净;公共部分(顶部导航、翻页器)
|
||||||
|
// 抽成 partials 后不再 14 份复制粘贴;样式与脚本独立成文件后可被浏览器缓存;
|
||||||
|
// 3. 零构建 —— 用 go:embed 打进二进制,部署仍然只是一个可执行文件,无外部依赖。
|
||||||
|
//
|
||||||
|
// 模板分层:
|
||||||
|
// layout.html 外壳(<head> + 共享 css/js + 内容插槽)
|
||||||
|
// partials.html 共享片段(bar 顶部导航 / pager 翻页器)
|
||||||
|
// pages/*.html 每个页面一个文件,文件名即 renderPage 的 page 参数
|
||||||
|
//
|
||||||
|
// 基模板(layout + partials)在启动时解析一次,之后为每个页面 Clone + Parse 一次并缓存,
|
||||||
|
// 因此请求期不再重复解析模板(原实现每次渲染都 Clone+Parse 一遍)。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fashionapi/internal/pkg/imgurl"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。
|
||||||
|
// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。
|
||||||
|
var imgComposer = imgurl.New("", "high", "thumb")
|
||||||
|
|
||||||
|
// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。
|
||||||
|
// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。
|
||||||
|
func SetImgComposer(c *imgurl.Composer) {
|
||||||
|
if c != nil {
|
||||||
|
imgComposer = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
|
||||||
|
var backstageFS embed.FS
|
||||||
|
|
||||||
|
// ctxNavStatsKey 是「导航统计」在 gin.Context 里的键;由 BackstageHandler.NavStats 中间件写入,
|
||||||
|
// renderPage 渲染前合并进页面数据,页面模板即可无条件引用 .PendingReviews。
|
||||||
|
const ctxNavStatsKey = "backstageNavStats"
|
||||||
|
|
||||||
|
// pageTemplates 页面名(= pages/ 下的文件名去扩展名)-> 已注入该页 define 的完整模板。
|
||||||
|
var pageTemplates = map[string]*template.Template{}
|
||||||
|
|
||||||
|
// assetFS 后台静态资源子树(admin.css / admin.js),经 BackstageAssetFS 交给路由挂载。
|
||||||
|
var assetFS fs.FS
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
|
||||||
|
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
|
||||||
|
"imgURL": func(stored string) string { return imgComposer.Compose(stored) },
|
||||||
|
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err := template.New("layout.html").Funcs(funcs).
|
||||||
|
ParseFS(backstageFS, "templates/layout.html", "templates/partials.html")
|
||||||
|
if err != nil {
|
||||||
|
panic("后台基模板解析失败: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := fs.ReadDir(backstageFS, "templates/pages")
|
||||||
|
if err != nil {
|
||||||
|
panic("后台页面模板目录读取失败: " + err.Error())
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clone, err := base.Clone()
|
||||||
|
if err != nil {
|
||||||
|
panic("克隆后台基模板失败: " + err.Error())
|
||||||
|
}
|
||||||
|
page, err := clone.ParseFS(backstageFS, "templates/pages/"+entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
// 模板语法错误在启动时直接 panic:go test / 起服务立刻暴露,
|
||||||
|
// 而不是等用户点到那个页面才 500(模板以前是字符串常量,编译期查不出来)。
|
||||||
|
panic("后台页面模板解析失败 " + entry.Name() + ": " + err.Error())
|
||||||
|
}
|
||||||
|
pageTemplates[strings.TrimSuffix(entry.Name(), ".html")] = page
|
||||||
|
}
|
||||||
|
|
||||||
|
assets, err := fs.Sub(backstageFS, "assets")
|
||||||
|
if err != nil {
|
||||||
|
panic("后台静态资源子树初始化失败: " + err.Error())
|
||||||
|
}
|
||||||
|
assetFS = assets
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackstageAssetFS 返回后台静态资源子树,供路由以 /admin/assets/ 挂载。
|
||||||
|
func BackstageAssetFS() fs.FS { return assetFS }
|
||||||
|
|
||||||
|
// PageNames 返回已注册的页面名(供测试断言模板集合齐全)。
|
||||||
|
func PageNames() []string {
|
||||||
|
names := make([]string, 0, len(pageTemplates))
|
||||||
|
for name := range pageTemplates {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
|
||||||
|
//
|
||||||
|
// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
|
||||||
|
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
|
||||||
|
func renderPage(c *gin.Context, status int, page string, data gin.H) {
|
||||||
|
tpl, ok := pageTemplates[page]
|
||||||
|
if !ok {
|
||||||
|
c.String(http.StatusInternalServerError, "unknown backstage page: "+page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
data = gin.H{}
|
||||||
|
}
|
||||||
|
if _, exists := data["NavKey"]; !exists {
|
||||||
|
data["NavKey"] = page
|
||||||
|
}
|
||||||
|
if _, exists := data["PendingReviews"]; !exists {
|
||||||
|
data["PendingReviews"] = 0
|
||||||
|
}
|
||||||
|
if v, ok := c.Get(ctxNavStatsKey); ok {
|
||||||
|
if stats, ok := v.(gin.H); ok {
|
||||||
|
for k, val := range stats {
|
||||||
|
data[k] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "render error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavStats 返回一个中间件:为后台每个页面预备导航统计(目前是待审总数)。
|
||||||
|
//
|
||||||
|
// 放在中间件而不是逐个 handler 里,是为了「任意页面都能看到待审队列深度」——
|
||||||
|
// 后台是内部工具、页面访问量小,每页多一次 count 查询可以接受。
|
||||||
|
func (h *BackstageHandler) NavStats() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h != nil && h.review != nil {
|
||||||
|
c.Set(ctxNavStatsKey, gin.H{"PendingReviews": h.PendingReviews(c.Request.Context())})
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingReviews 待审草稿总数。
|
||||||
|
//
|
||||||
|
// ⚠️ KindTabs() 的第 0 项是「全部」标签,它的 Pending 已经是各模块之和(见 ReviewService.KindTabs)。
|
||||||
|
// 因此不能对所有项无脑累加 —— 那会把待审数算成两倍。这里以「全部」项为准,退化为逐项累加时跳过空 Kind。
|
||||||
|
func (h *BackstageHandler) PendingReviews(ctx context.Context) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, k := range h.review.KindTabs(ctx) {
|
||||||
|
if k.Kind == "" {
|
||||||
|
return k.Pending
|
||||||
|
}
|
||||||
|
total += k.Pending
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
27
internal/handler/templates/layout.html
Normal file
27
internal/handler/templates/layout.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
后台页面外壳:<head> + 共享样式/脚本 + 页面内容插槽。
|
||||||
|
|
||||||
|
页面模板只需提供三个 define:
|
||||||
|
{{define "title"}} 浏览器标题
|
||||||
|
{{define "content"}} 正文(含自己的 .bar / .main 容器)
|
||||||
|
需要顶部导航时,在 content 里写 {{template "bar" .}}(登录页不需要,故不引用)。
|
||||||
|
|
||||||
|
样式与脚本走 /admin/assets/(同样是 embed 进二进制,零外部依赖、零构建):
|
||||||
|
CSS 独立成文件后浏览器可跨页缓存,也才有真正的 CSS 语法高亮与补全。
|
||||||
|
*/}}
|
||||||
|
{{define "layout"}}<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{{template "title" .}}</title>
|
||||||
|
<link rel="stylesheet" href="/admin/assets/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "content" .}}
|
||||||
|
<div id="toast" class="flash ok" hidden></div>
|
||||||
|
<dialog id="zoom-modal"><img id="zoom-img" alt=""></dialog>
|
||||||
|
<script src="/admin/assets/admin.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
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}}
|
||||||
15
internal/handler/templates/pages/brand-form.html
Normal file
15
internal/handler/templates/pages/brand-form.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{{define "title"}}{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/brands">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>{{if .UID}}编辑品牌{{else}}新建品牌{{end}}</h1>
|
||||||
|
<form method="post" action="{{if .UID}}/admin/brands/{{.UID}}{{else}}/admin/brands{{end}}">
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
<label>英文名(name_en)</label><input name="name_en" value="{{.NameEn}}" placeholder="如 Acne Studios">
|
||||||
|
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
|
||||||
|
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
51
internal/handler/templates/pages/brands.html
Normal file
51
internal/handler/templates/pages/brands.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{{define "title"}}品牌管理 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 品牌管理{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/brands/new">+ 新建</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>品牌列表</h1>
|
||||||
|
<form class="toolbar" method="get" action="/admin/brands">
|
||||||
|
<input name="keyword" placeholder="搜索品牌名…" value="{{.Keyword}}">
|
||||||
|
<button type="submit">搜索</button>
|
||||||
|
{{if .Keyword}}<a href="/admin/brands">清除</a>{{end}}
|
||||||
|
</form>
|
||||||
|
<div class="idx">
|
||||||
|
<a href="/admin/brands?keyword={{.Keyword}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
|
||||||
|
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
|
||||||
|
<a href="/admin/brands?letter=A&keyword={{.Keyword}}" class="{{if eq .Letter "A"}}on{{end}}">A</a>
|
||||||
|
<a href="/admin/brands?letter=B&keyword={{.Keyword}}" class="{{if eq .Letter "B"}}on{{end}}">B</a>
|
||||||
|
<a href="/admin/brands?letter=C&keyword={{.Keyword}}" class="{{if eq .Letter "C"}}on{{end}}">C</a>
|
||||||
|
<a href="/admin/brands?letter=D&keyword={{.Keyword}}" class="{{if eq .Letter "D"}}on{{end}}">D</a>
|
||||||
|
<a href="/admin/brands?letter=E&keyword={{.Keyword}}" class="{{if eq .Letter "E"}}on{{end}}">E</a>
|
||||||
|
<a href="/admin/brands?letter=F&keyword={{.Keyword}}" class="{{if eq .Letter "F"}}on{{end}}">F</a>
|
||||||
|
<a href="/admin/brands?letter=G&keyword={{.Keyword}}" class="{{if eq .Letter "G"}}on{{end}}">G</a>
|
||||||
|
<a href="/admin/brands?letter=H&keyword={{.Keyword}}" class="{{if eq .Letter "H"}}on{{end}}">H</a>
|
||||||
|
<a href="/admin/brands?letter=I&keyword={{.Keyword}}" class="{{if eq .Letter "I"}}on{{end}}">I</a>
|
||||||
|
<a href="/admin/brands?letter=J&keyword={{.Keyword}}" class="{{if eq .Letter "J"}}on{{end}}">J</a>
|
||||||
|
<a href="/admin/brands?letter=K&keyword={{.Keyword}}" class="{{if eq .Letter "K"}}on{{end}}">K</a>
|
||||||
|
<a href="/admin/brands?letter=L&keyword={{.Keyword}}" class="{{if eq .Letter "L"}}on{{end}}">L</a>
|
||||||
|
<a href="/admin/brands?letter=M&keyword={{.Keyword}}" class="{{if eq .Letter "M"}}on{{end}}">M</a>
|
||||||
|
<a href="/admin/brands?letter=N&keyword={{.Keyword}}" class="{{if eq .Letter "N"}}on{{end}}">N</a>
|
||||||
|
<a href="/admin/brands?letter=O&keyword={{.Keyword}}" class="{{if eq .Letter "O"}}on{{end}}">O</a>
|
||||||
|
<a href="/admin/brands?letter=P&keyword={{.Keyword}}" class="{{if eq .Letter "P"}}on{{end}}">P</a>
|
||||||
|
<a href="/admin/brands?letter=Q&keyword={{.Keyword}}" class="{{if eq .Letter "Q"}}on{{end}}">Q</a>
|
||||||
|
<a href="/admin/brands?letter=R&keyword={{.Keyword}}" class="{{if eq .Letter "R"}}on{{end}}">R</a>
|
||||||
|
<a href="/admin/brands?letter=S&keyword={{.Keyword}}" class="{{if eq .Letter "S"}}on{{end}}">S</a>
|
||||||
|
<a href="/admin/brands?letter=T&keyword={{.Keyword}}" class="{{if eq .Letter "T"}}on{{end}}">T</a>
|
||||||
|
<a href="/admin/brands?letter=U&keyword={{.Keyword}}" class="{{if eq .Letter "U"}}on{{end}}">U</a>
|
||||||
|
<a href="/admin/brands?letter=V&keyword={{.Keyword}}" class="{{if eq .Letter "V"}}on{{end}}">V</a>
|
||||||
|
<a href="/admin/brands?letter=W&keyword={{.Keyword}}" class="{{if eq .Letter "W"}}on{{end}}">W</a>
|
||||||
|
<a href="/admin/brands?letter=X&keyword={{.Keyword}}" class="{{if eq .Letter "X"}}on{{end}}">X</a>
|
||||||
|
<a href="/admin/brands?letter=Y&keyword={{.Keyword}}" class="{{if eq .Letter "Y"}}on{{end}}">Y</a>
|
||||||
|
<a href="/admin/brands?letter=Z&keyword={{.Keyword}}" class="{{if eq .Letter "Z"}}on{{end}}">Z</a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
16
internal/handler/templates/pages/dashboard.html
Normal file
16
internal/handler/templates/pages/dashboard.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{{define "title"}}管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · Fashion Archive{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/logout">退出({{.Username}})</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>欢迎,{{.Username}}</h1>
|
||||||
|
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
|
||||||
|
<div class="cards">
|
||||||
|
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
|
||||||
|
<a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 编辑 · 删除(下架)</div></a>
|
||||||
|
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
|
||||||
|
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
5
internal/handler/templates/pages/error.html
Normal file
5
internal/handler/templates/pages/error.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}错误{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>出错了</h1><p style="color:#c0392b">{{.Error}}</p>
|
||||||
|
<p><a href="/admin/">返回首页</a></p>
|
||||||
|
{{end}}
|
||||||
24
internal/handler/templates/pages/ingest-jobs.html
Normal file
24
internal/handler/templates/pages/ingest-jobs.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{{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">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Jobs}}<tr>
|
||||||
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td>{{.Attempts}}</td>
|
||||||
|
<td class="err break">{{.LastError}}</td>
|
||||||
|
<td class="muted">{{.UpdatedAt}}</td>
|
||||||
|
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{template "pager" .Pager}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
10
internal/handler/templates/pages/login.html
Normal file
10
internal/handler/templates/pages/login.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{define "title"}}管理后台 · 登录{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="wrap"><h1>管理后台登录</h1>
|
||||||
|
<form method="post" action="/admin/login">
|
||||||
|
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||||
|
<label>账号</label><input name="account" value="{{.Account}}" autofocus>
|
||||||
|
<label>密码</label><input name="password" type="password">
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
</form></div>
|
||||||
|
{{end}}
|
||||||
13
internal/handler/templates/pages/runway-detail.html
Normal file
13
internal/handler/templates/pages/runway-detail.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{{define "title"}}走秀详情 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 走秀详情{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/runways/{{.Article.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
||||||
|
<h1>{{.Article.Title}}</h1>
|
||||||
|
<div class="grid">
|
||||||
|
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
29
internal/handler/templates/pages/runway-edit.html
Normal file
29
internal/handler/templates/pages/runway-edit.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{{define "title"}}编辑走秀 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 编辑走秀 {{.UID}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/runways/{{.UID}}">返回详情</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
|
||||||
|
<form method="post" action="/admin/runways/{{.UID}}/edit">
|
||||||
|
<div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div>
|
||||||
|
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>
|
||||||
|
<div class="row"><div><label>年份</label><input name="year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" value="{{.Runway.Season}}"></div></div>
|
||||||
|
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" value="{{.Runway.CollectionType}}"></div><div><label>季节码 (season_code)</label><input name="season_code" value="{{.Runway.SeasonCode}}"></div></div>
|
||||||
|
<button type="submit">保存字段</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{template "imageGrid" .}}
|
||||||
|
|
||||||
|
{{if eq .Runway.Status "pending"}}
|
||||||
|
<div class="review-actions">
|
||||||
|
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
|
||||||
|
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
27
internal/handler/templates/pages/snap-edit.html
Normal file
27
internal/handler/templates/pages/snap-edit.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{{define "title"}}编辑街拍 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 编辑街拍 {{.UID}}{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/street-snaps/{{.UID}}">返回详情</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
|
||||||
|
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
|
||||||
|
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
|
||||||
|
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
|
||||||
|
<button type="submit">保存字段</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{template "imageGrid" .}}
|
||||||
|
|
||||||
|
{{if eq .Snap.Status "pending"}}
|
||||||
|
<div class="review-actions">
|
||||||
|
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
|
||||||
|
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
13
internal/handler/templates/pages/street-snap-detail.html
Normal file
13
internal/handler/templates/pages/street-snap-detail.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{{define "title"}}街拍详情 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 街拍详情{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/articles">返回列表</a> · <a href="/admin/street-snaps/{{.Snap.UID}}/edit">编辑</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<p class="muted">编码 {{.Snap.UID}}</p>
|
||||||
|
<h1>{{.Snap.Title}}</h1>
|
||||||
|
<div class="grid">
|
||||||
|
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
30
internal/handler/templates/pages/users.html
Normal file
30
internal/handler/templates/pages/users.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{{define "title"}}用户管理 · 管理后台{{end}}
|
||||||
|
{{define "barLabel"}}管理后台 · 用户管理{{end}}
|
||||||
|
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "bar" .}}
|
||||||
|
<div class="main">
|
||||||
|
<h1>用户管理({{len .Users}})</h1>
|
||||||
|
<p class="muted">图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>等级</th><th>操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}<tr>
|
||||||
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td class="muted">{{.Email}}</td>
|
||||||
|
<td>{{if eq .Tier "vip"}}<span class="badge vip">VIP</span>{{else}}<span class="badge free">FREE</span>{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<form class="tier" method="post" action="/admin/users/{{.ID}}/tier">
|
||||||
|
<select name="tier">
|
||||||
|
<option value="free" {{if eq .Tier "free"}}selected{{end}}>free(普通)</option>
|
||||||
|
<option value="vip" {{if eq .Tier "vip"}}selected{{end}}>vip</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">保存</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
82
internal/handler/templates/partials.html
Normal file
82
internal/handler/templates/partials.html
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{{/*
|
||||||
|
共享片段:所有后台页面通用的顶部导航与翻页器。
|
||||||
|
|
||||||
|
由 templates.go 在启动时与 layout.html 一起解析进「基模板」,各页面模板只需
|
||||||
|
{{template "bar" .}} / {{template "pager" .Pager}} 引用,改一处即全局生效
|
||||||
|
—— 这也是把导航从 14 份复制粘贴里提出来、补全模块入口的原因。
|
||||||
|
|
||||||
|
页面需要提供的数据:
|
||||||
|
.NavKey 由 renderPage 自动注入(= 当前页面名),用于导航高亮
|
||||||
|
.PendingReviews 由 backstageNavStats 中间件注入(待审总数,导航角标)
|
||||||
|
barLabel/barRight 两个 define 由各页面模板自己提供(见 pages/*.html)
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{define "bar"}}
|
||||||
|
<div class="bar">
|
||||||
|
<span class="bar-title">{{template "barLabel" .}}</span>
|
||||||
|
<nav class="bar-nav">
|
||||||
|
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
|
||||||
|
<a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
|
||||||
|
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
|
||||||
|
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
|
||||||
|
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
|
||||||
|
<span class="sep"></span>
|
||||||
|
{{template "barRight" .}}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "pager"}}
|
||||||
|
<div class="pager">
|
||||||
|
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
|
||||||
|
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
|
||||||
|
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
共享片段:街拍 / 走秀编辑页共用的「图片管理」块(网格 + 合并为一组 + 拆出)。
|
||||||
|
两个后台编辑页之前各写一份几乎相同的图片管理 HTML,现已抽到这里,改一处全局生效。
|
||||||
|
|
||||||
|
页面需要提供的字段:
|
||||||
|
.Kind "street" | "runway"(决定封面/删除路由前缀与主/副图文案)
|
||||||
|
.UID 记录的 hashid(用于 /admin/{street-snaps|runways}/<UID>/... 路由)
|
||||||
|
.RecordID 记录数字 id(用于 /admin/articles/<kind>/<id>/... 路由)
|
||||||
|
.Status 记录状态(pending 时显示待审角标)
|
||||||
|
.Cells []gin.H,每项 {id, image, name, isDetail, role}
|
||||||
|
*/}}
|
||||||
|
{{define "imageGrid"}}
|
||||||
|
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
|
||||||
|
<div class="hint">
|
||||||
|
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
|
||||||
|
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
|
||||||
|
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
|
||||||
|
</div>
|
||||||
|
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}{{$dupSources := .DupSources}}
|
||||||
|
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
|
||||||
|
<div class="grid">
|
||||||
|
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
|
||||||
|
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
|
||||||
|
<span class="role">{{.role}}</span>
|
||||||
|
<span class="nm">{{.name}}</span>
|
||||||
|
{{if eq .isDuplicate 1}}<span class="badge dup">重复</span>
|
||||||
|
{{with index $dupSources .dupOf}}{{if .srcTitle}}<details class="dup-src"><summary>查看重复来源</summary><div class="dup-src-body"><img src="{{imgURL .srcImage}}" loading="lazy" alt="源图缩略图"><div><div class="dup-title">属于:<b>{{.srcTitle}}</b>{{if .srcBrand}} · {{.srcBrand}}{{end}}</div><a class="dup-link" href="/admin/{{if eq .srcKind "runway"}}runways{{else}}street-snaps{{end}}/{{.srcUID}}/edit" target="_blank">打开所属{{if eq .srcKind "runway"}}走秀{{else}}街拍{{end}} →</a></div></div></details>{{end}}{{end}}
|
||||||
|
{{end}}
|
||||||
|
<div class="acts">
|
||||||
|
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
|
||||||
|
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||||
|
{{else}}<form method="post" action="/admin/street-snaps/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
|
||||||
|
<form method="post" action="/admin/street-snaps/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
|
||||||
|
{{end}}
|
||||||
|
{{if eq .isDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.id}}"> 主图</label>{{end}}
|
||||||
|
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.id}}"> 选入</label>
|
||||||
|
{{if and (eq .isDetail 0) .DetCount}}<button type="button" class="toggle-det" data-parent="{{.id}}">显示副图({{.DetCount}} 张)</button>{{end}}
|
||||||
|
{{if eq .isDetail 1}}<form data-only="detail" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/{{.id}}/detach"><button type="submit">拆出</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="batchbar">
|
||||||
|
<button type="submit" form="batch">合并选中为一组</button>
|
||||||
|
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
17
internal/model/content_status.go
Normal file
17
internal/model/content_status.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// 内容发布状态(单表发布模型)。
|
||||||
|
//
|
||||||
|
// 历史上「爬虫草稿」与「正式记录」是两张表,审核 = 从草稿晋升到正式表。
|
||||||
|
// 现在合并为一张表,审核态由本字段承载:
|
||||||
|
// - pending:刚入库,等待审核;公开视图不可见
|
||||||
|
// - published:已通过,对外可见
|
||||||
|
// - rejected:已驳回,留在库中备查;公开视图不可见
|
||||||
|
//
|
||||||
|
// 新增记录默认 pending(见各自模型的 gorm default),漏赋值的结果是「不可见」,
|
||||||
|
// 即 fail-closed,而不是意外对外发布。
|
||||||
|
const (
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusPublished = "published"
|
||||||
|
StatusRejected = "rejected"
|
||||||
|
)
|
||||||
@ -22,6 +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 添加)。
|
||||||
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||||
|
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||||
|
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名。
|
// TableName 指定表名。
|
||||||
@ -43,6 +49,7 @@ type RunwayRow struct {
|
|||||||
CollectionType string `gorm:"column:collection_type"`
|
CollectionType string `gorm:"column:collection_type"`
|
||||||
Season string `gorm:"column:season"`
|
Season string `gorm:"column:season"`
|
||||||
SeasonCode string `gorm:"column:season_code"`
|
SeasonCode string `gorm:"column:season_code"`
|
||||||
|
Status string `gorm:"column:status"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at"`
|
CreatedAt uint32 `gorm:"column:created_at"`
|
||||||
BrandNameEn string `gorm:"column:brand_name_en"`
|
BrandNameEn string `gorm:"column:brand_name_en"`
|
||||||
BrandNameCn string `gorm:"column:brand_name_cn"`
|
BrandNameCn string `gorm:"column:brand_name_cn"`
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "database/sql"
|
|
||||||
|
|
||||||
// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。
|
|
||||||
//
|
|
||||||
// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending;
|
|
||||||
// 后台审核通过(Approve)后再晋升(SaveRunwayFromDraft)到 brand_runways 正式表。
|
|
||||||
// 字段与 brand_runways 对齐(含 review 专用字段),便于审核页直接预览/微调。
|
|
||||||
type BrandRunwayDraft struct {
|
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
|
||||||
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
|
||||||
BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` // 品牌数字主键
|
|
||||||
TitleEn string `gorm:"column:title_en" json:"title_en"`
|
|
||||||
TitleCn string `gorm:"column:title_cn" json:"title_cn"`
|
|
||||||
DescriptionEn string `gorm:"column:description_en" json:"description_en"`
|
|
||||||
DescriptionCn string `gorm:"column:description_cn" json:"description_cn"`
|
|
||||||
Year uint16 `gorm:"column:year" json:"year"`
|
|
||||||
Season string `gorm:"column:season" json:"season"`
|
|
||||||
CollectionType string `gorm:"column:collection_type" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
|
|
||||||
SeasonCode string `gorm:"column:season_code" json:"season_code"` // SS26/FW25/RES26/PF25
|
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
|
||||||
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
|
||||||
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
|
||||||
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
|
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定草稿主表名。
|
|
||||||
func (BrandRunwayDraft) TableName() string { return "brand_runway_drafts" }
|
|
||||||
|
|
||||||
// BrandRunwayDraftImage 草稿图片(对应 brand_runway_draft_images)。
|
|
||||||
type BrandRunwayDraftImage struct {
|
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
|
||||||
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
|
|
||||||
Image string `gorm:"column:image" json:"image"`
|
|
||||||
Name string `gorm:"column:name" json:"name"`
|
|
||||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
|
|
||||||
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号
|
|
||||||
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图 1=细节图
|
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
|
||||||
|
|
||||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
|
||||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
|
||||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
|
||||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定草稿图片表名。
|
|
||||||
func (BrandRunwayDraftImage) TableName() string { return "brand_runway_draft_images" }
|
|
||||||
|
|
||||||
// 草稿状态常量。
|
|
||||||
const (
|
|
||||||
DraftStatusPending = "pending"
|
|
||||||
DraftStatusApproved = "approved"
|
|
||||||
DraftStatusRejected = "rejected"
|
|
||||||
)
|
|
||||||
@ -4,17 +4,21 @@ import "database/sql"
|
|||||||
|
|
||||||
// BrandRunwayImage 走秀图片。
|
// BrandRunwayImage 走秀图片。
|
||||||
type BrandRunwayImage struct {
|
type BrandRunwayImage struct {
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||||
Image string `gorm:"column:image" json:"image"`
|
Image string `gorm:"column:image" json:"image"`
|
||||||
RunwayID uint32 `gorm:"column:runway_id" json:"runway_id"`
|
RunwayID uint32 `gorm:"column:runway_id" json:"runway_id"`
|
||||||
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,14 +8,21 @@ 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"`
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
|
||||||
|
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
|
||||||
|
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||||
|
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
|
||||||
|
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
|
||||||
|
|
||||||
|
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定主表名。
|
// TableName 指定主表名。
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "database/sql"
|
|
||||||
|
|
||||||
// StreetSnapDraft 街拍草稿(爬虫入库待审核)。
|
|
||||||
//
|
|
||||||
// 与 brand_runway_drafts 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
|
|
||||||
// pending;后台审核通过(Approve)后再晋升(SaveStreetSnapFromDraft)到 street_snaps 正式表。
|
|
||||||
//
|
|
||||||
// 字段对齐 street_snaps 正式表(单 Title、有 City、无 brand_id、无 season 系列列),
|
|
||||||
// 外加审核专用字段,便于审核页直接预览/微调。
|
|
||||||
type StreetSnapDraft struct {
|
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
|
||||||
JobID uint32 `gorm:"column:job_id" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
|
||||||
Title string `gorm:"column:title" json:"title"` // 单标题(街拍无双语)
|
|
||||||
Year uint16 `gorm:"column:year" json:"year"`
|
|
||||||
City string `gorm:"column:city" json:"city"` // 地区/城市,'' 表示未归类
|
|
||||||
Cover string `gorm:"column:cover" json:"cover"`
|
|
||||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
|
||||||
Status string `gorm:"column:status" json:"status"` // pending | approved | rejected
|
|
||||||
Reviewer string `gorm:"column:reviewer" json:"reviewer"` // 审核人(admin 账号)
|
|
||||||
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
|
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定草稿主表名。
|
|
||||||
func (StreetSnapDraft) TableName() string { return "street_snap_drafts" }
|
|
||||||
|
|
||||||
// StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。
|
|
||||||
type StreetSnapDraftImage struct {
|
|
||||||
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
|
|
||||||
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
|
|
||||||
Image string `gorm:"column:image" json:"image"`
|
|
||||||
Name string `gorm:"column:name" json:"name"`
|
|
||||||
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
|
|
||||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
|
||||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
|
|
||||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
|
||||||
|
|
||||||
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
|
|
||||||
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
|
|
||||||
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
|
|
||||||
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
|
|
||||||
|
|
||||||
// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
|
|
||||||
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同草稿内主图的行 id。
|
|
||||||
// 存行 id 而非序号:序号会因重排 / 插入 / 删除而失效。
|
|
||||||
IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
|
|
||||||
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName 指定草稿图片表名。
|
|
||||||
func (StreetSnapDraftImage) TableName() string { return "street_snap_draft_images" }
|
|
||||||
@ -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,不参与近邻检索),不阻断入库。
|
||||||
|
|||||||
@ -90,7 +90,7 @@ func TestOfDifferent(t *testing.T) {
|
|||||||
|
|
||||||
// TestToVectorBits 验证向量串格式与维度(64 位、方括号包裹、0/1 组成)。
|
// TestToVectorBits 验证向量串格式与维度(64 位、方括号包裹、0/1 组成)。
|
||||||
func TestToVectorBits(t *testing.T) {
|
func TestToVectorBits(t *testing.T) {
|
||||||
s := ToVectorBits(0b1) // 仅最低位为 1
|
s := ToVectorBits(0b1) // 仅最低位为 1
|
||||||
if len(s) != 2*Size+1 { // 64 个 0/1 + 63 个逗号 + 2 个方括号 = 129
|
if len(s) != 2*Size+1 { // 64 个 0/1 + 63 个逗号 + 2 个方括号 = 129
|
||||||
t.Fatalf("向量串长度应为 %d,got %d(%s)", 2*Size+1, len(s), s)
|
t.Fatalf("向量串长度应为 %d,got %d(%s)", 2*Size+1, len(s), s)
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
||||||
)
|
)
|
||||||
@ -18,20 +20,32 @@ type ArticleRepository interface {
|
|||||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
||||||
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||||
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
// FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
|
||||||
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||||
// ListImages 查询某篇文章的全部图片,按排序值升序。
|
// FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。
|
||||||
|
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
|
||||||
|
FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error)
|
||||||
|
// ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。
|
||||||
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||||
|
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||||||
|
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||||
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
||||||
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
||||||
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
||||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, 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
|
||||||
// DeleteRunwayImage 软删单张走秀图片。
|
// DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。
|
||||||
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 更新走秀封面(同时回到图片列表首张)。
|
||||||
@ -62,11 +76,12 @@ const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_ru
|
|||||||
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||||
|
|
||||||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||||||
// collection_type / season_code 等管理字段(前端列表不展示这些)。
|
// collection_type / season_code / status 等管理字段(前端列表不展示这些)。
|
||||||
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
|
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
|
||||||
brand_runways.title_en, brand_runways.title_cn,
|
brand_runways.title_en, brand_runways.title_cn,
|
||||||
brand_runways.cover, brand_runways.image_count, brand_runways.year,
|
brand_runways.cover, brand_runways.image_count, brand_runways.year,
|
||||||
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
|
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
|
||||||
|
brand_runways.status,
|
||||||
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
|
||||||
|
|
||||||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||||
@ -142,7 +157,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
|||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Scopes(scope).
|
Scopes(scope).
|
||||||
Count(&total).Error; err != nil {
|
Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@ -153,7 +168,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
|||||||
|
|
||||||
var rows []model.RunwayRow
|
var rows []model.RunwayRow
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Scopes(scope).
|
Scopes(scope).
|
||||||
Select(articleListColumns).
|
Select(articleListColumns).
|
||||||
Joins(brandJoin).
|
Joins(brandJoin).
|
||||||
@ -196,11 +211,20 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
|
||||||
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。
|
||||||
|
func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。
|
||||||
|
func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) {
|
||||||
var row model.RunwayRow
|
var row model.RunwayRow
|
||||||
err := r.db.WithContext(ctx).
|
err := q.Select(articleDetailColumns).
|
||||||
Model(&model.BrandRunway{}).
|
|
||||||
Select(articleDetailColumns).
|
|
||||||
Joins(brandJoin).
|
Joins(brandJoin).
|
||||||
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
||||||
First(&row).Error
|
First(&row).Error
|
||||||
@ -213,7 +237,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
|
|||||||
return &row, nil
|
return &row, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
|
||||||
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||||||
|
var imgs []model.BrandRunwayImage
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicRunwayImages).
|
||||||
|
Where("runway_id = ?", runwayID).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&imgs).Error
|
||||||
|
return imgs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
|
||||||
|
func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
|
||||||
var imgs []model.BrandRunwayImage
|
var imgs []model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||||
@ -230,8 +266,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
|||||||
|
|
||||||
var imgs []model.BrandRunwayImage
|
var imgs []model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicRunwayImages).
|
||||||
Select("runway_id, image, name, sort_order").
|
Select("runway_id, image, name, sort_order").
|
||||||
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids).
|
Where("runway_id IN ? AND is_detail = 0", ids).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -265,7 +302,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
|
|||||||
}
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@ -289,11 +326,64 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
|
|||||||
Updates(clean).Error
|
Updates(clean).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。
|
||||||
|
//
|
||||||
|
// 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算
|
||||||
|
// (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。
|
||||||
|
//
|
||||||
|
// 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径,
|
||||||
|
// 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化,
|
||||||
|
// 并直接显示在公开列表卡片的「N 张」角标里。
|
||||||
|
//
|
||||||
|
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。
|
||||||
|
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
|
||||||
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).
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
Model(&model.BrandRunwayImage{}).
|
var img model.BrandRunwayImage
|
||||||
Where("id = ? AND is_deleted = 0", imageID).
|
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec(`UPDATE brand_runways SET image_count =
|
||||||
|
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
|
||||||
|
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; 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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||||
@ -308,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) {
|
||||||
@ -330,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,12 +13,16 @@ import (
|
|||||||
|
|
||||||
// BrandRepository 品牌数据访问接口。
|
// BrandRepository 品牌数据访问接口。
|
||||||
type BrandRepository interface {
|
type BrandRepository interface {
|
||||||
// List 按字母索引 / 关键词分页查询品牌。
|
// List 按字母索引 / 关键词分页查询品牌(公开侧:字母索引只认已发布走秀档案)。
|
||||||
//
|
//
|
||||||
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
|
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
|
||||||
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
|
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
|
||||||
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
|
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
|
||||||
|
|
||||||
|
// ListAdmin 后台列表:与 List 同构,但字母索引不要求走秀已发布,
|
||||||
|
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开侧保护不受影响)。
|
||||||
|
ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
|
||||||
|
|
||||||
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
|
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
|
||||||
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
|
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
|
||||||
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
|
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
|
||||||
@ -71,16 +75,28 @@ func NewBrandRepository(db *gorm.DB) BrandRepository {
|
|||||||
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
|
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
|
||||||
const latinPrefixRegexp = "^[A-Za-z]"
|
const latinPrefixRegexp = "^[A-Za-z]"
|
||||||
|
|
||||||
// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。
|
// hasArticlesSubQuery 子查询(公开侧):所有「至少有一篇已发布走秀档案」的品牌 id。
|
||||||
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
|
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
|
||||||
|
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
|
||||||
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
|
||||||
|
return r.db.Table(publicRunways).
|
||||||
|
Select("DISTINCT brand_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasArticlesSubQueryBase 子查询(后台侧):所有「至少有一篇未删除走秀档案」的品牌 id。
|
||||||
|
// 读基表且不看 status:后台品牌列表必须仍能看到只有待审 / 驳回走秀的品牌,
|
||||||
|
// 否则它们会从后台「消失」,审核员无处下手(与公开侧的保护并不冲突:公开走视图)。
|
||||||
|
func (r *brandRepository) hasArticlesSubQueryBase() *gorm.DB {
|
||||||
return r.db.Model(&model.BrandRunway{}).
|
return r.db.Model(&model.BrandRunway{}).
|
||||||
Select("DISTINCT brand_id").
|
Select("DISTINCT brand_id").
|
||||||
Where("is_deleted = 0")
|
Where("is_deleted = 0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
// brandFilterScope 把品牌查询条件编译为 GORM Scope。
|
||||||
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB {
|
//
|
||||||
|
// admin 为 true 时(后台列表)「必须有走秀档案」的子查询读基表且不过滤 status,
|
||||||
|
// 使只有待审 / 驳回走秀的品牌仍出现在后台;公开列表保持读视图(保护未发布内容)。
|
||||||
|
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32, admin bool) func(*gorm.DB) *gorm.DB {
|
||||||
return func(db *gorm.DB) *gorm.DB {
|
return func(db *gorm.DB) *gorm.DB {
|
||||||
db = db.Where("is_deleted = 0")
|
db = db.Where("is_deleted = 0")
|
||||||
|
|
||||||
@ -96,12 +112,19 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||||
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。
|
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
|
||||||
|
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
|
||||||
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
||||||
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
||||||
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
||||||
if q.Keyword == "" && q.Letter != "OTHER" {
|
if q.Keyword == "" && q.Letter != "OTHER" {
|
||||||
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
|
if admin {
|
||||||
|
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
|
||||||
|
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
|
||||||
|
} else {
|
||||||
|
// 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。
|
||||||
|
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.Keyword != "" {
|
if q.Keyword != "" {
|
||||||
@ -121,8 +144,19 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List 公开品牌列表:字母索引只保留「至少有一篇已发布走秀档案」的品牌。
|
||||||
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
|
func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
|
||||||
scope := r.brandFilterScope(q, restrictIDs)
|
return r.list(ctx, q, restrictIDs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdmin 后台品牌列表:与 List 同构,但字母索引不要求走秀已发布,
|
||||||
|
// 只有待审 / 驳回走秀的品牌仍能看到(否则它们从后台列表消失,无处审核/编辑)。
|
||||||
|
func (r *brandRepository) ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
|
||||||
|
return r.list(ctx, q, restrictIDs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32, admin bool) ([]model.Brand, int64, error) {
|
||||||
|
scope := r.brandFilterScope(q, restrictIDs, admin)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
@ -157,10 +191,10 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
|
|||||||
var agg []struct {
|
var agg []struct {
|
||||||
BrandID uint32 `gorm:"column:brand_id"`
|
BrandID uint32 `gorm:"column:brand_id"`
|
||||||
}
|
}
|
||||||
|
// 读公开视图:热门榜只能由已发布走秀构成,避免把待审/驳回内容的图片数计入热度。
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Table(publicRunways).
|
||||||
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
|
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
|
||||||
Where("is_deleted = 0").
|
|
||||||
Group("brand_id").
|
Group("brand_id").
|
||||||
Order(orderCol + " DESC").
|
Order(orderCol + " DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
@ -281,7 +315,7 @@ WITH latest_per_brand AS (
|
|||||||
END DESC,
|
END DESC,
|
||||||
id DESC
|
id DESC
|
||||||
) AS rn
|
) AS rn
|
||||||
FROM brand_runways
|
FROM public_brand_runways brand_runways
|
||||||
WHERE is_deleted = 0 AND brand_id IN (?)
|
WHERE is_deleted = 0 AND brand_id IN (?)
|
||||||
)
|
)
|
||||||
SELECT id, brand_id, cover, title_en, title_cn
|
SELECT id, brand_id, cover, title_en, title_cn
|
||||||
|
|||||||
@ -93,9 +93,16 @@ func TestDedupNearDuplicate(t *testing.T) {
|
|||||||
t.Fatalf("汉明距离 1 的近重复应被命中")
|
t.Fatalf("汉明距离 1 的近重复应被命中")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。
|
// 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题),
|
||||||
_, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold)
|
// 这里改用与库中那条完全相同的全 1 探针(非空、合法)并先断言查询本身不报错。
|
||||||
if found2 {
|
probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold)
|
||||||
t.Fatalf("汉明距离 64 的图不应被误判为近重复")
|
if errSame != nil {
|
||||||
|
t.Fatalf("去重查询不应报错: %v", errSame)
|
||||||
|
}
|
||||||
|
if !foundSame {
|
||||||
|
t.Fatalf("与库中那条完全相同的 phash 应命中")
|
||||||
|
}
|
||||||
|
if probeSame == 0 {
|
||||||
|
t.Fatalf("命中的行 id 不应为 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
410
internal/repository/editor_image_delete_integration_test.go
Normal file
410
internal/repository/editor_image_delete_integration_test.go
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:后台正式编辑页(ArticleRepository / StreetSnapRepository)删图后,
|
||||||
|
// image_count 必须按实际存活行**重算** —— 与审核侧 SoftDelete*Image 口径完全一致。
|
||||||
|
//
|
||||||
|
// 为什么必须有这条:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径。
|
||||||
|
// 旧流程下编辑页删图造成的计数漂移会在下次发布被抹平;现在不会了,漂移会永久留在公开列表
|
||||||
|
// 卡片的「N 张」角标上。入口链路:templates/pages/*-edit.html 删除按钮 →
|
||||||
|
// backstage_handler.go → article_service/street_snap_service.DeleteImage → 本仓储方法。
|
||||||
|
//
|
||||||
|
// 运行:go test -tags integration ./internal/repository/ -run TestEditorDeleteImage -v
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/phash"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// editorSeedSeq 让每次 seed 出来的实体键互不相同(避开 uq_br_entity / uq_ss_entity 部分唯一索引)。
|
||||||
|
var editorSeedSeq int
|
||||||
|
|
||||||
|
// seedEditorRunway 插入一条走秀记录(image_count 预置为入参,便于观察是否被重算)并返回其 id。
|
||||||
|
func seedEditorRunway(t *testing.T, db *gorm.DB, imageCount uint16) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
editorSeedSeq++
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
BrandID: 1, TitleEn: "editor-delete-test", Year: 2026,
|
||||||
|
SeasonCode: fmt.Sprintf("ED%04d", editorSeedSeq), CollectionType: "rtw",
|
||||||
|
Status: model.StatusPending, ImageCount: imageCount, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||||||
|
})
|
||||||
|
return rw.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回行 id。
|
||||||
|
func seedEditorRunwayImage(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: order, IsDetail: isDetail,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀图片失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorSnap 插入一条街拍记录(image_count 预置为入参)并返回其 id。
|
||||||
|
func seedEditorSnap(t *testing.T, db *gorm.DB, imageCount uint16) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
editorSeedSeq++
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
snap := model.StreetSnap{
|
||||||
|
Title: "editor-delete-test", City: fmt.Sprintf("EditorCity-%d", editorSeedSeq),
|
||||||
|
Year: 2026, Status: model.StatusPending, ImageCount: imageCount,
|
||||||
|
CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&snap).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
|
||||||
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
|
||||||
|
})
|
||||||
|
return snap.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorSnapImage 插入一张街拍图(isDetail=1 表示副图)并返回行 id。
|
||||||
|
func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string, order uint32, isDetail uint8, parent uint32) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
img := model.StreetSnapImage{
|
||||||
|
SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order,
|
||||||
|
IsDetail: isDetail, ParentImageID: parent,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍图片失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedEditorRunwayImagePhash 插入一张带 phash 的走秀图(isDetail 表示主/细节),返回行 id。
|
||||||
|
func seedEditorRunwayImagePhash(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8, phashBits string) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
var ph sql.NullString
|
||||||
|
if phashBits != "" {
|
||||||
|
ph = sql.NullString{String: phashBits, Valid: true}
|
||||||
|
}
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: order, IsDetail: isDetail, Phash: ph,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入带 phash 走秀图片失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorImageFlag 读某图片的 is_duplicate / dup_of(验证去重重算结果)。
|
||||||
|
func editorImageFlag(t *testing.T, db *gorm.DB, id uint32) (uint8, string) {
|
||||||
|
t.Helper()
|
||||||
|
var row struct {
|
||||||
|
IsDuplicate uint8 `gorm:"column:is_duplicate"`
|
||||||
|
DupOf string `gorm:"column:dup_of"`
|
||||||
|
}
|
||||||
|
if err := db.Table("brand_runway_images").Select("is_duplicate, dup_of").Where("id = ?", id).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatalf("读去重标记失败: %v", err)
|
||||||
|
}
|
||||||
|
return row.IsDuplicate, row.DupOf
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorReresolveRunwayDedupAfterSourceDeleted 模拟「删掉源图后」重算去重应消除悬空 dup_of:
|
||||||
|
// A 为源(被删),B、C 曾 dup_of=A;重算后 A 退出参照,B 成为新源(dup=0),C 重指 B(dup=1),
|
||||||
|
// 不再有任何图指向已删的 A —— 对应线上「删了源图后仍显示重复、源图查看重复来源打不开」。
|
||||||
|
func TestEditorReresolveRunwayDedupAfterSourceDeleted(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
id := seedEditorRunway(t, db, 3)
|
||||||
|
// A=全1 向量;B 翻转 1 位(距 A=1);C 翻转 2 位(距 A=2)—— 三者互为近重复。
|
||||||
|
allOne := phash.ToVectorBits(^uint64(0))
|
||||||
|
bVec := phash.ToVectorBits(^uint64(0) &^ (uint64(1) << 0))
|
||||||
|
cVec := phash.ToVectorBits(^uint64(0) &^ ((uint64(1) << 0) | (uint64(1) << 1)))
|
||||||
|
a := seedEditorRunwayImagePhash(t, db, id, "A", 1, 0, allOne)
|
||||||
|
b := seedEditorRunwayImagePhash(t, db, id, "B", 2, 0, bVec)
|
||||||
|
c := seedEditorRunwayImagePhash(t, db, id, "C", 3, 0, cVec)
|
||||||
|
|
||||||
|
// 预置入库去重后的陈旧态:B、C 都 dup_of=A。
|
||||||
|
if err := db.Model(&model.BrandRunwayImage{}).Where("id IN ?", []uint32{b, c}).
|
||||||
|
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(a), 10)}).Error; err != nil {
|
||||||
|
t.Fatalf("预置陈旧去重标记失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删掉源图 A(软删,模拟编辑页删除)。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, a); err != nil {
|
||||||
|
t.Fatalf("删源图失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重算去重(编辑页删图会自动调用,这里直接验证仓储方法)。
|
||||||
|
if err := repo.ReresolveRunwayDedup(ctx, id); err != nil {
|
||||||
|
t.Fatalf("重算去重失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diB, ofB := editorImageFlag(t, db, b)
|
||||||
|
diC, ofC := editorImageFlag(t, db, c)
|
||||||
|
if diB != 0 {
|
||||||
|
t.Fatalf("B 应成为新源(is_duplicate=0),实际 %d", diB)
|
||||||
|
}
|
||||||
|
if ofB != "" {
|
||||||
|
t.Fatalf("B 的 dup_of 应为空,实际 %q", ofB)
|
||||||
|
}
|
||||||
|
if diC != 1 {
|
||||||
|
t.Fatalf("C 应标记为重复(is_duplicate=1),实际 %d", diC)
|
||||||
|
}
|
||||||
|
if ofC != strconv.FormatUint(uint64(b), 10) {
|
||||||
|
t.Fatalf("C 的 dup_of 应指向 B(%d),实际 %q", b, ofC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
|
||||||
|
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
|
||||||
|
t.Helper()
|
||||||
|
var row struct {
|
||||||
|
ImageCount uint16 `gorm:"column:image_count"`
|
||||||
|
}
|
||||||
|
if err := db.Table(table).Select("image_count").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatalf("读 %s.image_count 失败: %v", table, err)
|
||||||
|
}
|
||||||
|
return row.ImageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorDeleteRunwayImageRecomputesCount 走秀编辑页删图:口径 = 存活主图行数(is_detail=0)。
|
||||||
|
func TestEditorDeleteRunwayImageRecomputesCount(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
id := seedEditorRunway(t, db, 2) // 与「2 张主图」一致的初始值
|
||||||
|
keep := seedEditorRunwayImage(t, db, id, "keep", 1, 0)
|
||||||
|
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
|
||||||
|
detail := seedEditorRunwayImage(t, db, id, "detail", 3, 1)
|
||||||
|
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 2 {
|
||||||
|
t.Fatalf("前置 image_count 应为 2,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删一张主图:应按存活主图行数重算为 1。若只置 is_deleted 不重算,会停在 2。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
|
||||||
|
t.Fatalf("编辑页删图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("删一张主图后 image_count 应重算为 1,实际 %d(只置 is_deleted 不重算会停在 2)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删细节图:口径只计主图,计数不变(锁住 is_detail = 0 过滤)。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, detail); err != nil {
|
||||||
|
t.Fatalf("删细节图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("删细节图不应改变主图计数,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复删同一张(已软删):重放幂等,计数不变。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
|
||||||
|
t.Fatalf("重复删同一张应幂等,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的 imageID:静默无操作,不得影响计数。
|
||||||
|
if err := repo.DeleteRunwayImage(ctx, 4_294_967_290); err != nil {
|
||||||
|
t.Fatalf("不存在的 imageID 不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
|
||||||
|
t.Fatalf("不存在的 imageID 不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alive []uint32
|
||||||
|
if err := db.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", id).Order("id ASC").
|
||||||
|
Pluck("id", &alive).Error; err != nil {
|
||||||
|
t.Fatalf("读存活图失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(alive) != 1 || alive[0] != keep {
|
||||||
|
t.Fatalf("应只剩 keep 一行,实际 %v", alive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditorDeleteSnapImageRecomputesCount 街拍编辑页删图:口径 = 全部存活图片行(主图 + 副图)。
|
||||||
|
func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewStreetSnapRepository(db)
|
||||||
|
|
||||||
|
id := seedEditorSnap(t, db, 2)
|
||||||
|
main := seedEditorSnapImage(t, db, id, "main", 1, 0, 0)
|
||||||
|
detail := seedEditorSnapImage(t, db, id, "detail", 2, 1, main)
|
||||||
|
|
||||||
|
// 删主图:副图仍存活,街拍口径计全部存活行 → 应重算为 1。
|
||||||
|
// 若误用走秀的「只计主图」口径,会得 0 —— 本断言锁得住。
|
||||||
|
if err := repo.DeleteSnapImage(ctx, main); err != nil {
|
||||||
|
t.Fatalf("编辑页删图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 {
|
||||||
|
t.Fatalf("删主图后 image_count 应按存活行重算为 1(副图也计入),实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复删同一张(已软删):重放幂等,计数不变。
|
||||||
|
if err := repo.DeleteSnapImage(ctx, main); err != nil {
|
||||||
|
t.Fatalf("重复删同一张应幂等,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 {
|
||||||
|
t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删副图:全部删完 → 0。
|
||||||
|
if err := repo.DeleteSnapImage(ctx, detail); err != nil {
|
||||||
|
t.Fatalf("删副图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 0 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
internal/repository/entity_key_unique_integration_test.go
Normal file
180
internal/repository/entity_key_unique_integration_test.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。
|
||||||
|
//
|
||||||
|
// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」;
|
||||||
|
// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体,
|
||||||
|
// 从而达成「删除即永久」。本测试只验证索引这一层的行为。
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunwayEntityKeyUnique 同一实体键的第二行应被拒绝;软删后可以再插入。
|
||||||
|
func TestRunwayEntityKeyUnique(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")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const season = "SS95"
|
||||||
|
rw := newRunwayForIngest(1, 1, season, "rtw", "uniq-a.jpg")
|
||||||
|
id, err := repo.CreateRunwayWithImages(ctx, rw, newRunwayImages("uniq-a.jpg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("首行应能插入: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
dup := newRunwayForIngest(1, 1, season, "rtw", "uniq-b.jpg")
|
||||||
|
if _, err := repo.CreateRunwayWithImages(ctx, dup, newRunwayImages("uniq-b.jpg")); err == nil {
|
||||||
|
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删首行后,部分索引(WHERE is_deleted = 0)不再占用该键,DB 层仍允许再插入。
|
||||||
|
// 「删除即永久」由入库服务层(RunwayEntityState 命中 isDeleted 跳过重爬)保证,而非本索引。
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
||||||
|
t.Fatalf("软删失败: %v", err)
|
||||||
|
}
|
||||||
|
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
|
||||||
|
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", newID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetEntityKeyUnique 街拍实体键 = city + year + title(2026-09-22-04 细化后)。
|
||||||
|
//
|
||||||
|
// 为什么键里要有 title:同城同年可以有多个专题(开发库实例 London 2027 Day 2 / Day 3),
|
||||||
|
// title 是数据里唯一能区分它们的字段。因此:
|
||||||
|
// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性);
|
||||||
|
// ② 同城同年**不同标题**的第二行必须允许(对应「Day 2 / Day 3 分成两个文章」)。
|
||||||
|
func TestStreetEntityKeyUnique(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")
|
||||||
|
// 04 是替换索引:先 DROP 掉 03 建的 (city, year),再按 (city, year, title) 重建同名索引。
|
||||||
|
// 两个都执行才真实还原「迁移链走完」的库结构,也才能验证旧键确实不再生效。
|
||||||
|
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const city = "UniqTestCity"
|
||||||
|
const year = 1994
|
||||||
|
const titleDay2 = "UniqTest Spring 1994 Day 2"
|
||||||
|
const titleDay3 = "UniqTest Spring 1994 Day 3"
|
||||||
|
|
||||||
|
snap := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
|
||||||
|
id, err := repo.CreateStreetSnapWithImages(ctx, snap, []model.StreetSnapImage{{Image: "uniq-a.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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ① 同城同年同标题:实体键命中,唯一索引必须拒绝。
|
||||||
|
dup := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
|
||||||
|
if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil {
|
||||||
|
t.Fatalf("同城同年同标题的第二行应被唯一索引拒绝,实际插入成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 同城同年不同标题:不同专题,必须允许共存。
|
||||||
|
// 这一条是本任务的核心:若还原成 (city, year) 的旧键,这里会插入失败。
|
||||||
|
other := &model.StreetSnap{JobID: 1, Title: titleDay3, Year: year, City: city, Status: model.StatusPending}
|
||||||
|
otherID, err := repo.CreateStreetSnapWithImages(ctx, other, []model.StreetSnapImage{{Image: "uniq-c.jpg", SortOrder: 1}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("同城同年不同标题应可插入,实际失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", otherID)
|
||||||
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", otherID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
|
||||||
|
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
||||||
|
t.Fatalf("软删失败: %v", err)
|
||||||
|
}
|
||||||
|
again := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
|
||||||
|
newID, err := repo.CreateStreetSnapWithImages(ctx, again, []model.StreetSnapImage{{Image: "uniq-d.jpg", SortOrder: 1}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("软删后应可再插入同实体键: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", newID)
|
||||||
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", newID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetEntityStateMatchesIndexKey 覆盖 StreetSnapEntityState 的查重口径:
|
||||||
|
// 它必须与唯一索引 uq_ss_entity 的表达式 COALESCE(title, '') 完全一致,
|
||||||
|
// 否则查重会比索引更严(漏判 → 写入注定被索引拒绝的行)或更松(重复内容被当成新实体)。
|
||||||
|
func TestStreetEntityStateMatchesIndexKey(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()
|
||||||
|
|
||||||
|
const city = "EntityStateTestCity"
|
||||||
|
const year = 1996
|
||||||
|
|
||||||
|
// 种子行:title = Day 2(经 ORM 写入,title 为普通空串语义)。
|
||||||
|
snap := &model.StreetSnap{JobID: 1, Title: "EntityState Day 2", Year: year, City: city, Status: model.StatusPending}
|
||||||
|
id, err := repo.CreateStreetSnapWithImages(ctx, snap, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("种子行应能插入: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
|
||||||
|
|
||||||
|
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
|
||||||
|
if _, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
|
||||||
|
t.Fatalf("查重出错: %v", err)
|
||||||
|
} else if found {
|
||||||
|
t.Fatalf("不同 title 不应命中既有行,实际命中")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
|
||||||
|
hitID, status, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查重出错: %v", err)
|
||||||
|
}
|
||||||
|
if !found || hitID != id || status != model.StatusPending {
|
||||||
|
t.Fatalf("同 title 应命中 id=%d status=pending,实际 found=%v id=%d status=%q", id, found, hitID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ title 为 NULL 的历史行 + 传入空串 title → 必须命中。
|
||||||
|
// 这是 COALESCE(title, '') 与裸 title = ? 唯一的分歧点:裸写法下 NULL 永不等于空串,
|
||||||
|
// 这里会误判「未命中」并插入一行与 NULL 行同键(COALESCE 后同为 '')的记录,被索引拒绝。
|
||||||
|
// 用原生 SQL 插入,因为 GORM 的 string 字段会写空串而非 NULL。
|
||||||
|
var nullID uint32
|
||||||
|
if err := db.Raw(
|
||||||
|
"INSERT INTO street_snaps (title, year, city, is_deleted, status, created_at, updated_at) "+
|
||||||
|
"VALUES (NULL, ?, ?, 0, ?, 0, 0) RETURNING id",
|
||||||
|
year, city, model.StatusPending,
|
||||||
|
).Scan(&nullID).Error; err != nil {
|
||||||
|
t.Fatalf("插入 NULL title 行失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
|
||||||
|
|
||||||
|
nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("查重出错: %v", err)
|
||||||
|
}
|
||||||
|
if !nfound || nid != nullID {
|
||||||
|
t.Fatalf("NULL title 行 + 空串应命中 id=%d,实际 found=%v id=%d", nullID, nfound, nid)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/repository/image_id_stability_integration_test.go
Normal file
140
internal/repository/image_id_stability_integration_test.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:单表发布模型的头号收益 —— 审核通过不再重建图片,**图片行 id 终身不变**。
|
||||||
|
//
|
||||||
|
// 旧流程(草稿表 → 晋升)会在通过时把草稿图片重新插入正式图片表,图片行 id 必然改变;
|
||||||
|
// 任何按 id 引用的下游(CDN 缓存键、分享链接、埋点)都会随之失效。改造把「通过」降级为
|
||||||
|
// 只改 status,本文件用「id 集合一字不差」把这条收益钉死。
|
||||||
|
//
|
||||||
|
// 为什么现有测试看不到它:
|
||||||
|
// - router/backstage_test.go 的桩只断言 op == "approve",根本看不到图片行 id;
|
||||||
|
// - 公开视图测试都是直接 Update("status"),绕过了 SetRecordStatus / SetStreetRecordStatus。
|
||||||
|
//
|
||||||
|
// 反证(本断言为何能区分「重建图片」的假实现):若把 SetRecordStatus 换成任何会重建图片的实现
|
||||||
|
// (先删旧图再插入、或从别处拷贝重建),通过前记下的 id 与通过后必然不同(旧 id 消失、出现新 id),
|
||||||
|
// 本断言立即变红。断言只比对「同一记录存活图片行的 id 序列」,不依赖计数或其它副作用,
|
||||||
|
// 因此不会被与重建无关的改动误伤,也不会因无关改动静默通过。
|
||||||
|
//
|
||||||
|
// 运行:go test -tags integration ./internal/repository/ -run TestApproveKeepsImageRowIDs -v
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sameUint32IDs 逐元素比较两个 id 序列(长度 + 顺序)。
|
||||||
|
func sameUint32IDs(a, b []uint32) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApproveKeepsImageRowIDs 走秀:pending 记录(3 张图)经 SetRecordStatus(published) 通过后,
|
||||||
|
// status 变为 published,且图片行 id 集合与通过前完全一致。
|
||||||
|
func TestApproveKeepsImageRowIDs(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewReviewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := seedRunway(t, db, model.StatusPending, 1992) // seedRunway 已插 1 张主图
|
||||||
|
now := uint32(1_800_000_000)
|
||||||
|
for i, name := range []string{"second", "third"} {
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: id, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: uint32(i + 2), CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀图片失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageIDs := func() []uint32 {
|
||||||
|
var ids []uint32
|
||||||
|
if err := db.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ?", id).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||||
|
t.Fatalf("读图片行 id 失败: %v", err)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
before := imageIDs()
|
||||||
|
if len(before) != 3 {
|
||||||
|
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
|
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if rw.Status != model.StatusPublished {
|
||||||
|
t.Fatalf("status 应为 published,实际 %s", rw.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := imageIDs(); !sameUint32IDs(before, after) {
|
||||||
|
t.Fatalf("通过审核不得重建图片:图片行 id 应完全一致,通过前 %v,通过后 %v", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApproveKeepsSnapImageRowIDs 街拍:与走秀同形,覆盖另一组记录/图片表与方法。
|
||||||
|
func TestApproveKeepsSnapImageRowIDs(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewReviewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := seedStreetSnap(t, db, model.StatusPending, 1986) // 已插 1 张图
|
||||||
|
now := uint32(1_800_000_000)
|
||||||
|
for i, name := range []string{"second", "third"} {
|
||||||
|
img := model.StreetSnapImage{
|
||||||
|
SnapID: id, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: uint32(i + 2), CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍图片失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imageIDs := func() []uint32 {
|
||||||
|
var ids []uint32
|
||||||
|
if err := db.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("snap_id = ?", id).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||||
|
t.Fatalf("读图片行 id 失败: %v", err)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
before := imageIDs()
|
||||||
|
if len(before) != 3 {
|
||||||
|
t.Fatalf("前置应恰有 3 行图片,实际 %d", len(before))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
|
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var snap model.StreetSnap
|
||||||
|
if err := db.Where("id = ?", id).First(&snap).Error; err != nil {
|
||||||
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if snap.Status != model.StatusPublished {
|
||||||
|
t.Fatalf("status 应为 published,实际 %s", snap.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := imageIDs(); !sameUint32IDs(before, after) {
|
||||||
|
t.Fatalf("通过审核不得重建图片:图片行 id 应完全一致,通过前 %v,通过后 %v", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,9 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
@ -14,9 +16,10 @@ import (
|
|||||||
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
||||||
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
|
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
|
||||||
//
|
//
|
||||||
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
|
// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year + title
|
||||||
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
|
// (同城同年可有多个专题,title 是数据里区分专题的字段)。
|
||||||
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
|
// 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支:
|
||||||
|
// pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。
|
||||||
//
|
//
|
||||||
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
|
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
|
||||||
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
||||||
@ -32,19 +35,34 @@ 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)
|
||||||
// RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表;
|
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,
|
||||||
// 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。
|
// 返回其状态与是否已被软删(isDeleted)。worker 据此分四支:
|
||||||
RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
// - 命中 pending/published → 直接放弃本次入库(ingestSkip)
|
||||||
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
|
// - 命中 rejected → 复用该行重审(ingestReuseRejected)
|
||||||
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
|
// - 命中但已软删 → 跳过重爬(删除即永久,不再因重爬复活)
|
||||||
// CreateRunwayDraftImages 批量插入草稿图片行。
|
// - 未命中 → 新建(ingestCreate)
|
||||||
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
|
// 注意:本查询不再以 is_deleted = 0 为过滤条件(旧版口径),否则软删行会被当成「未命中」
|
||||||
// StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。
|
// 而重新入库,违背「删除即永久」。优先取活着行(ORDER BY is_deleted ASC, id DESC)。
|
||||||
StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error)
|
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, isDeleted bool, err error)
|
||||||
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
|
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
|
||||||
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
|
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
|
||||||
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
|
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
||||||
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
|
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
||||||
|
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
||||||
|
// BackfillRunwayPublishedAt 临时回填:给已存在走秀补 published_at,并在标题缺年份时
|
||||||
|
// 用发布日期年份补全 year / season_code(不重下图片)。回填为一次性操作,按主键 id 更新。
|
||||||
|
// publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
|
||||||
|
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
|
||||||
|
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
|
||||||
|
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
|
||||||
|
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
|
||||||
|
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
|
||||||
|
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||||
|
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, isDeleted bool, err error)
|
||||||
|
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
|
||||||
|
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
|
||||||
|
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
|
||||||
|
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error
|
||||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
|
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
|
||||||
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
|
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
|
||||||
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||||
@ -239,94 +257,277 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。
|
// ---- 入库直写正式表(单表发布模型)----
|
||||||
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, 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"`
|
||||||
|
IsDeleted uint8 `gorm:"column:is_deleted"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunway{}).
|
Model(&model.BrandRunway{}).
|
||||||
Select("id").
|
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, true, nil
|
return row.ID, row.Status, true, row.IsDeleted == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
|
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
d.CreatedAt = now
|
rw.CreatedAt, rw.UpdatedAt = now, now
|
||||||
d.UpdatedAt = now
|
if rw.Status == "" {
|
||||||
if d.Status == "" {
|
rw.Status = model.StatusPending
|
||||||
d.Status = model.DraftStatusPending
|
|
||||||
}
|
}
|
||||||
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if cErr := tx.Create(rw).Error; cErr != nil {
|
||||||
|
return cErr
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range imgs {
|
||||||
|
imgs[i].RunwayID = rw.ID
|
||||||
|
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
|
||||||
|
}
|
||||||
|
return tx.Create(&imgs).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return d.ID, nil
|
return rw.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error {
|
func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error {
|
||||||
if len(imgs) == 0 {
|
now := uint32(time.Now().Unix())
|
||||||
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// WHERE 带 status=rejected 守卫:状态是读阶段拿到的,而写库发生在整批图片下载/上传之后
|
||||||
|
// (可能数十秒窗口)。若无条件覆盖,期间已被改成 published 的行会被置回 pending,
|
||||||
|
// 等于把已发布内容从公开视图上撤下。
|
||||||
|
upd := tx.Model(&model.BrandRunway{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected)
|
||||||
|
if uErr := upd.Updates(map[string]any{
|
||||||
|
"title_en": rw.TitleEn,
|
||||||
|
"title_cn": rw.TitleCn,
|
||||||
|
"description_en": rw.DescriptionEn,
|
||||||
|
"description_cn": rw.DescriptionCn,
|
||||||
|
"year": rw.Year,
|
||||||
|
"season": rw.Season,
|
||||||
|
"collection_type": rw.CollectionType,
|
||||||
|
"season_code": rw.SeasonCode,
|
||||||
|
"cover": rw.Cover,
|
||||||
|
"image_count": rw.ImageCount,
|
||||||
|
"job_id": rw.JobID,
|
||||||
|
"status": model.StatusPending,
|
||||||
|
"reviewer": "",
|
||||||
|
"reject_reason": "",
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; uErr != nil {
|
||||||
|
return uErr
|
||||||
|
}
|
||||||
|
if upd.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("runway %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id)
|
||||||
|
}
|
||||||
|
if dErr := tx.Model(&model.BrandRunwayImage{}).
|
||||||
|
Where("runway_id = ? AND is_deleted = 0", id).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
|
||||||
|
return dErr
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range imgs {
|
||||||
|
imgs[i].RunwayID = id
|
||||||
|
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
|
||||||
|
}
|
||||||
|
return tx.Create(&imgs).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
now := uint32(time.Now().Unix())
|
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
|
||||||
for i := range imgs {
|
return r.db.WithContext(ctx).
|
||||||
imgs[i].CreatedAt = now
|
Model(&model.BrandRunway{}).
|
||||||
imgs[i].UpdatedAt = now
|
Where("id = ? AND year = 0 AND is_deleted = 0 "+
|
||||||
}
|
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
|
||||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。
|
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
|
||||||
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
||||||
|
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
|
||||||
|
// 进而插入一行注定被索引拒绝的记录。
|
||||||
|
//
|
||||||
|
// 入参 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"`
|
||||||
|
IsDeleted uint8 `gorm:"column:is_deleted"`
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
Select("id").
|
Select("id, status, is_deleted").
|
||||||
Where("city = ? AND year = ? AND is_deleted = 0", city, year).
|
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, true, nil
|
return row.ID, row.Status, true, row.IsDeleted == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
|
// 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) {
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
d.CreatedAt = now
|
snap.CreatedAt, snap.UpdatedAt = now, now
|
||||||
d.UpdatedAt = now
|
if snap.Status == "" {
|
||||||
if d.Status == "" {
|
snap.Status = model.StatusPending
|
||||||
d.Status = model.DraftStatusPending
|
|
||||||
}
|
}
|
||||||
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if cErr := tx.Create(snap).Error; cErr != nil {
|
||||||
|
return cErr
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range imgs {
|
||||||
|
imgs[i].SnapID = snap.ID
|
||||||
|
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
|
||||||
|
}
|
||||||
|
return tx.Create(&imgs).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return d.ID, nil
|
return snap.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error {
|
func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error {
|
||||||
if len(imgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
for i := range imgs {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
imgs[i].CreatedAt = now
|
// 同 runway:带 status=rejected 守卫 + 行数校验,避免把期间已发布的记录撤下公开视图。
|
||||||
imgs[i].UpdatedAt = now
|
upd := tx.Model(&model.StreetSnap{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected)
|
||||||
}
|
if uErr := upd.Updates(map[string]any{
|
||||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
"title": snap.Title,
|
||||||
|
"year": snap.Year,
|
||||||
|
"city": snap.City,
|
||||||
|
"cover": snap.Cover,
|
||||||
|
"image_count": snap.ImageCount,
|
||||||
|
"job_id": snap.JobID,
|
||||||
|
"status": model.StatusPending,
|
||||||
|
"reviewer": "",
|
||||||
|
"reject_reason": "",
|
||||||
|
"updated_at": now,
|
||||||
|
}).Error; uErr != nil {
|
||||||
|
return uErr
|
||||||
|
}
|
||||||
|
if upd.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("street snap %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id)
|
||||||
|
}
|
||||||
|
if dErr := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("snap_id = ? AND is_deleted = 0", id).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
|
||||||
|
return dErr
|
||||||
|
}
|
||||||
|
if len(imgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := range imgs {
|
||||||
|
imgs[i].SnapID = id
|
||||||
|
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
|
||||||
|
}
|
||||||
|
return tx.Create(&imgs).Error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。
|
// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。
|
||||||
@ -369,11 +570,14 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
|
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
|
||||||
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
||||||
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
|
||||||
|
//
|
||||||
|
// 只比对未软删的行(is_deleted = 0):已被软删(审核剔除 / 复用重审)的旧图不应再作为
|
||||||
|
// 「重复」的参照。注意复用驳回行的时序 —— 去重发生在软删旧图**之前**,那一刻旧行仍是
|
||||||
|
// is_deleted = 0、会被正常命中;dup_of 只是人工留痕,不承担可见性语义。
|
||||||
|
// 本方法被 runway / street 两条入库路径共用,过滤对两者语义一致。
|
||||||
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||||
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
||||||
l2Limit := math.Sqrt(float64(threshold))
|
l2Limit := math.Sqrt(float64(threshold))
|
||||||
@ -385,6 +589,7 @@ func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []
|
|||||||
err := r.db.WithContext(ctx).Table(t).
|
err := r.db.WithContext(ctx).Table(t).
|
||||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||||
|
Where("is_deleted = 0").
|
||||||
Order("dist ASC").
|
Order("dist ASC").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(&row).Error
|
Scan(&row).Error
|
||||||
|
|||||||
260
internal/repository/ingest_single_table_integration_test.go
Normal file
260
internal/repository/ingest_single_table_integration_test.go
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:入库直写正式表的三条分支(新建 / 放弃 / 复用驳回行)。
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/phash"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway {
|
||||||
|
return &model.BrandRunway{
|
||||||
|
JobID: jobID, BrandID: brandID, TitleEn: "ingest-test",
|
||||||
|
SeasonCode: seasonCode, CollectionType: collectionType,
|
||||||
|
Cover: cover, ImageCount: 1, Status: model.StatusPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunwayImages(cover string) []model.BrandRunwayImage {
|
||||||
|
return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
|
||||||
|
func TestIngestCreatesPendingRecord(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const season = "SS97"
|
||||||
|
_, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunwayEntityState 出错: %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatalf("测试前置:实体键 %s 不应已存在", season)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(101, 1, season, "rtw", "ingest-ss97.jpg"), newRunwayImages("ingest-ss97.jpg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRunwayWithImages 出错: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if rw.Status != model.StatusPending {
|
||||||
|
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotID, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunwayEntityState 出错: %v", err)
|
||||||
|
}
|
||||||
|
if !found || gotID != id || status != model.StatusPending {
|
||||||
|
t.Fatalf("实体键应命中刚建的行,实际 found=%v id=%d status=%s", found, gotID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicCount := int64(0)
|
||||||
|
db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount)
|
||||||
|
if publicCount != 0 {
|
||||||
|
t.Fatalf("pending 记录不应出现在公开视图,实际 %d", publicCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIngestReusesRejectedRecord 复用分支:实体键命中 rejected → 覆盖内容、软删旧图、写新图、置回 pending。
|
||||||
|
func TestIngestReusesRejectedRecord(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const season = "FW96"
|
||||||
|
oldID, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(90, 1, season, "rtw", "old.jpg"), newRunwayImages("old.jpg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("预置记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", oldID)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
|
||||||
|
})
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
|
||||||
|
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin"}).Error; err != nil {
|
||||||
|
t.Fatalf("置为已驳回失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
|
||||||
|
if err != nil || !found || status != model.StatusRejected {
|
||||||
|
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newRW := newRunwayForIngest(91, 1, season, "rtw", "new.jpg")
|
||||||
|
newRW.TitleEn = "ingest-test-reused"
|
||||||
|
if err := repo.ReuseRejectedRunway(ctx, oldID, newRW, newRunwayImages("new.jpg")); err != nil {
|
||||||
|
t.Fatalf("ReuseRejectedRunway 出错: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := db.Where("id = ?", oldID).First(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if rw.Status != model.StatusPending {
|
||||||
|
t.Fatalf("复用后应回到 pending,实际 %s", rw.Status)
|
||||||
|
}
|
||||||
|
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
|
||||||
|
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
|
||||||
|
}
|
||||||
|
if rw.Reviewer != "" {
|
||||||
|
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alive []model.BrandRunwayImage
|
||||||
|
if err := db.Where("runway_id = ? AND is_deleted = 0", oldID).Find(&alive).Error; err != nil {
|
||||||
|
t.Fatalf("读图片失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(alive) != 1 || alive[0].Image != "new.jpg" {
|
||||||
|
t.Fatalf("旧图应被软删、只留新图,实际 %d 张", len(alive))
|
||||||
|
}
|
||||||
|
var oldAlive int64
|
||||||
|
db.Model(&model.BrandRunwayImage{}).Where("runway_id = ? AND image = ? AND is_deleted = 0", oldID, "old.jpg").Count(&oldAlive)
|
||||||
|
if oldAlive != 0 {
|
||||||
|
t.Fatalf("旧图应被软删")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIngestReuseRejectedRefusesPublished 复用守卫:实体键状态是读阶段拿到的,而写库发生在整批
|
||||||
|
// 图片下载/上传之后(可能数十秒窗口)。若该行在这段窗口内已被改成 published,复用必须失败,
|
||||||
|
// 绝不能把它置回 pending——那等于把已发布内容从公开视图上撤下。
|
||||||
|
func TestIngestReuseRejectedRefusesPublished(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const season = "SS95"
|
||||||
|
id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(80, 1, season, "rtw", "pub.jpg"), newRunwayImages("pub.jpg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("预置记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
|
||||||
|
})
|
||||||
|
// 模拟「读阶段之后、写库之前」该行被审核通过。
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).
|
||||||
|
Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newRW := newRunwayForIngest(81, 1, season, "rtw", "pub2.jpg")
|
||||||
|
newRW.TitleEn = "ingest-test-should-not-apply"
|
||||||
|
if err := repo.ReuseRejectedRunway(ctx, id, newRW, newRunwayImages("pub2.jpg")); err == nil {
|
||||||
|
t.Fatalf("对已发布行复用应报错,实际返回 nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if rw.Status != model.StatusPublished {
|
||||||
|
t.Fatalf("已发布行不得被复用改回 pending,实际 %s", rw.Status)
|
||||||
|
}
|
||||||
|
if rw.TitleEn != "ingest-test" || rw.Cover != "pub.jpg" {
|
||||||
|
t.Fatalf("复用失败时不应覆盖内容,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicCount := int64(0)
|
||||||
|
db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount)
|
||||||
|
if publicCount != 1 {
|
||||||
|
t.Fatalf("已发布行应仍在公开视图,实际 %d", publicCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDedupIgnoresSoftDeletedImage 去重只比对**未软删**的行(is_deleted = 0)。
|
||||||
|
//
|
||||||
|
// 别把它误读成「复用驳回行时不会自我命中」:复用路径里去重发生在软删旧图**之前**,那一刻
|
||||||
|
// 旧行仍是 is_deleted = 0、仍会被命中——dup_of 只是给人工看的留痕,不参与任何可见性判定。
|
||||||
|
// 本用例锁的是另一条独立保证:**已经**软删的行不再被当作重复参照。
|
||||||
|
//
|
||||||
|
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证它若被纳入就必然是「最近一条」,
|
||||||
|
// 从而确定性地验证 is_deleted = 0 过滤生效,与库中其它数据无关。
|
||||||
|
func TestDedupIgnoresSoftDeletedImage(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bits := phash.ToVectorBits(^uint64(0))
|
||||||
|
now := uint32(time.Now().Unix())
|
||||||
|
row := model.BrandRunwayImage{
|
||||||
|
RunwayID: 1, BrandID: 1, Image: "ingest/soft-deleted.jpg", Name: "look",
|
||||||
|
SortOrder: 1, IsDeleted: 1, CreatedAt: now, UpdatedAt: now,
|
||||||
|
Phash: sql.NullString{String: bits, Valid: true},
|
||||||
|
}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("插入软删图片失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Where("id = ?", row.ID).Delete(&model.BrandRunwayImage{}) })
|
||||||
|
|
||||||
|
dupID, found, err := repo.FindNearDuplicateImage(ctx, []string{"brand_runway_images"}, bits, phash.DefaultThreshold)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
|
||||||
|
}
|
||||||
|
if found && dupID == row.ID {
|
||||||
|
t.Fatalf("已软删的行(id=%d)不应参与近重复比对", row.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
|
||||||
|
//
|
||||||
|
// 与 service.processRunway 的 imageCount = countMainImages(images) 同口径,且与审核期
|
||||||
|
// SoftDeleteRunwayImage 的重算口径一致:三处必须同源,否则公开卡片「N 张」角标与
|
||||||
|
// FeaturedIDs 的 SUM(image_count) 热度会漂移(单表发布删掉了晋升重算的自愈路径)。
|
||||||
|
func TestIngestImageCountMatchesAliveMainRows(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewIngestRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const season = "IC71"
|
||||||
|
rw := newRunwayForIngest(70, 1, season, "rtw", "ic-main-a.jpg")
|
||||||
|
rw.ImageCount = 2 // 2 张主图
|
||||||
|
imgs := []model.BrandRunwayImage{
|
||||||
|
{BrandID: 1, Image: "ic-main-a.jpg", Name: "Look 1", SortOrder: 1, IsDetail: 0},
|
||||||
|
{BrandID: 1, Image: "ic-detail-a1.jpg", Name: "Look 1 — Detail 1", SortOrder: 2, IsDetail: 1},
|
||||||
|
{BrandID: 1, Image: "ic-main-b.jpg", Name: "Look 2", SortOrder: 3, IsDetail: 0},
|
||||||
|
}
|
||||||
|
id, err := repo.CreateRunwayWithImages(ctx, rw, imgs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRunwayWithImages 出错: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
var got struct {
|
||||||
|
ImageCount uint16 `gorm:"column:image_count"`
|
||||||
|
AliveMain int64 `gorm:"column:alive_main"`
|
||||||
|
}
|
||||||
|
if err := db.Raw(`SELECT r.image_count,
|
||||||
|
(SELECT COUNT(*) FROM brand_runway_images i
|
||||||
|
WHERE i.runway_id = r.id AND i.is_deleted = 0 AND i.is_detail = 0) AS alive_main
|
||||||
|
FROM brand_runways r WHERE r.id = ?`, id).Scan(&got).Error; err != nil {
|
||||||
|
t.Fatalf("读回计数失败: %v", err)
|
||||||
|
}
|
||||||
|
if int64(got.ImageCount) != got.AliveMain {
|
||||||
|
t.Fatalf("入库后 image_count=%d 必须等于存活主图行数 %d", got.ImageCount, got.AliveMain)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,16 +26,14 @@ func NewMediaRepository(db *gorm.DB) MediaRepository {
|
|||||||
return &mediaRepository{db: db}
|
return &mediaRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageTables 保存S4 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
|
// imageTables 保存S4 key 的全部图片明细表(单表发布模型下即两张正式图片表)。
|
||||||
// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
// 引用计数必须覆盖全部图片表,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
||||||
var imageTables = []string{
|
var imageTables = []string{
|
||||||
"brand_runway_images",
|
"brand_runway_images",
|
||||||
"brand_runway_draft_images",
|
|
||||||
"street_snap_images",
|
"street_snap_images",
|
||||||
"street_snap_draft_images",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountImageKeyRefs 跨 4 张表统计 key 的存活引用数(is_deleted=0)。
|
// CountImageKeyRefs 跨全部图片明细表统计 key 的存活引用数(is_deleted=0)。
|
||||||
func (r *mediaRepository) CountImageKeyRefs(ctx context.Context, key string) (int, error) {
|
func (r *mediaRepository) CountImageKeyRefs(ctx context.Context, key string) (int, error) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
201
internal/repository/public_read_integration_test.go
Normal file
201
internal/repository/public_read_integration_test.go
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:公开仓储方法必须读视图(读不到 pending),后台方法必须读基表(读得到 pending)。
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPublicReadHidesUnpublishedRunway 走秀公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
|
||||||
|
func TestPublicReadHidesUnpublishedRunway(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewArticleRepository(db)
|
||||||
|
|
||||||
|
const year = 1997
|
||||||
|
id := seedRunway(t, db, model.StatusPending, year)
|
||||||
|
idStr := strconv.FormatUint(uint64(id), 10)
|
||||||
|
|
||||||
|
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("pending 走秀详情应 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
imgs, err := repo.ListImages(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListImages 出错: %v", err)
|
||||||
|
}
|
||||||
|
if len(imgs) != 0 {
|
||||||
|
t.Fatalf("pending 走秀的图片不应公开,实际 %d 张", len(imgs))
|
||||||
|
}
|
||||||
|
_, total, err := repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("pending 走秀不应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台读基表:必须能看到这条待审记录与其图片,否则审核页是空的。
|
||||||
|
rw, baseImgs, err := repo.GetForEdit(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if rw == nil || len(baseImgs) == 0 {
|
||||||
|
t.Fatalf("后台应读到 pending 行与其图片,实际 rw=%v imgs=%d", rw != nil, len(baseImgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台详情通路:FindByIDBase 也必须读得到 pending(后台详情页据此渲染;
|
||||||
|
// 若它读视图,后台列表里的待审行点进去就 404)。
|
||||||
|
baseRow, err := repo.FindByIDBase(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if baseRow == nil || baseRow.ID != id {
|
||||||
|
t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 置为已发布后,公开侧才可见。
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.FindByID(ctx, idStr); err != nil {
|
||||||
|
t.Fatalf("已发布走秀详情应可读: %v", err)
|
||||||
|
}
|
||||||
|
_, total, err = repo.List(ctx, dto.ArticleQuery{Year: year, Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Fatalf("已发布走秀应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicReadHidesUnpublishedStreet 街拍公开列表/详情/图片看不到 pending;后台 GetForEdit 看得到。
|
||||||
|
func TestPublicReadHidesUnpublishedStreet(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewStreetSnapRepository(db)
|
||||||
|
|
||||||
|
const year = 1996
|
||||||
|
id := seedStreetSnap(t, db, model.StatusPending, year)
|
||||||
|
idStr := strconv.FormatUint(uint64(id), 10)
|
||||||
|
|
||||||
|
if _, err := repo.FindByID(ctx, idStr); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("pending 街拍详情应 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
imgs, err := repo.ListImages(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListImages 出错: %v", err)
|
||||||
|
}
|
||||||
|
if len(imgs) != 0 {
|
||||||
|
t.Fatalf("pending 街拍的图片不应公开,实际 %d 张", len(imgs))
|
||||||
|
}
|
||||||
|
_, total, err := repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("pending 街拍不应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, baseImgs, err := repo.GetForEdit(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 GetForEdit 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if snap == nil || len(baseImgs) == 0 {
|
||||||
|
t.Fatalf("后台应读到 pending 行与其图片,实际 snap=%v imgs=%d", snap != nil, len(baseImgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台列表 ListAdmin 读基表:必须能看到这条待审记录,否则审核员无从下手。
|
||||||
|
_, adminTotal, err := repo.ListAdmin(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAdmin 出错: %v", err)
|
||||||
|
}
|
||||||
|
if adminTotal != 1 {
|
||||||
|
t.Fatalf("后台列表应看到 pending 街拍,实际 total=%d", adminTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台详情通路:FindByIDBase 也必须读得到 pending(否则后台详情页 404)。
|
||||||
|
baseSnap, err := repo.FindByIDBase(ctx, idStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 FindByIDBase 应读到 pending 行: %v", err)
|
||||||
|
}
|
||||||
|
if baseSnap == nil || baseSnap.ID != id {
|
||||||
|
t.Fatalf("后台 FindByIDBase 返回不符: %+v", baseSnap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
_, total, err = repo.List(ctx, dto.StreetSnapQuery{City: "ViewTestCity", Size: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if total != 1 {
|
||||||
|
t.Fatalf("已发布街拍应出现在公开列表,实际 total=%d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasBrandID 判断品牌列表里是否含指定 id(按 id 判定,避免受库里既有同名/同首字母品牌干扰)。
|
||||||
|
func hasBrandID(items []model.Brand, id uint32) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBrandIndexSplitsPublicAndAdmin 品牌 A-Z 索引的公开/后台分野:
|
||||||
|
// 只有待审走秀的品牌不得出现在公开索引(否则未发布内容从索引泄露),
|
||||||
|
// 但必须出现在后台列表(否则该品牌从后台「消失」,审核员无从下手)。
|
||||||
|
func TestBrandIndexSplitsPublicAndAdmin(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := NewBrandRepository(db)
|
||||||
|
|
||||||
|
now := uint32(1_800_000_000)
|
||||||
|
brand := model.Brand{NameEn: "ViewTestBrand", NameCn: "视图测试品牌", CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(&brand).Error; err != nil {
|
||||||
|
t.Fatalf("插入品牌失败: %v", err)
|
||||||
|
}
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
BrandID: brand.ID, TitleEn: "view-test-brand", Year: 1995,
|
||||||
|
Status: model.StatusPending, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||||||
|
db.Exec("DELETE FROM brands WHERE id = ?", brand.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 字母桶 V + 大页,确保命中「必须有走秀档案」的子查询分支、且不会被分页截断。
|
||||||
|
q := dto.BrandQuery{Letter: "V", Page: 1, Size: 400}
|
||||||
|
|
||||||
|
pubItems, _, err := repo.List(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("公开 List 出错: %v", err)
|
||||||
|
}
|
||||||
|
if hasBrandID(pubItems, brand.ID) {
|
||||||
|
t.Fatalf("只有待审走秀的品牌不应出现在公开索引,实际出现在结果里(id=%d)", brand.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
admItems, _, err := repo.ListAdmin(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("后台 ListAdmin 出错: %v", err)
|
||||||
|
}
|
||||||
|
if !hasBrandID(admItems, brand.ID) {
|
||||||
|
t.Fatalf("后台列表应看到只有待审走秀的品牌(id=%d)", brand.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/repository/public_views.go
Normal file
19
internal/repository/public_views.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
// 公开只读视图的表名。
|
||||||
|
//
|
||||||
|
// 把「已发布且未删除」的条件固化在视图里(见 db/migrations/2026-09-22-01-single-table-publish.sql),
|
||||||
|
// 因此公开查询**不可能**漏写可见性过滤 —— 这是单表发布模型相对「每处记得加 status」的核心优势。
|
||||||
|
//
|
||||||
|
// publicRunways 带 `AS brand_runways` 别名:article_repository 里既有的查询大量使用
|
||||||
|
// `brand_runways.xxx` 限定列与 JOIN 条件,起同名别名后这些 SQL 一行都不用改。
|
||||||
|
//
|
||||||
|
// publicStreetSnaps 同样带 `AS street_snaps` 别名:street_snaps 的排序子句 snapOrderBy
|
||||||
|
// 使用 `street_snaps.xxx` 限定列,不带别名会报 missing FROM-clause entry。
|
||||||
|
// 后台读基表时**不要**使用这些常量。
|
||||||
|
const (
|
||||||
|
publicRunways = "public_brand_runways AS brand_runways"
|
||||||
|
publicRunwayImages = "public_brand_runway_images"
|
||||||
|
publicStreetSnaps = "public_street_snaps AS street_snaps"
|
||||||
|
publicStreetSnapImages = "public_street_snap_images"
|
||||||
|
)
|
||||||
140
internal/repository/publish_status_integration_test.go
Normal file
140
internal/repository/publish_status_integration_test.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:公开视图必须屏蔽未发布内容 —— 这是「单表发布模型」的核心保证。
|
||||||
|
//
|
||||||
|
// 运行(库需先就绪,见 internal/repository/dedup_integration_test.go 顶部说明):
|
||||||
|
//
|
||||||
|
// go test -tags integration ./internal/repository/ -run TestPublicViews -v
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyMigration 读取并执行 db/migrations 下的迁移文件。
|
||||||
|
// 迁移 SQL 因此每次测试都被真实跑一遍:语法错、视图列集漂移会立刻暴露。
|
||||||
|
// 迁移文件必须幂等,重复调用安全。
|
||||||
|
func applyMigration(t *testing.T, db *gorm.DB, name string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join("..", "..", "db", "migrations", name)
|
||||||
|
sqlBytes, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取迁移失败 %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if err := db.Exec(string(sqlBytes)).Error; err != nil {
|
||||||
|
t.Fatalf("执行迁移失败 %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedRunway 插入一条指定状态的走秀记录 + 一张主图,返回记录 id。
|
||||||
|
// year 用测试专用值(入参),便于列表按年份隔离断言;created_at 取未来时间戳,
|
||||||
|
// 保证不会被迁移里「存量行置已发布」的时间戳护栏误刷。
|
||||||
|
func seedRunway(t *testing.T, db *gorm.DB, status string, year uint16) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
now := uint32(1_800_000_000)
|
||||||
|
// 实体键必须非空且按 year 派生:2026-09-22-03 的唯一索引落库后,brand_id=0 且
|
||||||
|
// season_code / collection_type 均为空会让同一次运行里的多条种子行(乃至上次运行
|
||||||
|
// 未清理的遗留行)被当成「同一实体」而互相冲突,失败点还会伪装成迁移体检。
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
TitleEn: "view-test", Year: year, Status: status, CreatedAt: now, UpdatedAt: now,
|
||||||
|
SeasonCode: fmt.Sprintf("T%04d", year), CollectionType: "t",
|
||||||
|
}
|
||||||
|
if err := db.Create(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀失败: %v", err)
|
||||||
|
}
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: rw.ID, BrandID: 1, Image: "view-test.jpg", Name: "look",
|
||||||
|
SortOrder: 1, CreatedAt: now, UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀图片失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||||||
|
})
|
||||||
|
return rw.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedStreetSnap 插入一条指定状态的街拍记录 + 一张图,返回记录 id。
|
||||||
|
func seedStreetSnap(t *testing.T, db *gorm.DB, status string, year uint16) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
now := uint32(1_800_000_000)
|
||||||
|
snap := model.StreetSnap{Title: "view-test", City: "ViewTestCity", Year: year, Status: status, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(&snap).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍失败: %v", err)
|
||||||
|
}
|
||||||
|
img := model.StreetSnapImage{SnapID: snap.ID, Image: "view-test.jpg", Name: "look", SortOrder: 1, CreatedAt: now, UpdatedAt: now}
|
||||||
|
if err := db.Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍图片失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
|
||||||
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
|
||||||
|
})
|
||||||
|
return snap.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicViewsHideUnpublished 走秀:pending / rejected 的记录与图片都不得出现在公开视图,published 才可见。
|
||||||
|
func TestPublicViewsHideUnpublished(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := seedRunway(t, db, model.StatusPending, 1999)
|
||||||
|
|
||||||
|
count := func() (records, images int64) {
|
||||||
|
db.WithContext(ctx).Table("public_brand_runways").Where("id = ?", id).Count(&records)
|
||||||
|
db.WithContext(ctx).Table("public_brand_runway_images").Where("runway_id = ?", id).Count(&images)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r, i := count(); r != 0 || i != 0 {
|
||||||
|
t.Fatalf("pending 不应出现在公开视图,实际 records=%d images=%d", r, i)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
if r, i := count(); r != 1 || i != 1 {
|
||||||
|
t.Fatalf("published 应出现在公开视图,实际 records=%d images=%d", r, i)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("status", model.StatusRejected).Error; err != nil {
|
||||||
|
t.Fatalf("置为已驳回失败: %v", err)
|
||||||
|
}
|
||||||
|
if r, i := count(); r != 0 || i != 0 {
|
||||||
|
t.Fatalf("rejected 不应出现在公开视图,实际 records=%d images=%d", r, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublicViewsHideUnpublishedStreet 街拍:同上,覆盖另外两个视图。
|
||||||
|
func TestPublicViewsHideUnpublishedStreet(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := seedStreetSnap(t, db, model.StatusPending, 1998)
|
||||||
|
|
||||||
|
count := func() (records, images int64) {
|
||||||
|
db.WithContext(ctx).Table("public_street_snaps").Where("id = ?", id).Count(&records)
|
||||||
|
db.WithContext(ctx).Table("public_street_snap_images").Where("snap_id = ?", id).Count(&images)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r, i := count(); r != 0 || i != 0 {
|
||||||
|
t.Fatalf("pending 不应出现在公开视图,实际 records=%d images=%d", r, i)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("status", model.StatusPublished).Error; err != nil {
|
||||||
|
t.Fatalf("置为已发布失败: %v", err)
|
||||||
|
}
|
||||||
|
if r, i := count(); r != 1 || i != 1 {
|
||||||
|
t.Fatalf("published 应出现在公开视图,实际 records=%d images=%d", r, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,49 +11,50 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReviewRepository 审核草稿的数据访问接口。
|
// ReviewRepository 审核数据访问接口。
|
||||||
//
|
//
|
||||||
// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」:
|
// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
|
||||||
// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。
|
// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
|
||||||
type ReviewRepository interface {
|
type ReviewRepository interface {
|
||||||
// ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。
|
// ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
|
||||||
// 排序:pending 优先,其次按 id 倒序(最新在前)。
|
ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
|
||||||
ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error)
|
// RecordDetail 查单条走秀记录(不含图片)。
|
||||||
// DraftDetail 查单条草稿(不含图片)。
|
RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
|
||||||
DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error)
|
// RecordImages 查走秀记录的全部未删图片,按排序值升序。
|
||||||
// DraftImages 查草稿的全部图片,按排序值升序。
|
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
||||||
DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error)
|
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
||||||
// UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。
|
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
|
||||||
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
|
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
|
||||||
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
|
||||||
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。
|
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
||||||
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
||||||
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。
|
StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
|
||||||
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
|
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
||||||
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error)
|
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error)
|
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
|
||||||
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。
|
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
|
||||||
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
|
|
||||||
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
|
||||||
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
||||||
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
// SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。
|
||||||
// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
|
SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
|
||||||
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID ——
|
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||||
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。
|
// mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
|
||||||
|
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
|
||||||
// 幂等:已是 mainImgID 副图的行重复并入不报错。
|
// 幂等:已是 mainImgID 副图的行重复并入不报错。
|
||||||
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error
|
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
||||||
// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。
|
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
||||||
// 对主图调用是幂等成功(无副作用)。
|
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
|
||||||
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
|
||||||
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
|
||||||
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
|
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
|
||||||
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
|
||||||
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
|
||||||
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
|
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
||||||
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
|
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
||||||
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
|
CountByStatus(ctx context.Context, status string, dest any) (int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type reviewRepository struct {
|
type reviewRepository struct {
|
||||||
@ -65,27 +66,40 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
|||||||
return &reviewRepository{db: db}
|
return &reviewRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) {
|
// reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。
|
||||||
|
//
|
||||||
|
// 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用
|
||||||
|
// 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患
|
||||||
|
//(与 article_repository.filterScope 同一理由)。
|
||||||
|
func reviewListScope(status string) func(*gorm.DB) *gorm.DB {
|
||||||
|
return func(db *gorm.DB) *gorm.DB {
|
||||||
|
db = db.Where("is_deleted = 0")
|
||||||
|
if status != "" {
|
||||||
|
db = db.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if size < 1 || size > 100 {
|
if size < 1 || size > 100 {
|
||||||
size = 20
|
size = 20
|
||||||
}
|
}
|
||||||
q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0")
|
scope := reviewListScope(status)
|
||||||
if status != "" {
|
|
||||||
q = q.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return []model.BrandRunwayDraft{}, 0, nil
|
return []model.BrandRunway{}, 0, nil
|
||||||
}
|
}
|
||||||
var rows []model.BrandRunwayDraft
|
var rows []model.BrandRunway
|
||||||
if err := q.
|
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).
|
||||||
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||||
Offset((page - 1) * size).
|
Offset((page - 1) * size).
|
||||||
Limit(size).
|
Limit(size).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
@ -94,8 +108,8 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page,
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) {
|
func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
|
||||||
var d model.BrandRunwayDraft
|
var d model.BrandRunway
|
||||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@ -105,17 +119,17 @@ func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.B
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) {
|
func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
|
||||||
var imgs []model.BrandRunwayDraftImage
|
var imgs []model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("draft_id = ? AND is_deleted = 0", draftID).
|
Where("runway_id = ? AND is_deleted = 0", recordID).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
return imgs, err
|
return imgs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// draftEditable 审核前可微调的字段白名单(键即列名)。
|
// recordEditable 审核前可微调的字段白名单(键即列名)。
|
||||||
var draftEditable = map[string]bool{
|
var recordEditable = map[string]bool{
|
||||||
"title_en": true,
|
"title_en": true,
|
||||||
"title_cn": true,
|
"title_cn": true,
|
||||||
"description_en": true,
|
"description_en": true,
|
||||||
@ -126,11 +140,11 @@ var draftEditable = map[string]bool{
|
|||||||
"season_code": true,
|
"season_code": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。
|
// UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
|
||||||
func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
clean := map[string]any{}
|
clean := map[string]any{}
|
||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
if draftEditable[k] {
|
if recordEditable[k] {
|
||||||
clean[k] = v
|
clean[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -139,149 +153,39 @@ func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields ma
|
|||||||
}
|
}
|
||||||
clean["updated_at"] = uint32(time.Now().Unix())
|
clean["updated_at"] = uint32(time.Now().Unix())
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.BrandRunwayDraft{}).
|
Model(&model.BrandRunway{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(clean).Error
|
Updates(clean).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。
|
||||||
return r.db.WithContext(ctx).
|
// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL,
|
||||||
Model(&model.BrandRunwayDraft{}).
|
// 竞态窗口(记录刚被删除)下会给出假成功 302。
|
||||||
|
//
|
||||||
|
// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。
|
||||||
|
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
||||||
|
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
||||||
|
// 而不是悄悄在这个仓储方法里加限制。
|
||||||
|
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
|
||||||
|
res := r.db.WithContext(ctx).
|
||||||
|
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()),
|
})
|
||||||
}).Error
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRunwayFromDraft 晋升草稿到正式表:
|
// CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
|
||||||
// 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
|
func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
|
||||||
// 2. 存在则更新全部可编辑字段 + 图片数;不存在则新建;
|
|
||||||
// 3. 替换图片:软删旧图(is_deleted=1)+ 按草稿图片重建;
|
|
||||||
// 4. 返回正式表主键。
|
|
||||||
//
|
|
||||||
// 在事务内完成,保证草稿与正式表一致性。
|
|
||||||
func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
|
|
||||||
var draft model.BrandRunwayDraft
|
|
||||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return 0, ErrNotFound
|
|
||||||
}
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
// 多来源聚合:当前草稿图 + 同实体已 approved 的其他草稿图(按 image URL 去重,
|
|
||||||
// S4 key 已是 sha1,同图 URL 必相同)。当前正被审核的草稿强制纳入,其余只取已审核来源。
|
|
||||||
curImgs, err := r.DraftImages(ctx, draftID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
sibImgs, err := r.runwayApprovedSiblingImages(ctx, draftID, draft.BrandID, draft.SeasonCode, draft.CollectionType)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
imgs := unionRunwayDraftImages(curImgs, sibImgs)
|
|
||||||
|
|
||||||
// image_count 仅计主图(is_detail=0):对外列表卡片「N 张」与详情默认画廊都只展示主图,
|
|
||||||
// 细节图作为主图的附属按需展开,不计入主图数。
|
|
||||||
mainCount := 0
|
|
||||||
for _, im := range imgs {
|
|
||||||
if im.IsDetail == 0 {
|
|
||||||
mainCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
now := uint32(time.Now().Unix())
|
|
||||||
var runwayID uint32
|
|
||||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
// 1) 按实体键查找既有正式走秀
|
|
||||||
var existing model.BrandRunway
|
|
||||||
if eErr := tx.Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", draft.BrandID, draft.SeasonCode, draft.CollectionType).
|
|
||||||
Limit(1).Find(&existing).Error; eErr != nil {
|
|
||||||
return eErr
|
|
||||||
}
|
|
||||||
|
|
||||||
common := map[string]any{
|
|
||||||
"title_en": draft.TitleEn,
|
|
||||||
"title_cn": draft.TitleCn,
|
|
||||||
"description_en": draft.DescriptionEn,
|
|
||||||
"description_cn": draft.DescriptionCn,
|
|
||||||
"year": draft.Year,
|
|
||||||
"season": draft.Season,
|
|
||||||
"collection_type": draft.CollectionType,
|
|
||||||
"season_code": draft.SeasonCode,
|
|
||||||
"cover": draft.Cover,
|
|
||||||
"image_count": uint16(mainCount),
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing.ID != 0 {
|
|
||||||
runwayID = existing.ID
|
|
||||||
common["brand_id"] = existing.BrandID // 保留正式表的品牌归属
|
|
||||||
if uErr := tx.Model(&model.BrandRunway{}).Where("id = ?", runwayID).Updates(common).Error; uErr != nil {
|
|
||||||
return uErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rw := &model.BrandRunway{
|
|
||||||
BrandID: draft.BrandID,
|
|
||||||
TitleEn: draft.TitleEn,
|
|
||||||
TitleCn: draft.TitleCn,
|
|
||||||
DescriptionEn: draft.DescriptionEn,
|
|
||||||
DescriptionCn: draft.DescriptionCn,
|
|
||||||
Year: draft.Year,
|
|
||||||
Season: draft.Season,
|
|
||||||
CollectionType: draft.CollectionType,
|
|
||||||
SeasonCode: draft.SeasonCode,
|
|
||||||
Cover: draft.Cover,
|
|
||||||
ImageCount: uint16(mainCount),
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
if cErr := tx.Create(rw).Error; cErr != nil {
|
|
||||||
return cErr
|
|
||||||
}
|
|
||||||
runwayID = rw.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 替换图片:软删旧 + 重建
|
|
||||||
if dErr := tx.Model(&model.BrandRunwayImage{}).
|
|
||||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
|
|
||||||
return dErr
|
|
||||||
}
|
|
||||||
if len(imgs) > 0 {
|
|
||||||
rows := make([]model.BrandRunwayImage, 0, len(imgs))
|
|
||||||
for i, im := range imgs {
|
|
||||||
rows = append(rows, model.BrandRunwayImage{
|
|
||||||
RunwayID: runwayID,
|
|
||||||
BrandID: draft.BrandID,
|
|
||||||
Image: im.Image,
|
|
||||||
Name: im.Name,
|
|
||||||
SortOrder: uint32(i + 1),
|
|
||||||
LookIndex: im.LookIndex,
|
|
||||||
IsDetail: im.IsDetail,
|
|
||||||
Phash: im.Phash,
|
|
||||||
IsDuplicate: im.IsDuplicate,
|
|
||||||
DupOf: im.DupOf,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if cErr := tx.Create(&rows).Error; cErr != nil {
|
|
||||||
return cErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return runwayID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
|
|
||||||
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
|
|
||||||
var n int64
|
var n int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(dest).
|
Model(dest).
|
||||||
@ -292,29 +196,27 @@ func (r *reviewRepository) CountDraftPending(ctx context.Context, status string,
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 街拍草稿镜像实现(与 runway 同构) ----
|
// ---- 街拍镜像实现(与 runway 同构) ----
|
||||||
|
|
||||||
func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) {
|
func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
if size < 1 || size > 100 {
|
if size < 1 || size > 100 {
|
||||||
size = 20
|
size = 20
|
||||||
}
|
}
|
||||||
q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0")
|
scope := reviewListScope(status)
|
||||||
if status != "" {
|
|
||||||
q = q.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return []model.StreetSnapDraft{}, 0, nil
|
return []model.StreetSnap{}, 0, nil
|
||||||
}
|
}
|
||||||
var rows []model.StreetSnapDraft
|
var rows []model.StreetSnap
|
||||||
if err := q.
|
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).
|
||||||
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||||
Offset((page - 1) * size).
|
Offset((page - 1) * size).
|
||||||
Limit(size).
|
Limit(size).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
@ -323,8 +225,8 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string,
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) {
|
func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
|
||||||
var d model.StreetSnapDraft
|
var d model.StreetSnap
|
||||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@ -334,26 +236,26 @@ func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*m
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) {
|
func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
|
||||||
var imgs []model.StreetSnapDraftImage
|
var imgs []model.StreetSnapImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("draft_id = ? AND is_deleted = 0", draftID).
|
Where("snap_id = ? AND is_deleted = 0", recordID).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
return imgs, err
|
return imgs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。
|
// streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
|
||||||
var streetDraftEditable = map[string]bool{
|
var streetRecordEditable = map[string]bool{
|
||||||
"title": true,
|
"title": true,
|
||||||
"year": true,
|
"year": true,
|
||||||
"city": true,
|
"city": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
clean := map[string]any{}
|
clean := map[string]any{}
|
||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
if streetDraftEditable[k] {
|
if streetRecordEditable[k] {
|
||||||
clean[k] = v
|
clean[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -362,283 +264,82 @@ func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fie
|
|||||||
}
|
}
|
||||||
clean["updated_at"] = uint32(time.Now().Unix())
|
clean["updated_at"] = uint32(time.Now().Unix())
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnapDraft{}).
|
Model(&model.StreetSnap{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(clean).Error
|
Updates(clean).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
||||||
return r.db.WithContext(ctx).
|
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
||||||
Model(&model.StreetSnapDraft{}).
|
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
|
||||||
|
res := r.db.WithContext(ctx).
|
||||||
|
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()),
|
})
|
||||||
}).Error
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snaps(按实体键 city+year 幂等 upsert,替换图片)。
|
// ── 图片软删 ────────────────────────────────────────────────────────────────
|
||||||
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
|
|
||||||
// 在事务内完成,保证草稿与正式表一致性。
|
|
||||||
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
|
|
||||||
var draft model.StreetSnapDraft
|
|
||||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return 0, ErrNotFound
|
|
||||||
}
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
curImgs, err := r.StreetDraftImages(ctx, draftID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
imgs := unionStreetDraftImages(curImgs, sibImgs)
|
|
||||||
|
|
||||||
now := uint32(time.Now().Unix())
|
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。
|
||||||
var snapID uint32
|
// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
|
||||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
//
|
||||||
var existing model.StreetSnap
|
// 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」,
|
||||||
if eErr := tx.Where("city = ? AND year = ? AND is_deleted = 0", draft.City, draft.Year).
|
// 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行,
|
||||||
Limit(1).Find(&existing).Error; eErr != nil {
|
// GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。
|
||||||
return eErr
|
// 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建,
|
||||||
}
|
// 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。
|
||||||
|
//
|
||||||
common := map[string]any{
|
// 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks),
|
||||||
"title": draft.Title,
|
// 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。
|
||||||
"year": draft.Year,
|
func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error {
|
||||||
"city": draft.City,
|
|
||||||
"cover": draft.Cover,
|
|
||||||
"image_count": uint16(len(imgs)),
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing.ID != 0 {
|
|
||||||
snapID = existing.ID
|
|
||||||
if uErr := tx.Model(&model.StreetSnap{}).Where("id = ?", snapID).Updates(common).Error; uErr != nil {
|
|
||||||
return uErr
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
snap := &model.StreetSnap{
|
|
||||||
Title: draft.Title,
|
|
||||||
Year: draft.Year,
|
|
||||||
City: draft.City,
|
|
||||||
Cover: draft.Cover,
|
|
||||||
ImageCount: uint16(len(imgs)),
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
if cErr := tx.Create(snap).Error; cErr != nil {
|
|
||||||
return cErr
|
|
||||||
}
|
|
||||||
snapID = snap.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// 替换图片:软删旧 + 重建
|
|
||||||
if dErr := tx.Model(&model.StreetSnapImage{}).
|
|
||||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
|
|
||||||
return dErr
|
|
||||||
}
|
|
||||||
if len(imgs) > 0 {
|
|
||||||
// 第一遍:插入主图(is_detail=0),并记下「草稿行 id → 新正式行 id」的映射。
|
|
||||||
//
|
|
||||||
// 必须两遍插入:副图的 parent_image_id 存的是**草稿表**的行 id,
|
|
||||||
// 而这里插入的正式行拿到的是全新 id。若先插副图,父引用就会指向草稿表的旧 id,
|
|
||||||
// 详情页折叠结构随之错乱(见计划「任务 2」标题下的说明)。
|
|
||||||
newIDByDraftID := make(map[uint32]uint32, len(imgs))
|
|
||||||
mains := make([]model.StreetSnapImage, 0, len(imgs))
|
|
||||||
for _, im := range imgs {
|
|
||||||
if im.IsDetail == 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mains = append(mains, model.StreetSnapImage{
|
|
||||||
SnapID: snapID,
|
|
||||||
Image: im.Image,
|
|
||||||
Name: im.Name,
|
|
||||||
SortOrder: uint32(len(mains) + 1),
|
|
||||||
IsDetail: 0,
|
|
||||||
Phash: im.Phash,
|
|
||||||
IsDuplicate: im.IsDuplicate,
|
|
||||||
DupOf: im.DupOf,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for i := range mains {
|
|
||||||
if cErr := tx.Create(&mains[i]).Error; cErr != nil {
|
|
||||||
return cErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mainIdx := 0
|
|
||||||
for _, im := range imgs {
|
|
||||||
if im.IsDetail == 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newIDByDraftID[im.ID] = mains[mainIdx].ID
|
|
||||||
mainIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第二遍:插入副图,把父引用改写成新主图行 id。
|
|
||||||
details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains))
|
|
||||||
for _, im := range imgs {
|
|
||||||
if im.IsDetail != 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newParent, ok := newIDByDraftID[im.ParentImageID]
|
|
||||||
row := model.StreetSnapImage{
|
|
||||||
SnapID: snapID,
|
|
||||||
Image: im.Image,
|
|
||||||
Name: im.Name,
|
|
||||||
SortOrder: uint32(len(mains) + len(details) + 1),
|
|
||||||
IsDetail: 1,
|
|
||||||
Phash: im.Phash,
|
|
||||||
IsDuplicate: im.IsDuplicate,
|
|
||||||
DupOf: im.DupOf,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
if ok {
|
|
||||||
row.ParentImageID = newParent
|
|
||||||
} else {
|
|
||||||
row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错口径一致
|
|
||||||
}
|
|
||||||
details = append(details, row)
|
|
||||||
}
|
|
||||||
if len(details) > 0 {
|
|
||||||
if cErr := tx.Create(&details).Error; cErr != nil {
|
|
||||||
return cErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return snapID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runwayApprovedSiblingImages 返回同实体(品牌+季节码+系列)且已 approved 的其他草稿的图片,
|
|
||||||
// 用于多来源聚合:当前正被审核的草稿强制纳入,其余仅合并已审核来源,避免未审图泄露进正式表。
|
|
||||||
func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excludeDraftID, brandID uint32, seasonCode, collectionType string) ([]model.BrandRunwayDraftImage, error) {
|
|
||||||
var drafts []model.BrandRunwayDraft
|
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0 AND id <> ?", brandID, seasonCode, collectionType, model.DraftStatusApproved, excludeDraftID).
|
Model(&model.BrandRunwayImage{}).
|
||||||
Find(&drafts).Error; err != nil {
|
Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]model.BrandRunwayDraftImage, 0)
|
|
||||||
for _, d := range drafts {
|
|
||||||
imgs, err := r.DraftImages(ctx, d.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, imgs...)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unionRunwayDraftImages 按 image URL 去重合并(S4 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
|
|
||||||
func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage {
|
|
||||||
seen := make(map[string]struct{}, len(a)+len(b))
|
|
||||||
out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b))
|
|
||||||
for _, im := range a {
|
|
||||||
if _, ok := seen[im.Image]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[im.Image] = struct{}{}
|
|
||||||
out = append(out, im)
|
|
||||||
}
|
|
||||||
for _, im := range b {
|
|
||||||
if _, ok := seen[im.Image]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[im.Image] = struct{}{}
|
|
||||||
out = append(out, im)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// streetApprovedSiblingImages 同 city+year 已 approved 的其他街拍草稿图(多来源聚合)。
|
|
||||||
func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16) ([]model.StreetSnapDraftImage, error) {
|
|
||||||
var drafts []model.StreetSnapDraft
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0 AND id <> ?", city, year, model.DraftStatusApproved, excludeDraftID).
|
|
||||||
Find(&drafts).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]model.StreetSnapDraftImage, 0)
|
|
||||||
for _, d := range drafts {
|
|
||||||
imgs, err := r.StreetDraftImages(ctx, d.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, imgs...)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unionStreetDraftImages 按 image URL 去重合并。当前草稿图优先。
|
|
||||||
func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSnapDraftImage {
|
|
||||||
seen := make(map[string]struct{}, len(a)+len(b))
|
|
||||||
out := make([]model.StreetSnapDraftImage, 0, len(a)+len(b))
|
|
||||||
for _, im := range a {
|
|
||||||
if _, ok := seen[im.Image]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[im.Image] = struct{}{}
|
|
||||||
out = append(out, im)
|
|
||||||
}
|
|
||||||
for _, im := range b {
|
|
||||||
if _, ok := seen[im.Image]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[im.Image] = struct{}{}
|
|
||||||
out = append(out, im)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 草稿图片删除 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。
|
|
||||||
// 不删S4文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
|
|
||||||
func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&model.BrandRunwayDraftImage{}).
|
|
||||||
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
|
|
||||||
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 r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Exec("UPDATE brand_runway_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
|
Exec(`UPDATE brand_runways SET image_count =
|
||||||
|
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
|
||||||
|
WHERE id = ?`, recordID, recordID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
// SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。
|
||||||
func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
//
|
||||||
|
// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图),
|
||||||
|
// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的,
|
||||||
|
// 与走秀「is_detail 是入库时的细节图」语义不同。
|
||||||
|
func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnapDraftImage{}).
|
Model(&model.StreetSnapImage{}).
|
||||||
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
|
Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID).
|
||||||
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 r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Exec("UPDATE street_snap_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
|
Exec(`UPDATE street_snaps SET image_count =
|
||||||
|
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
|
||||||
|
WHERE id = ?`, recordID, recordID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||||
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。
|
// kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
|
||||||
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) {
|
func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case dto.IngestKindRunway:
|
case dto.IngestKindRunway:
|
||||||
var im model.BrandRunwayDraftImage
|
var im model.BrandRunwayImage
|
||||||
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
|
err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@ -647,8 +348,8 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
|
|||||||
}
|
}
|
||||||
return im.Image, nil
|
return im.Image, nil
|
||||||
case dto.IngestKindStreet:
|
case dto.IngestKindStreet:
|
||||||
var im model.StreetSnapDraftImage
|
var im model.StreetSnapImage
|
||||||
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
|
err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@ -661,13 +362,13 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 街拍草稿图片:主副图分组 ──────────────────────────────────────────────
|
// ── 街拍主副图分组 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||||
//
|
//
|
||||||
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
|
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
|
||||||
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
|
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
|
||||||
func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error {
|
func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error {
|
||||||
ids := make([]uint32, 0, len(imgIDs))
|
ids := make([]uint32, 0, len(imgIDs))
|
||||||
for _, id := range imgIDs {
|
for _, id := range imgIDs {
|
||||||
if id != 0 && id != mainImgID {
|
if id != 0 && id != mainImgID {
|
||||||
@ -684,8 +385,8 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var main model.StreetSnapDraftImage
|
var main model.StreetSnapImage
|
||||||
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID).
|
if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
|
||||||
First(&main).Error; err != nil {
|
First(&main).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return ErrInvalidImageGroup
|
return ErrInvalidImageGroup
|
||||||
@ -694,23 +395,109 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := uint32(time.Now().Unix())
|
now := uint32(time.Now().Unix())
|
||||||
if err := tx.Model(&model.StreetSnapDraftImage{}).
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids).
|
Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids).
|
||||||
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
|
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.StreetSnapDraftImage{}).
|
return tx.Model(&model.StreetSnapImage{}).
|
||||||
Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids).
|
Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids).
|
||||||
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
|
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。
|
// DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。
|
||||||
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
|
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
|
||||||
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnapDraftImage{}).
|
Model(&model.StreetSnapImage{}).
|
||||||
Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID).
|
Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID).
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
103
internal/repository/review_status_transition_integration_test.go
Normal file
103
internal/repository/review_status_transition_integration_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
// 集成测试:「通过 → 公开可见」这条单表发布主链路的**仓储级**证据。
|
||||||
|
//
|
||||||
|
// 背景:公开视图测试(TestPublicViewsHideUnpublished*)都是直接 `db.Model(...).Update("status", ...)`
|
||||||
|
// 改库、绕过了 `Approve` 走的 SetRecordStatus / SetStreetRecordStatus,这两个仓储方法在全仓零直接引用。
|
||||||
|
// 一旦它们改错列 / 漏写 status,那些测试仍会全绿、线上却无法通过审核。本文件补上这一段。
|
||||||
|
//
|
||||||
|
// 运行:go test -tags integration ./internal/repository/ -run TestSetRecordStatus -v
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetRecordStatusDrivesPublicVisibility 走秀:pending 经仓储置 published 后在公开视图可见;
|
||||||
|
// 再置 rejected 后不可见;对不存在的 id 返回 ErrNotFound。
|
||||||
|
func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewReviewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := seedRunway(t, db, model.StatusPending, 1991)
|
||||||
|
publicCount := func() int64 {
|
||||||
|
var n int64
|
||||||
|
db.Table("public_brand_runways").Where("id = ?", id).Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := publicCount(); got != 0 {
|
||||||
|
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
|
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
|
||||||
|
}
|
||||||
|
if got := publicCount(); got != 1 {
|
||||||
|
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
|
||||||
|
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
|
||||||
|
}
|
||||||
|
if got := publicCount(); got != 0 {
|
||||||
|
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审核痕迹应落库,便于列表页展示。
|
||||||
|
var rw model.BrandRunway
|
||||||
|
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("读回记录失败: %v", err)
|
||||||
|
}
|
||||||
|
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" {
|
||||||
|
t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
|
||||||
|
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetStreetRecordStatusDrivesPublicVisibility 街拍:与走秀同形,覆盖另一组方法与视图。
|
||||||
|
func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
|
||||||
|
db := testDB(t)
|
||||||
|
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||||||
|
repo := NewReviewRepository(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := seedStreetSnap(t, db, model.StatusPending, 1990)
|
||||||
|
publicCount := func() int64 {
|
||||||
|
var n int64
|
||||||
|
db.Table("public_street_snaps").Where("id = ?", id).Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := publicCount(); got != 0 {
|
||||||
|
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
|
||||||
|
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
|
||||||
|
}
|
||||||
|
if got := publicCount(); got != 1 {
|
||||||
|
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
|
||||||
|
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
|
||||||
|
}
|
||||||
|
if got := publicCount(); got != 0 {
|
||||||
|
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
//go:build integration
|
//go:build integration
|
||||||
|
|
||||||
// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。
|
// 集成测试:街拍主副图分组需要真实 PostgreSQL。
|
||||||
|
//
|
||||||
|
// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images),
|
||||||
|
// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。
|
||||||
//
|
//
|
||||||
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
|
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
|
||||||
//
|
//
|
||||||
@ -13,61 +16,74 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"fashionapi/internal/model"
|
"fashionapi/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedStreetDraft 插入一条街拍草稿并返回其 id。
|
// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。
|
||||||
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 {
|
// 正式表上有部分唯一索引 uq_ss_entity (city, year, COALESCE(title, 空串)) WHERE is_deleted = 0,
|
||||||
|
// 若测试沿用同一 city/year/title,第二条记录会被唯一索引拒绝。
|
||||||
|
var mainDetailSeedSeq int
|
||||||
|
|
||||||
|
// seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。
|
||||||
|
// 命名加 MainDetail 前缀以避免与 publish_status_integration_test.go 的 seedStreetSnap 冲突。
|
||||||
|
func seedMainDetailSnap(t *testing.T, repo ReviewRepository) uint32 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db := repo.(*reviewRepository).db
|
db := repo.(*reviewRepository).db
|
||||||
d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending}
|
mainDetailSeedSeq++
|
||||||
if err := db.WithContext(ctx).Create(&d).Error; err != nil {
|
snap := model.StreetSnap{
|
||||||
t.Fatalf("插入草稿失败: %v", err)
|
Title: "主副图测试",
|
||||||
|
Year: 2026,
|
||||||
|
City: fmt.Sprintf("TestCity-%d", mainDetailSeedSeq),
|
||||||
|
Status: model.StatusPending,
|
||||||
|
}
|
||||||
|
if err := db.WithContext(ctx).Create(&snap).Error; err != nil {
|
||||||
|
t.Fatalf("插入街拍记录失败: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID)
|
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
|
||||||
db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID)
|
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
|
||||||
})
|
})
|
||||||
return d.ID
|
return snap.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedStreetDraftImage 插入一张草稿图并返回其行 id。
|
// seedMainDetailImage 插入一张正式街拍图并返回其行 id。
|
||||||
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 {
|
func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
img := model.StreetSnapDraftImage{DraftID: draftID, Image: name + ".jpg", Name: name, SortOrder: order}
|
img := model.StreetSnapImage{SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order}
|
||||||
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
|
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
|
||||||
t.Fatalf("插入草稿图失败: %v", err)
|
t.Fatalf("插入街拍图失败: %v", err)
|
||||||
}
|
}
|
||||||
return img.ID
|
return img.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。
|
// reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。
|
||||||
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage {
|
func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
imgs, err := repo.StreetDraftImages(context.Background(), draftID)
|
imgs, err := repo.StreetRecordImages(context.Background(), snapID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("读草稿图失败: %v", err)
|
t.Fatalf("读街拍图失败: %v", err)
|
||||||
}
|
}
|
||||||
return imgs
|
return imgs
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。
|
// TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。
|
||||||
func TestStreetMainDetail(t *testing.T) {
|
func TestStreetMainDetail(t *testing.T) {
|
||||||
repo := NewReviewRepository(testDB(t))
|
repo := NewReviewRepository(testDB(t))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
draftID := seedStreetDraft(t, repo)
|
snapID := seedMainDetailSnap(t, repo)
|
||||||
m1 := seedStreetDraftImage(t, repo, draftID, "a", 1)
|
m1 := seedMainDetailImage(t, repo, snapID, "a", 1)
|
||||||
d2 := seedStreetDraftImage(t, repo, draftID, "b", 2)
|
d2 := seedMainDetailImage(t, repo, snapID, "b", 2)
|
||||||
d3 := seedStreetDraftImage(t, repo, draftID, "c", 3)
|
d3 := seedMainDetailImage(t, repo, snapID, "c", 3)
|
||||||
|
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
|
||||||
t.Fatalf("并入失败: %v", err)
|
t.Fatalf("并入失败: %v", err)
|
||||||
}
|
}
|
||||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||||
switch im.ID {
|
switch im.ID {
|
||||||
case m1:
|
case m1:
|
||||||
if im.IsDetail != 0 || im.ParentImageID != 0 {
|
if im.IsDetail != 0 || im.ParentImageID != 0 {
|
||||||
@ -81,40 +97,40 @@ func TestStreetMainDetail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 幂等:重复并入同一批不报错,结果不变。
|
// 幂等:重复并入同一批不报错,结果不变。
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
|
||||||
t.Fatalf("重复并入应幂等,实际报错: %v", err)
|
t.Fatalf("重复并入应幂等,实际报错: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
|
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m1}); err != nil {
|
||||||
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
|
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
|
||||||
}
|
}
|
||||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||||
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||||||
t.Fatalf("主图被自身并入后不应变化")
|
t.Fatalf("主图被自身并入后不应变化")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拆出:把 d3 恢复为主图。
|
// 拆出:把 d3 恢复为主图。
|
||||||
if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil {
|
if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil {
|
||||||
t.Fatalf("拆出失败: %v", err)
|
t.Fatalf("拆出失败: %v", err)
|
||||||
}
|
}
|
||||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||||
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||||||
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功
|
if err := repo.DetachStreetSnapImage(ctx, snapID, m1); err != nil { // 对主图幂等成功
|
||||||
t.Fatalf("对主图拆出应幂等成功: %v", err)
|
t.Fatalf("对主图拆出应幂等成功: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨草稿拦截:空集合 + 跨草稿主图。
|
// 跨记录拦截:空集合 + 跨记录主图。
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
|
||||||
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
|
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||||
}
|
}
|
||||||
otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1)
|
otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1)
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||||
t.Fatalf("跨草稿主图应被拦截,实际 %v", err)
|
t.Fatalf("跨记录主图应被拦截,实际 %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,18 +139,18 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
|
|||||||
repo := NewReviewRepository(testDB(t))
|
repo := NewReviewRepository(testDB(t))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
draftID := seedStreetDraft(t, repo)
|
snapID := seedMainDetailSnap(t, repo)
|
||||||
m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1)
|
m1 := seedMainDetailImage(t, repo, snapID, "m1", 1)
|
||||||
m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2)
|
m2 := seedMainDetailImage(t, repo, snapID, "m2", 2)
|
||||||
child := seedStreetDraftImage(t, repo, draftID, "child", 3)
|
child := seedMainDetailImage(t, repo, snapID, "child", 3)
|
||||||
|
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, m2, []uint32{child}); err != nil {
|
||||||
t.Fatalf("预置副图失败: %v", err)
|
t.Fatalf("预置副图失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil {
|
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m2}); err != nil {
|
||||||
t.Fatalf("并入整组失败: %v", err)
|
t.Fatalf("并入整组失败: %v", err)
|
||||||
}
|
}
|
||||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||||
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||||||
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||||
}
|
}
|
||||||
@ -144,49 +160,164 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。
|
// ── image_count 重算(软删不漂移) ─────────────────────────────────────────────
|
||||||
func TestStreetPromoteRebuildsParentRef(t *testing.T) {
|
|
||||||
|
// snapImageCount 读某街拍记录当前的 image_count。
|
||||||
|
func snapImageCount(t *testing.T, repo ReviewRepository, snapID uint32) uint16 {
|
||||||
|
t.Helper()
|
||||||
|
var row struct {
|
||||||
|
ImageCount uint16 `gorm:"column:image_count"`
|
||||||
|
}
|
||||||
|
if err := repo.(*reviewRepository).db.WithContext(context.Background()).
|
||||||
|
Table("street_snaps").Select("image_count").Where("id = ?", snapID).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatalf("读街拍 image_count 失败: %v", err)
|
||||||
|
}
|
||||||
|
return row.ImageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// runwayImageCount 读某走秀记录当前的 image_count。
|
||||||
|
func runwayImageCount(t *testing.T, repo ReviewRepository, runwayID uint32) uint16 {
|
||||||
|
t.Helper()
|
||||||
|
var row struct {
|
||||||
|
ImageCount uint16 `gorm:"column:image_count"`
|
||||||
|
}
|
||||||
|
if err := repo.(*reviewRepository).db.WithContext(context.Background()).
|
||||||
|
Table("brand_runways").Select("image_count").Where("id = ?", runwayID).Scan(&row).Error; err != nil {
|
||||||
|
t.Fatalf("读走秀 image_count 失败: %v", err)
|
||||||
|
}
|
||||||
|
return row.ImageCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedMainDetailRunway 插入一条走秀正式记录(status=pending)并返回其 id。
|
||||||
|
// SeasonCode 用递增序号,避开 uq_br_entity (brand_id, season_code, collection_type) 部分唯一索引。
|
||||||
|
func seedMainDetailRunway(t *testing.T, repo ReviewRepository) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
db := repo.(*reviewRepository).db
|
||||||
|
mainDetailSeedSeq++
|
||||||
|
rw := model.BrandRunway{
|
||||||
|
BrandID: 1,
|
||||||
|
TitleEn: "主副图计数测试",
|
||||||
|
Year: 2026,
|
||||||
|
SeasonCode: fmt.Sprintf("MD%d", mainDetailSeedSeq),
|
||||||
|
CollectionType: "rtw",
|
||||||
|
Status: model.StatusPending,
|
||||||
|
}
|
||||||
|
if err := db.WithContext(context.Background()).Create(&rw).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀记录失败: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
|
||||||
|
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||||||
|
})
|
||||||
|
return rw.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedMainDetailRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回其行 id。
|
||||||
|
func seedMainDetailRunwayImage(t *testing.T, repo ReviewRepository, runwayID uint32, name string, order uint32, isDetail uint8) uint32 {
|
||||||
|
t.Helper()
|
||||||
|
img := model.BrandRunwayImage{
|
||||||
|
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||||||
|
SortOrder: order, IsDetail: isDetail,
|
||||||
|
}
|
||||||
|
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
|
||||||
|
t.Fatalf("插入走秀图失败: %v", err)
|
||||||
|
}
|
||||||
|
return img.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSoftDeleteStreetImageRecomputesCount 软删街拍图片后 image_count 必须按实际存活行**重算**。
|
||||||
|
//
|
||||||
|
// 对「已被软删」或「不属于该记录」的 imgID 重放一次,计数必须不变:
|
||||||
|
// 旧实现是 `image_count - 1`(GREATEST 钳到 0),命中 0 行时仍会递减且永不回补;
|
||||||
|
// 单表发布模型删掉了晋升时按实际行重算的自愈路径,漂移会永久留在公开列表卡片的「N 张」上。
|
||||||
|
func TestSoftDeleteStreetImageRecomputesCount(t *testing.T) {
|
||||||
repo := NewReviewRepository(testDB(t))
|
repo := NewReviewRepository(testDB(t))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
draftID := seedStreetDraft(t, repo)
|
snapID := seedMainDetailSnap(t, repo)
|
||||||
m1 := seedStreetDraftImage(t, repo, draftID, "main", 1)
|
keep := seedMainDetailImage(t, repo, snapID, "keep", 1)
|
||||||
d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2)
|
drop := seedMainDetailImage(t, repo, snapID, "drop", 2)
|
||||||
d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3)
|
|
||||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
|
if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil {
|
||||||
t.Fatalf("预置主副图失败: %v", err)
|
t.Fatalf("软删失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := snapImageCount(t, repo, snapID); got != 1 {
|
||||||
|
t.Fatalf("软删一张后 image_count 应为 1,实际 %d", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID)
|
// 重放同一张(已被软删):命中 0 行,计数不得变化。
|
||||||
|
if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil {
|
||||||
|
t.Fatalf("重复软删应幂等,实际报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := snapImageCount(t, repo, snapID); got != 1 {
|
||||||
|
t.Fatalf("重复软删不应改变 image_count,实际 %d(旧递减实现会掉到 0)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不属于该记录的 imgID:同样不得影响本记录计数。
|
||||||
|
otherImg := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "other", 1)
|
||||||
|
if err := repo.SoftDeleteStreetImage(ctx, snapID, otherImg); err != nil {
|
||||||
|
t.Fatalf("跨记录软删不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := snapImageCount(t, repo, snapID); got != 1 {
|
||||||
|
t.Fatalf("跨记录 imgID 不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
imgs, err := repo.StreetRecordImages(ctx, snapID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("晋升失败: %v", err)
|
t.Fatalf("读存活图失败: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
if len(imgs) != 1 || imgs[0].ID != keep {
|
||||||
db := repo.(*reviewRepository).db
|
t.Fatalf("应只剩 keep 一行,实际 %+v", imgs)
|
||||||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID)
|
}
|
||||||
db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID)
|
}
|
||||||
})
|
|
||||||
|
// TestSoftDeleteRunwayImage 走秀 image_count 口径是「存活主图行数」(与入库 countMainImages 一致)。
|
||||||
db := repo.(*reviewRepository).db
|
//
|
||||||
var rows []model.StreetSnapImage
|
// 关键断言在「软删一张主图、让细节图**仍存活**」这一步:重算必须排除 is_detail=1 的行。
|
||||||
if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID).
|
// 若先删细节图再删主图,过滤与否结果相同(旧写法就锁不住口径),故这里把顺序倒过来。
|
||||||
Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
func TestSoftDeleteRunwayImage(t *testing.T) {
|
||||||
t.Fatalf("读正式图失败: %v", err)
|
repo := NewReviewRepository(testDB(t))
|
||||||
}
|
ctx := context.Background()
|
||||||
if len(rows) != 3 {
|
|
||||||
t.Fatalf("期望 3 行正式图,实际 %d", len(rows))
|
runwayID := seedMainDetailRunway(t, repo)
|
||||||
}
|
main1 := seedMainDetailRunwayImage(t, repo, runwayID, "main1", 1, 0)
|
||||||
|
main2 := seedMainDetailRunwayImage(t, repo, runwayID, "main2", 2, 0)
|
||||||
mainRow := rows[0]
|
detail := seedMainDetailRunwayImage(t, repo, runwayID, "detail1", 3, 1)
|
||||||
if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 {
|
|
||||||
t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID)
|
// 先软删一张主图,细节图此刻仍存活:只计主图 → 1(仅 main1)。
|
||||||
}
|
// 若重算用 COUNT(*) 不排除 is_detail,会把存活的 detail 一并算进来得 2 —— 本断言锁得住。
|
||||||
for _, row := range rows[1:] {
|
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
|
||||||
if row.IsDetail != 1 {
|
t.Fatalf("软删主图失败: %v", err)
|
||||||
t.Fatalf("第 %d 行应是副图", row.ID)
|
}
|
||||||
}
|
if got := runwayImageCount(t, repo, runwayID); got != 1 {
|
||||||
if row.ParentImageID != mainRow.ID {
|
t.Fatalf("image_count 只计存活主图,软删一张主图后应为 1(须排除仍存活的细节图),实际 %d", got)
|
||||||
t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID)
|
}
|
||||||
}
|
|
||||||
|
// 再软删细节图:主图数不变(细节图本就不计入)。
|
||||||
|
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, detail); err != nil {
|
||||||
|
t.Fatalf("软删细节图失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := runwayImageCount(t, repo, runwayID); got != 1 {
|
||||||
|
t.Fatalf("软删细节图不应改变主图计数,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重放(已软删)+ 跨记录:计数均不得变化。
|
||||||
|
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
|
||||||
|
t.Fatalf("重复软删应幂等: %v", err)
|
||||||
|
}
|
||||||
|
otherMain := seedMainDetailRunwayImage(t, repo, seedMainDetailRunway(t, repo), "x", 1, 0)
|
||||||
|
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, otherMain); err != nil {
|
||||||
|
t.Fatalf("跨记录软删不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if got := runwayImageCount(t, repo, runwayID); got != 1 {
|
||||||
|
t.Fatalf("重放 / 跨记录不应改变 image_count,实际 %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
imgs, err := repo.RecordImages(ctx, runwayID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读存活主图失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(imgs) != 1 || imgs[0].ID != main1 {
|
||||||
|
t.Fatalf("应只剩 main1 一行,实际 %+v", imgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
)
|
)
|
||||||
@ -16,10 +18,17 @@ import (
|
|||||||
type StreetSnapRepository interface {
|
type StreetSnapRepository interface {
|
||||||
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
|
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
|
||||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
||||||
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。
|
// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
|
||||||
|
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
|
||||||
|
// FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。
|
||||||
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
|
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
|
||||||
// ListImages 查询某条街拍的全部图片,按排序值升序。
|
// FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。
|
||||||
|
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
|
||||||
|
FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error)
|
||||||
|
// ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。
|
||||||
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
||||||
|
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||||||
|
ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
||||||
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
|
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
|
||||||
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
||||||
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
||||||
@ -28,10 +37,17 @@ type StreetSnapRepository interface {
|
|||||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, 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
|
||||||
// DeleteSnapImage 软删单张街拍图片。
|
// DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。
|
||||||
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 更新街拍封面。
|
||||||
@ -75,9 +91,39 @@ func snapOrderBy(sort string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List 公开读:读公开视图,pending / rejected 街拍不可见。
|
||||||
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||||
scope := snapFilterScope(q)
|
scope := snapFilterScope(q)
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
|
Scopes(scope).
|
||||||
|
Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []model.StreetSnap{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []model.StreetSnap
|
||||||
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
|
Scopes(scope).
|
||||||
|
Order(snapOrderBy(q.Sort)).
|
||||||
|
Offset(q.Offset()).
|
||||||
|
Limit(q.Size).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return rows, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected,
|
||||||
|
// 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。
|
||||||
|
func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||||
|
scope := snapFilterScope(q)
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&model.StreetSnap{}).
|
Model(&model.StreetSnap{}).
|
||||||
@ -102,11 +148,20 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery)
|
|||||||
return rows, total, nil
|
return rows, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
|
||||||
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
|
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。
|
||||||
|
func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) {
|
||||||
|
return r.findByID(r.db.WithContext(ctx), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。
|
||||||
|
func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) {
|
||||||
var snap model.StreetSnap
|
var snap model.StreetSnap
|
||||||
err := r.db.WithContext(ctx).
|
err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error
|
||||||
Where("id = ? AND is_deleted = 0", id).
|
|
||||||
First(&snap).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@ -116,7 +171,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.
|
|||||||
return &snap, nil
|
return &snap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。
|
||||||
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
||||||
|
var imgs []model.StreetSnapImage
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnapImages).
|
||||||
|
Where("snap_id = ?", snapID).
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&imgs).Error
|
||||||
|
return imgs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
|
||||||
|
func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
|
||||||
var imgs []model.StreetSnapImage
|
var imgs []model.StreetSnapImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||||
@ -133,8 +200,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
|||||||
|
|
||||||
var imgs []model.StreetSnapImage
|
var imgs []model.StreetSnapImage
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnapImages).
|
||||||
Select("snap_id, image, name, sort_order").
|
Select("snap_id, image, name, sort_order").
|
||||||
Where("snap_id IN ? AND is_deleted = 0", ids).
|
Where("snap_id IN ?", ids).
|
||||||
Order("sort_order ASC, id ASC").
|
Order("sort_order ASC, id ASC").
|
||||||
Find(&imgs).Error
|
Find(&imgs).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -146,11 +214,11 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。
|
|
||||||
// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。
|
// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。
|
||||||
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
||||||
var rows []model.StreetSnap
|
var rows []model.StreetSnap
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
|
Table(publicStreetSnaps).
|
||||||
Where("is_deleted = 0").
|
Where("is_deleted = 0").
|
||||||
Order("image_count DESC, id DESC").
|
Order("image_count DESC, id DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
@ -164,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) {
|
||||||
@ -178,7 +247,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
|
|||||||
}
|
}
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
|
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@ -202,11 +271,63 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
|
|||||||
Updates(clean).Error
|
Updates(clean).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteSnapImage 软删单张街拍图片(后台正式编辑页删图),并同步重算 image_count。
|
||||||
|
//
|
||||||
|
// 与审核侧 SoftDeleteStreetImage 口径一致:删完按**实际存活图片行数**重算
|
||||||
|
// (`is_deleted = 0`,主图 + 副图全部计入)。
|
||||||
|
//
|
||||||
|
// 为什么必须重算而不是「递减 1」:理由同 articleRepository.DeleteRunwayImage —— 单表发布模型
|
||||||
|
// 删掉了晋升重建,编辑页删图造成的计数漂移不再被抹平,会永久显示在公开列表卡片的「N 张」上。
|
||||||
|
//
|
||||||
|
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 snap_id → 软删 → 重算。
|
||||||
|
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
|
||||||
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).
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
Model(&model.StreetSnapImage{}).
|
var img model.StreetSnapImage
|
||||||
Where("id = ? AND is_deleted = 0", imageID).
|
err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error
|
||||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
|
||||||
|
var snap model.StreetSnap
|
||||||
|
if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.StreetSnapImage{}).
|
||||||
|
Where("id = ? AND is_deleted = 0", imageID).
|
||||||
|
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Exec(`UPDATE street_snaps SET image_count =
|
||||||
|
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
|
||||||
|
WHERE id = ?`, img.SnapID, img.SnapID).Error; 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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||||
@ -221,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) {
|
||||||
@ -243,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
r.Static(opt.UploadURLPrefix, opt.UploadDir)
|
r.Static(opt.UploadURLPrefix, opt.UploadDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后台自有静态资源(admin.css / admin.js):embed 进二进制,无 CDN、无构建产物依赖;
|
||||||
|
// 给一段缓存时间,避免每翻一页都重新下载。
|
||||||
|
assets := r.Group("/admin/assets")
|
||||||
|
assets.Use(func(c *gin.Context) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=600")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
assets.StaticFS("/", http.FS(handler.BackstageAssetFS()))
|
||||||
|
|
||||||
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
|
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
|
||||||
r.GET("/admin/login", opt.Backstage.LoginPage)
|
r.GET("/admin/login", opt.Backstage.LoginPage)
|
||||||
r.POST("/admin/login", opt.Backstage.Login)
|
r.POST("/admin/login", opt.Backstage.Login)
|
||||||
@ -65,38 +74,43 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
|||||||
// 受保护:后台页面,统一走 cookie 鉴权中间件
|
// 受保护:后台页面,统一走 cookie 鉴权中间件
|
||||||
admin := r.Group("/admin")
|
admin := r.Group("/admin")
|
||||||
admin.Use(requireBackstageLogin(opt.JWT))
|
admin.Use(requireBackstageLogin(opt.JWT))
|
||||||
|
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
|
||||||
|
admin.Use(opt.Backstage.NavStats())
|
||||||
{
|
{
|
||||||
|
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||||
|
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
||||||
|
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
|
||||||
|
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
|
||||||
|
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
|
||||||
|
|
||||||
admin.GET("/", opt.Backstage.Dashboard)
|
admin.GET("/", opt.Backstage.Dashboard)
|
||||||
admin.GET("/brands", opt.Backstage.Brands)
|
admin.GET("/brands", opt.Backstage.Brands)
|
||||||
admin.GET("/brands/new", opt.Backstage.BrandNew)
|
admin.GET("/brands/new", opt.Backstage.BrandNew)
|
||||||
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.ReviewDraftImageDelete)
|
|
||||||
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
|
|
||||||
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages)
|
|
||||||
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage)
|
|
||||||
// 爬虫入库任务监控:列出处理进度 + 失败重试
|
// 爬虫入库任务监控:列出处理进度 + 失败重试
|
||||||
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)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@ -12,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"
|
||||||
@ -49,6 +52,11 @@ type fakeBrand struct{}
|
|||||||
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||||
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
|
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台品牌列表:假实现里与 List 同源(本假实现不区分走秀发布态)。
|
||||||
|
func (f *fakeBrand) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||||
|
return f.List(ctx, q)
|
||||||
|
}
|
||||||
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
|
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@ -103,11 +111,19 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
|
|||||||
ImageCount: 12,
|
ImageCount: 12,
|
||||||
}}, 1, nil
|
}}, 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到,
|
||||||
|
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
||||||
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
|
return nil, service.ErrArticleNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
||||||
|
func (f *fakeArticle) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
return &dto.PublicArticleDetail{
|
return &dto.PublicArticleDetail{
|
||||||
UID: id,
|
UID: id,
|
||||||
BrandUID: "001DESke",
|
BrandUID: "001DESke",
|
||||||
Title: "Acne Studios Spring 2024",
|
Title: "Acne Studios Spring 2024(待审)",
|
||||||
BrandName: "Acne Studios",
|
BrandName: "Acne Studios",
|
||||||
Images: []dto.PublicArticleImage{
|
Images: []dto.PublicArticleImage{
|
||||||
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
||||||
@ -131,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{}
|
||||||
|
|
||||||
@ -142,20 +161,38 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub
|
|||||||
ImageCount: 8,
|
ImageCount: 8,
|
||||||
}}, 1, nil
|
}}, 1, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
|
||||||
|
// AdminList 后台列表读基表;假实现里与 List 同源即可(本假实现不区分可见性)。
|
||||||
|
func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
|
||||||
|
return f.List(ctx, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
|
||||||
|
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
||||||
|
func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
|
return nil, service.ErrStreetSnapNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
||||||
|
func (f *fakeStreet) DetailAdmin(ctx context.Context, id, 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(待审)",
|
||||||
Images: []dto.PublicArticleImage{
|
Images: []dto.PublicArticleImage{
|
||||||
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
|
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
|
||||||
},
|
},
|
||||||
}, 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
|
||||||
@ -170,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
|
||||||
|
}
|
||||||
|
|
||||||
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
|
||||||
|
|
||||||
@ -177,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) 登录页可访问
|
||||||
@ -254,91 +347,138 @@ 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) 后台走秀详情渲染图集。
|
||||||
|
// fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found,
|
||||||
|
// 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
|
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", 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(), "Look 1") {
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") {
|
||||||
t.Fatalf("走秀详情异常: %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) 后台街拍详情渲染图集。
|
||||||
|
// 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
|
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", 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(), "Snap 1") {
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") {
|
||||||
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
|
t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
|
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
|
||||||
|
|
||||||
// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。
|
// fakeReview 实现审核服务接口;
|
||||||
|
// ⚠️ 嵌入的 service.ReviewService 是 nil 接口,只对「确实实现的方法」安全 ——
|
||||||
|
// 任何未覆盖的方法被调用时都会 nil panic(曾经因为导航中间件调 KindTabs 而炸出来)。
|
||||||
|
// 因此凡是页面/接口会走到的服务方法,都必须在这里显式实现。
|
||||||
type fakeReview struct {
|
type fakeReview struct {
|
||||||
service.ReviewService
|
service.ReviewService
|
||||||
attachOK bool
|
attachOK bool
|
||||||
last struct {
|
approveErr error
|
||||||
kind string
|
last struct {
|
||||||
draftID uint32
|
kind string
|
||||||
mainID uint32
|
recordID uint32
|
||||||
imgIDs []uint32
|
mainID uint32
|
||||||
imgID uint32
|
imgIDs []uint32
|
||||||
op string
|
imgID uint32
|
||||||
|
op string
|
||||||
}
|
}
|
||||||
view *service.DraftDetailView
|
// calls 记录批量审核的逐条调用,供断言。
|
||||||
|
calls []struct {
|
||||||
|
Action string
|
||||||
|
Kind string
|
||||||
|
ID uint32
|
||||||
|
}
|
||||||
|
view *service.RecordDetailView
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ service.ReviewService = (*fakeReview)(nil)
|
var _ service.ReviewService = (*fakeReview)(nil)
|
||||||
|
|
||||||
func (f *fakeReview) DraftDetail(_ context.Context, _ string, _ uint32) (*service.DraftDetailView, error) {
|
func (f *fakeReview) RecordDetail(_ context.Context, _ string, _ uint32) (*service.RecordDetailView, error) {
|
||||||
if f.view != nil {
|
if f.view != nil {
|
||||||
return f.view, nil
|
return f.view, nil
|
||||||
}
|
}
|
||||||
return &service.DraftDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
|
return &service.RecordDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, mainID uint32, imgIDs []uint32) error {
|
// UpdateRecord 表单式通过路径会先应用字段微调;这里只做无操作桩(真正的白名单在仓储层)。
|
||||||
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach"
|
func (f *fakeReview) UpdateRecord(_ context.Context, _ string, _ uint32, _ map[string]any) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReview) AttachImages(_ context.Context, kind string, recordID, mainID uint32, imgIDs []uint32) error {
|
||||||
|
f.last.kind, f.last.recordID, f.last.mainID, f.last.imgIDs, f.last.op = kind, recordID, mainID, imgIDs, "attach"
|
||||||
if !f.attachOK {
|
if !f.attachOK {
|
||||||
return errors.New("主图无效")
|
return errors.New("主图无效")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error {
|
func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error {
|
||||||
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach"
|
f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach"
|
||||||
if !f.attachOK {
|
if !f.attachOK {
|
||||||
return errors.New("主图无效")
|
return errors.New("主图无效")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KindTabs 是导航中间件在每个后台页面上都会调用的方法(用于显示待审计数角标),
|
||||||
|
// 必须显式实现,否则会落到嵌入的 nil 接口上 panic。
|
||||||
|
func (f *fakeReview) KindTabs(_ context.Context) []service.KindTab {
|
||||||
|
return []service.KindTab{
|
||||||
|
{Kind: "", Label: "全部", Pending: 2},
|
||||||
|
{Kind: "street", Label: "街拍", Pending: 1},
|
||||||
|
{Kind: "runway", Label: "走秀", Pending: 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer string) (uint32, error) {
|
||||||
|
f.calls = append(f.calls, struct {
|
||||||
|
Action string
|
||||||
|
Kind string
|
||||||
|
ID uint32
|
||||||
|
}{"approve", kind, id})
|
||||||
|
// last 供「表单式通过」用例断言(批量接口用 calls)。
|
||||||
|
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
|
||||||
|
if f.approveErr != nil {
|
||||||
|
return 0, f.approveErr
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@ -362,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 {
|
||||||
@ -383,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)
|
||||||
@ -401,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 {
|
||||||
@ -417,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.DraftDetailView{
|
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
|
||||||
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
|
|
||||||
Images: []service.DraftImageRef{
|
|
||||||
{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.DraftImageGroup{
|
|
||||||
{Image: service.DraftImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.DraftImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
|
|
||||||
{Image: service.DraftImageRef{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("副图应显示所属主图")
|
||||||
}
|
}
|
||||||
@ -462,3 +592,136 @@ func TestBackstageStreetRenderGroups(t *testing.T) {
|
|||||||
t.Fatalf("不应再出现「并入上一张」")
|
t.Fatalf("不应再出现「并入上一张」")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBackstageApprovePublishes 通过 = 只改状态,不再晋升重建。
|
||||||
|
// 断言表单式通过路径确实走到了 service.Approve(而不是别的方法)。
|
||||||
|
func TestBackstageApprovePublishes(t *testing.T) {
|
||||||
|
r, rev := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("通过应 302,实际 %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if rev.last.op != "approve" {
|
||||||
|
t.Fatalf("应调用 Approve,实际 op=%s", rev.last.op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ───────────────────────────
|
||||||
|
|
||||||
|
// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。
|
||||||
|
func TestBackstageAssets(t *testing.T) {
|
||||||
|
r, _ := newReviewRouter(t)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ path, want string }{
|
||||||
|
{"/admin/assets/admin.css", ".bar-nav"},
|
||||||
|
{"/admin/assets/admin.js", "data-admin-js"},
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("%s 应 200,实际 %d", tc.path, w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), tc.want) {
|
||||||
|
t.Fatalf("%s 内容缺少 %q", tc.path, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageNavStatsOnEveryPage 后台任意页面都带待审计数角标。
|
||||||
|
// 该角标由 NavStats 中间件提供;它同时是「所有页面都会调用 ReviewService.KindTabs」的证据。
|
||||||
|
func TestBackstageNavStatsOnEveryPage(t *testing.T) {
|
||||||
|
r, _ := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/brands", nil)
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("品牌页应 200: %d", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "bar-badge") || !strings.Contains(body, ">2<") {
|
||||||
|
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
|
||||||
|
}
|
||||||
|
// 导航已在共享 partial 里,各模块入口都应在
|
||||||
|
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
|
||||||
|
if !strings.Contains(body, ">"+label) {
|
||||||
|
t.Fatalf("导航应包含入口 %q", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageReviewBatchApprove 批量通过:一次请求处理多条,返回 ok 计数。
|
||||||
|
func TestBackstageReviewBatchApprove(t *testing.T) {
|
||||||
|
r, rev := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("批量通过应 200: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||||
|
t.Fatalf("响应不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
if res.OK != 2 {
|
||||||
|
t.Fatalf("应通过 2 条,实际 %d", res.OK)
|
||||||
|
}
|
||||||
|
if res.Pending != 2 {
|
||||||
|
t.Fatalf("应回带最新待审计数 2,实际 %d", res.Pending)
|
||||||
|
}
|
||||||
|
if len(rev.calls) != 2 || rev.calls[0].ID != 11 || rev.calls[1].Kind != "runway" {
|
||||||
|
t.Fatalf("逐条调用未透传到 service: %+v", rev.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
|
||||||
|
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
|
||||||
|
r, rev := newReviewRouter(t)
|
||||||
|
tok := reviewToken(t, r)
|
||||||
|
|
||||||
|
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.AddCookie(tok)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("部分失败仍应 200: %d", w.Code)
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
OK int `json:"ok"`
|
||||||
|
Failed []struct {
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
} `json:"failed"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||||
|
t.Fatalf("响应不是合法 JSON: %v", err)
|
||||||
|
}
|
||||||
|
if res.OK != 1 || len(res.Failed) != 1 || res.Failed[0].ID != 12 {
|
||||||
|
t.Fatalf("应 1 成功 1 失败(id=12): ok=%d failed=%+v", res.OK, res.Failed)
|
||||||
|
}
|
||||||
|
if len(rev.calls) != 1 {
|
||||||
|
t.Fatalf("未注册类型不应调用 service: %+v", rev.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,10 +24,14 @@ type MediaCleanupEnqueuer interface {
|
|||||||
type ArticleService interface {
|
type ArticleService interface {
|
||||||
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
|
||||||
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||||
|
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
|
||||||
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
|
||||||
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 软删单张走秀图片。
|
||||||
@ -87,7 +91,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
|||||||
return list, total, nil
|
return list, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
|
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / 状态
|
||||||
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
||||||
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
|
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
|
||||||
q.Normalize()
|
q.Normalize()
|
||||||
@ -107,6 +111,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
|
|||||||
CollectionType: r.CollectionType,
|
CollectionType: r.CollectionType,
|
||||||
SeasonCode: r.SeasonCode,
|
SeasonCode: r.SeasonCode,
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
|
Status: r.Status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return list, total, nil
|
return list, total, nil
|
||||||
@ -151,41 +156,62 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
|
||||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
// locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。
|
||||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
row, err := s.articles.FindByID(ctx, id)
|
row, err := s.articles.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
return nil, s.detailErr(err)
|
||||||
return nil, ErrArticleNotFound
|
|
||||||
}
|
|
||||||
return nil, NewError(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
imgs, err := s.articles.ListImages(ctx, id)
|
imgs, err := s.articles.ListImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
return s.buildDetail(row, imgs, locale), nil
|
||||||
|
}
|
||||||
|
|
||||||
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||||
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
//
|
||||||
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||||
|
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||||
|
func (s *articleService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
|
row, err := s.articles.FindByIDBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, s.detailErr(err)
|
||||||
|
}
|
||||||
|
imgs, err := s.articles.ListImagesBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.buildDetail(row, imgs, locale), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||||
|
func (s *articleService) detailErr(err error) error {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return ErrArticleNotFound
|
||||||
|
}
|
||||||
|
return NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDetail 把查询行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||||
|
func (s *articleService) buildDetail(row *model.RunwayRow, imgs []model.BrandRunwayImage, locale string) *dto.PublicArticleDetail {
|
||||||
|
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 parent_image_id
|
||||||
|
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,13 +224,56 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
|
|||||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||||
Images: pubMains,
|
Images: pubMains,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 {
|
||||||
@ -223,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,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
|
||||||
@ -298,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
|
||||||
|
|||||||
@ -16,6 +16,9 @@ import (
|
|||||||
// BrandService 品牌业务接口。
|
// BrandService 品牌业务接口。
|
||||||
type BrandService interface {
|
type BrandService interface {
|
||||||
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||||
|
// AdminList 后台品牌列表:字母索引不要求走秀已发布,
|
||||||
|
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开 List 读视图,会挡掉它们)。
|
||||||
|
AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
|
||||||
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
|
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
|
||||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||||
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
|
||||||
@ -70,6 +73,18 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public
|
|||||||
return s.toPublicBrands(items, q.Locale), total, nil
|
return s.toPublicBrands(items, q.Locale), total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台品牌列表:与 List 同形,但走 ListAdmin —— 字母索引不过滤走秀发布态,
|
||||||
|
// 只有待审 / 驳回走秀的品牌仍可见。后台不做 featured 收窄,故 restrictIDs 传 nil。
|
||||||
|
func (s *brandService) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
|
||||||
|
q.Normalize()
|
||||||
|
|
||||||
|
items, total, err := s.brands.ListAdmin(ctx, q, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.toPublicBrands(items, q.Locale), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
|
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
|
||||||
//
|
//
|
||||||
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——
|
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——
|
||||||
|
|||||||
168
internal/service/ingest_image_count_test.go
Normal file
168
internal/service/ingest_image_count_test.go
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fashionapi/internal/dto"
|
||||||
|
"fashionapi/internal/model"
|
||||||
|
"fashionapi/internal/pkg/hashid"
|
||||||
|
"fashionapi/internal/pkg/storage"
|
||||||
|
"fashionapi/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureIngestRepo 捕获 processRunway 交给仓储的「待落库」记录与图片行。
|
||||||
|
// 只实现被测路径会用到的方法,其余由内嵌 nil 接口兜底(不应被调用)。
|
||||||
|
type captureIngestRepo struct {
|
||||||
|
repository.IngestRepository
|
||||||
|
createdRW *model.BrandRunway
|
||||||
|
createdImages []model.BrandRunwayImage
|
||||||
|
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
|
||||||
|
if c.deletedEntity {
|
||||||
|
return 1, model.StatusPending, true, true, nil // 命中但已软删
|
||||||
|
}
|
||||||
|
return 0, "", false, false, nil // 实体键未命中 → 新建分支
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
|
||||||
|
c.createdRW = rw
|
||||||
|
c.createdImages = imgs
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
|
||||||
|
return 0, false, 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 品牌校验恒通过。
|
||||||
|
type fakeBrandRepo struct{ repository.BrandRepository }
|
||||||
|
|
||||||
|
func (fakeBrandRepo) FindByID(ctx context.Context, id uint32) (*model.Brand, error) {
|
||||||
|
return &model.Brand{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessRunwayImageCountEqualsAliveMainRows 回归 image_count 漂移:
|
||||||
|
//
|
||||||
|
// 旧实现取 imageCount = len(p.Looks),但 fetchLookImages 会跳过 look.Main == "" 的 look
|
||||||
|
// 与 sha1 重复的行 —— 实际落库的主图行可能更少。旧流程靠晋升按实际行重算自愈,单表发布后
|
||||||
|
// 该路径已删除,漂移会永久留在公开卡片的「N 张」角标与热度 SUM(image_count) 上。
|
||||||
|
//
|
||||||
|
// 本用例断言:入库时交给仓储的 image_count == 实际存活主图行数(is_detail = 0),
|
||||||
|
// 且在数据触发跳过时**严格小于** len(Looks)。旧实现下 image_count 会是 4,确定性失败。
|
||||||
|
func TestProcessRunwayImageCountEqualsAliveMainRows(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")
|
||||||
|
// 按路径返回不同内容:Content 不同 → sha1 不同;同路径 → sha1 相同(触发批次内去重)。
|
||||||
|
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
repo := &captureIngestRepo{}
|
||||||
|
s := &IngestService{
|
||||||
|
repo: repo,
|
||||||
|
brandRepo: fakeBrandRepo{},
|
||||||
|
uploader: local,
|
||||||
|
local: local,
|
||||||
|
httpClient: srv.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
p := dto.RunwayIngest{
|
||||||
|
Kind: dto.IngestKindRunway,
|
||||||
|
BrandUID: hashid.Encode(1),
|
||||||
|
TitleEn: "image-count-test",
|
||||||
|
Year: 2027,
|
||||||
|
Season: "spring",
|
||||||
|
CollectionType: "rtw",
|
||||||
|
Looks: []dto.RunwayLook{
|
||||||
|
// look 1:主图 a + 细节图 a1 → 1 主图
|
||||||
|
{Main: srv.URL + "/img/a.jpg", Details: []string{srv.URL + "/img/a1.jpg"}},
|
||||||
|
// look 2:空主图,只有细节图 b1 → 0 主图(旧实现仍按 1 计)
|
||||||
|
{Details: []string{srv.URL + "/img/b1.jpg"}},
|
||||||
|
// look 3:主图与 look 1 内容相同(sha1 重复)→ 被跳过,0 主图(旧实现仍按 1 计)
|
||||||
|
{Main: srv.URL + "/img/a.jpg"},
|
||||||
|
// look 4:主图 c → 1 主图
|
||||||
|
{Main: srv.URL + "/img/c.jpg"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
job := model.IngestJob{ID: 1, Kind: model.IngestKindCrawl}
|
||||||
|
s.processRunway(context.Background(), job, p)
|
||||||
|
|
||||||
|
if repo.createdRW == nil {
|
||||||
|
t.Fatal("未写入走秀记录")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实际落库主图行:a、c → 2 张;细节图 a1、b1 → 2 张。
|
||||||
|
var actualMain uint16
|
||||||
|
for _, im := range repo.createdImages {
|
||||||
|
if im.IsDetail == 0 {
|
||||||
|
actualMain++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if actualMain != 2 {
|
||||||
|
t.Fatalf("前置:实际主图行应为 2,实际 %d(图片行=%+v)", actualMain, repo.createdImages)
|
||||||
|
}
|
||||||
|
if got := repo.createdRW.ImageCount; got != actualMain {
|
||||||
|
t.Fatalf("入库 image_count 应等于存活主图行数 %d,实际 %d", actualMain, got)
|
||||||
|
}
|
||||||
|
if repo.createdRW.ImageCount >= uint16(len(p.Looks)) {
|
||||||
|
t.Fatalf("本用例数据应让 image_count(%d) 严格小于 len(Looks)=%d,否则测不出漂移",
|
||||||
|
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -229,7 +245,7 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_drafts。
|
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
|
||||||
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
|
||||||
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
||||||
brandID, err := hashid.Decode(p.BrandUID)
|
brandID, err := hashid.Decode(p.BrandUID)
|
||||||
@ -242,42 +258,85 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过,
|
// 2) 实体键查重并决定分支(单表模型)
|
||||||
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
|
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
|
||||||
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
|
// 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。
|
||||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
effYear := p.Year
|
||||||
dedupStart := time.Now()
|
if effYear == 0 && p.PublishedAt != 0 {
|
||||||
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found {
|
effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year())
|
||||||
log.Printf("[ingest] job=%d runway 实体去重命中(season=%s type=%s),跳过 耗时=%v",
|
}
|
||||||
job.ID, seasonCode, p.CollectionType, time.Since(dedupStart).Round(time.Millisecond))
|
seasonCode := season.Derive(effYear, p.CollectionType, p.Season)
|
||||||
|
reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
|
||||||
|
if err != nil {
|
||||||
|
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
|
||||||
|
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
|
||||||
|
// 而直接跳过。但唯一索引是最后一道防线,不该靠它来替代这里的前置判断,
|
||||||
|
// 故仍 fail-closed + 退避重试,不做无谓的写尝试。
|
||||||
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exist && p.Year == 0 {
|
||||||
|
// 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次,
|
||||||
|
// 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。
|
||||||
|
var rid uint32
|
||||||
|
rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType)
|
||||||
|
if err != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reuseID = rid
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action := decideIngestAction(exist, existStatus)
|
||||||
|
if action == ingestSkip {
|
||||||
|
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 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
|
||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d runway 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
|
|
||||||
|
|
||||||
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
|
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
|
||||||
|
|
||||||
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||||
var cover string
|
var cover string
|
||||||
var draftImages []model.BrandRunwayDraftImage
|
var images []model.BrandRunwayImage
|
||||||
var keys []string
|
var keys []string
|
||||||
var imgFailed bool
|
var imgFailed bool
|
||||||
var imageCount uint16
|
var imageCount uint16
|
||||||
var timing *fetchTiming
|
var timing *fetchTiming
|
||||||
if len(p.Looks) > 0 {
|
if len(p.Looks) > 0 {
|
||||||
cover, draftImages, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
cover, images, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||||
imageCount = uint16(len(p.Looks))
|
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
|
||||||
|
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
|
||||||
|
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
|
||||||
|
// 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。
|
||||||
|
imageCount = countMainImages(images)
|
||||||
} else {
|
} else {
|
||||||
var fimgs []fetchedImage
|
var fimgs []fetchedImage
|
||||||
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||||
imageCount = uint16(len(fimgs))
|
imageCount = uint16(len(fimgs))
|
||||||
for i, fi := range fimgs {
|
for i, fi := range fimgs {
|
||||||
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
images = append(images, model.BrandRunwayImage{
|
||||||
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,
|
||||||
@ -294,53 +353,65 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
|
// 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片)
|
||||||
writeStart := time.Now()
|
writeStart := time.Now()
|
||||||
draft := &model.BrandRunwayDraft{
|
rw := &model.BrandRunway{
|
||||||
JobID: job.ID,
|
JobID: job.ID,
|
||||||
BrandID: brandID,
|
BrandID: brandID,
|
||||||
TitleEn: p.TitleEn,
|
TitleEn: p.TitleEn,
|
||||||
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.DraftStatusPending,
|
Status: model.StatusPending,
|
||||||
}
|
}
|
||||||
id, err := s.repo.CreateRunwayDraft(ctx, draft)
|
for i := range images {
|
||||||
if err != nil {
|
images[i].BrandID = brandID
|
||||||
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
|
}
|
||||||
|
var saveErr error
|
||||||
|
if action == ingestReuseRejected {
|
||||||
|
saveErr = s.repo.ReuseRejectedRunway(ctx, reuseID, rw, images)
|
||||||
|
} else {
|
||||||
|
_, saveErr = s.repo.CreateRunwayWithImages(ctx, rw, images)
|
||||||
|
}
|
||||||
|
if saveErr != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := range draftImages {
|
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
|
||||||
draftImages[i].DraftID = id
|
runwayID := rw.ID
|
||||||
|
if runwayID == 0 {
|
||||||
|
runwayID = reuseID
|
||||||
}
|
}
|
||||||
if err := s.repo.CreateRunwayDraftImages(ctx, draftImages); err != nil {
|
if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil {
|
||||||
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d runway 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(draftImages))
|
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
|
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落草稿的
|
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||||
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
|
||||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||||
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||||
|
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||||
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
|
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
|
||||||
//
|
//
|
||||||
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
||||||
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
|
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
|
||||||
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
|
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
|
||||||
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool, *fetchTiming) {
|
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
timing := &fetchTiming{}
|
timing := &fetchTiming{}
|
||||||
cover := ""
|
cover := ""
|
||||||
rows := make([]model.BrandRunwayDraftImage, 0)
|
rows := make([]model.BrandRunwayImage, 0)
|
||||||
keys := make([]string, 0)
|
keys := make([]string, 0)
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
failed := false
|
failed := false
|
||||||
@ -392,11 +463,10 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
|
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
|
||||||
isDetail = 1
|
isDetail = 1
|
||||||
}
|
}
|
||||||
rows = append(rows, model.BrandRunwayDraftImage{
|
rows = append(rows, model.BrandRunwayImage{
|
||||||
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,
|
||||||
@ -408,18 +478,43 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
|||||||
return cover, rows, keys, failed, timing
|
return cover, rows, keys, failed, timing
|
||||||
}
|
}
|
||||||
|
|
||||||
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
|
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
|
||||||
|
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
|
||||||
|
func countMainImages(images []model.BrandRunwayImage) uint16 {
|
||||||
|
var n uint16
|
||||||
|
for i := range images {
|
||||||
|
if images[i].IsDetail == 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
|
// 1) 实体键查重并决定分支(单表模型,同 runway)。
|
||||||
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
|
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
|
||||||
dedupStart := time.Now()
|
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
||||||
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found {
|
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
|
||||||
log.Printf("[ingest] job=%d street 实体去重命中(city=%s year=%d),跳过 耗时=%v",
|
title := repository.NormalizeStreetTitle(p.TitleEn)
|
||||||
job.ID, p.City, p.Year, time.Since(dedupStart).Round(time.Millisecond))
|
reuseID, existStatus, exist, isDeleted, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
|
||||||
|
if err != nil {
|
||||||
|
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||||
|
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
|
||||||
|
if exist && isDeleted {
|
||||||
|
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
|
||||||
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := decideIngestAction(exist, existStatus)
|
||||||
|
if action == ingestSkip {
|
||||||
|
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d street 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
|
|
||||||
|
|
||||||
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
||||||
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||||
@ -432,26 +527,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snaps 正式表
|
// 3) 直写正式表(status=pending)
|
||||||
writeStart := time.Now()
|
writeStart := time.Now()
|
||||||
draft := &model.StreetSnapDraft{
|
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,
|
||||||
ImageCount: uint16(len(fimgs)),
|
ImageCount: uint16(len(fimgs)),
|
||||||
Status: model.DraftStatusPending,
|
Status: model.StatusPending,
|
||||||
}
|
}
|
||||||
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
|
rows := make([]model.StreetSnapImage, 0, len(fimgs))
|
||||||
if err != nil {
|
|
||||||
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
|
|
||||||
for i, fi := range fimgs {
|
for i, fi := range fimgs {
|
||||||
rows = append(rows, model.StreetSnapDraftImage{
|
rows = append(rows, model.StreetSnapImage{
|
||||||
DraftID: id,
|
|
||||||
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),
|
||||||
@ -460,12 +549,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
|||||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
var saveErr error
|
||||||
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
|
if action == ingestReuseRejected {
|
||||||
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
|
saveErr = s.repo.ReuseRejectedStreetSnap(ctx, reuseID, snap, rows)
|
||||||
|
} else {
|
||||||
|
_, saveErr = s.repo.CreateStreetSnapWithImages(ctx, snap, rows)
|
||||||
|
}
|
||||||
|
if saveErr != nil {
|
||||||
|
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[ingest] job=%d street 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows))
|
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
|
||||||
|
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
|
||||||
_ = s.repo.MarkDone(ctx, job.ID)
|
_ = s.repo.MarkDone(ctx, job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -606,9 +701,9 @@ func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string,
|
|||||||
var tables []string
|
var tables []string
|
||||||
switch kind {
|
switch kind {
|
||||||
case dto.IngestKindRunway:
|
case dto.IngestKindRunway:
|
||||||
tables = []string{"brand_runway_draft_images", "brand_runway_images"}
|
tables = []string{"brand_runway_images"}
|
||||||
case dto.IngestKindStreet:
|
case dto.IngestKindStreet:
|
||||||
tables = []string{"street_snap_draft_images", "street_snap_images"}
|
tables = []string{"street_snap_images"}
|
||||||
default:
|
default:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
@ -666,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 序号
|
||||||
@ -742,3 +837,25 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
|
|||||||
}
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ingestActionKind 入库分支决策结果(单表发布模型)。
|
||||||
|
type ingestActionKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ingestCreate ingestActionKind = iota // 实体键未命中 → 新建记录
|
||||||
|
ingestSkip // 命中 pending/published → 放弃本次入库
|
||||||
|
ingestReuseRejected // 命中 rejected → 复用该行重审
|
||||||
|
)
|
||||||
|
|
||||||
|
// decideIngestAction 按实体键命中情况与既有行状态决定入库分支。
|
||||||
|
// 抽成纯函数,是为了让「驳回不是永久黑名单」这条规则有独立的、不需要数据库的测试。
|
||||||
|
func decideIngestAction(found bool, status string) ingestActionKind {
|
||||||
|
switch {
|
||||||
|
case !found:
|
||||||
|
return ingestCreate
|
||||||
|
case status == model.StatusRejected:
|
||||||
|
return ingestReuseRejected
|
||||||
|
default:
|
||||||
|
return ingestSkip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
@ -84,3 +84,26 @@ func TestIngestRetryBackoff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDecideIngestAction 入库三分支决策:未命中→新建;命中 pending/published→放弃;命中 rejected→复用重审。
|
||||||
|
// 这是「驳回不是永久黑名单」这条规则的直接证据。
|
||||||
|
func TestDecideIngestAction(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
found bool
|
||||||
|
status string
|
||||||
|
want ingestActionKind
|
||||||
|
}{
|
||||||
|
{"未命中→新建", false, "", ingestCreate},
|
||||||
|
{"命中 pending→放弃", true, model.StatusPending, ingestSkip},
|
||||||
|
{"命中 published→放弃", true, model.StatusPublished, ingestSkip},
|
||||||
|
{"命中 rejected→复用重审", true, model.StatusRejected, ingestReuseRejected},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := decideIngestAction(c.found, c.status); got != c.want {
|
||||||
|
t.Fatalf("decideIngestAction(%v, %q) = %v, want %v", c.found, c.status, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,8 +13,8 @@ import (
|
|||||||
"fashionapi/internal/repository"
|
"fashionapi/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
|
// RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
|
||||||
type DraftCard struct {
|
type RecordCard struct {
|
||||||
Kind string `json:"kind"` // runway | street | 未来其它模块
|
Kind string `json:"kind"` // runway | street | 未来其它模块
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Title string `json:"title"` // 展示标题
|
Title string `json:"title"` // 展示标题
|
||||||
@ -26,11 +26,11 @@ type DraftCard struct {
|
|||||||
UpdatedAt uint32 `json:"updated_at"`
|
UpdatedAt uint32 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftImageRef 草稿图片引用(统一结构)。
|
// RecordImageRef 记录图片引用(统一结构)。
|
||||||
//
|
//
|
||||||
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
|
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
|
||||||
// DetailCount 让模板不用自己数副图。
|
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
|
||||||
type DraftImageRef struct {
|
type RecordImageRef struct {
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -49,41 +49,42 @@ type EditField struct {
|
|||||||
Type string `json:"type"` // text | textarea | number
|
Type string `json:"type"` // text | textarea | number
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftDetailView 审核详情统一视图(所有模块共用)。
|
// RecordDetailView 审核详情统一视图(所有模块共用)。
|
||||||
type DraftDetailView struct {
|
type RecordDetailView struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
|
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
|
||||||
Title string `json:"title"` // 展示标题(runway=title_en,street=title)
|
Title string `json:"title"` // 展示标题(runway=title_en,street=title)
|
||||||
TitleEn string `json:"title_en"`
|
TitleEn string `json:"title_en"`
|
||||||
TitleCn string `json:"title_cn"`
|
TitleCn string `json:"title_cn"`
|
||||||
DescriptionEn string `json:"description_en"`
|
DescriptionEn string `json:"description_en"`
|
||||||
DescriptionCn string `json:"description_cn"`
|
DescriptionCn string `json:"description_cn"`
|
||||||
Year uint16 `json:"year"`
|
Year uint16 `json:"year"`
|
||||||
Season string `json:"season"`
|
Season string `json:"season"`
|
||||||
CollectionType string `json:"collection_type"`
|
CollectionType string `json:"collection_type"`
|
||||||
SeasonCode string `json:"season_code"`
|
SeasonCode string `json:"season_code"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
ImageCount uint16 `json:"image_count"`
|
ImageCount uint16 `json:"image_count"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Images []DraftImageRef `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),结构同构。
|
||||||
Groups []DraftImageGroup `json:"groups,omitempty"`
|
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
|
||||||
|
Groups []ImageGroup `json:"groups,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
|
// ImageGroup 一张主图及其副图,供审核页按组折叠渲染。
|
||||||
type DraftImageGroup struct {
|
type ImageGroup struct {
|
||||||
Image DraftImageRef `json:"image"` // 主图
|
Image RecordImageRef `json:"image"` // 主图
|
||||||
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空)
|
Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。
|
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。
|
||||||
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
|
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
|
||||||
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")
|
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本条记录、未被删除,且自身是主图")
|
||||||
|
|
||||||
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
|
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
|
||||||
type KindTab struct {
|
type KindTab struct {
|
||||||
@ -92,40 +93,43 @@ type KindTab struct {
|
|||||||
Pending int64 `json:"pending"`
|
Pending int64 `json:"pending"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。
|
// ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。
|
||||||
//
|
//
|
||||||
// 新增一种爬虫数据类型需要审核时,只需:
|
// 新增一种爬虫数据类型需要审核时,只需:
|
||||||
// 1. 建草稿表 + 迁移;
|
// 1. 建正式表 + 迁移(记录自带 status 列);
|
||||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending;
|
// 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
|
||||||
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error)
|
ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error)
|
||||||
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error)
|
RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error)
|
||||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||||
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。
|
// 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 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。
|
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
|
||||||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||||||
|
//
|
||||||
|
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
|
||||||
|
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
|
||||||
|
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
|
||||||
type ReviewService interface {
|
type ReviewService interface {
|
||||||
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error)
|
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
||||||
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error)
|
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
||||||
UpdateDraft(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文件)。
|
||||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||||
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||||
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error
|
||||||
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error
|
// DetachImage 把副图拆出恢复为主图(街拍专用)。
|
||||||
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。
|
DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||||
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
|
||||||
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||||
KindTabs(ctx context.Context) []KindTab
|
KindTabs(ctx context.Context) []KindTab
|
||||||
}
|
}
|
||||||
@ -165,22 +169,22 @@ func (s *reviewService) module(kind string) (ReviewModule, bool) {
|
|||||||
return m, ok
|
return m, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) {
|
func (s *reviewService) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) {
|
||||||
if kind == "all" {
|
if kind == "all" {
|
||||||
kind = ""
|
kind = ""
|
||||||
}
|
}
|
||||||
if kind != "" {
|
if kind != "" {
|
||||||
m, ok := s.module(kind)
|
m, ok := s.module(kind)
|
||||||
if !ok {
|
if !ok {
|
||||||
return []DraftCard{}, 0, nil
|
return []RecordCard{}, 0, nil
|
||||||
}
|
}
|
||||||
return m.ListDrafts(ctx, status, page, size)
|
return m.ListRecords(ctx, status, page, size)
|
||||||
}
|
}
|
||||||
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
|
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
|
||||||
var all []DraftCard
|
var all []RecordCard
|
||||||
var total int64
|
var total int64
|
||||||
for _, k := range s.order {
|
for _, k := range s.order {
|
||||||
cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000)
|
cards, t, err := s.modules[k].ListRecords(ctx, status, 1, 1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@ -208,20 +212,20 @@ func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, pag
|
|||||||
return all[start:end], total, nil
|
return all[start:end], total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) {
|
func (s *reviewService) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) {
|
||||||
m, ok := s.module(kind)
|
m, ok := s.module(kind)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, repository.ErrNotFound
|
return nil, repository.ErrNotFound
|
||||||
}
|
}
|
||||||
return m.DraftDetail(ctx, id)
|
return m.RecordDetail(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error {
|
func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error {
|
||||||
m, ok := s.module(kind)
|
m, ok := s.module(kind)
|
||||||
if !ok {
|
if !ok {
|
||||||
return repository.ErrNotFound
|
return repository.ErrNotFound
|
||||||
}
|
}
|
||||||
return m.UpdateDraft(ctx, id, fields)
|
return m.UpdateRecord(ctx, id, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
|
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
|
||||||
@ -232,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
|
||||||
@ -256,22 +252,22 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
|||||||
return tabs
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。
|
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。
|
||||||
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count;
|
// 按 kind 委派到对应图片表的软删,并同步记录 image_count;
|
||||||
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
|
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
|
||||||
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error {
|
||||||
if _, ok := s.module(kind); !ok {
|
if _, ok := s.module(kind); !ok {
|
||||||
return repository.ErrNotFound
|
return repository.ErrNotFound
|
||||||
}
|
}
|
||||||
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID)
|
key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case dto.IngestKindRunway:
|
case dto.IngestKindRunway:
|
||||||
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID)
|
err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID)
|
||||||
case dto.IngestKindStreet:
|
case dto.IngestKindStreet:
|
||||||
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID)
|
err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID)
|
||||||
default:
|
default:
|
||||||
return repository.ErrNotFound
|
return repository.ErrNotFound
|
||||||
}
|
}
|
||||||
@ -284,21 +280,29 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draftID, 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.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs))
|
switch kind {
|
||||||
}
|
case dto.IngestKindStreet:
|
||||||
|
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||||||
func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
case dto.IngestKindRunway:
|
||||||
if kind != dto.IngestKindStreet {
|
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 {
|
||||||
|
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.DetachStreetDraftImage(ctx, draftID, imgID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
||||||
@ -322,24 +326,24 @@ type runwayModule struct {
|
|||||||
func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
|
func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
|
||||||
func (m *runwayModule) Label() string { return "走秀" }
|
func (m *runwayModule) Label() string { return "走秀" }
|
||||||
|
|
||||||
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
|
func (m *runwayModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
|
||||||
rows, total, err := m.repo.ListDrafts(ctx, status, page, size)
|
rows, total, err := m.repo.ListRecords(ctx, status, page, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return m.toRunwayCards(rows), total, nil
|
return m.toRunwayCards(rows), total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
|
func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
|
||||||
d, err := m.repo.DraftDetail(ctx, id)
|
d, err := m.repo.RecordDetail(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
imgs, err := m.repo.DraftImages(ctx, id)
|
imgs, err := m.repo.RecordImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
v := &DraftDetailView{
|
v := &RecordDetailView{
|
||||||
Kind: dto.IngestKindRunway,
|
Kind: dto.IngestKindRunway,
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
BrandID: d.BrandID,
|
BrandID: d.BrandID,
|
||||||
@ -366,33 +370,71 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
{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, DraftImageRef{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) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
|
||||||
return m.repo.UpdateDraft(ctx, id, fields)
|
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
|
return m.repo.UpdateRecord(ctx, id, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||||
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id)
|
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
|
return id, nil
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return officialID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
|
||||||
return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
|
||||||
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.BrandRunwayDraft{})
|
return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── street 模块 ────────────────────────────────────────────────────────────────
|
// ── street 模块 ────────────────────────────────────────────────────────────────
|
||||||
@ -405,24 +447,24 @@ type streetModule struct {
|
|||||||
func (m *streetModule) Kind() string { return dto.IngestKindStreet }
|
func (m *streetModule) Kind() string { return dto.IngestKindStreet }
|
||||||
func (m *streetModule) Label() string { return "街拍" }
|
func (m *streetModule) Label() string { return "街拍" }
|
||||||
|
|
||||||
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
|
func (m *streetModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
|
||||||
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size)
|
rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return m.toStreetCards(rows), total, nil
|
return m.toStreetCards(rows), total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
|
func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
|
||||||
d, err := m.repo.StreetDraftDetail(ctx, id)
|
d, err := m.repo.StreetRecordDetail(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
imgs, err := m.repo.StreetDraftImages(ctx, id)
|
imgs, err := m.repo.StreetRecordImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
v := &DraftDetailView{
|
v := &RecordDetailView{
|
||||||
Kind: dto.IngestKindStreet,
|
Kind: dto.IngestKindStreet,
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: d.Title,
|
Title: d.Title,
|
||||||
@ -440,7 +482,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
|
|
||||||
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
|
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
|
||||||
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
|
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
|
||||||
byID := make(map[uint32]model.StreetSnapDraftImage, len(imgs))
|
byID := make(map[uint32]model.StreetSnapImage, len(imgs))
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
byID[im.ID] = im
|
byID[im.ID] = im
|
||||||
}
|
}
|
||||||
@ -472,7 +514,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := make([]DraftImageGroup, 0, len(imgs))
|
groups := make([]ImageGroup, 0, len(imgs))
|
||||||
groupIndex := make(map[uint32]int, len(imgs))
|
groupIndex := make(map[uint32]int, len(imgs))
|
||||||
for _, im := range imgs {
|
for _, im := range imgs {
|
||||||
root := resolveRoot(im.ID)
|
root := resolveRoot(im.ID)
|
||||||
@ -483,7 +525,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
isDetailVal = 1
|
isDetailVal = 1
|
||||||
parent = root
|
parent = root
|
||||||
}
|
}
|
||||||
ref := DraftImageRef{
|
ref := RecordImageRef{
|
||||||
ID: im.ID,
|
ID: im.ID,
|
||||||
Image: m.img.Compose(im.Image),
|
Image: m.img.Compose(im.Image),
|
||||||
Name: im.Name,
|
Name: im.Name,
|
||||||
@ -494,14 +536,14 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
}
|
}
|
||||||
v.Images = append(v.Images, ref)
|
v.Images = append(v.Images, ref)
|
||||||
if !isDetail {
|
if !isDetail {
|
||||||
groups = append(groups, DraftImageGroup{Image: ref})
|
groups = append(groups, ImageGroup{Image: ref})
|
||||||
groupIndex[im.ID] = len(groups) - 1
|
groupIndex[im.ID] = len(groups) - 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if gi, ok := groupIndex[root]; ok {
|
if gi, ok := groupIndex[root]; ok {
|
||||||
groups[gi].Details = append(groups[gi].Details, ref)
|
groups[gi].Details = append(groups[gi].Details, ref)
|
||||||
} else { // 兜底(理论不发生):主图未先出现,自成一组
|
} else { // 兜底(理论不发生):主图未先出现,自成一组
|
||||||
groups = append(groups, DraftImageGroup{Image: ref})
|
groups = append(groups, ImageGroup{Image: ref})
|
||||||
groupIndex[im.ID] = len(groups) - 1
|
groupIndex[im.ID] = len(groups) - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -509,33 +551,25 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||||
return m.repo.UpdateStreetDraft(ctx, id, fields)
|
return m.repo.UpdateStreetRecord(ctx, id, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id)
|
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
|
return id, nil
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return officialID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
|
||||||
return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
|
||||||
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{})
|
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
|
// ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard {
|
func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard {
|
||||||
cards := make([]DraftCard, 0, len(rows))
|
cards := make([]RecordCard, 0, len(rows))
|
||||||
for _, d := range rows {
|
for _, d := range rows {
|
||||||
sub := d.SeasonCode
|
sub := d.SeasonCode
|
||||||
if d.CollectionType != "" {
|
if d.CollectionType != "" {
|
||||||
@ -544,7 +578,7 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
|
|||||||
if sub == "" {
|
if sub == "" {
|
||||||
sub = "—"
|
sub = "—"
|
||||||
}
|
}
|
||||||
cards = append(cards, DraftCard{
|
cards = append(cards, RecordCard{
|
||||||
Kind: dto.IngestKindRunway,
|
Kind: dto.IngestKindRunway,
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: d.TitleEn,
|
Title: d.TitleEn,
|
||||||
@ -559,14 +593,14 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
|
|||||||
return cards
|
return cards
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
|
func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard {
|
||||||
cards := make([]DraftCard, 0, len(rows))
|
cards := make([]RecordCard, 0, len(rows))
|
||||||
for _, d := range rows {
|
for _, d := range rows {
|
||||||
sub := d.City
|
sub := d.City
|
||||||
if sub == "" {
|
if sub == "" {
|
||||||
sub = "—"
|
sub = "—"
|
||||||
}
|
}
|
||||||
cards = append(cards, DraftCard{
|
cards = append(cards, RecordCard{
|
||||||
Kind: dto.IngestKindStreet,
|
Kind: dto.IngestKindStreet,
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: d.Title,
|
Title: d.Title,
|
||||||
|
|||||||
@ -14,33 +14,45 @@ import (
|
|||||||
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||||
type fakeGroupRepo struct {
|
type fakeGroupRepo struct {
|
||||||
repository.ReviewRepository
|
repository.ReviewRepository
|
||||||
images []model.StreetSnapDraftImage
|
images []model.StreetSnapImage
|
||||||
detail *model.StreetSnapDraft
|
detail *model.StreetSnap
|
||||||
attachFn func(draftID, mainID uint32, imgIDs []uint32) error
|
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
|
||||||
|
attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeGroupRepo) StreetDraftDetail(_ context.Context, _ uint32) (*model.StreetSnapDraft, error) {
|
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
|
||||||
return f.detail, nil
|
return f.detail, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeGroupRepo) StreetDraftImages(_ context.Context, _ uint32) ([]model.StreetSnapDraftImage, error) {
|
func (f *fakeGroupRepo) StreetRecordImages(_ context.Context, _ uint32) ([]model.StreetSnapImage, error) {
|
||||||
return f.images, nil
|
return f.images, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, mainID uint32, imgIDs []uint32) error {
|
func (f *fakeGroupRepo) AttachStreetSnapImages(_ context.Context, snapID, mainID uint32, imgIDs []uint32) error {
|
||||||
if f.attachFn != nil {
|
if f.attachFn != nil {
|
||||||
return f.attachFn(draftID, mainID, imgIDs)
|
return f.attachFn(snapID, mainID, imgIDs)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error {
|
func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uint32) error {
|
||||||
if f.attachFn != nil {
|
if f.attachFn != nil {
|
||||||
return f.attachFn(0, 0, nil)
|
return f.attachFn(0, 0, nil)
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@ -50,17 +62,17 @@ func newGroupService(f *fakeGroupRepo) ReviewService {
|
|||||||
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
|
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
|
||||||
func TestStreetGroupDetailExposesGrouping(t *testing.T) {
|
func TestStreetGroupDetailExposesGrouping(t *testing.T) {
|
||||||
repo := &fakeGroupRepo{
|
repo := &fakeGroupRepo{
|
||||||
detail: &model.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.DraftStatusPending},
|
detail: &model.StreetSnap{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.StatusPending},
|
||||||
images: []model.StreetSnapDraftImage{
|
images: []model.StreetSnapImage{
|
||||||
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
|
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
|
||||||
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
|
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
|
||||||
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
|
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := newGroupService(repo)
|
svc := newGroupService(repo)
|
||||||
v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7)
|
v, err := svc.RecordDetail(context.Background(), dto.IngestKindStreet, 7)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DraftDetail 失败: %v", err)
|
t.Fatalf("RecordDetail 失败: %v", err)
|
||||||
}
|
}
|
||||||
if len(v.Images) != 3 {
|
if len(v.Images) != 3 {
|
||||||
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
|
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
|
||||||
@ -85,43 +97,63 @@ func TestStreetGroupDetailExposesGrouping(t *testing.T) {
|
|||||||
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
|
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
|
||||||
func TestStreetGroupAttachDelegates(t *testing.T) {
|
func TestStreetGroupAttachDelegates(t *testing.T) {
|
||||||
var got struct {
|
var got struct {
|
||||||
draftID uint32
|
snapID uint32
|
||||||
mainID uint32
|
mainID uint32
|
||||||
imgIDs []uint32
|
imgIDs []uint32
|
||||||
}
|
}
|
||||||
repo := &fakeGroupRepo{attachFn: func(draftID, mainID uint32, imgIDs []uint32) error {
|
repo := &fakeGroupRepo{attachFn: func(snapID, mainID uint32, imgIDs []uint32) error {
|
||||||
got.draftID, got.mainID, got.imgIDs = draftID, mainID, imgIDs
|
got.snapID, got.mainID, got.imgIDs = snapID, mainID, imgIDs
|
||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
svc := newGroupService(repo)
|
svc := newGroupService(repo)
|
||||||
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
|
if err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
|
||||||
t.Fatalf("AttachDraftImages 失败: %v", err)
|
t.Fatalf("AttachImages 失败: %v", err)
|
||||||
}
|
}
|
||||||
if got.draftID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
|
if got.snapID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
|
||||||
t.Fatalf("参数透传错误: draftID=%d mainID=%d imgIDs=%v", got.draftID, got.mainID, got.imgIDs)
|
t.Fatalf("参数透传错误: snapID=%d mainID=%d imgIDs=%v", got.snapID, got.mainID, got.imgIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.AttachDraftImages(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStreetGroupPreventCrossDraft 仓储拦截跨草稿主图后,service 翻成中文用户错误。
|
// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。
|
||||||
func TestStreetGroupPreventCrossDraft(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreetGroupPreventCrossRecord 仓储拦截跨记录主图后,service 翻成中文用户错误。
|
||||||
|
func TestStreetGroupPreventCrossRecord(t *testing.T) {
|
||||||
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
|
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
|
||||||
return repository.ErrInvalidImageGroup
|
return repository.ErrInvalidImageGroup
|
||||||
}}
|
}}
|
||||||
svc := newGroupService(repo)
|
svc := newGroupService(repo)
|
||||||
err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
|
err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("跨草稿应报错")
|
t.Fatal("跨记录应报错")
|
||||||
}
|
}
|
||||||
if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" {
|
if err.Error() != "主图无效:它必须属于本条记录、未被删除,且自身是主图" {
|
||||||
t.Fatalf("错误文案不符: %q", err.Error())
|
t.Fatalf("错误文案不符: %q", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
@ -17,11 +18,17 @@ import (
|
|||||||
// StreetSnapService 街拍业务接口。
|
// StreetSnapService 街拍业务接口。
|
||||||
type StreetSnapService interface {
|
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)
|
||||||
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
||||||
|
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||||
|
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||||
|
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 软删单张街拍图片。
|
||||||
@ -62,17 +69,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
return nil, 0, internalErr(err.Error())
|
return nil, 0, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
|
||||||
ids := make([]uint32, 0, len(rows))
|
ids := make([]uint32, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
ids = append(ids, r.ID)
|
ids = append(ids, r.ID)
|
||||||
list = append(list, dto.PublicStreetSnap{
|
|
||||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
|
||||||
Title: r.Title,
|
|
||||||
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
|
|
||||||
ImageCount: r.ImageCount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
@ -82,6 +83,36 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
return list, total, nil
|
return list, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminList 后台街拍列表:读基表,包含 pending / rejected。
|
||||||
|
//
|
||||||
|
// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段,
|
||||||
|
// 与走秀后台 AdminList 的口径一致。
|
||||||
|
func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
|
||||||
|
q.Normalize()
|
||||||
|
|
||||||
|
rows, total, err := s.repo.ListAdmin(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.toPublicSnaps(rows, q.Locale), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
||||||
|
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
|
||||||
|
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap {
|
||||||
|
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, dto.PublicStreetSnap{
|
||||||
|
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||||
|
Title: pickLocale(r.Title, r.TitleCn, locale),
|
||||||
|
Cover: s.img.ComposeThumb(r.Cover),
|
||||||
|
ImageCount: r.ImageCount,
|
||||||
|
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
|
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
|
||||||
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
|
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
|
||||||
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
|
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
|
||||||
@ -119,33 +150,58 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
|||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
|
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
||||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||||
|
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 {
|
||||||
if errors.Is(err, repository.ErrNotFound) {
|
return nil, s.detailErr(err)
|
||||||
return nil, ErrStreetSnapNotFound
|
|
||||||
}
|
|
||||||
return nil, internalErr(err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
imgs, err := s.repo.ListImages(ctx, id)
|
imgs, err := s.repo.ListImages(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr(err.Error())
|
return nil, internalErr(err.Error())
|
||||||
}
|
}
|
||||||
|
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||||
|
//
|
||||||
|
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||||
|
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||||
|
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
|
snap, err := s.repo.FindByIDBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, s.detailErr(err)
|
||||||
|
}
|
||||||
|
imgs, err := s.repo.ListImagesBase(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr(err.Error())
|
||||||
|
}
|
||||||
|
return s.buildSnapDetail(snap, imgs, locale), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
|
||||||
|
func (s *streetSnapService) detailErr(err error) error {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
return ErrStreetSnapNotFound
|
||||||
|
}
|
||||||
|
return internalErr(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
|
||||||
|
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),
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@ -184,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 {
|
||||||
@ -201,15 +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
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
|
||||||
// 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())
|
||||||
@ -218,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,
|
||||||
})
|
})
|
||||||
@ -271,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 {
|
||||||
@ -290,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