Compare commits
18 Commits
bfe890bd56
...
f04b5707cb
| Author | SHA1 | Date | |
|---|---|---|---|
| f04b5707cb | |||
| be4db0bf96 | |||
| 3fc8b1f924 | |||
| 762e528334 | |||
| d68a57009c | |||
| c4bafc5b41 | |||
| 12d05f75e9 | |||
| ce4f8b786d | |||
| d8b3424663 | |||
| f9068f530b | |||
| 76626b8372 | |||
| e0ce81318d | |||
| 474e1c1a64 | |||
| 8f8b06def7 | |||
| dba615d636 | |||
| 4169ebe1ed | |||
| f5144038ab | |||
| 83823a8d49 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -20,6 +20,9 @@ gotest*.exe
|
||||
# 数据库备份
|
||||
/db/backups/
|
||||
|
||||
# dbtool 陈旧导出产物(权威 schema 是仓库根目录的 /db_dump.sql,勿混淆)
|
||||
cmd/dbtool/db_dump.*
|
||||
|
||||
# 本地配置
|
||||
configs/config.local.yml
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ internal/
|
||||
### 2.3 管理后台(8092)
|
||||
|
||||
- `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。
|
||||
- `/admin/*`:品牌 / 走秀 / 街拍管理、草稿审核(通过晋升正式表 / 驳回)、入库任务监控与重试、用户管理(提级 VIP)。
|
||||
- `/admin/*`:品牌 / 走秀 / 街拍管理、内容审核(单表 + `status`:通过置 `published`、驳回置 `rejected`)、入库任务监控与重试、用户管理(提级 VIP)。
|
||||
- `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。
|
||||
|
||||
---
|
||||
@ -95,7 +95,7 @@ internal/
|
||||
- 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`),付费墙已取消。
|
||||
|
||||
---
|
||||
@ -174,7 +174,7 @@ backend/
|
||||
│ └── dbdiag/ # 数据库诊断
|
||||
├── internal/
|
||||
│ ├── config/ # 配置加载
|
||||
│ ├── model/ # 实体(brand / runway / street_snap / user / 草稿 / ingest)
|
||||
│ ├── model/ # 实体(brand / runway / street_snap / user / ingest)
|
||||
│ ├── database/ # 连接池(结构由 dbtool 导出的 SQL 维护,不做 DDL)
|
||||
│ ├── repository/ # 数据访问接口 + GORM 实现
|
||||
│ ├── 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 是后台管理用的数据库导出 / 导入脚本。
|
||||
//
|
||||
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 数据」导出为
|
||||
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为
|
||||
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
|
||||
//
|
||||
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
|
||||
@ -23,9 +23,13 @@
|
||||
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
|
||||
//
|
||||
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
|
||||
// 数据、序列当前值。
|
||||
// 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。
|
||||
//
|
||||
// 视图 / 触发器 / 外键 / 注释 / 权限不导出(当前库中也不存在)。
|
||||
// 触发器 / 外键 / 注释 / 权限不导出。
|
||||
//
|
||||
// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01),
|
||||
// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图,
|
||||
// 公开查询直接报 relation does not exist。
|
||||
//
|
||||
// 目标库为空还是已有数据:
|
||||
//
|
||||
@ -57,6 +61,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -112,7 +117,7 @@ func usage() {
|
||||
|
||||
用法:
|
||||
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
|
||||
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件
|
||||
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||
把 dump 出来的 SQL 文件灌入目标库
|
||||
|
||||
@ -178,13 +183,17 @@ func runDump(args []string) {
|
||||
if len(tables) == 0 {
|
||||
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
|
||||
}
|
||||
views, err := listViews(db)
|
||||
if err != nil {
|
||||
log.Fatalf("✗ 列举视图失败: %v", err)
|
||||
}
|
||||
|
||||
cols := make(map[string][]column, len(tables))
|
||||
|
||||
// ① 结构(-clean 时先输出清理段)
|
||||
fmt.Fprintln(w, "-- ==================== 结构 ====================")
|
||||
if *clean {
|
||||
writeClean(w, tables)
|
||||
writeClean(w, tables, views)
|
||||
}
|
||||
for _, t := range tables {
|
||||
cs, err := listColumns(db, t)
|
||||
@ -194,7 +203,11 @@ func runDump(args []string) {
|
||||
cols[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, "-- ==================== 数据 ====================")
|
||||
@ -306,19 +319,175 @@ func execScript(ctx context.Context, db *sql.DB, script string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// writeClean 输出清理段:DROP 掉本次要重建的表,让脚本可以灌进「已有数据的库」。
|
||||
// writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。
|
||||
//
|
||||
// 只 DROP 本次 dump 里出现的表(与 pg_dump --clean 口径一致)——目标库中多出来的表保持不动。
|
||||
// 用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
|
||||
// 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。
|
||||
// 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。
|
||||
// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
|
||||
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
|
||||
func writeClean(w *bufio.Writer, tables []string) {
|
||||
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列表,目标库中这些表的既有数据将丢失")
|
||||
func writeClean(w *bufio.Writer, tables []string, views []viewDef) {
|
||||
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 {
|
||||
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
|
||||
}
|
||||
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 写文件头与几个会话设置。
|
||||
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
|
||||
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())
|
||||
|
||||
54
db/migrations/2026-09-22-01-single-table-publish.sql
Normal file
54
db/migrations/2026-09-22-01-single-table-publish.sql
Normal file
@ -0,0 +1,54 @@
|
||||
-- 单表发布模型(方案 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 '';
|
||||
|
||||
-- 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 * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。
|
||||
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;
|
||||
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';
|
||||
47
db/migrations/2026-09-22-03-entity-key-unique.sql
Normal file
47
db/migrations/2026-09-22-03-entity-key-unique.sql
Normal file
@ -0,0 +1,47 @@
|
||||
-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。
|
||||
--
|
||||
-- 为什么必须有:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈)。
|
||||
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
||||
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
||||
--
|
||||
-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致,
|
||||
-- 软删的旧实体键不再占用,允许同实体重新入库。
|
||||
|
||||
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
||||
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;
|
||||
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 而库里已存在正确的同名索引,跳过是无害的。
|
||||
2561
db_dump.sql
2561
db_dump.sql
File diff suppressed because it is too large
Load Diff
@ -101,14 +101,20 @@ const (
|
||||
```
|
||||
|
||||
- **通过**:`UPDATE ... SET status='published', reviewer=?` + 应用表单里的字段编辑。**不再触碰图片行**。
|
||||
- **驳回**:`UPDATE ... SET status='rejected', reviewer=?, reject_reason=?`。行保留。
|
||||
- **驳回**:`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/rejected` 的任意行 —— 命中即整任务 `MarkDone` **直接放弃**(用户裁定),与现状对"已发布实体"的行为一致,且顺带把"重复爬取同一实体"也收敛掉。
|
||||
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` 的写入位置不变。
|
||||
@ -175,8 +181,16 @@ CREATE VIEW public_street_snap_images AS
|
||||
|
||||
### 第 1 步:DDL + 视图(`db/migrations/2026-09-22-01-single-table-publish.sql`)
|
||||
1. §3.2 加列。
|
||||
2. 存量正式行视为已发布:`UPDATE brand_runways SET status='published' WHERE created_at < :迁移时刻;`(street 同理)。**带时间戳护栏**,避免脚本被重复执行时把新入库的 pending 行误刷成 published。
|
||||
3. 建 §6 的 4 个视图。
|
||||
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`)。
|
||||
|
||||
@ -192,7 +206,7 @@ CREATE VIEW public_street_snap_images AS
|
||||
|
||||
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
|
||||
|
||||
### 第 3 步:删表(`db/migrations/2026-09-22-02-drop-draft-tables.sql`,校验通过后再执行)
|
||||
### 第 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;
|
||||
@ -207,7 +221,7 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
||||
| 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-02-*.sql`、`scripts/migrate_single_table/main.go` |
|
||||
| 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. 测试策略
|
||||
@ -240,3 +254,5 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
||||
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
|
||||
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
|
||||
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
|
||||
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题只做空白 / 大小写 / 措辞微调,入库查重会当作新实体,从而多出一行(内容重复)。目前无标题归一化。
|
||||
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
|
||||
|
||||
@ -132,4 +132,7 @@ type AdminRunway struct {
|
||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
||||
ImageCount uint16 `json:"image_count"`
|
||||
// Status 审核态(pending / published / rejected),供后台列表展示状态列。
|
||||
// 后台专用 DTO,不外泄到公开接口。
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@ -58,6 +58,11 @@ type PublicStreetSnap struct {
|
||||
Cover string `json:"cover"`
|
||||
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
|
||||
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
||||
|
||||
// Status 审核态(pending / published / rejected)。
|
||||
// 后台街拍列表复用本 DTO 渲染状态列,但公开列表接口**不返回**它:
|
||||
// json:"-" 保证公开 API 响应结构与既有约定一致。
|
||||
Status string `json:"-"`
|
||||
}
|
||||
|
||||
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
|
||||
|
||||
@ -240,7 +240,8 @@ tbody tr:last-child td { border-bottom: 0; }
|
||||
}
|
||||
.badge.pending { background: #fff4e0; color: #a85b00; }
|
||||
.badge.processing { background: #eef2ff; color: #3b4bb5; }
|
||||
.badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
||||
/* 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; }
|
||||
|
||||
@ -32,7 +32,7 @@ type reviewBatchReq struct {
|
||||
Items []reviewBatchItem `json:"items"`
|
||||
}
|
||||
|
||||
// ReviewBatch 批量通过 / 驳回审核草稿。
|
||||
// ReviewBatch 批量通过 / 驳回审核记录。
|
||||
//
|
||||
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
||||
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
|
||||
@ -49,7 +49,7 @@ func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何草稿"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何记录"})
|
||||
return
|
||||
}
|
||||
if len(req.Items) > maxReviewBatch {
|
||||
@ -99,9 +99,10 @@ type reviewAttachReq struct {
|
||||
Imgs []uint32 `json:"imgs"`
|
||||
}
|
||||
|
||||
// ReviewAttachImages 把多张草稿图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
|
||||
// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
|
||||
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
||||
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
||||
// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。
|
||||
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
@ -121,15 +122,15 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
|
||||
return
|
||||
}
|
||||
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil {
|
||||
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.respondDraftImages(c, kind, uint32(id))
|
||||
h.respondRecordImages(c, kind, uint32(id))
|
||||
}
|
||||
|
||||
// ReviewDetachImage 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImages。
|
||||
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
|
||||
// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
|
||||
func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
@ -137,18 +138,18 @@ func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
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.respondDraftImages(c, kind, uint32(id))
|
||||
h.respondRecordImages(c, kind, uint32(id))
|
||||
}
|
||||
|
||||
// respondDraftImages 重新读取草稿并把「图片分组状态」按前端需要的形状返回。
|
||||
// respondRecordImages 重新读取记录并把「图片分组状态」按前端需要的形状返回。
|
||||
//
|
||||
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
|
||||
func (h *BackstageHandler) respondDraftImages(c *gin.Context, kind string, id uint32) {
|
||||
view, err := h.review.DraftDetail(c.Request.Context(), kind, id)
|
||||
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
|
||||
|
||||
@ -107,7 +107,10 @@ func (h *BackstageHandler) UserSetTier(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.List。
|
||||
// Brands 品牌管理列表:支持关键词搜索、A-Z 字母索引、分页,复用 BrandService.AdminList。
|
||||
//
|
||||
// 必须用 AdminList:公开 List 的「必须有已发布走秀档案」子查询会把只有待审走秀的品牌挡掉,
|
||||
// 后台需要仍能看到它们(否则待审品牌从后台列表消失)。
|
||||
func (h *BackstageHandler) Brands(c *gin.Context) {
|
||||
q := dto.BrandQuery{
|
||||
Keyword: c.Query("keyword"),
|
||||
@ -115,7 +118,7 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
|
||||
Page: atoiDefault(c.Query("page"), 1),
|
||||
Locale: "en",
|
||||
}
|
||||
items, total, err := h.brand.List(c.Request.Context(), q)
|
||||
items, total, err := h.brand.AdminList(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
@ -225,7 +228,10 @@ func (h *BackstageHandler) Runways(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.Detail。
|
||||
// RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.DetailAdmin(读基表,含待审)。
|
||||
//
|
||||
// 必须用 DetailAdmin:后台列表刻意展示 pending / rejected 行并链到本页,
|
||||
// 公开的 ArticleService.Detail 读视图会把它们当不存在,导致待审项 404。
|
||||
// 路由 :id 是 runway 类型化编码(无前缀,类型进密码),按 TypeRunway 解码(与公开接口 article_handler 一致)。
|
||||
func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
||||
raw := c.Param("id")
|
||||
@ -234,7 +240,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||
return
|
||||
}
|
||||
art, err := h.article.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
|
||||
art, err := h.article.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), "en")
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
|
||||
return
|
||||
@ -242,7 +248,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
|
||||
renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art})
|
||||
}
|
||||
|
||||
// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.List。
|
||||
// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.AdminList(读基表,含待审)。
|
||||
func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
||||
q := dto.StreetSnapQuery{
|
||||
Year: atoiDefault(c.Query("year"), 0),
|
||||
@ -251,7 +257,7 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
||||
Page: atoiDefault(c.Query("page"), 1),
|
||||
Locale: "en",
|
||||
}
|
||||
items, total, err := h.street.List(c.Request.Context(), q)
|
||||
items, total, err := h.street.AdminList(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
@ -278,7 +284,10 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.Detail。
|
||||
// StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.DetailAdmin(读基表,含待审)。
|
||||
//
|
||||
// 必须用 DetailAdmin:后台列表刻意展示 pending / rejected 行并链到本页,
|
||||
// 公开的 StreetSnapService.Detail 读视图会把它们当不存在,导致待审项 404。
|
||||
// 路由 :id 是 snap 类型化编码(无前缀,类型进密码),按 TypeSnap 解码(与公开接口 street_snap_handler 一致)。
|
||||
func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
||||
raw := c.Param("id")
|
||||
@ -287,7 +296,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||
return
|
||||
}
|
||||
snap, err := h.street.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||
snap, err := h.street.DetailAdmin(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
|
||||
return
|
||||
@ -295,9 +304,9 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
|
||||
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
|
||||
}
|
||||
|
||||
// ── 审核草稿 ───────────────────────────────────────────────────────────────────
|
||||
// ── 审核记录 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ReviewList 待审草稿列表:按类型(kind,缺省全部)与状态筛选。
|
||||
// ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
|
||||
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
|
||||
func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
@ -320,7 +329,7 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
||||
if kind != "" && kind != "all" && !registered[kind] {
|
||||
kind = ""
|
||||
}
|
||||
drafts, total, err := h.review.ListDrafts(c.Request.Context(), kind, status, page, 20)
|
||||
records, total, err := h.review.ListRecords(c.Request.Context(), kind, status, page, 20)
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
@ -333,7 +342,7 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
||||
p := pagination.Params{Page: page, PageSize: 20}
|
||||
meta := p.NewMeta(total)
|
||||
renderPage(c, http.StatusOK, "review-list", gin.H{
|
||||
"Drafts": drafts,
|
||||
"Records": records,
|
||||
"Total": total,
|
||||
"Status": status,
|
||||
"Kind": kind,
|
||||
@ -349,23 +358,23 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewDetail 草稿详情:展示预览 + 可编辑字段 + 通过/驳回操作。
|
||||
// ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
|
||||
func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")})
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
draft, err := h.review.DraftDetail(c.Request.Context(), kind, uint32(id))
|
||||
record, err := h.review.RecordDetail(c.Request.Context(), kind, uint32(id))
|
||||
if err != nil {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在或已删除: " + c.Param("id")})
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在或已删除: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
|
||||
brandName := ""
|
||||
if draft.BrandID > 0 {
|
||||
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(draft.BrandID)); berr == nil {
|
||||
if record.BrandID > 0 {
|
||||
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil {
|
||||
brandName = b.NameEn
|
||||
if brandName == "" {
|
||||
brandName = b.NameCn
|
||||
@ -373,21 +382,21 @@ func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
renderPage(c, http.StatusOK, "review-detail", gin.H{
|
||||
"Draft": draft,
|
||||
"Record": record,
|
||||
"BrandName": brandName,
|
||||
})
|
||||
}
|
||||
|
||||
// ReviewApprove 通过:先应用审核人的字段微调,再晋升到正式表。
|
||||
// ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
|
||||
func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")})
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
fields := draftFormFields(c)
|
||||
if err := h.review.UpdateDraft(c.Request.Context(), kind, uint32(id), fields); err != nil {
|
||||
fields := reviewFormFields(c)
|
||||
if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
@ -404,7 +413,7 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")})
|
||||
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
|
||||
return
|
||||
}
|
||||
reason := c.PostForm("reason")
|
||||
@ -416,9 +425,9 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, reviewListURL(kind))
|
||||
}
|
||||
|
||||
// ReviewDraftImageDelete 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
// 校验 kind 合法后委派给审核服务,成功后跳回草稿详情页。
|
||||
func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
|
||||
// ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
// 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。
|
||||
func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
@ -426,16 +435,17 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.review.DeleteDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
if err := h.review.DeleteImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
|
||||
}
|
||||
|
||||
// ReviewAttachDraftImages 把勾选的草稿图并入指定主图(街拍审核页「合并选中为一组」)。
|
||||
// ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
|
||||
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
|
||||
func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
|
||||
// (AJAX 版同名的 JSON 接口是 ReviewAttachImagesAPI —— 两者响应形态不同,故不能合并成一个。)
|
||||
func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
if id == 0 {
|
||||
@ -457,15 +467,15 @@ func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
|
||||
return
|
||||
}
|
||||
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
|
||||
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
|
||||
}
|
||||
|
||||
// ReviewDetachDraftImage 把副图拆出恢复为主图。
|
||||
func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) {
|
||||
// ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
|
||||
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
id := atoiDefault(c.Param("id"), 0)
|
||||
imgID := atoiDefault(c.Param("img"), 0)
|
||||
@ -473,7 +483,7 @@ func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) {
|
||||
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
|
||||
return
|
||||
}
|
||||
@ -562,9 +572,9 @@ func (h *BackstageHandler) IngestJobRetry(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/admin/ingest-jobs")
|
||||
}
|
||||
|
||||
// draftFormFields 从表单收集可编辑草稿字段(runway + street 一并收集,
|
||||
// reviewFormFields 从表单收集可编辑字段(runway + street 一并收集,
|
||||
// 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。
|
||||
func draftFormFields(c *gin.Context) map[string]any {
|
||||
func reviewFormFields(c *gin.Context) map[string]any {
|
||||
f := map[string]any{}
|
||||
if v := c.PostForm("title_en"); v != "" {
|
||||
f["title_en"] = v
|
||||
|
||||
@ -74,7 +74,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
||||
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
|
||||
"Images": []any{},
|
||||
// 列表数据
|
||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Drafts": []any{},
|
||||
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{},
|
||||
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
|
||||
"Article": &dto.PublicArticleDetail{UID: "abc123"},
|
||||
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
|
||||
@ -100,7 +100,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
|
||||
for k, v := range perPage[page] {
|
||||
data[k] = v
|
||||
}
|
||||
data["Draft"] = &service.DraftDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
||||
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@ -1,37 +1,37 @@
|
||||
{{define "title"}}草稿审核 · 管理后台{{end}}
|
||||
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Draft.ID}}{{end}}
|
||||
{{define "barRight"}}<a href="{{reviewDetailURL .Draft.Kind .Draft.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}}
|
||||
{{define "barRight"}}<a href="{{reviewDetailURL .Record.Kind .Record.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
|
||||
{{define "content"}}
|
||||
{{template "bar" .}}
|
||||
<div class="main mid">
|
||||
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p>
|
||||
<p class="muted">类型 <b>{{.Record.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Record.Status}}</b></p>
|
||||
|
||||
{{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
|
||||
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
|
||||
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
|
||||
<form method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/approve">
|
||||
{{range .Record.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
|
||||
<button type="submit">通过并发布</button>
|
||||
</form>
|
||||
|
||||
{{if eq .Draft.Kind "street"}}
|
||||
{{if eq .Record.Kind "street"}}
|
||||
<div class="hint">
|
||||
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
|
||||
快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过
|
||||
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
|
||||
</div>
|
||||
|
||||
<h2>图片(<span id="img-stat">{{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组</span>)</h2>
|
||||
<h2>图片(<span id="img-stat">{{len .Record.Images}} 张 · {{len .Record.Groups}} 组</span>)</h2>
|
||||
{{/* 合并表单本体在 grid 之前;各格的 radio/checkbox 通过 form="batch" 关联过来 */}}
|
||||
<form id="batch" method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/images/attach"></form>
|
||||
<form id="batch" method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.ID}}/images/attach"></form>
|
||||
<div class="grid">
|
||||
{{range .Draft.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}">
|
||||
{{range .Record.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}" data-img-id="{{.ID}}">
|
||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
||||
<span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
|
||||
<span class="nm">{{.Name}}</span>
|
||||
<div class="acts">
|
||||
{{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
|
||||
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
|
||||
{{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
|
||||
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
{{if eq .IsDetail 1}}<form data-only="detail" method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
|
||||
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
@ -41,13 +41,13 @@
|
||||
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<h2>图片管理({{len .Draft.Images}} 张)</h2>
|
||||
<h2>图片管理({{len .Record.Images}} 张)</h2>
|
||||
<div class="grid">
|
||||
{{range .Draft.Images}}<div class="cell" data-img-id="{{.ID}}">
|
||||
{{range .Record.Images}}<div class="cell" data-img-id="{{.ID}}">
|
||||
<button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
|
||||
<span class="nm">{{.Name}}</span>
|
||||
<div class="acts">
|
||||
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
<form method="post" action="/admin/reviews/{{$.Record.Kind}}/{{$.Record.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<div class="tabs">
|
||||
<a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=pending" class="{{if eq .Status "pending"}}on{{end}}">待审核</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=approved" class="{{if eq .Status "approved"}}on{{end}}">已通过</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=published" class="{{if eq .Status "published"}}on{{end}}">已通过</a>
|
||||
<a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a>
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
<th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Drafts}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
||||
{{range .Records}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
||||
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
|
||||
@ -32,7 +32,7 @@
|
||||
<td class="muted">{{.Subtitle}}</td>
|
||||
<td>{{.ImageCount}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接晋升到正式表">通过</button>{{end}}</td>
|
||||
<td><a class="view" href="/admin/reviews/{{.Kind}}/{{.ID}}">查看/审核</a>{{if eq .Status "pending"}} · <button type="button" class="btn" data-quick-approve title="按当前内容直接发布">通过</button>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -29,9 +29,9 @@
|
||||
<a href="/admin/runways">清除</a>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th></th></tr></thead>
|
||||
<thead><tr><th>编码 ID</th><th>品牌</th><th>标题</th><th>系列</th><th>季节</th><th>年份</th><th>季节码</th><th>图数</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
|
||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.BrandName}}</td><td>{{.Title}}</td><td>{{.CollectionType}}</td><td>{{.Season}}</td><td>{{.Year}}</td><td>{{.SeasonCode}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/runways/{{.UID}}">查看</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "pager" .Pager}}
|
||||
|
||||
@ -17,9 +17,9 @@
|
||||
<a href="/admin/street-snaps">清除</a>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th></th></tr></thead>
|
||||
<thead><tr><th>编码 ID</th><th>标题</th><th>图片数</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
|
||||
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.Title}}</td><td>{{.ImageCount}}</td><td><span class="badge {{.Status}}">{{.Status}}</span></td><td><a class="view" href="/admin/street-snaps/{{.UID}}">查看</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{template "pager" .Pager}}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
|
||||
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
|
||||
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
|
||||
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
|
||||
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
|
||||
}
|
||||
|
||||
// TableName 指定表名。
|
||||
@ -43,6 +49,7 @@ type RunwayRow struct {
|
||||
CollectionType string `gorm:"column:collection_type"`
|
||||
Season string `gorm:"column:season"`
|
||||
SeasonCode string `gorm:"column:season_code"`
|
||||
Status string `gorm:"column:status"`
|
||||
CreatedAt uint32 `gorm:"column:created_at"`
|
||||
BrandNameEn string `gorm:"column:brand_name_en"`
|
||||
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"
|
||||
)
|
||||
@ -15,6 +15,13 @@ type StreetSnap struct {
|
||||
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
|
||||
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
|
||||
CreatedAt uint32 `gorm:"column:created_at" json:"created_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"`
|
||||
RejectReason string `gorm:"column:reject_reason;size:255;not null;default:''" json:"reject_reason"`
|
||||
|
||||
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
|
||||
@ -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" }
|
||||
@ -18,17 +18,22 @@ type ArticleRepository interface {
|
||||
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
|
||||
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
|
||||
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)
|
||||
// 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)
|
||||
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||||
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
|
||||
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
|
||||
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
|
||||
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
|
||||
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
|
||||
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteRunwayImage 软删单张走秀图片。
|
||||
// DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。
|
||||
DeleteRunwayImage(ctx context.Context, imageID uint32) error
|
||||
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
@ -62,11 +67,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`
|
||||
|
||||
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
|
||||
// collection_type / season_code 等管理字段(前端列表不展示这些)。
|
||||
// collection_type / season_code / status 等管理字段(前端列表不展示这些)。
|
||||
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
|
||||
brand_runways.title_en, brand_runways.title_cn,
|
||||
brand_runways.cover, brand_runways.image_count, brand_runways.year,
|
||||
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`
|
||||
|
||||
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
|
||||
@ -142,7 +148,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
||||
|
||||
var total int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Scopes(scope).
|
||||
Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
@ -153,7 +159,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
|
||||
|
||||
var rows []model.RunwayRow
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Scopes(scope).
|
||||
Select(articleListColumns).
|
||||
Joins(brandJoin).
|
||||
@ -196,11 +202,20 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
|
||||
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
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Select(articleDetailColumns).
|
||||
err := q.Select(articleDetailColumns).
|
||||
Joins(brandJoin).
|
||||
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
|
||||
First(&row).Error
|
||||
@ -213,7 +228,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
|
||||
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
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("runway_id = ? AND is_deleted = 0", runwayID).
|
||||
@ -230,8 +257,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
|
||||
|
||||
var imgs []model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Table(publicRunwayImages).
|
||||
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").
|
||||
Find(&imgs).Error
|
||||
if err != nil {
|
||||
@ -265,7 +293,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
|
||||
}
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -289,11 +317,36 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
|
||||
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 {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var img model.BrandRunwayImage
|
||||
err := tx.Select("runway_id").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if 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
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`UPDATE brand_runways SET image_count =
|
||||
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
|
||||
WHERE id = ?`, img.RunwayID, img.RunwayID).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||
|
||||
@ -13,12 +13,16 @@ import (
|
||||
|
||||
// BrandRepository 品牌数据访问接口。
|
||||
type BrandRepository interface {
|
||||
// List 按字母索引 / 关键词分页查询品牌。
|
||||
// List 按字母索引 / 关键词分页查询品牌(公开侧:字母索引只认已发布走秀档案)。
|
||||
//
|
||||
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
|
||||
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
|
||||
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 集合:在拥有走秀档案的品牌中,
|
||||
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
|
||||
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 桶。
|
||||
const latinPrefixRegexp = "^[A-Za-z]"
|
||||
|
||||
// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。
|
||||
// hasArticlesSubQuery 子查询(公开侧):所有「至少有一篇已发布走秀档案」的品牌 id。
|
||||
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
|
||||
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
|
||||
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{}).
|
||||
Select("DISTINCT brand_id").
|
||||
Where("is_deleted = 0")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
db = db.Where("is_deleted = 0")
|
||||
|
||||
@ -96,13 +112,20 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
|
||||
}
|
||||
|
||||
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
|
||||
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。
|
||||
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
|
||||
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
|
||||
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
|
||||
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
|
||||
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
|
||||
if q.Keyword == "" && q.Letter != "OTHER" {
|
||||
if admin {
|
||||
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
|
||||
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
|
||||
} else {
|
||||
// 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。
|
||||
db = db.Where("id IN (?)", r.hasArticlesSubQuery())
|
||||
}
|
||||
}
|
||||
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + 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) {
|
||||
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
|
||||
if err := r.db.WithContext(ctx).
|
||||
@ -157,10 +191,10 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
|
||||
var agg []struct {
|
||||
BrandID uint32 `gorm:"column:brand_id"`
|
||||
}
|
||||
// 读公开视图:热门榜只能由已发布走秀构成,避免把待审/驳回内容的图片数计入热度。
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Table(publicRunways).
|
||||
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
|
||||
Where("is_deleted = 0").
|
||||
Group("brand_id").
|
||||
Order(orderCol + " DESC").
|
||||
Limit(limit).
|
||||
@ -281,7 +315,7 @@ WITH latest_per_brand AS (
|
||||
END DESC,
|
||||
id DESC
|
||||
) AS rn
|
||||
FROM brand_runways
|
||||
FROM public_brand_runways brand_runways
|
||||
WHERE is_deleted = 0 AND brand_id IN (?)
|
||||
)
|
||||
SELECT id, brand_id, cover, title_en, title_cn
|
||||
|
||||
@ -93,9 +93,16 @@ func TestDedupNearDuplicate(t *testing.T) {
|
||||
t.Fatalf("汉明距离 1 的近重复应被命中")
|
||||
}
|
||||
|
||||
// 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。
|
||||
_, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold)
|
||||
if found2 {
|
||||
t.Fatalf("汉明距离 64 的图不应被误判为近重复")
|
||||
// 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题),
|
||||
// 这里改用与库中那条完全相同的全 1 探针(非空、合法)并先断言查询本身不报错。
|
||||
probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold)
|
||||
if errSame != nil {
|
||||
t.Fatalf("去重查询不应报错: %v", errSame)
|
||||
}
|
||||
if !foundSame {
|
||||
t.Fatalf("与库中那条完全相同的 phash 应命中")
|
||||
}
|
||||
if probeSame == 0 {
|
||||
t.Fatalf("命中的行 id 不应为 0")
|
||||
}
|
||||
}
|
||||
|
||||
198
internal/repository/editor_image_delete_integration_test.go
Normal file
198
internal/repository/editor_image_delete_integration_test.go
Normal file
@ -0,0 +1,198 @@
|
||||
//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"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
176
internal/repository/entity_key_unique_integration_test.go
Normal file
176
internal/repository/entity_key_unique_integration_test.go
Normal file
@ -0,0 +1,176 @@
|
||||
//go:build integration
|
||||
|
||||
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
|
||||
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
|
||||
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("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
||||
}
|
||||
|
||||
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
|
||||
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,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
@ -14,9 +15,10 @@ import (
|
||||
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
|
||||
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
|
||||
//
|
||||
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
|
||||
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
|
||||
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
|
||||
// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year + title
|
||||
// (同城同年可有多个专题,title 是数据里区分专题的字段)。
|
||||
// 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支:
|
||||
// pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。
|
||||
//
|
||||
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
|
||||
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
|
||||
@ -32,19 +34,21 @@ type IngestRepository interface {
|
||||
MarkFailed(ctx context.Context, id uint32, errMsg string) error
|
||||
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
|
||||
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
|
||||
// RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表;
|
||||
// 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。
|
||||
RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
|
||||
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
|
||||
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
|
||||
// CreateRunwayDraftImages 批量插入草稿图片行。
|
||||
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
|
||||
// StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。
|
||||
StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error)
|
||||
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
|
||||
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
|
||||
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
|
||||
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
|
||||
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
|
||||
// worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
|
||||
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
|
||||
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
|
||||
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
|
||||
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
|
||||
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
|
||||
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
|
||||
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found 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(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
|
||||
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
|
||||
@ -239,94 +243,183 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。
|
||||
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
|
||||
// ---- 入库直写正式表(单表发布模型)----
|
||||
|
||||
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Status string `gorm:"column:status"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Select("id").
|
||||
Select("id, status").
|
||||
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
return 0, "", false, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return 0, false, nil
|
||||
return 0, "", false, nil
|
||||
}
|
||||
return row.ID, true, nil
|
||||
return row.ID, row.Status, true, 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())
|
||||
d.CreatedAt = now
|
||||
d.UpdatedAt = now
|
||||
if d.Status == "" {
|
||||
d.Status = model.DraftStatusPending
|
||||
rw.CreatedAt, rw.UpdatedAt = now, now
|
||||
if rw.Status == "" {
|
||||
rw.Status = model.StatusPending
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
|
||||
return 0, err
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if cErr := tx.Create(rw).Error; cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
return d.ID, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := uint32(time.Now().Unix())
|
||||
for i := range imgs {
|
||||
imgs[i].CreatedAt = now
|
||||
imgs[i].UpdatedAt = now
|
||||
imgs[i].RunwayID = rw.ID
|
||||
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||
return tx.Create(&imgs).Error
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rw.ID, nil
|
||||
}
|
||||
|
||||
// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。
|
||||
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) {
|
||||
func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
|
||||
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
|
||||
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
|
||||
// 进而插入一行注定被索引拒绝的记录。
|
||||
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, error) {
|
||||
var row struct {
|
||||
ID uint32 `gorm:"column:id"`
|
||||
Status string `gorm:"column:status"`
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Select("id").
|
||||
Where("city = ? AND year = ? AND is_deleted = 0", city, year).
|
||||
Select("id, status").
|
||||
Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
|
||||
Limit(1).
|
||||
Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
return 0, "", false, err
|
||||
}
|
||||
if row.ID == 0 {
|
||||
return 0, false, nil
|
||||
return 0, "", false, nil
|
||||
}
|
||||
return row.ID, true, nil
|
||||
return row.ID, row.Status, true, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
|
||||
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
|
||||
now := uint32(time.Now().Unix())
|
||||
d.CreatedAt = now
|
||||
d.UpdatedAt = now
|
||||
if d.Status == "" {
|
||||
d.Status = model.DraftStatusPending
|
||||
snap.CreatedAt, snap.UpdatedAt = now, now
|
||||
if snap.Status == "" {
|
||||
snap.Status = model.StatusPending
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
|
||||
return 0, err
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if cErr := tx.Create(snap).Error; cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
return d.ID, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error {
|
||||
if len(imgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := uint32(time.Now().Unix())
|
||||
for i := range imgs {
|
||||
imgs[i].CreatedAt = now
|
||||
imgs[i].UpdatedAt = now
|
||||
imgs[i].SnapID = snap.ID
|
||||
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&imgs).Error
|
||||
return tx.Create(&imgs).Error
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return snap.ID, nil
|
||||
}
|
||||
|
||||
func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error {
|
||||
now := uint32(time.Now().Unix())
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 同 runway:带 status=rejected 守卫 + 行数校验,避免把期间已发布的记录撤下公开视图。
|
||||
upd := tx.Model(&model.StreetSnap{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected)
|
||||
if uErr := upd.Updates(map[string]any{
|
||||
"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 控制分页区间。
|
||||
@ -369,11 +462,14 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
|
||||
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
|
||||
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
|
||||
// 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) {
|
||||
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
|
||||
l2Limit := math.Sqrt(float64(threshold))
|
||||
@ -385,6 +481,7 @@ func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []
|
||||
err := r.db.WithContext(ctx).Table(t).
|
||||
Select("id, (phash <-> ?::vector) AS dist", phashBits).
|
||||
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
|
||||
Where("is_deleted = 0").
|
||||
Order("dist ASC").
|
||||
Limit(1).
|
||||
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, LookIndex: 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", "reject_reason": "图片缺失"}).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 != "" || rw.RejectReason != "" {
|
||||
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason)
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
// imageTables 保存S4 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。
|
||||
// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
||||
// imageTables 保存S4 key 的全部图片明细表(单表发布模型下即两张正式图片表)。
|
||||
// 引用计数必须覆盖全部图片表,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
|
||||
var imageTables = []string{
|
||||
"brand_runway_images",
|
||||
"brand_runway_draft_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) {
|
||||
if key == "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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,45 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ReviewRepository 审核草稿的数据访问接口。
|
||||
// ReviewRepository 审核数据访问接口。
|
||||
//
|
||||
// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」:
|
||||
// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。
|
||||
// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
|
||||
// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
|
||||
type ReviewRepository interface {
|
||||
// ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。
|
||||
// 排序:pending 优先,其次按 id 倒序(最新在前)。
|
||||
ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error)
|
||||
// DraftDetail 查单条草稿(不含图片)。
|
||||
DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error)
|
||||
// DraftImages 查草稿的全部图片,按排序值升序。
|
||||
DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error)
|
||||
// UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。
|
||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
|
||||
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。
|
||||
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。
|
||||
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
|
||||
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error)
|
||||
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error)
|
||||
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。
|
||||
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
|
||||
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||
// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
|
||||
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID ——
|
||||
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。
|
||||
// ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
|
||||
ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
|
||||
// RecordDetail 查单条走秀记录(不含图片)。
|
||||
RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
|
||||
// RecordImages 查走秀记录的全部未删图片,按排序值升序。
|
||||
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
|
||||
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
|
||||
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。
|
||||
SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
|
||||
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
|
||||
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
|
||||
StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
|
||||
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
|
||||
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
|
||||
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
|
||||
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
|
||||
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
|
||||
// SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。
|
||||
SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
|
||||
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||
// mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
|
||||
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
|
||||
// 幂等:已是 mainImgID 副图的行重复并入不报错。
|
||||
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。
|
||||
// 对主图调用是幂等成功(无副作用)。
|
||||
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
|
||||
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
|
||||
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
|
||||
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
|
||||
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
|
||||
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
|
||||
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
|
||||
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
|
||||
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
|
||||
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
|
||||
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
|
||||
CountByStatus(ctx context.Context, status string, dest any) (int64, error)
|
||||
}
|
||||
|
||||
type reviewRepository struct {
|
||||
@ -65,27 +61,40 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
||||
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 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
scope := reviewListScope(status)
|
||||
|
||||
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
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.BrandRunwayDraft{}, 0, nil
|
||||
return []model.BrandRunway{}, 0, nil
|
||||
}
|
||||
var rows []model.BrandRunwayDraft
|
||||
if err := q.
|
||||
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||
var rows []model.BrandRunway
|
||||
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).
|
||||
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
Find(&rows).Error; err != nil {
|
||||
@ -94,8 +103,8 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page,
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) {
|
||||
var d model.BrandRunwayDraft
|
||||
func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
|
||||
var d model.BrandRunway
|
||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
@ -105,17 +114,17 @@ func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.B
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) {
|
||||
var imgs []model.BrandRunwayDraftImage
|
||||
func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
|
||||
var imgs []model.BrandRunwayImage
|
||||
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").
|
||||
Find(&imgs).Error
|
||||
return imgs, err
|
||||
}
|
||||
|
||||
// draftEditable 审核前可微调的字段白名单(键即列名)。
|
||||
var draftEditable = map[string]bool{
|
||||
// recordEditable 审核前可微调的字段白名单(键即列名)。
|
||||
var recordEditable = map[string]bool{
|
||||
"title_en": true,
|
||||
"title_cn": true,
|
||||
"description_en": true,
|
||||
@ -126,11 +135,11 @@ var draftEditable = map[string]bool{
|
||||
"season_code": true,
|
||||
}
|
||||
|
||||
// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。
|
||||
func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
// UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
|
||||
func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
clean := map[string]any{}
|
||||
for k, v := range fields {
|
||||
if draftEditable[k] {
|
||||
if recordEditable[k] {
|
||||
clean[k] = v
|
||||
}
|
||||
}
|
||||
@ -139,149 +148,40 @@ func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields ma
|
||||
}
|
||||
clean["updated_at"] = uint32(time.Now().Unix())
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayDraft{}).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", id).
|
||||
Updates(clean).Error
|
||||
}
|
||||
|
||||
func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunwayDraft{}).
|
||||
// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。
|
||||
// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL,
|
||||
// 竞态窗口(记录刚被删除)下会给出假成功 302。
|
||||
//
|
||||
// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。
|
||||
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
|
||||
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
|
||||
// 而不是悄悄在这个仓储方法里加限制。
|
||||
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.BrandRunway{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"reviewer": reviewer,
|
||||
"reject_reason": reason,
|
||||
"updated_at": uint32(time.Now().Unix()),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// SaveRunwayFromDraft 晋升草稿到正式表:
|
||||
// 1. 按实体键(brand_id + season_code + collection_type)查找已存在的正式走秀(幂等:重审同一草稿只更新,不重复建行);
|
||||
// 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 res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if cErr := tx.Create(&rows).Error; cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
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) {
|
||||
// CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
|
||||
func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(dest).
|
||||
@ -292,29 +192,27 @@ func (r *reviewRepository) CountDraftPending(ctx context.Context, status string,
|
||||
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 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0")
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
scope := reviewListScope(status)
|
||||
|
||||
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
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.StreetSnapDraft{}, 0, nil
|
||||
return []model.StreetSnap{}, 0, nil
|
||||
}
|
||||
var rows []model.StreetSnapDraft
|
||||
if err := q.
|
||||
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||
var rows []model.StreetSnap
|
||||
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).
|
||||
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
Find(&rows).Error; err != nil {
|
||||
@ -323,8 +221,8 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string,
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) {
|
||||
var d model.StreetSnapDraft
|
||||
func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
|
||||
var d model.StreetSnap
|
||||
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
@ -334,26 +232,26 @@ func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*m
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) {
|
||||
var imgs []model.StreetSnapDraftImage
|
||||
func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
|
||||
var imgs []model.StreetSnapImage
|
||||
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").
|
||||
Find(&imgs).Error
|
||||
return imgs, err
|
||||
}
|
||||
|
||||
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。
|
||||
var streetDraftEditable = map[string]bool{
|
||||
// streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
|
||||
var streetRecordEditable = map[string]bool{
|
||||
"title": true,
|
||||
"year": 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{}
|
||||
for k, v := range fields {
|
||||
if streetDraftEditable[k] {
|
||||
if streetRecordEditable[k] {
|
||||
clean[k] = v
|
||||
}
|
||||
}
|
||||
@ -362,283 +260,83 @@ func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fie
|
||||
}
|
||||
clean["updated_at"] = uint32(time.Now().Unix())
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapDraft{}).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", id).
|
||||
Updates(clean).Error
|
||||
}
|
||||
|
||||
func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapDraft{}).
|
||||
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
|
||||
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
|
||||
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"reviewer": reviewer,
|
||||
"reject_reason": reason,
|
||||
"updated_at": uint32(time.Now().Unix()),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// 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())
|
||||
var snapID uint32
|
||||
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var existing model.StreetSnap
|
||||
if eErr := tx.Where("city = ? AND year = ? AND is_deleted = 0", draft.City, draft.Year).
|
||||
Limit(1).Find(&existing).Error; eErr != nil {
|
||||
return eErr
|
||||
}
|
||||
|
||||
common := map[string]any{
|
||||
"title": draft.Title,
|
||||
"year": draft.Year,
|
||||
"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,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
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
|
||||
// ── 图片软删 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。
|
||||
// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
|
||||
//
|
||||
// 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」,
|
||||
// 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行,
|
||||
// GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。
|
||||
// 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建,
|
||||
// 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。
|
||||
//
|
||||
// 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks),
|
||||
// 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。
|
||||
func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error {
|
||||
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).
|
||||
Find(&drafts).Error; err != nil {
|
||||
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).
|
||||
Model(&model.BrandRunwayImage{}).
|
||||
Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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。
|
||||
func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
|
||||
// SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。
|
||||
//
|
||||
// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图),
|
||||
// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的,
|
||||
// 与走秀「is_detail 是入库时的细节图」语义不同。
|
||||
func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapDraftImage{}).
|
||||
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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(删除前清理用);不存在返回空串。
|
||||
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。
|
||||
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) {
|
||||
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
|
||||
// kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
|
||||
func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
var im model.BrandRunwayDraftImage
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
|
||||
var im model.BrandRunwayImage
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
@ -647,8 +345,8 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
|
||||
}
|
||||
return im.Image, nil
|
||||
case dto.IngestKindStreet:
|
||||
var im model.StreetSnapDraftImage
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
|
||||
var im model.StreetSnapImage
|
||||
err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
@ -661,13 +359,13 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
|
||||
}
|
||||
}
|
||||
|
||||
// ── 街拍草稿图片:主副图分组 ──────────────────────────────────────────────
|
||||
// ── 街拍主副图分组 ──────────────────────────────────────────────────────────
|
||||
|
||||
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
|
||||
//
|
||||
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
|
||||
// 改挂到新主图(避免副图的副图);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))
|
||||
for _, id := range imgIDs {
|
||||
if id != 0 && id != mainImgID {
|
||||
@ -684,8 +382,8 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var main model.StreetSnapDraftImage
|
||||
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID).
|
||||
var main model.StreetSnapImage
|
||||
if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
|
||||
First(&main).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrInvalidImageGroup
|
||||
@ -694,23 +392,23 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
|
||||
}
|
||||
|
||||
now := uint32(time.Now().Unix())
|
||||
if err := tx.Model(&model.StreetSnapDraftImage{}).
|
||||
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids).
|
||||
if err := tx.Model(&model.StreetSnapImage{}).
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.StreetSnapDraftImage{}).
|
||||
Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids).
|
||||
return tx.Model(&model.StreetSnapImage{}).
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// 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).
|
||||
Model(&model.StreetSnapDraftImage{}).
|
||||
Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
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())}).
|
||||
Error
|
||||
}
|
||||
|
||||
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" || rw.RejectReason != "图片不对" {
|
||||
t.Fatalf("审核态与痕迹应落库,实际 status=%s reviewer=%s reason=%s", rw.Status, rw.Reviewer, rw.RejectReason)
|
||||
}
|
||||
|
||||
// 不存在的 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
|
||||
|
||||
// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。
|
||||
// 集成测试:街拍主副图分组需要真实 PostgreSQL。
|
||||
//
|
||||
// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images),
|
||||
// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。
|
||||
//
|
||||
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
|
||||
//
|
||||
@ -13,61 +16,74 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"fashionapi/internal/model"
|
||||
)
|
||||
|
||||
// seedStreetDraft 插入一条街拍草稿并返回其 id。
|
||||
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 {
|
||||
// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。
|
||||
// 正式表上有部分唯一索引 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()
|
||||
ctx := context.Background()
|
||||
db := repo.(*reviewRepository).db
|
||||
d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending}
|
||||
if err := db.WithContext(ctx).Create(&d).Error; err != nil {
|
||||
t.Fatalf("插入草稿失败: %v", err)
|
||||
mainDetailSeedSeq++
|
||||
snap := model.StreetSnap{
|
||||
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() {
|
||||
db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID)
|
||||
db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID)
|
||||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
|
||||
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
|
||||
})
|
||||
return d.ID
|
||||
return snap.ID
|
||||
}
|
||||
|
||||
// seedStreetDraftImage 插入一张草稿图并返回其行 id。
|
||||
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 {
|
||||
// seedMainDetailImage 插入一张正式街拍图并返回其行 id。
|
||||
func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 {
|
||||
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 {
|
||||
t.Fatalf("插入草稿图失败: %v", err)
|
||||
t.Fatalf("插入街拍图失败: %v", err)
|
||||
}
|
||||
return img.ID
|
||||
}
|
||||
|
||||
// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。
|
||||
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage {
|
||||
// reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。
|
||||
func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage {
|
||||
t.Helper()
|
||||
imgs, err := repo.StreetDraftImages(context.Background(), draftID)
|
||||
imgs, err := repo.StreetRecordImages(context.Background(), snapID)
|
||||
if err != nil {
|
||||
t.Fatalf("读草稿图失败: %v", err)
|
||||
t.Fatalf("读街拍图失败: %v", err)
|
||||
}
|
||||
return imgs
|
||||
}
|
||||
|
||||
// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。
|
||||
// TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。
|
||||
func TestStreetMainDetail(t *testing.T) {
|
||||
repo := NewReviewRepository(testDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
draftID := seedStreetDraft(t, repo)
|
||||
m1 := seedStreetDraftImage(t, repo, draftID, "a", 1)
|
||||
d2 := seedStreetDraftImage(t, repo, draftID, "b", 2)
|
||||
d3 := seedStreetDraftImage(t, repo, draftID, "c", 3)
|
||||
snapID := seedMainDetailSnap(t, repo)
|
||||
m1 := seedMainDetailImage(t, repo, snapID, "a", 1)
|
||||
d2 := seedMainDetailImage(t, repo, snapID, "b", 2)
|
||||
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)
|
||||
}
|
||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||
switch im.ID {
|
||||
case m1:
|
||||
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)
|
||||
}
|
||||
|
||||
// 传入主图自身:调用方过滤掉的语义由仓储兜住(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)
|
||||
}
|
||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||||
t.Fatalf("主图被自身并入后不应变化")
|
||||
}
|
||||
}
|
||||
|
||||
// 拆出:把 d3 恢复为主图。
|
||||
if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil {
|
||||
if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 跨草稿拦截:空集合 + 跨草稿主图。
|
||||
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)
|
||||
}
|
||||
otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1)
|
||||
if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("跨草稿主图应被拦截,实际 %v", err)
|
||||
otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1)
|
||||
if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("跨记录主图应被拦截,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,18 +139,18 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
|
||||
repo := NewReviewRepository(testDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
draftID := seedStreetDraft(t, repo)
|
||||
m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1)
|
||||
m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2)
|
||||
child := seedStreetDraftImage(t, repo, draftID, "child", 3)
|
||||
snapID := seedMainDetailSnap(t, repo)
|
||||
m1 := seedMainDetailImage(t, repo, snapID, "m1", 1)
|
||||
m2 := seedMainDetailImage(t, repo, snapID, "m2", 2)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, im := range reloadStreetImages(t, repo, draftID) {
|
||||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||||
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||||
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||||
}
|
||||
@ -144,49 +160,164 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。
|
||||
func TestStreetPromoteRebuildsParentRef(t *testing.T) {
|
||||
// ── image_count 重算(软删不漂移) ─────────────────────────────────────────────
|
||||
|
||||
// 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))
|
||||
ctx := context.Background()
|
||||
|
||||
draftID := seedStreetDraft(t, repo)
|
||||
m1 := seedStreetDraftImage(t, repo, draftID, "main", 1)
|
||||
d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2)
|
||||
d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3)
|
||||
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
|
||||
t.Fatalf("预置主副图失败: %v", err)
|
||||
snapID := seedMainDetailSnap(t, repo)
|
||||
keep := seedMainDetailImage(t, repo, snapID, "keep", 1)
|
||||
drop := seedMainDetailImage(t, repo, snapID, "drop", 2)
|
||||
|
||||
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 应为 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 {
|
||||
t.Fatalf("晋升失败: %v", err)
|
||||
t.Fatalf("读存活图失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db := repo.(*reviewRepository).db
|
||||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID)
|
||||
db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID)
|
||||
})
|
||||
|
||||
db := repo.(*reviewRepository).db
|
||||
var rows []model.StreetSnapImage
|
||||
if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("读正式图失败: %v", err)
|
||||
if len(imgs) != 1 || imgs[0].ID != keep {
|
||||
t.Fatalf("应只剩 keep 一行,实际 %+v", imgs)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("期望 3 行正式图,实际 %d", len(rows))
|
||||
}
|
||||
|
||||
mainRow := rows[0]
|
||||
if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 {
|
||||
t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID)
|
||||
// TestSoftDeleteRunwayImage 走秀 image_count 口径是「存活主图行数」(与入库 countMainImages 一致)。
|
||||
//
|
||||
// 关键断言在「软删一张主图、让细节图**仍存活**」这一步:重算必须排除 is_detail=1 的行。
|
||||
// 若先删细节图再删主图,过滤与否结果相同(旧写法就锁不住口径),故这里把顺序倒过来。
|
||||
func TestSoftDeleteRunwayImage(t *testing.T) {
|
||||
repo := NewReviewRepository(testDB(t))
|
||||
ctx := context.Background()
|
||||
|
||||
runwayID := seedMainDetailRunway(t, repo)
|
||||
main1 := seedMainDetailRunwayImage(t, repo, runwayID, "main1", 1, 0)
|
||||
main2 := seedMainDetailRunwayImage(t, repo, runwayID, "main2", 2, 0)
|
||||
detail := seedMainDetailRunwayImage(t, repo, runwayID, "detail1", 3, 1)
|
||||
|
||||
// 先软删一张主图,细节图此刻仍存活:只计主图 → 1(仅 main1)。
|
||||
// 若重算用 COUNT(*) 不排除 is_detail,会把存活的 detail 一并算进来得 2 —— 本断言锁得住。
|
||||
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
|
||||
t.Fatalf("软删主图失败: %v", err)
|
||||
}
|
||||
for _, row := range rows[1:] {
|
||||
if row.IsDetail != 1 {
|
||||
t.Fatalf("第 %d 行应是副图", row.ID)
|
||||
}
|
||||
if row.ParentImageID != mainRow.ID {
|
||||
t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID)
|
||||
if got := runwayImageCount(t, repo, runwayID); got != 1 {
|
||||
t.Fatalf("image_count 只计存活主图,软删一张主图后应为 1(须排除仍存活的细节图),实际 %d", got)
|
||||
}
|
||||
|
||||
// 再软删细节图:主图数不变(细节图本就不计入)。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,10 +16,17 @@ import (
|
||||
type StreetSnapRepository interface {
|
||||
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
|
||||
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)
|
||||
// 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)
|
||||
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
|
||||
ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
|
||||
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
|
||||
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
|
||||
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
|
||||
@ -28,7 +35,7 @@ type StreetSnapRepository interface {
|
||||
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
|
||||
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
|
||||
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// DeleteSnapImage 软删单张街拍图片。
|
||||
// DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。
|
||||
DeleteSnapImage(ctx context.Context, imageID uint32) error
|
||||
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
|
||||
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
|
||||
@ -75,9 +82,39 @@ func snapOrderBy(sort string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// List 公开读:读公开视图,pending / rejected 街拍不可见。
|
||||
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
|
||||
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
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnap{}).
|
||||
@ -102,11 +139,20 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery)
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
|
||||
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
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("id = ? AND is_deleted = 0", id).
|
||||
First(&snap).Error
|
||||
err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
@ -116,7 +162,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。
|
||||
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
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("snap_id = ? AND is_deleted = 0", snapID).
|
||||
@ -133,8 +191,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
||||
|
||||
var imgs []model.StreetSnapImage
|
||||
err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnapImages).
|
||||
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").
|
||||
Find(&imgs).Error
|
||||
if err != nil {
|
||||
@ -146,11 +205,11 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。
|
||||
// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。
|
||||
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
|
||||
var rows []model.StreetSnap
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table(publicStreetSnaps).
|
||||
Where("is_deleted = 0").
|
||||
Order("image_count DESC, id DESC").
|
||||
Limit(limit).
|
||||
@ -178,7 +237,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
|
||||
}
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -202,11 +261,35 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
|
||||
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 {
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&model.StreetSnapImage{}).
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var img model.StreetSnapImage
|
||||
err := tx.Select("snap_id").Where("id = ?", imageID).First(&img).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if 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
|
||||
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`UPDATE street_snaps SET image_count =
|
||||
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
|
||||
WHERE id = ?`, img.SnapID, img.SnapID).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
|
||||
|
||||
@ -80,8 +80,8 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
|
||||
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
|
||||
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch)
|
||||
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
|
||||
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
|
||||
admin.POST("/api/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImagesAPI)
|
||||
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
|
||||
|
||||
admin.GET("/", opt.Backstage.Dashboard)
|
||||
admin.GET("/brands", opt.Backstage.Brands)
|
||||
@ -103,17 +103,17 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
|
||||
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
|
||||
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
|
||||
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
|
||||
// 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。
|
||||
// 审核记录:爬虫入库待人工审核 → 通过置为已发布 / 驳回。
|
||||
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
|
||||
admin.GET("/reviews", opt.Backstage.ReviewList)
|
||||
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
|
||||
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
|
||||
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
|
||||
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
|
||||
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete)
|
||||
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewImageDelete)
|
||||
// 街拍主副图分组操作(零 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.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
|
||||
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
|
||||
// 爬虫入库任务监控:列出处理进度 + 失败重试
|
||||
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
|
||||
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)
|
||||
|
||||
@ -50,6 +50,11 @@ type fakeBrand struct{}
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return nil, nil
|
||||
}
|
||||
@ -104,11 +109,19 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
|
||||
ImageCount: 12,
|
||||
}}, 1, nil
|
||||
}
|
||||
|
||||
// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到,
|
||||
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
|
||||
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{
|
||||
UID: id,
|
||||
BrandUID: "001DESke",
|
||||
Title: "Acne Studios Spring 2024",
|
||||
Title: "Acne Studios Spring 2024(待审)",
|
||||
BrandName: "Acne Studios",
|
||||
Images: []dto.PublicArticleImage{
|
||||
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
|
||||
@ -143,10 +156,23 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub
|
||||
ImageCount: 8,
|
||||
}}, 1, nil
|
||||
}
|
||||
|
||||
// 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 string) (*dto.PublicStreetSnapDetail, error) {
|
||||
return nil, service.ErrStreetSnapNotFound
|
||||
}
|
||||
|
||||
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
|
||||
func (f *fakeStreet) DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
return &dto.PublicStreetSnapDetail{
|
||||
UID: id,
|
||||
Title: "Paris Fashion Week Street",
|
||||
Title: "Paris Fashion Week Street(待审)",
|
||||
Images: []dto.PublicArticleImage{
|
||||
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
|
||||
},
|
||||
@ -264,13 +290,15 @@ func TestBackstage(t *testing.T) {
|
||||
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 7) 走秀详情渲染图集
|
||||
// 7) 后台走秀详情渲染图集。
|
||||
// fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found,
|
||||
// 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
|
||||
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") {
|
||||
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String())
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") {
|
||||
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 8) 街拍列表渲染
|
||||
@ -282,13 +310,14 @@ func TestBackstage(t *testing.T) {
|
||||
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 9) 街拍详情渲染图集
|
||||
// 9) 后台街拍详情渲染图集。
|
||||
// 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
|
||||
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") {
|
||||
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") {
|
||||
t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -305,7 +334,7 @@ type fakeReview struct {
|
||||
rejectErr error
|
||||
last struct {
|
||||
kind string
|
||||
draftID uint32
|
||||
recordID uint32
|
||||
mainID uint32
|
||||
imgIDs []uint32
|
||||
imgID uint32
|
||||
@ -318,27 +347,32 @@ type fakeReview struct {
|
||||
ID uint32
|
||||
Reason string
|
||||
}
|
||||
view *service.DraftDetailView
|
||||
view *service.RecordDetailView
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach"
|
||||
// UpdateRecord 表单式通过路径会先应用字段微调;这里只做无操作桩(真正的白名单在仓储层)。
|
||||
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 {
|
||||
return errors.New("主图无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error {
|
||||
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach"
|
||||
func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error {
|
||||
f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach"
|
||||
if !f.attachOK {
|
||||
return errors.New("主图无效")
|
||||
}
|
||||
@ -362,6 +396,8 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
|
||||
ID uint32
|
||||
Reason string
|
||||
}{"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
|
||||
}
|
||||
@ -465,16 +501,16 @@ func TestBackstageStreetDetach(t *testing.T) {
|
||||
func TestBackstageStreetRenderGroups(t *testing.T) {
|
||||
r, rev := newReviewRouter(t)
|
||||
tok := reviewToken(t, r)
|
||||
rev.view = &service.DraftDetailView{
|
||||
rev.view = &service.RecordDetailView{
|
||||
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
|
||||
Images: []service.DraftImageRef{
|
||||
Images: []service.RecordImageRef{
|
||||
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
|
||||
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
|
||||
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
|
||||
},
|
||||
Groups: []service.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"}},
|
||||
Groups: []service.ImageGroup{
|
||||
{Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
|
||||
{Image: service.RecordImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
|
||||
},
|
||||
}
|
||||
|
||||
@ -509,6 +545,26 @@ func TestBackstageStreetRenderGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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/reviews/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 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。
|
||||
|
||||
@ -24,6 +24,8 @@ type MediaCleanupEnqueuer interface {
|
||||
type ArticleService interface {
|
||||
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, 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(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
|
||||
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
|
||||
@ -87,7 +89,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
|
||||
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / 状态
|
||||
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
|
||||
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
|
||||
q.Normalize()
|
||||
@ -107,6 +109,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
|
||||
CollectionType: r.CollectionType,
|
||||
SeasonCode: r.SeasonCode,
|
||||
ImageCount: r.ImageCount,
|
||||
Status: r.Status,
|
||||
})
|
||||
}
|
||||
return list, total, nil
|
||||
@ -151,22 +154,46 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
|
||||
return kept
|
||||
}
|
||||
|
||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
|
||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
|
||||
// locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。
|
||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||
row, err := s.articles.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrArticleNotFound
|
||||
return nil, s.detailErr(err)
|
||||
}
|
||||
return nil, NewError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
imgs, err := s.articles.ListImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return s.buildDetail(row, imgs, locale), nil
|
||||
}
|
||||
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||
//
|
||||
// 后台列表(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)按 look_index
|
||||
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
|
||||
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
|
||||
@ -198,7 +225,7 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
|
||||
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
|
||||
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
|
||||
Images: pubMains,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -16,6 +16,9 @@ import (
|
||||
// BrandService 品牌业务接口。
|
||||
type BrandService interface {
|
||||
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 个)并附带各品牌档案数。
|
||||
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
|
||||
// 复用 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
|
||||
}
|
||||
|
||||
// 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 个,结果按热度顺序(而非字母序)排列。
|
||||
//
|
||||
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——
|
||||
|
||||
117
internal/service/ingest_image_count_test.go
Normal file
117
internal/service/ingest_image_count_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
|
||||
return 0, "", 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 }
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
@ -229,7 +229,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) {
|
||||
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
|
||||
brandID, err := hashid.Decode(p.BrandUID)
|
||||
@ -242,38 +242,47 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过,
|
||||
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
|
||||
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
|
||||
// 2) 实体键查重并决定分支(单表模型)
|
||||
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
|
||||
dedupStart := time.Now()
|
||||
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found {
|
||||
log.Printf("[ingest] job=%d runway 实体去重命中(season=%s type=%s),跳过 耗时=%v",
|
||||
job.ID, seasonCode, p.CollectionType, time.Since(dedupStart).Round(time.Millisecond))
|
||||
reuseID, existStatus, exist, 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
|
||||
}
|
||||
action := decideIngestAction(exist, existStatus)
|
||||
if action == ingestSkip {
|
||||
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过", job.ID, existStatus)
|
||||
_ = s.repo.MarkDone(ctx, job.ID)
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d runway 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
|
||||
|
||||
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
|
||||
|
||||
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
|
||||
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
|
||||
var cover string
|
||||
var draftImages []model.BrandRunwayDraftImage
|
||||
var images []model.BrandRunwayImage
|
||||
var keys []string
|
||||
var imgFailed bool
|
||||
var imageCount uint16
|
||||
var timing *fetchTiming
|
||||
if len(p.Looks) > 0 {
|
||||
cover, draftImages, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
imageCount = uint16(len(p.Looks))
|
||||
cover, images, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
|
||||
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
|
||||
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
|
||||
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
|
||||
// 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。
|
||||
imageCount = countMainImages(images)
|
||||
} else {
|
||||
var fimgs []fetchedImage
|
||||
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
|
||||
imageCount = uint16(len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
draftImages = append(draftImages, model.BrandRunwayDraftImage{
|
||||
images = append(images, model.BrandRunwayImage{
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
@ -294,9 +303,9 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
|
||||
// 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片)
|
||||
writeStart := time.Now()
|
||||
draft := &model.BrandRunwayDraft{
|
||||
rw := &model.BrandRunway{
|
||||
JobID: job.ID,
|
||||
BrandID: brandID,
|
||||
TitleEn: p.TitleEn,
|
||||
@ -309,38 +318,41 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
|
||||
SeasonCode: seasonCode,
|
||||
Cover: cover,
|
||||
ImageCount: imageCount,
|
||||
Status: model.DraftStatusPending,
|
||||
Status: model.StatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateRunwayDraft(ctx, draft)
|
||||
if err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
|
||||
for i := range images {
|
||||
images[i].BrandID = brandID
|
||||
}
|
||||
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
|
||||
}
|
||||
for i := range draftImages {
|
||||
draftImages[i].DraftID = id
|
||||
}
|
||||
if err := s.repo.CreateRunwayDraftImages(ctx, draftImages); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
|
||||
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)
|
||||
}
|
||||
|
||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落草稿的
|
||||
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
|
||||
// BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
|
||||
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
|
||||
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
|
||||
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
|
||||
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
|
||||
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
|
||||
//
|
||||
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
|
||||
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
|
||||
// 这样既拿到并发收益,又保证 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()
|
||||
timing := &fetchTiming{}
|
||||
cover := ""
|
||||
rows := make([]model.BrandRunwayDraftImage, 0)
|
||||
rows := make([]model.BrandRunwayImage, 0)
|
||||
keys := make([]string, 0)
|
||||
seen := make(map[string]bool)
|
||||
failed := false
|
||||
@ -392,7 +404,7 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
|
||||
isDetail = 1
|
||||
}
|
||||
rows = append(rows, model.BrandRunwayDraftImage{
|
||||
rows = append(rows, model.BrandRunwayImage{
|
||||
Image: r.url,
|
||||
Name: name,
|
||||
SortOrder: uint32(order),
|
||||
@ -408,18 +420,35 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
|
||||
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) {
|
||||
// 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
|
||||
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
|
||||
dedupStart := time.Now()
|
||||
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found {
|
||||
log.Printf("[ingest] job=%d street 实体去重命中(city=%s year=%d),跳过 耗时=%v",
|
||||
job.ID, p.City, p.Year, time.Since(dedupStart).Round(time.Millisecond))
|
||||
// 1) 实体键查重并决定分支(单表模型,同 runway)。
|
||||
// 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致,
|
||||
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
|
||||
reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn)
|
||||
if err != nil {
|
||||
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
|
||||
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
|
||||
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)
|
||||
return
|
||||
}
|
||||
log.Printf("[ingest] job=%d street 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
|
||||
|
||||
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
|
||||
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
|
||||
@ -432,26 +461,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snaps 正式表
|
||||
// 3) 直写正式表(status=pending)
|
||||
writeStart := time.Now()
|
||||
draft := &model.StreetSnapDraft{
|
||||
snap := &model.StreetSnap{
|
||||
JobID: job.ID,
|
||||
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
|
||||
Year: p.Year,
|
||||
City: p.City,
|
||||
Cover: cover,
|
||||
ImageCount: uint16(len(fimgs)),
|
||||
Status: model.DraftStatusPending,
|
||||
Status: model.StatusPending,
|
||||
}
|
||||
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
|
||||
if err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
|
||||
return
|
||||
}
|
||||
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
|
||||
rows := make([]model.StreetSnapImage, 0, len(fimgs))
|
||||
for i, fi := range fimgs {
|
||||
rows = append(rows, model.StreetSnapDraftImage{
|
||||
DraftID: id,
|
||||
rows = append(rows, model.StreetSnapImage{
|
||||
Image: fi.url,
|
||||
Name: fmt.Sprintf("Look %d", i+1),
|
||||
SortOrder: uint32(i + 1),
|
||||
@ -460,12 +483,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
|
||||
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
|
||||
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
|
||||
var saveErr error
|
||||
if action == ingestReuseRejected {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@ -606,9 +635,9 @@ func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string,
|
||||
var tables []string
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
tables = []string{"brand_runway_draft_images", "brand_runway_images"}
|
||||
tables = []string{"brand_runway_images"}
|
||||
case dto.IngestKindStreet:
|
||||
tables = []string{"street_snap_draft_images", "street_snap_images"}
|
||||
tables = []string{"street_snap_images"}
|
||||
default:
|
||||
return 0, 0
|
||||
}
|
||||
@ -742,3 +771,25 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
|
||||
type DraftCard struct {
|
||||
// RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
|
||||
type RecordCard struct {
|
||||
Kind string `json:"kind"` // runway | street | 未来其它模块
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"` // 展示标题
|
||||
@ -26,11 +26,11 @@ type DraftCard struct {
|
||||
UpdatedAt uint32 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DraftImageRef 草稿图片引用(统一结构)。
|
||||
// RecordImageRef 记录图片引用(统一结构)。
|
||||
//
|
||||
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
|
||||
// DetailCount 让模板不用自己数副图。
|
||||
type DraftImageRef struct {
|
||||
type RecordImageRef struct {
|
||||
ID uint32 `json:"id"`
|
||||
Image string `json:"image"`
|
||||
Name string `json:"name"`
|
||||
@ -49,8 +49,8 @@ type EditField struct {
|
||||
Type string `json:"type"` // text | textarea | number
|
||||
}
|
||||
|
||||
// DraftDetailView 审核详情统一视图(所有模块共用)。
|
||||
type DraftDetailView struct {
|
||||
// RecordDetailView 审核详情统一视图(所有模块共用)。
|
||||
type RecordDetailView struct {
|
||||
Kind string `json:"kind"`
|
||||
ID uint32 `json:"id"`
|
||||
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
|
||||
@ -67,23 +67,23 @@ type DraftDetailView struct {
|
||||
Cover string `json:"cover"`
|
||||
ImageCount uint16 `json:"image_count"`
|
||||
Status string `json:"status"`
|
||||
Images []DraftImageRef `json:"images"`
|
||||
Images []RecordImageRef `json:"images"`
|
||||
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
|
||||
Fields []EditField `json:"fields"`
|
||||
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
|
||||
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
|
||||
Groups []DraftImageGroup `json:"groups,omitempty"`
|
||||
Groups []ImageGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
|
||||
type DraftImageGroup struct {
|
||||
Image DraftImageRef `json:"image"` // 主图
|
||||
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空)
|
||||
// ImageGroup 一张主图及其副图,供审核页按组折叠渲染。
|
||||
type ImageGroup struct {
|
||||
Image RecordImageRef `json:"image"` // 主图
|
||||
Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空)
|
||||
}
|
||||
|
||||
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。
|
||||
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。
|
||||
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
|
||||
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")
|
||||
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本条记录、未被删除,且自身是主图")
|
||||
|
||||
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
|
||||
type KindTab struct {
|
||||
@ -92,40 +92,41 @@ type KindTab struct {
|
||||
Pending int64 `json:"pending"`
|
||||
}
|
||||
|
||||
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。
|
||||
// ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。
|
||||
//
|
||||
// 新增一种爬虫数据类型需要审核时,只需:
|
||||
// 1. 建草稿表 + 迁移;
|
||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending;
|
||||
// 1. 建正式表 + 迁移(记录自带 status 列);
|
||||
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
|
||||
// 3. 在此文件实现本接口并注册到 NewReviewService。
|
||||
//
|
||||
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
|
||||
type ReviewModule interface {
|
||||
Kind() string
|
||||
Label() string
|
||||
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error)
|
||||
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error)
|
||||
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。
|
||||
ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error)
|
||||
RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error)
|
||||
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
|
||||
// Approve 把正式记录置为 published;返回记录主键。
|
||||
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
|
||||
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
|
||||
Reject(ctx context.Context, id uint32, reviewer, reason string) error
|
||||
CountPending(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
// ReviewService 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。
|
||||
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
|
||||
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
|
||||
type ReviewService interface {
|
||||
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error)
|
||||
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error)
|
||||
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||||
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
|
||||
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
|
||||
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
|
||||
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
|
||||
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。
|
||||
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
|
||||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
|
||||
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
|
||||
AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error
|
||||
// DetachImage 把副图拆出恢复为主图(街拍专用)。
|
||||
DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error
|
||||
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
|
||||
KindTabs(ctx context.Context) []KindTab
|
||||
}
|
||||
@ -165,22 +166,22 @@ func (s *reviewService) module(kind string) (ReviewModule, bool) {
|
||||
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" {
|
||||
kind = ""
|
||||
}
|
||||
if kind != "" {
|
||||
m, ok := s.module(kind)
|
||||
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
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -208,20 +209,20 @@ func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, pag
|
||||
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)
|
||||
if !ok {
|
||||
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)
|
||||
if !ok {
|
||||
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) {
|
||||
@ -256,22 +257,22 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
|
||||
return tabs
|
||||
}
|
||||
|
||||
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。
|
||||
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count;
|
||||
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。
|
||||
// 按 kind 委派到对应图片表的软删,并同步记录 image_count;
|
||||
// 若该 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 {
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID)
|
||||
key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch kind {
|
||||
case dto.IngestKindRunway:
|
||||
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID)
|
||||
err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID)
|
||||
case dto.IngestKindStreet:
|
||||
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID)
|
||||
err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID)
|
||||
default:
|
||||
return repository.ErrNotFound
|
||||
}
|
||||
@ -284,21 +285,21 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
|
||||
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 {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs))
|
||||
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
|
||||
}
|
||||
|
||||
func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
|
||||
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
|
||||
if kind != dto.IngestKindStreet {
|
||||
return ErrInvalidImageGroup
|
||||
}
|
||||
return translateGroupErr(s.repo.DetachStreetDraftImage(ctx, draftID, imgID))
|
||||
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
|
||||
}
|
||||
|
||||
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
|
||||
@ -322,24 +323,24 @@ type runwayModule struct {
|
||||
func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
|
||||
func (m *runwayModule) Label() string { return "走秀" }
|
||||
|
||||
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
|
||||
rows, total, err := m.repo.ListDrafts(ctx, status, page, size)
|
||||
func (m *runwayModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
|
||||
rows, total, err := m.repo.ListRecords(ctx, status, page, size)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.toRunwayCards(rows), total, nil
|
||||
}
|
||||
|
||||
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
|
||||
d, err := m.repo.DraftDetail(ctx, id)
|
||||
func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
|
||||
d, err := m.repo.RecordDetail(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgs, err := m.repo.DraftImages(ctx, id)
|
||||
imgs, err := m.repo.RecordImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := &DraftDetailView{
|
||||
v := &RecordDetailView{
|
||||
Kind: dto.IngestKindRunway,
|
||||
ID: d.ID,
|
||||
BrandID: d.BrandID,
|
||||
@ -367,32 +368,28 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
},
|
||||
}
|
||||
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})
|
||||
v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
|
||||
}
|
||||
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) {
|
||||
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id)
|
||||
if err != nil {
|
||||
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return officialID, nil
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
||||
return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
|
||||
return m.repo.SetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
||||
}
|
||||
|
||||
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 模块 ────────────────────────────────────────────────────────────────
|
||||
@ -405,24 +402,24 @@ type streetModule struct {
|
||||
func (m *streetModule) Kind() string { return dto.IngestKindStreet }
|
||||
func (m *streetModule) Label() string { return "街拍" }
|
||||
|
||||
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
|
||||
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size)
|
||||
func (m *streetModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
|
||||
rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return m.toStreetCards(rows), total, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
|
||||
d, err := m.repo.StreetDraftDetail(ctx, id)
|
||||
func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
|
||||
d, err := m.repo.StreetRecordDetail(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imgs, err := m.repo.StreetDraftImages(ctx, id)
|
||||
imgs, err := m.repo.StreetRecordImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := &DraftDetailView{
|
||||
v := &RecordDetailView{
|
||||
Kind: dto.IngestKindStreet,
|
||||
ID: d.ID,
|
||||
Title: d.Title,
|
||||
@ -440,7 +437,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
|
||||
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
|
||||
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
|
||||
byID := make(map[uint32]model.StreetSnapDraftImage, len(imgs))
|
||||
byID := make(map[uint32]model.StreetSnapImage, len(imgs))
|
||||
for _, im := range imgs {
|
||||
byID[im.ID] = im
|
||||
}
|
||||
@ -472,7 +469,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))
|
||||
for _, im := range imgs {
|
||||
root := resolveRoot(im.ID)
|
||||
@ -483,7 +480,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
isDetailVal = 1
|
||||
parent = root
|
||||
}
|
||||
ref := DraftImageRef{
|
||||
ref := RecordImageRef{
|
||||
ID: im.ID,
|
||||
Image: m.img.Compose(im.Image),
|
||||
Name: im.Name,
|
||||
@ -494,14 +491,14 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
}
|
||||
v.Images = append(v.Images, ref)
|
||||
if !isDetail {
|
||||
groups = append(groups, DraftImageGroup{Image: ref})
|
||||
groups = append(groups, ImageGroup{Image: ref})
|
||||
groupIndex[im.ID] = len(groups) - 1
|
||||
continue
|
||||
}
|
||||
if gi, ok := groupIndex[root]; ok {
|
||||
groups[gi].Details = append(groups[gi].Details, ref)
|
||||
} else { // 兜底(理论不发生):主图未先出现,自成一组
|
||||
groups = append(groups, DraftImageGroup{Image: ref})
|
||||
groups = append(groups, ImageGroup{Image: ref})
|
||||
groupIndex[im.ID] = len(groups) - 1
|
||||
}
|
||||
}
|
||||
@ -509,33 +506,29 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return m.repo.UpdateStreetDraft(ctx, id, fields)
|
||||
func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
|
||||
return m.repo.UpdateStreetRecord(ctx, id, fields)
|
||||
}
|
||||
|
||||
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
|
||||
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id)
|
||||
if err != nil {
|
||||
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return officialID, nil
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
|
||||
return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
|
||||
return m.repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, reviewer, reason)
|
||||
}
|
||||
|
||||
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 {
|
||||
cards := make([]DraftCard, 0, len(rows))
|
||||
func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard {
|
||||
cards := make([]RecordCard, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
sub := d.SeasonCode
|
||||
if d.CollectionType != "" {
|
||||
@ -544,7 +537,7 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
|
||||
if sub == "" {
|
||||
sub = "—"
|
||||
}
|
||||
cards = append(cards, DraftCard{
|
||||
cards = append(cards, RecordCard{
|
||||
Kind: dto.IngestKindRunway,
|
||||
ID: d.ID,
|
||||
Title: d.TitleEn,
|
||||
@ -559,14 +552,14 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
|
||||
return cards
|
||||
}
|
||||
|
||||
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
|
||||
cards := make([]DraftCard, 0, len(rows))
|
||||
func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard {
|
||||
cards := make([]RecordCard, 0, len(rows))
|
||||
for _, d := range rows {
|
||||
sub := d.City
|
||||
if sub == "" {
|
||||
sub = "—"
|
||||
}
|
||||
cards = append(cards, DraftCard{
|
||||
cards = append(cards, RecordCard{
|
||||
Kind: dto.IngestKindStreet,
|
||||
ID: d.ID,
|
||||
Title: d.Title,
|
||||
|
||||
@ -14,27 +14,27 @@ import (
|
||||
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
|
||||
type fakeGroupRepo struct {
|
||||
repository.ReviewRepository
|
||||
images []model.StreetSnapDraftImage
|
||||
detail *model.StreetSnapDraft
|
||||
attachFn func(draftID, mainID uint32, imgIDs []uint32) error
|
||||
images []model.StreetSnapImage
|
||||
detail *model.StreetSnap
|
||||
attachFn func(snapID, 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
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) StreetDraftImages(_ context.Context, _ uint32) ([]model.StreetSnapDraftImage, error) {
|
||||
func (f *fakeGroupRepo) StreetRecordImages(_ context.Context, _ uint32) ([]model.StreetSnapImage, error) {
|
||||
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 {
|
||||
return f.attachFn(draftID, mainID, imgIDs)
|
||||
return f.attachFn(snapID, mainID, imgIDs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error {
|
||||
func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uint32) error {
|
||||
if f.attachFn != nil {
|
||||
return f.attachFn(0, 0, nil)
|
||||
}
|
||||
@ -50,17 +50,17 @@ func newGroupService(f *fakeGroupRepo) ReviewService {
|
||||
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
|
||||
func TestStreetGroupDetailExposesGrouping(t *testing.T) {
|
||||
repo := &fakeGroupRepo{
|
||||
detail: &model.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.DraftStatusPending},
|
||||
images: []model.StreetSnapDraftImage{
|
||||
detail: &model.StreetSnap{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.StatusPending},
|
||||
images: []model.StreetSnapImage{
|
||||
{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: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
|
||||
},
|
||||
}
|
||||
svc := newGroupService(repo)
|
||||
v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7)
|
||||
v, err := svc.RecordDetail(context.Background(), dto.IngestKindStreet, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("DraftDetail 失败: %v", err)
|
||||
t.Fatalf("RecordDetail 失败: %v", err)
|
||||
}
|
||||
if len(v.Images) != 3 {
|
||||
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
|
||||
@ -85,20 +85,20 @@ func TestStreetGroupDetailExposesGrouping(t *testing.T) {
|
||||
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
|
||||
func TestStreetGroupAttachDelegates(t *testing.T) {
|
||||
var got struct {
|
||||
draftID uint32
|
||||
snapID uint32
|
||||
mainID uint32
|
||||
imgIDs []uint32
|
||||
}
|
||||
repo := &fakeGroupRepo{attachFn: func(draftID, mainID uint32, imgIDs []uint32) error {
|
||||
got.draftID, got.mainID, got.imgIDs = draftID, mainID, imgIDs
|
||||
repo := &fakeGroupRepo{attachFn: func(snapID, mainID uint32, imgIDs []uint32) error {
|
||||
got.snapID, got.mainID, got.imgIDs = snapID, mainID, imgIDs
|
||||
return nil
|
||||
}}
|
||||
svc := newGroupService(repo)
|
||||
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
|
||||
t.Fatalf("AttachDraftImages 失败: %v", err)
|
||||
if err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
|
||||
t.Fatalf("AttachImages 失败: %v", err)
|
||||
}
|
||||
if got.draftID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
|
||||
t.Fatalf("参数透传错误: draftID=%d mainID=%d imgIDs=%v", got.draftID, got.mainID, got.imgIDs)
|
||||
if got.snapID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
|
||||
t.Fatalf("参数透传错误: snapID=%d mainID=%d imgIDs=%v", got.snapID, got.mainID, got.imgIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,22 +106,22 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
|
||||
func TestStreetGroupNonStreetRejected(t *testing.T) {
|
||||
repo := &fakeGroupRepo{}
|
||||
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(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||||
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreetGroupPreventCrossDraft 仓储拦截跨草稿主图后,service 翻成中文用户错误。
|
||||
func TestStreetGroupPreventCrossDraft(t *testing.T) {
|
||||
// TestStreetGroupPreventCrossRecord 仓储拦截跨记录主图后,service 翻成中文用户错误。
|
||||
func TestStreetGroupPreventCrossRecord(t *testing.T) {
|
||||
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
|
||||
return repository.ErrInvalidImageGroup
|
||||
}}
|
||||
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 {
|
||||
t.Fatal("跨草稿应报错")
|
||||
t.Fatal("跨记录应报错")
|
||||
}
|
||||
if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" {
|
||||
if err.Error() != "主图无效:它必须属于本条记录、未被删除,且自身是主图" {
|
||||
t.Fatalf("错误文案不符: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,11 @@ import (
|
||||
// StreetSnapService 街拍业务接口。
|
||||
type StreetSnapService interface {
|
||||
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
|
||||
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
|
||||
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
|
||||
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
|
||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
|
||||
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
|
||||
@ -62,17 +66,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
||||
return nil, 0, internalErr(err.Error())
|
||||
}
|
||||
|
||||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||
ids := make([]uint32, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
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)
|
||||
|
||||
if q.Images > 0 && len(list) > 0 {
|
||||
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
|
||||
@ -82,6 +80,36 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
||||
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), total, nil
|
||||
}
|
||||
|
||||
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
|
||||
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
|
||||
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap) []dto.PublicStreetSnap {
|
||||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, dto.PublicStreetSnap{
|
||||
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
|
||||
Title: r.Title,
|
||||
Cover: s.img.ComposeThumb(r.Cover),
|
||||
ImageCount: r.ImageCount,
|
||||
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
|
||||
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
|
||||
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
|
||||
@ -119,27 +147,52 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
|
||||
return kept
|
||||
}
|
||||
|
||||
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
|
||||
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
|
||||
// 图片统一走展示样式(VIP 与免费同质量)。
|
||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||
snap, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
return nil, ErrStreetSnapNotFound
|
||||
return nil, s.detailErr(err)
|
||||
}
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
|
||||
imgs, err := s.repo.ListImages(ctx, id)
|
||||
if err != nil {
|
||||
return nil, internalErr(err.Error())
|
||||
}
|
||||
return s.buildSnapDetail(snap, imgs), nil
|
||||
}
|
||||
|
||||
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
|
||||
//
|
||||
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
|
||||
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
|
||||
func (s *streetSnapService) DetailAdmin(ctx context.Context, id 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), 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) *dto.PublicStreetSnapDetail {
|
||||
return &dto.PublicStreetSnapDetail{
|
||||
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
|
||||
Title: snap.Title,
|
||||
Cover: s.img.Compose(snap.Cover),
|
||||
Images: s.buildSnapImages(imgs),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
|
||||
@ -207,7 +260,6 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
|
||||
return out
|
||||
}
|
||||
|
||||
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
|
||||
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
|
||||
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
|
||||
rows, err := s.repo.Popular(ctx, limit)
|
||||
|
||||
Reference in New Issue
Block a user