Compare commits

...

18 Commits

Author SHA1 Message Date
f04b5707cb fix(publish): 最终审查补修(编辑页删图重算计数、迁移编号后置、图片 id 稳定断言)
D1 编辑页删图:DeleteRunwayImage/DeleteSnapImage 在单事务内取父 id→软删→按存活行重算 image_count,口径与审核侧 SoftDelete*Image 完全一致(走秀只计主图、街拍计全部),并补集成测试(含重复删同一张)

D2 不可逆删表迁移 02→05(git mv),使文件名顺序 01→01b→03→04→05 与必需执行顺序一致;同步 README/文件头/规格/计划全部引用

D3 新增「通过审核后图片行 id 集合完全一致」的集成断言,锁住单表发布头号收益

D4 修正 01 迁移文件头「只做三件事」自相矛盾表述;D5 删除受版本控制的陈旧产物 cmd/dbtool/db_dump.json 并加 gitignore 规则;D6 清理遗留空目录 scripts/tmp_verify/
2026-09-23 16:27:14 +08:00
be4db0bf96 fix(publish): 最终审查修复波——迁移索引口径 / 入库计数 / 仓储证据
C1(关键):2026-09-22-03 的街拍实体键索引改用最终表达式
(city, year, COALESCE(title, '')),与体检及 StreetSnapEntityState 口径一致。
此前索引按 (city, year) 比体检更窄,同城同年多专题的合法数据会通过体检、
再由索引抛原生 23505;04 退化为幂等兜底并在注释说明真正常态由 03 承载。
新增 db/migrations/README.md,写清迁移链执行顺序与各步前提/不可逆点。

I1:processRunway 的 image_count 改为按实际落库主图行数(countMainImages),
不再用 len(p.Looks)(空主图 / sha1 重复会被跳过,导致计数偏大且不再自愈)。
补 service 单测与「入库后 image_count == 存活主图行数」的 DB 断言。

I2:三个迁移文件不再把已删除的一次性搬迁脚本写成硬前置,改为写明取回方式
(git show c4bafc5:scripts/migrate_single_table/main.go,并须在旧代码树上运行)。

I3:新增 SetRecordStatus / SetStreetRecordStatus 仓储集成测试:
pending 经仓储通过后在公开视图可见、驳回后不可见、不存在的 id 返回 ErrNotFound。

Minor:修正锁不住口径的走秀 image_count 测试(改为删主图、留细节图);
修正去重注释与事实不符(含 FindNearDuplicateImage 注释);
ListRecords / ListStreetRecords 改用 Scope 杜绝 Count 后复用 *gorm.DB;
dbtool 视图改 CREATE OR REPLACE 并重跑 dump(仍 4 视图、无草稿表);
删除挂在 Popular 上的「IDs 返回…」注释;README 改为单表 + status 现状;
SetRecordStatus 注释写明有意不校验前置状态;规格补两条已知不一致。
2026-09-23 16:03:22 +08:00
3fc8b1f924 chore(publish): 收尾清理陈旧 dump 与 gofmt 偏差
删除 cmd/dbtool/db_dump.sql:它是陈旧的全库 dump 旧产物(仍含 4 张已删草稿表与旧 schema),与权威的根 db_dump.sql 同名易混,且未被 .gitignore 覆盖;确认代码与文档均无引用,删除比再生成更干净。gofmt 修正 4 个文件(仅空白与注释,无语义变化):runway_image.go 结构体字段对齐、phash_test.go 行尾注释对齐、ingest_repository.go 两行多余空行、street_main_detail_integration_test.go 文档注释中的两个单引号会被 gofmt 改写成弯引号(改述为「空串」)。
2026-09-23 14:30:20 +08:00
762e528334 fix(dbtool): 导出视图并刷新 db_dump.sql(补 4 个公开视图) 2026-09-23 14:07:43 +08:00
d68a57009c chore(publish): 删除草稿表与草稿模型 2026-09-23 13:55:24 +08:00
c4bafc5b41 fix(publish): 搬迁脚本补撞键前置校验与守恒核对,统一 dry-run 统计口径 2026-09-23 13:28:10 +08:00
12d05f75e9 fix(publish): 迁移03街拍体检同步 city+year+title 口径,补 04 体检与查重口径测试 2026-09-23 13:14:48 +08:00
ce4f8b786d feat(publish): 街拍实体键细化为 city+year+title,支持同城同年多专题 2026-09-23 13:04:37 +08:00
d8b3424663 chore(publish): 增加历史草稿搬迁脚本(含校验与 dry-run) 2026-09-23 11:34:27 +08:00
f9068f530b fix(publish): 软删图片按实际行重算 image_count,状态写入拒绝不存在的记录 2026-09-23 11:21:46 +08:00
76626b8372 refactor(publish): 审核侧切到正式表,通过即改状态不再晋升 2026-09-23 11:09:12 +08:00
e0ce81318d fix(publish): 补齐实体键唯一索引的测试覆盖与守卫消息 2026-09-23 10:56:08 +08:00
474e1c1a64 feat(publish): 实体键唯一索引,保证一个实体一行 2026-09-23 10:47:37 +08:00
8f8b06def7 fix(publish): 入库实体键读错误 fail-closed,复用加 rejected 守卫,去重排除软删行 2026-09-23 10:41:16 +08:00
dba615d636 feat(publish): 爬虫入库直写正式表,驳回行可复用重审 2026-09-23 10:21:51 +08:00
4169ebe1ed fix(publish): 后台详情与品牌列表改读基表,修复未发布内容在后台不可达 2026-09-23 10:16:09 +08:00
f5144038ab refactor(publish): 公开读改走只读视图,后台仍读基表 2026-09-23 10:07:43 +08:00
83823a8d49 feat(publish): 正式表加发布状态与公开只读视图 2026-09-23 09:56:35 +08:00
56 changed files with 3908 additions and 43546 deletions

3
.gitignore vendored
View File

@ -20,6 +20,9 @@ gotest*.exe
# 数据库备份 # 数据库备份
/db/backups/ /db/backups/
# dbtool 陈旧导出产物(权威 schema 是仓库根目录的 /db_dump.sql,勿混淆)
cmd/dbtool/db_dump.*
# 本地配置 # 本地配置
configs/config.local.yml configs/config.local.yml

View File

@ -81,7 +81,7 @@ internal/
### 2.3 管理后台(8092) ### 2.3 管理后台(8092)
- `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。 - `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。
- `/admin/*`:品牌 / 走秀 / 街拍管理、草稿审核(通过晋升正式表 / 驳回)、入库任务监控与重试、用户管理(提级 VIP)。 - `/admin/*`:品牌 / 走秀 / 街拍管理、内容审核(单表 + `status`:通过置 `published`、驳回置 `rejected`)、入库任务监控与重试、用户管理(提级 VIP)。
- `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。 - `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。
--- ---
@ -95,7 +95,7 @@ internal/
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。 - key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。 - 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
- **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。 - **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、写草稿表;后台人工审核通过后晋升正式表。失败按指数退避重试,单图失败整任务回滚。 - **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、**直写正式表**(`status=pending`,已无草稿表);后台人工审核只改状态(`published` / `rejected`),不重建图片;公开读走 `public_*` 只读视图,未发布内容结构性不可见。失败按指数退避重试,单图失败整任务回滚。
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。 - **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
--- ---
@ -174,7 +174,7 @@ backend/
│ └── dbdiag/ # 数据库诊断 │ └── dbdiag/ # 数据库诊断
├── internal/ ├── internal/
│ ├── config/ # 配置加载 │ ├── config/ # 配置加载
│ ├── model/ # 实体(brand / runway / street_snap / user / 草稿 / ingest) │ ├── model/ # 实体(brand / runway / street_snap / user / ingest)
│ ├── database/ # 连接池(结构由 dbtool 导出的 SQL 维护,不做 DDL) │ ├── database/ # 连接池(结构由 dbtool 导出的 SQL 维护,不做 DDL)
│ ├── repository/ # 数据访问接口 + GORM 实现 │ ├── repository/ # 数据访问接口 + GORM 实现
│ ├── service/ # 业务逻辑 │ ├── service/ # 业务逻辑

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
// Command dbtool 是后台管理用的数据库导出 / 导入脚本。 // Command dbtool 是后台管理用的数据库导出 / 导入脚本。
// //
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 数据」导出为 // 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。 // **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
// //
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出 // dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
@ -23,9 +23,13 @@
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**: // 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
// //
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、 // 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
// 数据、序列当前值。 // 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。
// //
// 视图 / 触发器 / 外键 / 注释 / 权限不导出(当前库中也不存在)。 // 触发器 / 外键 / 注释 / 权限不导出。
//
// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01),
// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图,
// 公开查询直接报 relation does not exist。
// //
// 目标库为空还是已有数据: // 目标库为空还是已有数据:
// //
@ -57,6 +61,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"sort"
"strings" "strings"
"time" "time"
@ -112,7 +117,7 @@ func usage() {
用法: 用法:
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean] dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件 导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
dbtool import [-config <yml>] [-in <file>] [-clean] dbtool import [-config <yml>] [-in <file>] [-clean]
把 dump 出来的 SQL 文件灌入目标库 把 dump 出来的 SQL 文件灌入目标库
@ -178,13 +183,17 @@ func runDump(args []string) {
if len(tables) == 0 { if len(tables) == 0 {
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确") log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
} }
views, err := listViews(db)
if err != nil {
log.Fatalf("✗ 列举视图失败: %v", err)
}
cols := make(map[string][]column, len(tables)) cols := make(map[string][]column, len(tables))
// ① 结构(-clean 时先输出清理段) // ① 结构(-clean 时先输出清理段)
fmt.Fprintln(w, "-- ==================== 结构 ====================") fmt.Fprintln(w, "-- ==================== 结构 ====================")
if *clean { if *clean {
writeClean(w, tables) writeClean(w, tables, views)
} }
for _, t := range tables { for _, t := range tables {
cs, err := listColumns(db, t) cs, err := listColumns(db, t)
@ -194,7 +203,11 @@ func runDump(args []string) {
cols[t] = cs cols[t] = cs
writeCreateTable(w, t, cs) writeCreateTable(w, t, cs)
} }
log.Printf("→ 结构:%d 张表", len(tables)) // 视图放在全部基表之后建:视图可能引用基表,也可能引用另一个视图(listViews 已按依赖排序)。
for _, v := range views {
writeCreateView(w, v)
}
log.Printf("→ 结构:%d 张表、%d 个视图", len(tables), len(views))
// ② 数据 // ② 数据
fmt.Fprintln(w, "-- ==================== 数据 ====================") fmt.Fprintln(w, "-- ==================== 数据 ====================")
@ -306,19 +319,175 @@ func execScript(ctx context.Context, db *sql.DB, script string) error {
}) })
} }
// writeClean 输出清理段:DROP 掉本次要重建的表,让脚本可以灌进「已有数据的库」。 // writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。
// //
// 只 DROP 本次 dump 里出现的表(与 pg_dump --clean 口径一致)——目标库中多出来的表保持不动。 // 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。
// 用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举; // 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。
// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。 // 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
func writeClean(w *bufio.Writer, tables []string) { func writeClean(w *bufio.Writer, tables []string, views []viewDef) {
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列表,目标库中这些表的既有数据将丢失") fmt.Fprintln(w, "-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失")
for i := len(views) - 1; i >= 0; i-- {
fmt.Fprintf(w, "DROP VIEW IF EXISTS %s;\n", qname(views[i].Name))
}
for _, t := range tables { for _, t := range tables {
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t)) fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
} }
fmt.Fprintln(w) fmt.Fprintln(w)
} }
// viewDef 单个视图的名字与定义(定义取自 pg_get_viewdef,已去掉前导空白)。
type viewDef struct {
Name string
Def string
}
// listViews 返回 public 下全部视图,按**依赖顺序**排列(被依赖者在前)。
//
// 视图可以依赖基表,也可以依赖另一个视图;重建时必须先建被依赖者,否则 CREATE VIEW 会报
// relation does not exist。顺序由 pg_depend 推出的「视图→视图」依赖做拓扑排序得到。
func listViews(db *sql.DB) ([]viewDef, error) {
rows, err := db.Query(`
SELECT c.relname, pg_get_viewdef(c.oid, true)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'v'
ORDER BY c.relname`)
if err != nil {
return nil, err
}
defer rows.Close()
defs := map[string]string{}
var names []string
for rows.Next() {
var name, def string
if err := rows.Scan(&name, &def); err != nil {
return nil, err
}
defs[name] = def
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(names) == 0 {
return nil, nil
}
deps, err := listViewDeps(db)
if err != nil {
return nil, err
}
ordered, err := topoSortViews(names, deps)
if err != nil {
return nil, err
}
out := make([]viewDef, 0, len(ordered))
for _, name := range ordered {
// pg_get_viewdef 末尾自带分号,去掉后由 writeCreateView 统一补,避免出现 ";;"。
body := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(defs[name]), ";"))
out = append(out, viewDef{Name: name, Def: body})
}
return out, nil
}
// listViewDeps 返回「视图 → 它直接引用的另一个视图」的依赖边(忽略对基表的引用)。
func listViewDeps(db *sql.DB) (map[string][]string, error) {
rows, err := db.Query(`
SELECT DISTINCT v.relname, d.relname
FROM pg_depend dep
JOIN pg_rewrite rw ON rw.oid = dep.objid
JOIN pg_class v ON v.oid = rw.ev_class
JOIN pg_class d ON d.oid = dep.refobjid
JOIN pg_namespace n ON n.oid = v.relnamespace
WHERE dep.classid = 'pg_rewrite'::regclass
AND dep.refclassid = 'pg_class'::regclass
AND dep.deptype = 'n'
AND v.relkind = 'v' AND d.relkind = 'v'
AND v.oid <> d.oid
AND n.nspname = 'public'`)
if err != nil {
return nil, err
}
defer rows.Close()
deps := map[string][]string{}
for rows.Next() {
var v, d string
if err := rows.Scan(&v, &d); err != nil {
return nil, err
}
deps[v] = append(deps[v], d)
}
return deps, rows.Err()
}
// topoSortViews 对视图做拓扑排序:被依赖者在前。每步用名字排序兜底,保证导出可复现。
func topoSortViews(names []string, deps map[string][]string) ([]string, error) {
known := make(map[string]bool, len(names))
indeg := make(map[string]int, len(names))
for _, n := range names {
known[n] = true
indeg[n] = 0
}
adj := map[string][]string{}
for v, ds := range deps {
if !known[v] {
continue
}
for _, d := range ds {
if !known[d] || d == v {
continue
}
adj[d] = append(adj[d], v) // d 必须先建,建完 d 才轮到 v
indeg[v]++
}
}
ready := make([]string, 0, len(names))
for _, n := range names {
if indeg[n] == 0 {
ready = append(ready, n)
}
}
sort.Strings(ready)
out := make([]string, 0, len(names))
for len(ready) > 0 {
n := ready[0]
ready = ready[1:]
out = append(out, n)
next := append([]string(nil), adj[n]...)
sort.Strings(next)
for _, m := range next {
indeg[m]--
if indeg[m] == 0 {
ready = append(ready, m)
}
}
sort.Strings(ready)
}
if len(out) != len(names) {
return nil, fmt.Errorf("视图依赖存在环,无法排序(已完成 %d/%d)", len(out), len(names))
}
return out, nil
}
// writeCreateView 输出一个视图的 CREATE OR REPLACE VIEW。
// 定义来自 pg_get_viewdef(形如「 SELECT ...」),去掉前导空白后接到 AS 之后即可。
//
// 用 OR REPLACE 而非裸 CREATE:非 -clean 产物里其余语句都幂等(表 IF NOT EXISTS、
// 约束/索引 DROP ... IF EXISTS、索引 IF NOT EXISTS),视图也应对齐,重复灌入不报
// relation already exists。
// 注意 OR REPLACE 不允许改变已有视图的列名 / 列序 / 类型 —— 视图列集被 SELECT * 冻结,
// 给基表加列必须同批重建视图(见 db/migrations/2026-09-22-01 的备注)。
func writeCreateView(w *bufio.Writer, v viewDef) {
fmt.Fprintf(w, "CREATE OR REPLACE VIEW %s AS\n%s;\n\n", qname(v.Name), v.Def)
}
// writeHeader 写文件头与几个会话设置。 // writeHeader 写文件头与几个会话设置。
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) { func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr()) fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())

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

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

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

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

View 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
View 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 而库里已存在正确的同名索引,跳过是无害的。

File diff suppressed because it is too large Load Diff

View File

@ -101,14 +101,20 @@ const (
``` ```
- **通过**:`UPDATE ... SET status='published', reviewer=?` + 应用表单里的字段编辑。**不再触碰图片行**。 - **通过**:`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 并存"。 - 每条记录**只有一个实体键行**(runway: `brand_id+season_code+collection_type`;street: `city+year`),不存在"同实体的多条 pending 并存"。
## 5. 写入链路(ingest 直写正式表) ## 5. 写入链路(ingest 直写正式表)
`internal/service/ingest_service.go` 的 `processRunway` / `processStreet`: `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`)。 2. **建记录**:`CreateRunwayDraft` → `CreateRunway`(写 `brand_runways`,`status='pending'`、`job_id`);`CreateStreetSnapDraft` → `CreateStreetSnap`(写 `street_snaps`)。
3. **建图片**:把图直接写 `brand_runway_images` / `street_snap_images`。 3. **建图片**:把图直接写 `brand_runway_images` / `street_snap_images`。
4. **图片去重**:`FindNearDuplicateImage` 的比对表从"草稿图 + 正式图"两张收敛为**一张正式图表**;`phash` / `is_duplicate` / `dup_of` 的写入位置不变。 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 步:DDL + 视图(`db/migrations/2026-09-22-01-single-table-publish.sql`)
1. §3.2 加列。 1. §3.2 加列。
2. 存量正式行视为已发布:`UPDATE brand_runways SET status='published' WHERE created_at < :迁移时刻;`(street 同理)。**带时间戳护栏**,避免脚本被重复执行时把新入库的 pending 行误刷成 published。 2. 建 §6 的 4 个视图(视图定义必须幂等:`DROP VIEW IF EXISTS` 后重建)。
3. 建 §6 的 4 个视图。
**存量行置已发布拆成独立的一次性文件**(`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`)。 > 本步必须在部署新代码**之前**应用(新代码开始写 `status`)。
@ -192,7 +206,7 @@ CREATE VIEW public_street_snap_images AS
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。 > `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
### 第 3 步:删表(`db/migrations/2026-09-22-02-drop-draft-tables.sql`,校验通过后再执行) ### 第 3 步:删表(`db/migrations/2026-09-22-05-drop-draft-tables.sql`,校验通过后再执行)
```sql ```sql
DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts, DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
street_snap_draft_images, street_snap_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` | | 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`(应无需改动,公开过滤下沉到仓储) | | 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`(状态列) | | 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 层单测 | | 测试 | `street_main_detail_integration_test.go`、`dedup_integration_test.go`、`ingest_repository_test.go`、`backstage_handler_test.go`、`router/backstage_test.go`、service 层单测 |
## 10. 测试策略 ## 10. 测试策略
@ -240,3 +254,5 @@ DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。 1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。 2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。 3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题只做空白 / 大小写 / 措辞微调,入库查重会当作新实体,从而多出一行(内容重复)。目前无标题归一化。
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。

View File

@ -132,4 +132,7 @@ type AdminRunway struct {
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25 SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
ImageCount uint16 `json:"image_count"` ImageCount uint16 `json:"image_count"`
// Status 审核态(pending / published / rejected),供后台列表展示状态列。
// 后台专用 DTO,不外泄到公开接口。
Status string `json:"status"`
} }

View File

@ -58,6 +58,11 @@ type PublicStreetSnap struct {
Cover string `json:"cover"` Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用) ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
// Status 审核态(pending / published / rejected)。
// 后台街拍列表复用本 DTO 渲染状态列,但公开列表接口**不返回**它:
// json:"-" 保证公开 API 响应结构与既有约定一致。
Status string `json:"-"`
} }
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。 // PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。

View File

@ -240,7 +240,8 @@ tbody tr:last-child td { border-bottom: 0; }
} }
.badge.pending { background: #fff4e0; color: #a85b00; } .badge.pending { background: #fff4e0; color: #a85b00; }
.badge.processing { background: #eef2ff; color: #3b4bb5; } .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.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
.badge.dup { background: #f3e8ff; color: #6b21a8; } .badge.dup { background: #f3e8ff; color: #6b21a8; }
.badge.free { background: #eef2ff; color: #3b4bb5; } .badge.free { background: #eef2ff; color: #3b4bb5; }

View File

@ -32,7 +32,7 @@ type reviewBatchReq struct {
Items []reviewBatchItem `json:"items"` Items []reviewBatchItem `json:"items"`
} }
// ReviewBatch 批量通过 / 驳回审核草稿。 // ReviewBatch 批量通过 / 驳回审核记录。
// //
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]} // 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17} // 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
@ -49,7 +49,7 @@ func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
return return
} }
if len(req.Items) == 0 { if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何草稿"}) c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何记录"})
return return
} }
if len(req.Items) > maxReviewBatch { if len(req.Items) > maxReviewBatch {
@ -99,9 +99,10 @@ type reviewAttachReq struct {
Imgs []uint32 `json:"imgs"` 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") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
@ -121,15 +122,15 @@ func (h *BackstageHandler) ReviewAttachImages(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
return 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
h.respondDraftImages(c, kind, uint32(id)) h.respondRecordImages(c, kind, uint32(id))
} }
// ReviewDetachImage 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImages。 // ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) { func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)
@ -137,18 +138,18 @@ func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"}) c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
h.respondDraftImages(c, kind, uint32(id)) h.respondRecordImages(c, kind, uint32(id))
} }
// respondDraftImages 重新读取草稿并把「图片分组状态」按前端需要的形状返回。 // respondRecordImages 重新读取记录并把「图片分组状态」按前端需要的形状返回。
// //
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。 // 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
func (h *BackstageHandler) respondDraftImages(c *gin.Context, kind string, id uint32) { func (h *BackstageHandler) respondRecordImages(c *gin.Context, kind string, id uint32) {
view, err := h.review.DraftDetail(c.Request.Context(), kind, id) view, err := h.review.RecordDetail(c.Request.Context(), kind, id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return

View File

@ -107,7 +107,10 @@ func (h *BackstageHandler) UserSetTier(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/users") 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) { func (h *BackstageHandler) Brands(c *gin.Context) {
q := dto.BrandQuery{ q := dto.BrandQuery{
Keyword: c.Query("keyword"), Keyword: c.Query("keyword"),
@ -115,7 +118,7 @@ func (h *BackstageHandler) Brands(c *gin.Context) {
Page: atoiDefault(c.Query("page"), 1), Page: atoiDefault(c.Query("page"), 1),
Locale: "en", 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 { if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
@ -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 一致)。 // 路由 :id 是 runway 类型化编码(无前缀,类型进密码),按 TypeRunway 解码(与公开接口 article_handler 一致)。
func (h *BackstageHandler) RunwayDetail(c *gin.Context) { func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
raw := c.Param("id") raw := c.Param("id")
@ -234,7 +240,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
return 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 { if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
return return
@ -242,7 +248,7 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art}) renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art})
} }
// StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.List。 // StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.AdminList(读基表,含待审)。
func (h *BackstageHandler) StreetSnaps(c *gin.Context) { func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
q := dto.StreetSnapQuery{ q := dto.StreetSnapQuery{
Year: atoiDefault(c.Query("year"), 0), Year: atoiDefault(c.Query("year"), 0),
@ -251,7 +257,7 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) {
Page: atoiDefault(c.Query("page"), 1), Page: atoiDefault(c.Query("page"), 1),
Locale: "en", 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 { if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
@ -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 一致)。 // 路由 :id 是 snap 类型化编码(无前缀,类型进密码),按 TypeSnap 解码(与公开接口 street_snap_handler 一致)。
func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) { func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
raw := c.Param("id") raw := c.Param("id")
@ -287,7 +296,7 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return return
} }
snap, err := h.street.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 { if err != nil {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return return
@ -295,9 +304,9 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap}) renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap})
} }
// ── 审核草稿 ─────────────────────────────────────────────────────────────────── // ── 审核记录 ───────────────────────────────────────────────────────────────────
// ReviewList 待审草稿列表:按类型(kind,缺省全部)与状态筛选。 // ReviewList 待审记录列表:按类型(kind,缺省全部)与状态筛选。
// 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。 // 类型标签由审核注册表生成(见 ReviewService.KindTabs),新增模块自动出现,无需改此分支。
func (h *BackstageHandler) ReviewList(c *gin.Context) { func (h *BackstageHandler) ReviewList(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
@ -320,7 +329,7 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
if kind != "" && kind != "all" && !registered[kind] { if kind != "" && kind != "all" && !registered[kind] {
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 { if err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
@ -333,7 +342,7 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
p := pagination.Params{Page: page, PageSize: 20} p := pagination.Params{Page: page, PageSize: 20}
meta := p.NewMeta(total) meta := p.NewMeta(total)
renderPage(c, http.StatusOK, "review-list", gin.H{ renderPage(c, http.StatusOK, "review-list", gin.H{
"Drafts": drafts, "Records": records,
"Total": total, "Total": total,
"Status": status, "Status": status,
"Kind": kind, "Kind": kind,
@ -349,23 +358,23 @@ func (h *BackstageHandler) ReviewList(c *gin.Context) {
}) })
} }
// ReviewDetail 草稿详情:展示预览 + 可编辑字段 + 通过/驳回操作。 // ReviewDetail 记录详情:展示预览 + 可编辑字段 + 通过/驳回操作。
func (h *BackstageHandler) ReviewDetail(c *gin.Context) { func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return 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 { 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 return
} }
// 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。 // 有品牌归属的模块(如走秀)解析品牌名;其余模块 BrandID=0 跳过。
brandName := "" brandName := ""
if draft.BrandID > 0 { if record.BrandID > 0 {
if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(draft.BrandID)); berr == nil { if b, berr := h.brand.Get(c.Request.Context(), hashid.Encode(record.BrandID)); berr == nil {
brandName = b.NameEn brandName = b.NameEn
if brandName == "" { if brandName == "" {
brandName = b.NameCn brandName = b.NameCn
@ -373,21 +382,21 @@ func (h *BackstageHandler) ReviewDetail(c *gin.Context) {
} }
} }
renderPage(c, http.StatusOK, "review-detail", gin.H{ renderPage(c, http.StatusOK, "review-detail", gin.H{
"Draft": draft, "Record": record,
"BrandName": brandName, "BrandName": brandName,
}) })
} }
// ReviewApprove 通过:先应用审核人的字段微调,再晋升到正式表。 // ReviewApprove 通过:先应用审核人的字段微调,再把记录状态置为已发布(单表模型:不再晋升重建)。
func (h *BackstageHandler) ReviewApprove(c *gin.Context) { func (h *BackstageHandler) ReviewApprove(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return return
} }
fields := draftFormFields(c) fields := reviewFormFields(c)
if err := h.review.UpdateDraft(c.Request.Context(), kind, uint32(id), fields); err != nil { if err := h.review.UpdateRecord(c.Request.Context(), kind, uint32(id), fields); err != nil {
renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
@ -404,7 +413,7 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "草稿不存在: " + c.Param("id")}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "记录不存在: " + c.Param("id")})
return return
} }
reason := c.PostForm("reason") reason := c.PostForm("reason")
@ -416,9 +425,9 @@ func (h *BackstageHandler) ReviewReject(c *gin.Context) {
c.Redirect(http.StatusFound, reviewListURL(kind)) c.Redirect(http.StatusFound, reviewListURL(kind))
} }
// ReviewDraftImageDelete 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。 // ReviewImageDelete 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
// 校验 kind 合法后委派给审核服务,成功后跳回草稿详情页。 // 校验 kind 合法后委派给审核服务,成功后跳回记录详情页。
func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) { func (h *BackstageHandler) ReviewImageDelete(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)
@ -426,16 +435,17 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id")) c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
} }
// ReviewAttachDraftImages 把勾选的草稿图并入指定主图(街拍审核页「合并选中为一组」)。 // ReviewAttachImages 把勾选的图并入指定主图(街拍审核页「合并选中为一组」的表单版)。
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。 // 主图由表单里的单选 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") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
if id == 0 { if id == 0 {
@ -457,15 +467,15 @@ func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id))) c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
} }
// ReviewDetachDraftImage 把副图拆出恢复为主图。 // ReviewDetachImage 把副图拆出恢复为主图(表单版;AJAX 版见 ReviewDetachImageAPI)。
func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) { func (h *BackstageHandler) ReviewDetachImage(c *gin.Context) {
kind := c.Param("kind") kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0) id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0) imgID := atoiDefault(c.Param("img"), 0)
@ -473,7 +483,7 @@ func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) {
renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"}) renderPage(c, http.StatusBadRequest, "error", gin.H{"Error": "参数无效"})
return 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()}) renderPage(c, http.StatusInternalServerError, "error", gin.H{"Error": err.Error()})
return return
} }
@ -562,9 +572,9 @@ func (h *BackstageHandler) IngestJobRetry(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/ingest-jobs") c.Redirect(http.StatusFound, "/admin/ingest-jobs")
} }
// draftFormFields 从表单收集可编辑草稿字段(runway + street 一并收集, // reviewFormFields 从表单收集可编辑字段(runway + street 一并收集,
// 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。 // 最终白名单在仓储层按 kind 强制,非本 kind 的字段会被忽略)。
func draftFormFields(c *gin.Context) map[string]any { func reviewFormFields(c *gin.Context) map[string]any {
f := map[string]any{} f := map[string]any{}
if v := c.PostForm("title_en"); v != "" { if v := c.PostForm("title_en"); v != "" {
f["title_en"] = v f["title_en"] = v

View File

@ -74,7 +74,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "", "UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
"Images": []any{}, "Images": []any{},
// 列表数据 // 列表数据
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Drafts": []any{}, "Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{},
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖) // 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
"Article": &dto.PublicArticleDetail{UID: "abc123"}, "Article": &dto.PublicArticleDetail{UID: "abc123"},
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"}, "Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
@ -100,7 +100,7 @@ func TestBackstageTemplatesRender(t *testing.T) {
for k, v := range perPage[page] { for k, v := range perPage[page] {
data[k] = v 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() w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w) c, _ := gin.CreateTestContext(w)

View File

@ -1,37 +1,37 @@
{{define "title"}}草稿审核 · 管理后台{{end}} {{define "title"}}草稿审核 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 草稿审核 #{{.Draft.ID}}{{end}} {{define "barLabel"}}管理后台 · 草稿审核 #{{.Record.ID}}{{end}}
{{define "barRight"}}<a href="{{reviewDetailURL .Draft.Kind .Draft.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}} {{define "barRight"}}<a href="{{reviewDetailURL .Record.Kind .Record.ID}}">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}} {{define "content"}}
{{template "bar" .}} {{template "bar" .}}
<div class="main mid"> <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 会触发同一个表单 */}} {{/* 字段微调 + 通过:仍是普通表单提交(无 JS 也能用);键盘 a 会触发同一个表单 */}}
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve"> <form method="post" action="/admin/reviews/{{.Record.Kind}}/{{.Record.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}} {{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> <button type="submit">通过并发布</button>
</form> </form>
{{if eq .Draft.Kind "street"}} {{if eq .Record.Kind "street"}}
<div class="hint"> <div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br> 操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
快捷键:<b>j</b>/<b>k</b> 上下移动 · <b>x</b> 勾选 · <b>m</b> 设为主图 · <b>d</b> 拆出 · <b>Enter</b> 看大图 · <b>a</b> 通过 快捷键:<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> <button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div> </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" 关联过来 */}} {{/* 合并表单本体在 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"> <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> <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="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
<span class="nm">{{.Name}}</span> <span class="nm">{{.Name}}</span>
<div class="acts"> <div class="acts">
{{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}} {{if eq .IsDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label> <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}} {{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/{{$.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>
</div>{{end}} </div>{{end}}
</div> </div>
@ -41,13 +41,13 @@
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span> <span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div> </div>
{{else}} {{else}}
<h2>图片管理({{len .Draft.Images}} 张)</h2> <h2>图片管理({{len .Record.Images}} 张)</h2>
<div class="grid"> <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> <button class="thumb" type="button" data-zoom><img src="{{.Image}}" loading="lazy"></button>
<span class="nm">{{.Name}}</span> <span class="nm">{{.Name}}</span>
<div class="acts"> <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>
</div>{{end}} </div>{{end}}
</div> </div>

View File

@ -12,7 +12,7 @@
<div class="tabs"> <div class="tabs">
<a href="/admin/reviews{{.ListBase}}" class="{{if eq .Status ""}}on{{end}}">全部</a> <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=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> <a href="/admin/reviews{{.ListBase}}?status=rejected" class="{{if eq .Status "rejected"}}on{{end}}">已驳回</a>
</div> </div>
@ -24,7 +24,7 @@
<th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th> <th>ID</th><th>类型</th><th>标题</th><th>副标题</th><th>图数</th><th>状态</th><th>操作</th>
</tr></thead> </tr></thead>
<tbody> <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><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td><code>{{.ID}}</code></td> <td><code>{{.ID}}</code></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td> <td>{{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 class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td> <td>{{.ImageCount}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></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}} </tr>{{end}}
</tbody> </tbody>
</table> </table>

View File

@ -29,9 +29,9 @@
<a href="/admin/runways">清除</a> <a href="/admin/runways">清除</a>
</form> </form>
<table> <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> <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> </tbody>
</table> </table>
{{template "pager" .Pager}} {{template "pager" .Pager}}

View File

@ -17,9 +17,9 @@
<a href="/admin/street-snaps">清除</a> <a href="/admin/street-snaps">清除</a>
</form> </form>
<table> <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> <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> </tbody>
</table> </table>
{{template "pager" .Pager}} {{template "pager" .Pager}}

View File

@ -0,0 +1,17 @@
package model
// 内容发布状态(单表发布模型)。
//
// 历史上「爬虫草稿」与「正式记录」是两张表,审核 = 从草稿晋升到正式表。
// 现在合并为一张表,审核态由本字段承载:
// - pending:刚入库,等待审核;公开视图不可见
// - published:已通过,对外可见
// - rejected:已驳回,留在库中备查;公开视图不可见
//
// 新增记录默认 pending(见各自模型的 gorm default),漏赋值的结果是「不可见」,
// 即 fail-closed,而不是意外对外发布。
const (
StatusPending = "pending"
StatusPublished = "published"
StatusRejected = "rejected"
)

View File

@ -22,6 +22,12 @@ type BrandRunway struct {
CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall
Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null
SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25 SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25
// 审核态与溯源(单表发布模型;列由 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 指定表名。 // TableName 指定表名。
@ -43,6 +49,7 @@ type RunwayRow struct {
CollectionType string `gorm:"column:collection_type"` CollectionType string `gorm:"column:collection_type"`
Season string `gorm:"column:season"` Season string `gorm:"column:season"`
SeasonCode string `gorm:"column:season_code"` SeasonCode string `gorm:"column:season_code"`
Status string `gorm:"column:status"`
CreatedAt uint32 `gorm:"column:created_at"` CreatedAt uint32 `gorm:"column:created_at"`
BrandNameEn string `gorm:"column:brand_name_en"` BrandNameEn string `gorm:"column:brand_name_en"`
BrandNameCn string `gorm:"column:brand_name_cn"` BrandNameCn string `gorm:"column:brand_name_cn"`

View File

@ -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"
)

View File

@ -15,6 +15,13 @@ type StreetSnap struct {
ImageCount uint16 `gorm:"column:image_count" json:"image_count"` ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
// 审核态与溯源(单表发布模型;列由 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"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
} }

View File

@ -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" }

View File

@ -18,17 +18,22 @@ type ArticleRepository interface {
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。 // ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。 // FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
FindByID(ctx context.Context, id string) (*model.RunwayRow, error) FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 查询某篇文章的全部图片,按排序值升序。 // FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。 // ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。 // GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。 // UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
// DeleteRunwayImage 软删单张走秀图片。 // DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。
DeleteRunwayImage(ctx context.Context, imageID uint32) error DeleteRunwayImage(ctx context.Context, imageID uint32) error
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。 // GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
@ -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` brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year / // articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
// collection_type / season_code 等管理字段(前端列表不展示这些)。 // collection_type / season_code / status 等管理字段(前端列表不展示这些)。
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id, const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_cn, brand_runways.title_en, brand_runways.title_cn,
brand_runways.cover, brand_runways.image_count, brand_runways.year, brand_runways.cover, brand_runways.image_count, brand_runways.year,
brand_runways.collection_type, brand_runways.season, brand_runways.season_code, brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
brand_runways.status,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 // brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
@ -142,7 +148,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
var total int64 var total int64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}). Table(publicRunways).
Scopes(scope). Scopes(scope).
Count(&total).Error; err != nil { Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
@ -153,7 +159,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
var rows []model.RunwayRow var rows []model.RunwayRow
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}). Table(publicRunways).
Scopes(scope). Scopes(scope).
Select(articleListColumns). Select(articleListColumns).
Joins(brandJoin). Joins(brandJoin).
@ -196,11 +202,20 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
return rows, total, nil return rows, total, nil
} }
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) { func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id)
}
// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。
func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) {
return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id)
}
// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。
func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) {
var row model.RunwayRow var row model.RunwayRow
err := r.db.WithContext(ctx). err := q.Select(articleDetailColumns).
Model(&model.BrandRunway{}).
Select(articleDetailColumns).
Joins(brandJoin). Joins(brandJoin).
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id). Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
First(&row).Error First(&row).Error
@ -213,7 +228,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
return &row, nil return &row, nil
} }
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) { func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Table(publicRunwayImages).
Where("runway_id = ?", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID). Where("runway_id = ? AND is_deleted = 0", runwayID).
@ -230,8 +257,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
var imgs []model.BrandRunwayImage var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table(publicRunwayImages).
Select("runway_id, image, name, sort_order"). Select("runway_id, image, name, sort_order").
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids). Where("runway_id IN ? AND is_detail = 0", ids).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&imgs).Error Find(&imgs).Error
if err != nil { if err != nil {
@ -265,7 +293,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
} }
return nil, nil, err return nil, nil, err
} }
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10)) imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -289,11 +317,36 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
Updates(clean).Error Updates(clean).Error
} }
// DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。
//
// 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算
// (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。
//
// 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径,
// 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化,
// 并直接显示在公开列表卡片的「N 张」角标里。
//
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error { func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Model(&model.BrandRunwayImage{}). var img model.BrandRunwayImage
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). 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) { func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {

View File

@ -13,12 +13,16 @@ import (
// BrandRepository 品牌数据访问接口。 // BrandRepository 品牌数据访问接口。
type BrandRepository interface { type BrandRepository interface {
// List 按字母索引 / 关键词分页查询品牌。 // List 按字母索引 / 关键词分页查询品牌(公开侧:字母索引只认已发布走秀档案)。
// //
// restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内; // restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内;
// 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。 // 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。
List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
// ListAdmin 后台列表:与 List 同构,但字母索引不要求走秀已发布,
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开侧保护不受影响)。
ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error)
// FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中, // FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中,
// 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。 // 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。
FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error)
@ -71,16 +75,28 @@ func NewBrandRepository(db *gorm.DB) BrandRepository {
// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。 // 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。
const latinPrefixRegexp = "^[A-Za-z]" const latinPrefixRegexp = "^[A-Za-z]"
// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。 // hasArticlesSubQuery 子查询(公开侧):所有「至少有一篇已发布走秀档案」的品牌 id。
// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。 // 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。
// 读公开视图而非基表:否则只有待审走秀的品牌会提前出现在前台的 A-Z 索引里。
func (r *brandRepository) hasArticlesSubQuery() *gorm.DB { func (r *brandRepository) hasArticlesSubQuery() *gorm.DB {
return r.db.Table(publicRunways).
Select("DISTINCT brand_id")
}
// hasArticlesSubQueryBase 子查询(后台侧):所有「至少有一篇未删除走秀档案」的品牌 id。
// 读基表且不看 status:后台品牌列表必须仍能看到只有待审 / 驳回走秀的品牌,
// 否则它们会从后台「消失」,审核员无处下手(与公开侧的保护并不冲突:公开走视图)。
func (r *brandRepository) hasArticlesSubQueryBase() *gorm.DB {
return r.db.Model(&model.BrandRunway{}). return r.db.Model(&model.BrandRunway{}).
Select("DISTINCT brand_id"). Select("DISTINCT brand_id").
Where("is_deleted = 0") Where("is_deleted = 0")
} }
// brandFilterScope 把品牌查询条件编译为 GORM Scope。 // brandFilterScope 把品牌查询条件编译为 GORM Scope。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB { //
// admin 为 true 时(后台列表)「必须有走秀档案」的子查询读基表且不过滤 status,
// 使只有待审 / 驳回走秀的品牌仍出现在后台;公开列表保持读视图(保护未发布内容)。
func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32, admin bool) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0") db = db.Where("is_deleted = 0")
@ -96,13 +112,20 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
} }
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌, // 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,
// 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效。 // 不过滤会污染 A-Z 索引)。原 only_with_articles 参数已删除,此过滤无条件生效;
// 唯一区别是"档案"的口径:公开=已发布(视图),后台=未删除(基表,含待审)。
// 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌, // 例外:关键词搜索时不限制"必须有走秀档案"(用户按名字找品牌,
// 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头); // 即使该品牌暂无走秀档案也应被命中,如 A Bathing Ape / 猿人头);
// 中文分桶(OTHER)同样列出全部中文名品牌便于检索。 // 中文分桶(OTHER)同样列出全部中文名品牌便于检索。
if q.Keyword == "" && q.Letter != "OTHER" { if q.Keyword == "" && q.Letter != "OTHER" {
if admin {
// 后台:只要求「存在未删除走秀档案」,待审 / 驳回的品牌也带出来。
db = db.Where("id IN (?)", r.hasArticlesSubQueryBase())
} else {
// 公开:只认已发布走秀(视图),未发布内容不得出现在前台 A-Z 索引。
db = db.Where("id IN (?)", r.hasArticlesSubQuery()) db = db.Where("id IN (?)", r.hasArticlesSubQuery())
} }
}
if q.Keyword != "" { if q.Keyword != "" {
kw := "%" + 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) { func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs) return r.list(ctx, q, restrictIDs, false)
}
// ListAdmin 后台品牌列表:与 List 同构,但字母索引不要求走秀已发布,
// 只有待审 / 驳回走秀的品牌仍能看到(否则它们从后台列表消失,无处审核/编辑)。
func (r *brandRepository) ListAdmin(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) {
return r.list(ctx, q, restrictIDs, true)
}
func (r *brandRepository) list(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32, admin bool) ([]model.Brand, int64, error) {
scope := r.brandFilterScope(q, restrictIDs, admin)
var total int64 var total int64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
@ -157,10 +191,10 @@ func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit
var agg []struct { var agg []struct {
BrandID uint32 `gorm:"column:brand_id"` BrandID uint32 `gorm:"column:brand_id"`
} }
// 读公开视图:热门榜只能由已发布走秀构成,避免把待审/驳回内容的图片数计入热度。
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}). Table(publicRunways).
Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows"). Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows").
Where("is_deleted = 0").
Group("brand_id"). Group("brand_id").
Order(orderCol + " DESC"). Order(orderCol + " DESC").
Limit(limit). Limit(limit).
@ -281,7 +315,7 @@ WITH latest_per_brand AS (
END DESC, END DESC,
id DESC id DESC
) AS rn ) AS rn
FROM brand_runways FROM public_brand_runways brand_runways
WHERE is_deleted = 0 AND brand_id IN (?) WHERE is_deleted = 0 AND brand_id IN (?)
) )
SELECT id, brand_id, cover, title_en, title_cn SELECT id, brand_id, cover, title_en, title_cn

View File

@ -93,9 +93,16 @@ func TestDedupNearDuplicate(t *testing.T) {
t.Fatalf("汉明距离 1 的近重复应被命中") t.Fatalf("汉明距离 1 的近重复应被命中")
} }
// 全 0 向量:与全 1 向量汉明距离 = 64,远超阈值,不应命中。 // 全 1 向量(与库中那条距离 0)的取反不是合法探针(会有空串问题),
_, found2, _ := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(0), phash.DefaultThreshold) // 这里改用与库中那条完全相同的全 1 探针(非空、合法)并先断言查询本身不报错。
if found2 { probeSame, foundSame, errSame := repo.FindNearDuplicateImage(ctx, tables, phash.ToVectorBits(^uint64(0)), phash.DefaultThreshold)
t.Fatalf("汉明距离 64 的图不应被误判为近重复") if errSame != nil {
t.Fatalf("去重查询不应报错: %v", errSame)
}
if !foundSame {
t.Fatalf("与库中那条完全相同的 phash 应命中")
}
if probeSame == 0 {
t.Fatalf("命中的行 id 不应为 0")
} }
} }

View 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)
}
}

View 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)
}
}

View 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)
}
}

View File

@ -3,6 +3,7 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"math" "math"
"time" "time"
@ -14,9 +15,10 @@ import (
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放 // IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。 // (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
// //
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type, // 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year + title
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft // (同城同年可有多个专题,title 是数据里区分专题的字段)。
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。 // 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支:
// pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。
// //
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED // 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。 // 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
@ -32,19 +34,21 @@ type IngestRepository interface {
MarkFailed(ctx context.Context, id uint32, errMsg string) error MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。 // ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error) ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
// RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表; // RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,返回其状态。
// 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。 // worker 据此分三支:pending/published → 直接放弃本次入库;rejected → 复用该行重审;未命中 → 新建。
RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, err error)
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。 // CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
// CreateRunwayDraftImages 批量插入草稿图片行。 // ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error // 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
// StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。 ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) // StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态。
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。 // title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, err error)
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。 // CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。 // FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。 // ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
@ -239,94 +243,183 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
return true, nil return true, nil
} }
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。 // ---- 入库直写正式表(单表发布模型)----
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, error) {
var row struct { var row struct {
ID uint32 `gorm:"column:id"` ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
} }
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}). Model(&model.BrandRunway{}).
Select("id"). Select("id, status").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType). Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
Limit(1). Limit(1).
Scan(&row).Error Scan(&row).Error
if err != nil { if err != nil {
return 0, false, err return 0, "", false, err
} }
if row.ID == 0 { 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()) now := uint32(time.Now().Unix())
d.CreatedAt = now rw.CreatedAt, rw.UpdatedAt = now, now
d.UpdatedAt = now if rw.Status == "" {
if d.Status == "" { rw.Status = model.StatusPending
d.Status = model.DraftStatusPending
} }
if err := r.db.WithContext(ctx).Create(d).Error; err != nil { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return 0, err 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 { if len(imgs) == 0 {
return nil return nil
} }
now := uint32(time.Now().Unix())
for i := range imgs { for i := range imgs {
imgs[i].CreatedAt = now imgs[i].RunwayID = rw.ID
imgs[i].UpdatedAt = now 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) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error {
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, 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 { var row struct {
ID uint32 `gorm:"column:id"` ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
} }
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}). Model(&model.StreetSnap{}).
Select("id"). Select("id, status").
Where("city = ? AND year = ? AND is_deleted = 0", city, year). Where("city = ? AND year = ? AND COALESCE(title, '') = ? AND is_deleted = 0", city, year, title).
Limit(1). Limit(1).
Scan(&row).Error Scan(&row).Error
if err != nil { if err != nil {
return 0, false, err return 0, "", false, err
} }
if row.ID == 0 { 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()) now := uint32(time.Now().Unix())
d.CreatedAt = now snap.CreatedAt, snap.UpdatedAt = now, now
d.UpdatedAt = now if snap.Status == "" {
if d.Status == "" { snap.Status = model.StatusPending
d.Status = model.DraftStatusPending
} }
if err := r.db.WithContext(ctx).Create(d).Error; err != nil { err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return 0, err 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 { if len(imgs) == 0 {
return nil return nil
} }
now := uint32(time.Now().Unix())
for i := range imgs { for i := range imgs {
imgs[i].CreatedAt = now imgs[i].SnapID = snap.ID
imgs[i].UpdatedAt = now 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 控制分页区间。 // ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。
@ -369,11 +462,14 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
}).Error }).Error
} }
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。 // FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。 // 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。 // phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
//
// 只比对未软删的行(is_deleted = 0):已被软删(审核剔除 / 复用重审)的旧图不应再作为
// 「重复」的参照。注意复用驳回行的时序 —— 去重发生在软删旧图**之前**,那一刻旧行仍是
// is_deleted = 0、会被正常命中;dup_of 只是人工留痕,不承担可见性语义。
// 本方法被 runway / street 两条入库路径共用,过滤对两者语义一致。
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) { func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。 // 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
l2Limit := math.Sqrt(float64(threshold)) l2Limit := math.Sqrt(float64(threshold))
@ -385,6 +481,7 @@ func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []
err := r.db.WithContext(ctx).Table(t). err := r.db.WithContext(ctx).Table(t).
Select("id, (phash <-> ?::vector) AS dist", phashBits). Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit). Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Where("is_deleted = 0").
Order("dist ASC"). Order("dist ASC").
Limit(1). Limit(1).
Scan(&row).Error Scan(&row).Error

View 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)
}
}

View File

@ -26,16 +26,14 @@ func NewMediaRepository(db *gorm.DB) MediaRepository {
return &mediaRepository{db: db} return &mediaRepository{db: db}
} }
// imageTables 保存S4 key 的全部图片明细表(已晋升的正式表 + 待审草稿表)。 // imageTables 保存S4 key 的全部图片明细表(单表发布模型下即两张正式图片表)。
// 引用计数必须覆盖这 4 张,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。 // 引用计数必须覆盖全部图片表,漏掉任意一张都会把「仍被引用」误判为孤儿而误删。
var imageTables = []string{ var imageTables = []string{
"brand_runway_images", "brand_runway_images",
"brand_runway_draft_images",
"street_snap_images", "street_snap_images",
"street_snap_draft_images",
} }
// CountImageKeyRefs 跨 4 张表统计 key 的存活引用数(is_deleted=0)。 // CountImageKeyRefs 跨全部图片明细表统计 key 的存活引用数(is_deleted=0)。
func (r *mediaRepository) CountImageKeyRefs(ctx context.Context, key string) (int, error) { func (r *mediaRepository) CountImageKeyRefs(ctx context.Context, key string) (int, error) {
if key == "" { if key == "" {
return 0, nil return 0, nil

View 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)
}
}

View 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"
)

View 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)
}
}

View File

@ -11,49 +11,45 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// ReviewRepository 审核草稿的数据访问接口。 // ReviewRepository 审核数据访问接口。
// //
// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」: // 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。 // 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
type ReviewRepository interface { type ReviewRepository interface {
// ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。 // ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
// 排序:pending 优先,其次按 id 倒序(最新在前)。 ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) // RecordDetail 查单条走秀记录(不含图片)。
// DraftDetail 查单条草稿(不含图片)。 RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) // RecordImages 查走秀记录的全部未删图片,按排序值升序。
// DraftImages 查草稿的全部图片,按排序值升序。 RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) // UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
// UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。 UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error // SetRecordStatus 设置状态(通过/驳回)与审核人/驳回理由;记录不存在返回 ErrNotFound。
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。 SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。 // 街拍镜像方法(对象为 street_snaps / street_snap_images)。
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。 StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) // SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。 SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 // SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。
// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。 SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。 // mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
// 幂等:已是 mainImgID 副图的行重复并入不报错。 // 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。 // DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
// 对主图调用是幂等成功(无副作用)。 DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。 GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) // CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error CountByStatus(ctx context.Context, status string, dest any) (int64, 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)
} }
type reviewRepository struct { type reviewRepository struct {
@ -65,27 +61,40 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db} return &reviewRepository{db: db}
} }
func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) { // reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。
//
// 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用
// 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患
//(与 article_repository.filterScope 同一理由)。
func reviewListScope(status string) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0")
if status != "" {
db = db.Where("status = ?", status)
}
return db
}
}
func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
if size < 1 || size > 100 { if size < 1 || size > 100 {
size = 20 size = 20
} }
q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0") scope := reviewListScope(status)
if status != "" {
q = q.Where("status = ?", status)
}
var total int64 var total int64
if err := q.Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
if total == 0 { if total == 0 {
return []model.BrandRunwayDraft{}, 0, nil return []model.BrandRunway{}, 0, nil
} }
var rows []model.BrandRunwayDraft var rows []model.BrandRunway
if err := q. if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size). Offset((page - 1) * size).
Limit(size). Limit(size).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
@ -94,8 +103,8 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page,
return rows, total, nil return rows, total, nil
} }
func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) { func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
var d model.BrandRunwayDraft var d model.BrandRunway
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@ -105,17 +114,17 @@ func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.B
return &d, nil return &d, nil
} }
func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) { func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayDraftImage var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID). Where("runway_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&imgs).Error Find(&imgs).Error
return imgs, err return imgs, err
} }
// draftEditable 审核前可微调的字段白名单(键即列名)。 // recordEditable 审核前可微调的字段白名单(键即列名)。
var draftEditable = map[string]bool{ var recordEditable = map[string]bool{
"title_en": true, "title_en": true,
"title_cn": true, "title_cn": true,
"description_en": true, "description_en": true,
@ -126,11 +135,11 @@ var draftEditable = map[string]bool{
"season_code": true, "season_code": true,
} }
// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。 // UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{} clean := map[string]any{}
for k, v := range fields { for k, v := range fields {
if draftEditable[k] { if recordEditable[k] {
clean[k] = v clean[k] = v
} }
} }
@ -139,149 +148,40 @@ func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields ma
} }
clean["updated_at"] = uint32(time.Now().Unix()) clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}). Model(&model.BrandRunway{}).
Where("id = ?", id). Where("id = ?", id).
Updates(clean).Error Updates(clean).Error
} }
func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { // SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。
return r.db.WithContext(ctx). // 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL,
Model(&model.BrandRunwayDraft{}). // 竞态窗口(记录刚被删除)下会给出假成功 302。
//
// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
// 而不是悄悄在这个仓储方法里加限制。
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
res := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"status": status, "status": status,
"reviewer": reviewer, "reviewer": reviewer,
"reject_reason": reason, "reject_reason": reason,
"updated_at": uint32(time.Now().Unix()), "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 { if res.RowsAffected == 0 {
return cErr return ErrNotFound
}
} }
return nil return nil
})
if err != nil {
return 0, err
}
return runwayID, nil
} }
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。 // CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) { func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
var n int64 var n int64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(dest). Model(dest).
@ -292,29 +192,27 @@ func (r *reviewRepository) CountDraftPending(ctx context.Context, status string,
return n, nil return n, nil
} }
// ---- 街拍草稿镜像实现(与 runway 同构) ---- // ---- 街拍镜像实现(与 runway 同构) ----
func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) { func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
if size < 1 || size > 100 { if size < 1 || size > 100 {
size = 20 size = 20
} }
q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0") scope := reviewListScope(status)
if status != "" {
q = q.Where("status = ?", status)
}
var total int64 var total int64
if err := q.Count(&total).Error; err != nil { if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
if total == 0 { if total == 0 {
return []model.StreetSnapDraft{}, 0, nil return []model.StreetSnap{}, 0, nil
} }
var rows []model.StreetSnapDraft var rows []model.StreetSnap
if err := q. if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC"). Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size). Offset((page - 1) * size).
Limit(size). Limit(size).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
@ -323,8 +221,8 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string,
return rows, total, nil return rows, total, nil
} }
func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) { func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
var d model.StreetSnapDraft var d model.StreetSnap
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@ -334,26 +232,26 @@ func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*m
return &d, nil return &d, nil
} }
func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) { func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapDraftImage var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID). Where("snap_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&imgs).Error Find(&imgs).Error
return imgs, err return imgs, err
} }
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。 // streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
var streetDraftEditable = map[string]bool{ var streetRecordEditable = map[string]bool{
"title": true, "title": true,
"year": true, "year": true,
"city": true, "city": true,
} }
func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error { func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{} clean := map[string]any{}
for k, v := range fields { for k, v := range fields {
if streetDraftEditable[k] { if streetRecordEditable[k] {
clean[k] = v clean[k] = v
} }
} }
@ -362,283 +260,83 @@ func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fie
} }
clean["updated_at"] = uint32(time.Now().Unix()) clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}). Model(&model.StreetSnap{}).
Where("id = ?", id). Where("id = ?", id).
Updates(clean).Error Updates(clean).Error
} }
func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error { // SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
return r.db.WithContext(ctx). // 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
Model(&model.StreetSnapDraft{}). func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
res := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{ Updates(map[string]any{
"status": status, "status": status,
"reviewer": reviewer, "reviewer": reviewer,
"reject_reason": reason, "reject_reason": reason,
"updated_at": uint32(time.Now().Unix()), "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 res.RowsAffected == 0 {
if cErr := tx.Create(&mains[i]).Error; cErr != nil { return ErrNotFound
return cErr
}
}
mainIdx := 0
for _, im := range imgs {
if im.IsDetail == 1 {
continue
}
newIDByDraftID[im.ID] = mains[mainIdx].ID
mainIdx++
}
// 第二遍:插入副图,把父引用改写成新主图行 id。
details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains))
for _, im := range imgs {
if im.IsDetail != 1 {
continue
}
newParent, ok := newIDByDraftID[im.ParentImageID]
row := model.StreetSnapImage{
SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(len(mains) + len(details) + 1),
IsDetail: 1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
CreatedAt: now,
UpdatedAt: now,
}
if ok {
row.ParentImageID = newParent
} else {
row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错口径一致
}
details = append(details, row)
}
if len(details) > 0 {
if cErr := tx.Create(&details).Error; cErr != nil {
return cErr
}
}
} }
return nil 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) { // SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。
var drafts []model.BrandRunwayDraft // 不删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). if err := r.db.WithContext(ctx).
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0 AND id <> ?", brandID, seasonCode, collectionType, model.DraftStatusApproved, excludeDraftID). Model(&model.BrandRunwayImage{}).
Find(&drafts).Error; err != nil { Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
return nil, err
}
out := make([]model.BrandRunwayDraftImage, 0)
for _, d := range drafts {
imgs, err := r.DraftImages(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, imgs...)
}
return out, nil
}
// unionRunwayDraftImages 按 image URL 去重合并(S4 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b))
for _, im := range a {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
for _, im := range b {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
return out
}
// streetApprovedSiblingImages 同 city+year 已 approved 的其他街拍草稿图(多来源聚合)。
func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16) ([]model.StreetSnapDraftImage, error) {
var drafts []model.StreetSnapDraft
if err := r.db.WithContext(ctx).
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0 AND id <> ?", city, year, model.DraftStatusApproved, excludeDraftID).
Find(&drafts).Error; err != nil {
return nil, err
}
out := make([]model.StreetSnapDraftImage, 0)
for _, d := range drafts {
imgs, err := r.StreetDraftImages(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, imgs...)
}
return out, nil
}
// unionStreetDraftImages 按 image URL 去重合并。当前草稿图优先。
func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSnapDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.StreetSnapDraftImage, 0, len(a)+len(b))
for _, im := range a {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
for _, im := range b {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
return out
}
// ── 草稿图片删除 ────────────────────────────────────────────────────────────────
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。
// 不删S4文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraftImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err return err
} }
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Exec("UPDATE brand_runway_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error Exec(`UPDATE brand_runways SET image_count =
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
WHERE id = ?`, recordID, recordID).Error
} }
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 // SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。
func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { //
// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图),
// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的,
// 与走秀「is_detail 是入库时的细节图」语义不同。
func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}). Model(&model.StreetSnapImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID). Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil { Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err return err
} }
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Exec("UPDATE street_snap_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error Exec(`UPDATE street_snaps SET image_count =
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
WHERE id = ?`, recordID, recordID).Error
} }
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。 // GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。 // kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) { func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
switch kind { switch kind {
case dto.IngestKindRunway: case dto.IngestKindRunway:
var im model.BrandRunwayDraftImage var im model.BrandRunwayImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil return "", nil
} }
@ -647,8 +345,8 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
} }
return im.Image, nil return im.Image, nil
case dto.IngestKindStreet: case dto.IngestKindStreet:
var im model.StreetSnapDraftImage var im model.StreetSnapImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil return "", nil
} }
@ -661,13 +359,13 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
} }
} }
// ── 街拍草稿图片:主副图分组 ────────────────────────────────────────────── // ── 街拍主副图分组 ──────────────────────────────────────────────────────────
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。 // AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// //
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图 // 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。 // 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error { func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs)) ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs { for _, id := range imgIDs {
if id != 0 && id != mainImgID { if id != 0 && id != mainImgID {
@ -684,8 +382,8 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
} }
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.StreetSnapDraftImage var main model.StreetSnapImage
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID). if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
First(&main).Error; err != nil { First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup return ErrInvalidImageGroup
@ -694,23 +392,23 @@ func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID,
} }
now := uint32(time.Now().Unix()) now := uint32(time.Now().Unix())
if err := tx.Model(&model.StreetSnapDraftImage{}). if err := tx.Model(&model.StreetSnapImage{}).
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids). Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil { Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err return err
} }
return tx.Model(&model.StreetSnapDraftImage{}). return tx.Model(&model.StreetSnapImage{}).
Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids). Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
}) })
} }
// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。 // DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。 // 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error { func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}). Model(&model.StreetSnapImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID). Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}). Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error Error
} }

View 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)
}
}

View File

@ -1,6 +1,9 @@
//go:build integration //go:build integration
// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。 // 集成测试:街拍主副图分组需要真实 PostgreSQL。
//
// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images),
// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。
// //
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节): // 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
// //
@ -13,61 +16,74 @@ package repository
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"testing" "testing"
"fashionapi/internal/model" "fashionapi/internal/model"
) )
// seedStreetDraft 插入一条街拍草稿并返回其 id。 // mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 { // 正式表上有部分唯一索引 uq_ss_entity (city, year, COALESCE(title, 空串)) WHERE is_deleted = 0,
// 若测试沿用同一 city/year/title,第二条记录会被唯一索引拒绝。
var mainDetailSeedSeq int
// seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。
// 命名加 MainDetail 前缀以避免与 publish_status_integration_test.go 的 seedStreetSnap 冲突。
func seedMainDetailSnap(t *testing.T, repo ReviewRepository) uint32 {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
db := repo.(*reviewRepository).db db := repo.(*reviewRepository).db
d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending} mainDetailSeedSeq++
if err := db.WithContext(ctx).Create(&d).Error; err != nil { snap := model.StreetSnap{
t.Fatalf("插入草稿失败: %v", err) Title: "主副图测试",
Year: 2026,
City: fmt.Sprintf("TestCity-%d", mainDetailSeedSeq),
Status: model.StatusPending,
}
if err := db.WithContext(ctx).Create(&snap).Error; err != nil {
t.Fatalf("插入街拍记录失败: %v", err)
} }
t.Cleanup(func() { t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID) db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID) db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
}) })
return d.ID return snap.ID
} }
// seedStreetDraftImage 插入一张草稿图并返回其行 id。 // seedMainDetailImage 插入一张正式街拍图并返回其行 id。
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 { func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 {
t.Helper() t.Helper()
img := model.StreetSnapDraftImage{DraftID: draftID, Image: name + ".jpg", Name: name, SortOrder: order} img := model.StreetSnapImage{SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order}
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil { if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
t.Fatalf("插入草稿图失败: %v", err) t.Fatalf("插入街拍图失败: %v", err)
} }
return img.ID return img.ID
} }
// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。 // reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage { func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage {
t.Helper() t.Helper()
imgs, err := repo.StreetDraftImages(context.Background(), draftID) imgs, err := repo.StreetRecordImages(context.Background(), snapID)
if err != nil { if err != nil {
t.Fatalf("读草稿图失败: %v", err) t.Fatalf("读街拍图失败: %v", err)
} }
return imgs return imgs
} }
// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。 // TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。
func TestStreetMainDetail(t *testing.T) { func TestStreetMainDetail(t *testing.T) {
repo := NewReviewRepository(testDB(t)) repo := NewReviewRepository(testDB(t))
ctx := context.Background() ctx := context.Background()
draftID := seedStreetDraft(t, repo) snapID := seedMainDetailSnap(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "a", 1) m1 := seedMainDetailImage(t, repo, snapID, "a", 1)
d2 := seedStreetDraftImage(t, repo, draftID, "b", 2) d2 := seedMainDetailImage(t, repo, snapID, "b", 2)
d3 := seedStreetDraftImage(t, repo, draftID, "c", 3) d3 := seedMainDetailImage(t, repo, snapID, "c", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("并入失败: %v", err) t.Fatalf("并入失败: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
switch im.ID { switch im.ID {
case m1: case m1:
if im.IsDetail != 0 || im.ParentImageID != 0 { if im.IsDetail != 0 || im.ParentImageID != 0 {
@ -81,40 +97,40 @@ func TestStreetMainDetail(t *testing.T) {
} }
// 幂等:重复并入同一批不报错,结果不变。 // 幂等:重复并入同一批不报错,结果不变。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("重复并入应幂等,实际报错: %v", err) t.Fatalf("重复并入应幂等,实际报错: %v", err)
} }
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。 // 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m1}); err != nil {
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err) t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) { if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("主图被自身并入后不应变化") t.Fatalf("主图被自身并入后不应变化")
} }
} }
// 拆出:把 d3 恢复为主图。 // 拆出:把 d3 恢复为主图。
if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil { if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil {
t.Fatalf("拆出失败: %v", err) t.Fatalf("拆出失败: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) { if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID) t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
} }
} }
if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功 if err := repo.DetachStreetSnapImage(ctx, snapID, m1); err != nil { // 对主图幂等成功
t.Fatalf("对主图拆出应幂等成功: %v", err) t.Fatalf("对主图拆出应幂等成功: %v", err)
} }
// 跨草稿拦截:空集合 + 跨草稿主图。 // 跨记录拦截:空集合 + 跨记录主图。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
} }
otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1) otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1)
if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) { if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("跨草稿主图应被拦截,实际 %v", err) t.Fatalf("跨记录主图应被拦截,实际 %v", err)
} }
} }
@ -123,18 +139,18 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
repo := NewReviewRepository(testDB(t)) repo := NewReviewRepository(testDB(t))
ctx := context.Background() ctx := context.Background()
draftID := seedStreetDraft(t, repo) snapID := seedMainDetailSnap(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1) m1 := seedMainDetailImage(t, repo, snapID, "m1", 1)
m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2) m2 := seedMainDetailImage(t, repo, snapID, "m2", 2)
child := seedStreetDraftImage(t, repo, draftID, "child", 3) child := seedMainDetailImage(t, repo, snapID, "child", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m2, []uint32{child}); err != nil {
t.Fatalf("预置副图失败: %v", err) t.Fatalf("预置副图失败: %v", err)
} }
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil { if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m2}); err != nil {
t.Fatalf("并入整组失败: %v", err) t.Fatalf("并入整组失败: %v", err)
} }
for _, im := range reloadStreetImages(t, repo, draftID) { for _, im := range reloadSnapImages(t, repo, snapID) {
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) { if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID) t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
} }
@ -144,49 +160,164 @@ func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
} }
} }
// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。 // ── image_count 重算(软删不漂移) ─────────────────────────────────────────────
func TestStreetPromoteRebuildsParentRef(t *testing.T) {
// snapImageCount 读某街拍记录当前的 image_count。
func snapImageCount(t *testing.T, repo ReviewRepository, snapID uint32) uint16 {
t.Helper()
var row struct {
ImageCount uint16 `gorm:"column:image_count"`
}
if err := repo.(*reviewRepository).db.WithContext(context.Background()).
Table("street_snaps").Select("image_count").Where("id = ?", snapID).Scan(&row).Error; err != nil {
t.Fatalf("读街拍 image_count 失败: %v", err)
}
return row.ImageCount
}
// runwayImageCount 读某走秀记录当前的 image_count。
func runwayImageCount(t *testing.T, repo ReviewRepository, runwayID uint32) uint16 {
t.Helper()
var row struct {
ImageCount uint16 `gorm:"column:image_count"`
}
if err := repo.(*reviewRepository).db.WithContext(context.Background()).
Table("brand_runways").Select("image_count").Where("id = ?", runwayID).Scan(&row).Error; err != nil {
t.Fatalf("读走秀 image_count 失败: %v", err)
}
return row.ImageCount
}
// seedMainDetailRunway 插入一条走秀正式记录(status=pending)并返回其 id。
// SeasonCode 用递增序号,避开 uq_br_entity (brand_id, season_code, collection_type) 部分唯一索引。
func seedMainDetailRunway(t *testing.T, repo ReviewRepository) uint32 {
t.Helper()
db := repo.(*reviewRepository).db
mainDetailSeedSeq++
rw := model.BrandRunway{
BrandID: 1,
TitleEn: "主副图计数测试",
Year: 2026,
SeasonCode: fmt.Sprintf("MD%d", mainDetailSeedSeq),
CollectionType: "rtw",
Status: model.StatusPending,
}
if err := db.WithContext(context.Background()).Create(&rw).Error; err != nil {
t.Fatalf("插入走秀记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
})
return rw.ID
}
// seedMainDetailRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回其行 id。
func seedMainDetailRunwayImage(t *testing.T, repo ReviewRepository, runwayID uint32, name string, order uint32, isDetail uint8) uint32 {
t.Helper()
img := model.BrandRunwayImage{
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
SortOrder: order, IsDetail: isDetail,
}
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
t.Fatalf("插入走秀图失败: %v", err)
}
return img.ID
}
// TestSoftDeleteStreetImageRecomputesCount 软删街拍图片后 image_count 必须按实际存活行**重算**。
//
// 对「已被软删」或「不属于该记录」的 imgID 重放一次,计数必须不变:
// 旧实现是 `image_count - 1`(GREATEST 钳到 0),命中 0 行时仍会递减且永不回补;
// 单表发布模型删掉了晋升时按实际行重算的自愈路径,漂移会永久留在公开列表卡片的「N 张」上。
func TestSoftDeleteStreetImageRecomputesCount(t *testing.T) {
repo := NewReviewRepository(testDB(t)) repo := NewReviewRepository(testDB(t))
ctx := context.Background() ctx := context.Background()
draftID := seedStreetDraft(t, repo) snapID := seedMainDetailSnap(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "main", 1) keep := seedMainDetailImage(t, repo, snapID, "keep", 1)
d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2) drop := seedMainDetailImage(t, repo, snapID, "drop", 2)
d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil {
t.Fatalf("预置主副图失败: %v", err) t.Fatalf("软删失败: %v", err)
}
if got := snapImageCount(t, repo, snapID); got != 1 {
t.Fatalf("软删一张后 image_count 应为 1,实际 %d", got)
} }
snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID) // 重放同一张(已被软删):命中 0 行,计数不得变化。
if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil {
t.Fatalf("重复软删应幂等,实际报错: %v", err)
}
if got := snapImageCount(t, repo, snapID); got != 1 {
t.Fatalf("重复软删不应改变 image_count,实际 %d(旧递减实现会掉到 0)", got)
}
// 不属于该记录的 imgID:同样不得影响本记录计数。
otherImg := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "other", 1)
if err := repo.SoftDeleteStreetImage(ctx, snapID, otherImg); err != nil {
t.Fatalf("跨记录软删不应报错: %v", err)
}
if got := snapImageCount(t, repo, snapID); got != 1 {
t.Fatalf("跨记录 imgID 不应改变 image_count,实际 %d", got)
}
imgs, err := repo.StreetRecordImages(ctx, snapID)
if err != nil { if err != nil {
t.Fatalf("晋升失败: %v", err) t.Fatalf("读存活图失败: %v", err)
} }
t.Cleanup(func() { if len(imgs) != 1 || imgs[0].ID != keep {
db := repo.(*reviewRepository).db t.Fatalf("应只剩 keep 一行,实际 %+v", imgs)
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID)
})
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(rows) != 3 {
t.Fatalf("期望 3 行正式图,实际 %d", len(rows))
} }
mainRow := rows[0] // TestSoftDeleteRunwayImage 走秀 image_count 口径是「存活主图行数」(与入库 countMainImages 一致)。
if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 { //
t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID) // 关键断言在「软删一张主图、让细节图**仍存活**」这一步:重算必须排除 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 got := runwayImageCount(t, repo, runwayID); got != 1 {
if row.IsDetail != 1 { t.Fatalf("image_count 只计存活主图,软删一张主图后应为 1(须排除仍存活的细节图),实际 %d", got)
t.Fatalf("第 %d 行应是副图", row.ID)
}
if row.ParentImageID != mainRow.ID {
t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID)
} }
// 再软删细节图:主图数不变(细节图本就不计入)。
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, detail); err != nil {
t.Fatalf("软删细节图失败: %v", err)
}
if got := runwayImageCount(t, repo, runwayID); got != 1 {
t.Fatalf("软删细节图不应改变主图计数,实际 %d", got)
}
// 重放(已软删)+ 跨记录:计数均不得变化。
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
t.Fatalf("重复软删应幂等: %v", err)
}
otherMain := seedMainDetailRunwayImage(t, repo, seedMainDetailRunway(t, repo), "x", 1, 0)
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, otherMain); err != nil {
t.Fatalf("跨记录软删不应报错: %v", err)
}
if got := runwayImageCount(t, repo, runwayID); got != 1 {
t.Fatalf("重放 / 跨记录不应改变 image_count,实际 %d", got)
}
imgs, err := repo.RecordImages(ctx, runwayID)
if err != nil {
t.Fatalf("读存活主图失败: %v", err)
}
if len(imgs) != 1 || imgs[0].ID != main1 {
t.Fatalf("应只剩 main1 一行,实际 %+v", imgs)
} }
} }

View File

@ -16,10 +16,17 @@ import (
type StreetSnapRepository interface { type StreetSnapRepository interface {
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。 // List 按筛选条件分页查询街拍,同时返回符合条件的总数。
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。 // ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
// FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。
FindByID(ctx context.Context, id string) (*model.StreetSnap, error) FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
// ListImages 查询某条街拍的全部图片,按排序值升序。 // FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error)
// ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。 // ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error) ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。 // Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
@ -28,7 +35,7 @@ type StreetSnapRepository interface {
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。 // UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
// DeleteSnapImage 软删单张街拍图片。 // DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。
DeleteSnapImage(ctx context.Context, imageID uint32) error DeleteSnapImage(ctx context.Context, imageID uint32) error
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。 // GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
@ -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) { func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
scope := snapFilterScope(q) scope := snapFilterScope(q)
var total int64
if err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Scopes(scope).
Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnap{}, 0, nil
}
var rows []model.StreetSnap
if err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Scopes(scope).
Order(snapOrderBy(q.Sort)).
Offset(q.Offset()).
Limit(q.Size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
// ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected,
// 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。
func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
scope := snapFilterScope(q)
var total int64 var total int64
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}). Model(&model.StreetSnap{}).
@ -102,11 +139,20 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery)
return rows, total, nil return rows, total, nil
} }
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) { func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id)
}
// FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。
func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) {
return r.findByID(r.db.WithContext(ctx), id)
}
// findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。
func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) {
var snap model.StreetSnap var snap model.StreetSnap
err := r.db.WithContext(ctx). err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error
Where("id = ? AND is_deleted = 0", id).
First(&snap).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@ -116,7 +162,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.
return &snap, nil return &snap, nil
} }
// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) { func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Table(publicStreetSnapImages).
Where("snap_id = ?", snapID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("snap_id = ? AND is_deleted = 0", snapID). Where("snap_id = ? AND is_deleted = 0", snapID).
@ -133,8 +191,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
var imgs []model.StreetSnapImage var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Table(publicStreetSnapImages).
Select("snap_id, image, name, sort_order"). Select("snap_id, image, name, sort_order").
Where("snap_id IN ? AND is_deleted = 0", ids). Where("snap_id IN ?", ids).
Order("sort_order ASC, id ASC"). Order("sort_order ASC, id ASC").
Find(&imgs).Error Find(&imgs).Error
if err != nil { if err != nil {
@ -146,11 +205,11 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
return result, nil return result, nil
} }
// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。
// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。 // Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) { func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
var rows []model.StreetSnap var rows []model.StreetSnap
if err := r.db.WithContext(ctx). if err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Where("is_deleted = 0"). Where("is_deleted = 0").
Order("image_count DESC, id DESC"). Order("image_count DESC, id DESC").
Limit(limit). Limit(limit).
@ -178,7 +237,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
} }
return nil, nil, err return nil, nil, err
} }
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10)) imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -202,11 +261,35 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
Updates(clean).Error Updates(clean).Error
} }
// DeleteSnapImage 软删单张街拍图片(后台正式编辑页删图),并同步重算 image_count。
//
// 与审核侧 SoftDeleteStreetImage 口径一致:删完按**实际存活图片行数**重算
// (`is_deleted = 0`,主图 + 副图全部计入)。
//
// 为什么必须重算而不是「递减 1」:理由同 articleRepository.DeleteRunwayImage —— 单表发布模型
// 删掉了晋升重建,编辑页删图造成的计数漂移不再被抹平,会永久显示在公开列表卡片的「N 张」上。
//
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 snap_id → 软删 → 重算。
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error { func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx). return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
Model(&model.StreetSnapImage{}). var img model.StreetSnapImage
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). 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) { func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {

View File

@ -80,8 +80,8 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
// 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。 // 审核页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。 // 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/reviews/batch", opt.Backstage.ReviewBatch) 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/attach", opt.Backstage.ReviewAttachImagesAPI)
admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage) admin.POST("/api/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard) admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands) 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/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover) admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted) admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。 // 审核记录:爬虫入库待人工审核 → 通过置为已发布 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。 // 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList) admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList) admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail) admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove) admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject) 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 回详情页)。 // 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages) admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage) admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试 // 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs) admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry) admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -50,6 +50,11 @@ type fakeBrand struct{}
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) { func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
} }
// AdminList 后台品牌列表:假实现里与 List 同源(本假实现不区分走秀发布态)。
func (f *fakeBrand) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return f.List(ctx, q)
}
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) { func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
return nil, nil return nil, nil
} }
@ -104,11 +109,19 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
ImageCount: 12, ImageCount: 12,
}}, 1, nil }}, 1, nil
} }
// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) { func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
return nil, service.ErrArticleNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeArticle) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
return &dto.PublicArticleDetail{ return &dto.PublicArticleDetail{
UID: id, UID: id,
BrandUID: "001DESke", BrandUID: "001DESke",
Title: "Acne Studios Spring 2024", Title: "Acne Studios Spring 2024(待审)",
BrandName: "Acne Studios", BrandName: "Acne Studios",
Images: []dto.PublicArticleImage{ Images: []dto.PublicArticleImage{
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"}, {ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
@ -143,10 +156,23 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub
ImageCount: 8, ImageCount: 8,
}}, 1, nil }}, 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) { 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{ return &dto.PublicStreetSnapDetail{
UID: id, UID: id,
Title: "Paris Fashion Week Street", Title: "Paris Fashion Week Street(待审)",
Images: []dto.PublicArticleImage{ Images: []dto.PublicArticleImage{
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"}, {ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
}, },
@ -264,13 +290,15 @@ func TestBackstage(t *testing.T) {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String()) t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
} }
// 7) 走秀详情渲染图集 // 7) 后台走秀详情渲染图集。
// fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found,
// 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。
w = httptest.NewRecorder() w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil) req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token}) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") { if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") {
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String()) t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
} }
// 8) 街拍列表渲染 // 8) 街拍列表渲染
@ -282,13 +310,14 @@ func TestBackstage(t *testing.T) {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String()) t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
} }
// 9) 街拍详情渲染图集 // 9) 后台街拍详情渲染图集。
// 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。
w = httptest.NewRecorder() w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil) req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token}) req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") { if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String()) t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
} }
} }
@ -305,7 +334,7 @@ type fakeReview struct {
rejectErr error rejectErr error
last struct { last struct {
kind string kind string
draftID uint32 recordID uint32
mainID uint32 mainID uint32
imgIDs []uint32 imgIDs []uint32
imgID uint32 imgID uint32
@ -318,27 +347,32 @@ type fakeReview struct {
ID uint32 ID uint32
Reason string Reason string
} }
view *service.DraftDetailView view *service.RecordDetailView
} }
var _ service.ReviewService = (*fakeReview)(nil) var _ service.ReviewService = (*fakeReview)(nil)
func (f *fakeReview) DraftDetail(_ context.Context, _ string, _ uint32) (*service.DraftDetailView, error) { func (f *fakeReview) RecordDetail(_ context.Context, _ string, _ uint32) (*service.RecordDetailView, error) {
if f.view != nil { if f.view != nil {
return f.view, nil return f.view, nil
} }
return &service.DraftDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil return &service.RecordDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
} }
func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, mainID uint32, imgIDs []uint32) error { // UpdateRecord 表单式通过路径会先应用字段微调;这里只做无操作桩(真正的白名单在仓储层)。
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach" func (f *fakeReview) UpdateRecord(_ context.Context, _ string, _ uint32, _ map[string]any) error {
return nil
}
func (f *fakeReview) AttachImages(_ context.Context, kind string, recordID, mainID uint32, imgIDs []uint32) error {
f.last.kind, f.last.recordID, f.last.mainID, f.last.imgIDs, f.last.op = kind, recordID, mainID, imgIDs, "attach"
if !f.attachOK { if !f.attachOK {
return errors.New("主图无效") return errors.New("主图无效")
} }
return nil return nil
} }
func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error { func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error {
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach" f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach"
if !f.attachOK { if !f.attachOK {
return errors.New("主图无效") return errors.New("主图无效")
} }
@ -362,6 +396,8 @@ func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer
ID uint32 ID uint32
Reason string Reason string
}{"approve", kind, id, ""}) }{"approve", kind, id, ""})
// last 供「表单式通过」用例断言(批量接口用 calls)。
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
if f.approveErr != nil { if f.approveErr != nil {
return 0, f.approveErr return 0, f.approveErr
} }
@ -465,16 +501,16 @@ func TestBackstageStreetDetach(t *testing.T) {
func TestBackstageStreetRenderGroups(t *testing.T) { func TestBackstageStreetRenderGroups(t *testing.T) {
r, rev := newReviewRouter(t) r, rev := newReviewRouter(t)
tok := reviewToken(t, r) 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", 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: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1}, {ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0}, {ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
}, },
Groups: []service.DraftImageGroup{ Groups: []service.ImageGroup{
{Image: service.DraftImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.DraftImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}}, {Image: service.RecordImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.RecordImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.DraftImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}}, {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 接口 ─────────────────────────── // ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ───────────────────────────
// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。 // TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。

View File

@ -24,6 +24,8 @@ type MediaCleanupEnqueuer interface {
type ArticleService interface { type ArticleService interface {
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。 // AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。 // GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
@ -87,7 +89,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
return list, total, nil return list, total, nil
} }
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / // AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / 状态
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。 // 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) { func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
q.Normalize() q.Normalize()
@ -107,6 +109,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
CollectionType: r.CollectionType, CollectionType: r.CollectionType,
SeasonCode: r.SeasonCode, SeasonCode: r.SeasonCode,
ImageCount: r.ImageCount, ImageCount: r.ImageCount,
Status: r.Status,
}) })
} }
return list, total, nil return list, total, nil
@ -151,22 +154,46 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
return kept return kept
} }
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言; // Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。 // locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) { func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
row, err := s.articles.FindByID(ctx, id) row, err := s.articles.FindByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, repository.ErrNotFound) { return nil, s.detailErr(err)
return nil, ErrArticleNotFound
} }
return nil, NewError(http.StatusInternalServerError, err.Error())
}
imgs, err := s.articles.ListImages(ctx, id) imgs, err := s.articles.ListImages(ctx, id)
if err != nil { if err != nil {
return nil, internalErr(err.Error()) return nil, internalErr(err.Error())
} }
return s.buildDetail(row, imgs, locale), nil
}
// 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 // 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致, // 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。 // 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
@ -198,7 +225,7 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"), BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
Images: pubMains, Images: pubMains,
}, nil }
} }
// ── 后台编辑支持 ────────────────────────────────────────────────────────────── // ── 后台编辑支持 ──────────────────────────────────────────────────────────────

View File

@ -16,6 +16,9 @@ import (
// BrandService 品牌业务接口。 // BrandService 品牌业务接口。
type BrandService interface { type BrandService interface {
List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
// AdminList 后台品牌列表:字母索引不要求走秀已发布,
// 使只有待审 / 驳回走秀的品牌仍出现在后台(公开 List 读视图,会挡掉它们)。
AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error)
// Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。 // Hot 返回热门品牌(按走秀图片数排名前 limit 个)并附带各品牌档案数。
// 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌; // 供 SSG 构建期(RunwayLooks 侧栏 / 品牌弹窗 HOT 标签)一次性取前 N 个热门品牌;
// 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。 // 复用 List 的 featured 收窄逻辑(FeaturedIDs("images", limit)),与首页精选口径一致。
@ -70,6 +73,18 @@ func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.Public
return s.toPublicBrands(items, q.Locale), total, nil return s.toPublicBrands(items, q.Locale), total, nil
} }
// AdminList 后台品牌列表:与 List 同形,但走 ListAdmin —— 字母索引不过滤走秀发布态,
// 只有待审 / 驳回走秀的品牌仍可见。后台不做 featured 收窄,故 restrictIDs 传 nil。
func (s *brandService) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
q.Normalize()
items, total, err := s.brands.ListAdmin(ctx, q, nil)
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicBrands(items, q.Locale), total, nil
}
// Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。 // Hot 返回热门品牌:按走秀图片总数排名取前 limit 个,结果按热度顺序(而非字母序)排列。
// //
// 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用—— // 供 SSG 构建期(走秀页侧栏品牌 filter / 品牌弹窗 HOT 标签)一次性取用——

View 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))
}
}

View File

@ -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) { func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 品牌必须存在(爬虫负责先建/复用品牌) // 1) 品牌必须存在(爬虫负责先建/复用品牌)
brandID, err := hashid.Decode(p.BrandUID) brandID, err := hashid.Decode(p.BrandUID)
@ -242,38 +242,47 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return return
} }
// 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过, // 2) 实体键查重并决定分支(单表模型)
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season) seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
dedupStart := time.Now() reuseID, existStatus, exist, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found { if err != nil {
log.Printf("[ingest] job=%d runway 实体去重命中(season=%s type=%s),跳过 耗时=%v", // 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
job.ID, seasonCode, p.CollectionType, time.Since(dedupStart).Round(time.Millisecond)) //(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) _ = s.repo.MarkDone(ctx, job.ID)
return return
} }
log.Printf("[ingest] job=%d runway 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底) // 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。 // 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。 // 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
var cover string var cover string
var draftImages []model.BrandRunwayDraftImage var images []model.BrandRunwayImage
var keys []string var keys []string
var imgFailed bool var imgFailed bool
var imageCount uint16 var imageCount uint16
var timing *fetchTiming var timing *fetchTiming
if len(p.Looks) > 0 { if len(p.Looks) > 0 {
cover, draftImages, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway") cover, images, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
imageCount = uint16(len(p.Looks)) // image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
// 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。
imageCount = countMainImages(images)
} else { } else {
var fimgs []fetchedImage var fimgs []fetchedImage
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway) cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
imageCount = uint16(len(fimgs)) imageCount = uint16(len(fimgs))
for i, fi := range fimgs { for i, fi := range fimgs {
draftImages = append(draftImages, model.BrandRunwayDraftImage{ images = append(images, model.BrandRunwayImage{
Image: fi.url, Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1), Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1), SortOrder: uint32(i + 1),
@ -294,9 +303,9 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return return
} }
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表 // 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片)
writeStart := time.Now() writeStart := time.Now()
draft := &model.BrandRunwayDraft{ rw := &model.BrandRunway{
JobID: job.ID, JobID: job.ID,
BrandID: brandID, BrandID: brandID,
TitleEn: p.TitleEn, TitleEn: p.TitleEn,
@ -309,38 +318,41 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
SeasonCode: seasonCode, SeasonCode: seasonCode,
Cover: cover, Cover: cover,
ImageCount: imageCount, ImageCount: imageCount,
Status: model.DraftStatusPending, Status: model.StatusPending,
} }
id, err := s.repo.CreateRunwayDraft(ctx, draft) for i := range images {
if err != nil { images[i].BrandID = brandID
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error()) }
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedRunway(ctx, reuseID, rw, images)
} else {
_, saveErr = s.repo.CreateRunwayWithImages(ctx, rw, images)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
return return
} }
for i := range draftImages { log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
draftImages[i].DraftID = id job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
}
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))
_ = s.repo.MarkDone(ctx, job.ID) _ = s.repo.MarkDone(ctx, job.ID)
} }
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落草稿的 // fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把 // BrandRunwayImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。 // failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。 // cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。 // 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
// //
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成 // 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。 // 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。 // 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool, *fetchTiming) { func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) {
start := time.Now() start := time.Now()
timing := &fetchTiming{} timing := &fetchTiming{}
cover := "" cover := ""
rows := make([]model.BrandRunwayDraftImage, 0) rows := make([]model.BrandRunwayImage, 0)
keys := make([]string, 0) keys := make([]string, 0)
seen := make(map[string]bool) seen := make(map[string]bool)
failed := false failed := false
@ -392,7 +404,7 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo) name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
isDetail = 1 isDetail = 1
} }
rows = append(rows, model.BrandRunwayDraftImage{ rows = append(rows, model.BrandRunwayImage{
Image: r.url, Image: r.url,
Name: name, Name: name,
SortOrder: uint32(order), SortOrder: uint32(order),
@ -408,18 +420,35 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
return cover, rows, keys, failed, timing return cover, rows, keys, failed, timing
} }
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。 // countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
func countMainImages(images []model.BrandRunwayImage) uint16 {
var n uint16
for i := range images {
if images[i].IsDetail == 0 {
n++
}
}
return n
}
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) { func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。 // 1) 实体键查重并决定分支(单表模型,同 runway)。
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。 // 实体键含 title:与下方 snap.Title 用的是同一个 p.TitleEn,保证查重口径与落库内容一致,
dedupStart := time.Now() // 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found { reuseID, existStatus, exist, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, p.TitleEn)
log.Printf("[ingest] job=%d street 实体去重命中(city=%s year=%d),跳过 耗时=%v", if err != nil {
job.ID, p.City, p.Year, time.Since(dedupStart).Round(time.Millisecond)) // 同 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) _ = s.repo.MarkDone(ctx, job.ID)
return return
} }
log.Printf("[ingest] job=%d street 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。 // 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet) cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
@ -432,26 +461,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
return return
} }
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snaps 正式表 // 3) 直写正式表(status=pending)
writeStart := time.Now() writeStart := time.Now()
draft := &model.StreetSnapDraft{ snap := &model.StreetSnap{
JobID: job.ID, JobID: job.ID,
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
Year: p.Year, Year: p.Year,
City: p.City, City: p.City,
Cover: cover, Cover: cover,
ImageCount: uint16(len(fimgs)), ImageCount: uint16(len(fimgs)),
Status: model.DraftStatusPending, Status: model.StatusPending,
} }
id, err := s.repo.CreateStreetSnapDraft(ctx, draft) rows := make([]model.StreetSnapImage, 0, len(fimgs))
if err != nil {
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
return
}
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
for i, fi := range fimgs { for i, fi := range fimgs {
rows = append(rows, model.StreetSnapDraftImage{ rows = append(rows, model.StreetSnapImage{
DraftID: id,
Image: fi.url, Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1), Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1), SortOrder: uint32(i + 1),
@ -460,12 +483,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10), DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
}) })
} }
var saveErr error
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil { if action == ingestReuseRejected {
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error()) saveErr = s.repo.ReuseRejectedStreetSnap(ctx, reuseID, snap, rows)
} else {
_, saveErr = s.repo.CreateStreetSnapWithImages(ctx, snap, rows)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
return return
} }
log.Printf("[ingest] job=%d street 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows)) log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID) _ = s.repo.MarkDone(ctx, job.ID)
} }
@ -606,9 +635,9 @@ func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string,
var tables []string var tables []string
switch kind { switch kind {
case dto.IngestKindRunway: case dto.IngestKindRunway:
tables = []string{"brand_runway_draft_images", "brand_runway_images"} tables = []string{"brand_runway_images"}
case dto.IngestKindStreet: case dto.IngestKindStreet:
tables = []string{"street_snap_draft_images", "street_snap_images"} tables = []string{"street_snap_images"}
default: default:
return 0, 0 return 0, 0
} }
@ -742,3 +771,25 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
} }
return res return res
} }
// ingestActionKind 入库分支决策结果(单表发布模型)。
type ingestActionKind int
const (
ingestCreate ingestActionKind = iota // 实体键未命中 → 新建记录
ingestSkip // 命中 pending/published → 放弃本次入库
ingestReuseRejected // 命中 rejected → 复用该行重审
)
// decideIngestAction 按实体键命中情况与既有行状态决定入库分支。
// 抽成纯函数,是为了让「驳回不是永久黑名单」这条规则有独立的、不需要数据库的测试。
func decideIngestAction(found bool, status string) ingestActionKind {
switch {
case !found:
return ingestCreate
case status == model.StatusRejected:
return ingestReuseRejected
default:
return ingestSkip
}
}

View File

@ -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)
}
})
}
}

View File

@ -13,8 +13,8 @@ import (
"fashionapi/internal/repository" "fashionapi/internal/repository"
) )
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。 // RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
type DraftCard struct { type RecordCard struct {
Kind string `json:"kind"` // runway | street | 未来其它模块 Kind string `json:"kind"` // runway | street | 未来其它模块
ID uint32 `json:"id"` ID uint32 `json:"id"`
Title string `json:"title"` // 展示标题 Title string `json:"title"` // 展示标题
@ -26,11 +26,11 @@ type DraftCard struct {
UpdatedAt uint32 `json:"updated_at"` UpdatedAt uint32 `json:"updated_at"`
} }
// DraftImageRef 草稿图片引用(统一结构)。 // RecordImageRef 记录图片引用(统一结构)。
// //
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用); // IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。 // DetailCount 让模板不用自己数副图。
type DraftImageRef struct { type RecordImageRef struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Image string `json:"image"` Image string `json:"image"`
Name string `json:"name"` Name string `json:"name"`
@ -49,8 +49,8 @@ type EditField struct {
Type string `json:"type"` // text | textarea | number Type string `json:"type"` // text | textarea | number
} }
// DraftDetailView 审核详情统一视图(所有模块共用)。 // RecordDetailView 审核详情统一视图(所有模块共用)。
type DraftDetailView struct { type RecordDetailView struct {
Kind string `json:"kind"` Kind string `json:"kind"`
ID uint32 `json:"id"` ID uint32 `json:"id"`
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0 BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
@ -67,23 +67,23 @@ type DraftDetailView struct {
Cover string `json:"cover"` Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"` ImageCount uint16 `json:"image_count"`
Status string `json:"status"` Status string `json:"status"`
Images []DraftImageRef `json:"images"` Images []RecordImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"` Fields []EditField `json:"fields"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。 // Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。 // 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
Groups []DraftImageGroup `json:"groups,omitempty"` Groups []ImageGroup `json:"groups,omitempty"`
} }
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。 // ImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type DraftImageGroup struct { type ImageGroup struct {
Image DraftImageRef `json:"image"` // 主图 Image RecordImageRef `json:"image"` // 主图
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空) Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空)
} }
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。 // ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。
// 这里是给用户看的文案,handler 直接渲染 err.Error()。 // 这里是给用户看的文案,handler 直接渲染 err.Error()。
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图") var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本条记录、未被删除,且自身是主图")
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。 // KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
type KindTab struct { type KindTab struct {
@ -92,40 +92,41 @@ type KindTab struct {
Pending int64 `json:"pending"` Pending int64 `json:"pending"`
} }
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。 // ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。
// //
// 新增一种爬虫数据类型需要审核时,只需: // 新增一种爬虫数据类型需要审核时,只需:
// 1. 建草稿表 + 迁移; // 1. 建正式表 + 迁移(记录自带 status 列);
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending; // 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
// 3. 在此文件实现本接口并注册到 NewReviewService。 // 3. 在此文件实现本接口并注册到 NewReviewService。
// //
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。 // 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
type ReviewModule interface { type ReviewModule interface {
Kind() string Kind() string
Label() string Label() string
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error)
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error)
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。 // Approve 把正式记录置为 published;返回记录主键。
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, id uint32, reviewer, reason string) error Reject(ctx context.Context, id uint32, reviewer, reason string) error
CountPending(ctx context.Context) (int64, error) CountPending(ctx context.Context) (int64, error)
} }
// ReviewService 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。 // ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)/ 驳回。
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。 // 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
type ReviewService interface { type ReviewService interface {
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。 // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。 // AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。 // DetachImage 把副图拆出恢复为主图(街拍专用)。
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
KindTabs(ctx context.Context) []KindTab KindTabs(ctx context.Context) []KindTab
} }
@ -165,22 +166,22 @@ func (s *reviewService) module(kind string) (ReviewModule, bool) {
return m, ok return m, ok
} }
func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) { func (s *reviewService) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) {
if kind == "all" { if kind == "all" {
kind = "" kind = ""
} }
if kind != "" { if kind != "" {
m, ok := s.module(kind) m, ok := s.module(kind)
if !ok { if !ok {
return []DraftCard{}, 0, nil return []RecordCard{}, 0, nil
} }
return m.ListDrafts(ctx, status, page, size) return m.ListRecords(ctx, status, page, size)
} }
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。 // 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
var all []DraftCard var all []RecordCard
var total int64 var total int64
for _, k := range s.order { for _, k := range s.order {
cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000) cards, t, err := s.modules[k].ListRecords(ctx, status, 1, 1000)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@ -208,20 +209,20 @@ func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, pag
return all[start:end], total, nil return all[start:end], total, nil
} }
func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) { func (s *reviewService) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) {
m, ok := s.module(kind) m, ok := s.module(kind)
if !ok { if !ok {
return nil, repository.ErrNotFound return nil, repository.ErrNotFound
} }
return m.DraftDetail(ctx, id) return m.RecordDetail(ctx, id)
} }
func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error { func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error {
m, ok := s.module(kind) m, ok := s.module(kind)
if !ok { if !ok {
return repository.ErrNotFound return repository.ErrNotFound
} }
return m.UpdateDraft(ctx, id, fields) return m.UpdateRecord(ctx, id, fields)
} }
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) { func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
@ -256,22 +257,22 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
return tabs return tabs
} }
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。 // DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count; // 按 kind 委派到对应图片表的软删,并同步记录 image_count;
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。 // 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error { func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error {
if _, ok := s.module(kind); !ok { if _, ok := s.module(kind); !ok {
return repository.ErrNotFound return repository.ErrNotFound
} }
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID) key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID)
if err != nil { if err != nil {
return err return err
} }
switch kind { switch kind {
case dto.IngestKindRunway: case dto.IngestKindRunway:
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID) err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID)
case dto.IngestKindStreet: case dto.IngestKindStreet:
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID) err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID)
default: default:
return repository.ErrNotFound return repository.ErrNotFound
} }
@ -284,21 +285,21 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
return nil return nil
} }
func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error { func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
if kind != dto.IngestKindStreet { if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup return ErrInvalidImageGroup
} }
if len(imgIDs) == 0 { if len(imgIDs) == 0 {
return ErrInvalidImageGroup 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 { if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup return ErrInvalidImageGroup
} }
return translateGroupErr(s.repo.DetachStreetDraftImage(ctx, draftID, imgID)) return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
} }
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。 // translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
@ -322,24 +323,24 @@ type runwayModule struct {
func (m *runwayModule) Kind() string { return dto.IngestKindRunway } func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
func (m *runwayModule) Label() string { return "走秀" } func (m *runwayModule) Label() string { return "走秀" }
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) { func (m *runwayModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
rows, total, err := m.repo.ListDrafts(ctx, status, page, size) rows, total, err := m.repo.ListRecords(ctx, status, page, size)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
return m.toRunwayCards(rows), total, nil return m.toRunwayCards(rows), total, nil
} }
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) { func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
d, err := m.repo.DraftDetail(ctx, id) d, err := m.repo.RecordDetail(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
imgs, err := m.repo.DraftImages(ctx, id) imgs, err := m.repo.RecordImages(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
v := &DraftDetailView{ v := &RecordDetailView{
Kind: dto.IngestKindRunway, Kind: dto.IngestKindRunway,
ID: d.ID, ID: d.ID,
BrandID: d.BrandID, BrandID: d.BrandID,
@ -367,32 +368,28 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
}, },
} }
for _, im := range imgs { for _, im := range imgs {
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder}) v.Images = append(v.Images, RecordImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
} }
return v, nil return v, nil
} }
func (m *runwayModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateDraft(ctx, id, fields) return m.repo.UpdateRecord(ctx, id, fields)
} }
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id) if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
if err != nil {
return 0, err return 0, err
} }
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil { return id, nil
return 0, err
}
return officialID, nil
} }
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { 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) { func (m *runwayModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.BrandRunwayDraft{}) return m.repo.CountByStatus(ctx, model.StatusPending, &model.BrandRunway{})
} }
// ── street 模块 ──────────────────────────────────────────────────────────────── // ── street 模块 ────────────────────────────────────────────────────────────────
@ -405,24 +402,24 @@ type streetModule struct {
func (m *streetModule) Kind() string { return dto.IngestKindStreet } func (m *streetModule) Kind() string { return dto.IngestKindStreet }
func (m *streetModule) Label() string { return "街拍" } func (m *streetModule) Label() string { return "街拍" }
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) { func (m *streetModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size) rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
return m.toStreetCards(rows), total, nil return m.toStreetCards(rows), total, nil
} }
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) { func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
d, err := m.repo.StreetDraftDetail(ctx, id) d, err := m.repo.StreetRecordDetail(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
imgs, err := m.repo.StreetDraftImages(ctx, id) imgs, err := m.repo.StreetRecordImages(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
v := &DraftDetailView{ v := &RecordDetailView{
Kind: dto.IngestKindStreet, Kind: dto.IngestKindStreet,
ID: d.ID, ID: d.ID,
Title: d.Title, Title: d.Title,
@ -440,7 +437,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。 // 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。 // 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
byID := make(map[uint32]model.StreetSnapDraftImage, len(imgs)) byID := make(map[uint32]model.StreetSnapImage, len(imgs))
for _, im := range imgs { for _, im := range imgs {
byID[im.ID] = im byID[im.ID] = im
} }
@ -472,7 +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)) groupIndex := make(map[uint32]int, len(imgs))
for _, im := range imgs { for _, im := range imgs {
root := resolveRoot(im.ID) root := resolveRoot(im.ID)
@ -483,7 +480,7 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
isDetailVal = 1 isDetailVal = 1
parent = root parent = root
} }
ref := DraftImageRef{ ref := RecordImageRef{
ID: im.ID, ID: im.ID,
Image: m.img.Compose(im.Image), Image: m.img.Compose(im.Image),
Name: im.Name, Name: im.Name,
@ -494,14 +491,14 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
} }
v.Images = append(v.Images, ref) v.Images = append(v.Images, ref)
if !isDetail { if !isDetail {
groups = append(groups, DraftImageGroup{Image: ref}) groups = append(groups, ImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1 groupIndex[im.ID] = len(groups) - 1
continue continue
} }
if gi, ok := groupIndex[root]; ok { if gi, ok := groupIndex[root]; ok {
groups[gi].Details = append(groups[gi].Details, ref) groups[gi].Details = append(groups[gi].Details, ref)
} else { // 兜底(理论不发生):主图未先出现,自成一组 } else { // 兜底(理论不发生):主图未先出现,自成一组
groups = append(groups, DraftImageGroup{Image: ref}) groups = append(groups, ImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1 groupIndex[im.ID] = len(groups) - 1
} }
} }
@ -509,33 +506,29 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
return v, nil return v, nil
} }
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error { func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateStreetDraft(ctx, id, fields) return m.repo.UpdateStreetRecord(ctx, id, fields)
} }
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) { func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id) if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer, ""); err != nil {
if err != nil {
return 0, err return 0, err
} }
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil { return id, nil
return 0, err
}
return officialID, nil
} }
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error { 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) { func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{}) return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
} }
// ── 卡片转换(保留原逻辑)────────────────────────────────────────────────────── // ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard { func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard {
cards := make([]DraftCard, 0, len(rows)) cards := make([]RecordCard, 0, len(rows))
for _, d := range rows { for _, d := range rows {
sub := d.SeasonCode sub := d.SeasonCode
if d.CollectionType != "" { if d.CollectionType != "" {
@ -544,7 +537,7 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
if sub == "" { if sub == "" {
sub = "—" sub = "—"
} }
cards = append(cards, DraftCard{ cards = append(cards, RecordCard{
Kind: dto.IngestKindRunway, Kind: dto.IngestKindRunway,
ID: d.ID, ID: d.ID,
Title: d.TitleEn, Title: d.TitleEn,
@ -559,14 +552,14 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
return cards return cards
} }
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard { func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard {
cards := make([]DraftCard, 0, len(rows)) cards := make([]RecordCard, 0, len(rows))
for _, d := range rows { for _, d := range rows {
sub := d.City sub := d.City
if sub == "" { if sub == "" {
sub = "—" sub = "—"
} }
cards = append(cards, DraftCard{ cards = append(cards, RecordCard{
Kind: dto.IngestKindStreet, Kind: dto.IngestKindStreet,
ID: d.ID, ID: d.ID,
Title: d.Title, Title: d.Title,

View File

@ -14,27 +14,27 @@ import (
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。 // fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
type fakeGroupRepo struct { type fakeGroupRepo struct {
repository.ReviewRepository repository.ReviewRepository
images []model.StreetSnapDraftImage images []model.StreetSnapImage
detail *model.StreetSnapDraft detail *model.StreetSnap
attachFn func(draftID, mainID uint32, imgIDs []uint32) error attachFn func(snapID, mainID uint32, imgIDs []uint32) error
} }
func (f *fakeGroupRepo) StreetDraftDetail(_ context.Context, _ uint32) (*model.StreetSnapDraft, error) { func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
return f.detail, nil return f.detail, nil
} }
func (f *fakeGroupRepo) StreetDraftImages(_ context.Context, _ uint32) ([]model.StreetSnapDraftImage, error) { func (f *fakeGroupRepo) StreetRecordImages(_ context.Context, _ uint32) ([]model.StreetSnapImage, error) {
return f.images, nil return f.images, nil
} }
func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, mainID uint32, imgIDs []uint32) error { func (f *fakeGroupRepo) AttachStreetSnapImages(_ context.Context, snapID, mainID uint32, imgIDs []uint32) error {
if f.attachFn != nil { if f.attachFn != nil {
return f.attachFn(draftID, mainID, imgIDs) return f.attachFn(snapID, mainID, imgIDs)
} }
return nil return nil
} }
func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error { func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uint32) error {
if f.attachFn != nil { if f.attachFn != nil {
return f.attachFn(0, 0, nil) return f.attachFn(0, 0, nil)
} }
@ -50,17 +50,17 @@ func newGroupService(f *fakeGroupRepo) ReviewService {
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。 // TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
func TestStreetGroupDetailExposesGrouping(t *testing.T) { func TestStreetGroupDetailExposesGrouping(t *testing.T) {
repo := &fakeGroupRepo{ repo := &fakeGroupRepo{
detail: &model.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.DraftStatusPending}, detail: &model.StreetSnap{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.StatusPending},
images: []model.StreetSnapDraftImage{ images: []model.StreetSnapImage{
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0}, {ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1}, {ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0}, {ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
}, },
} }
svc := newGroupService(repo) svc := newGroupService(repo)
v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7) v, err := svc.RecordDetail(context.Background(), dto.IngestKindStreet, 7)
if err != nil { if err != nil {
t.Fatalf("DraftDetail 失败: %v", err) t.Fatalf("RecordDetail 失败: %v", err)
} }
if len(v.Images) != 3 { if len(v.Images) != 3 {
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images)) t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
@ -85,20 +85,20 @@ func TestStreetGroupDetailExposesGrouping(t *testing.T) {
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。 // TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
func TestStreetGroupAttachDelegates(t *testing.T) { func TestStreetGroupAttachDelegates(t *testing.T) {
var got struct { var got struct {
draftID uint32 snapID uint32
mainID uint32 mainID uint32
imgIDs []uint32 imgIDs []uint32
} }
repo := &fakeGroupRepo{attachFn: func(draftID, mainID uint32, imgIDs []uint32) error { repo := &fakeGroupRepo{attachFn: func(snapID, mainID uint32, imgIDs []uint32) error {
got.draftID, got.mainID, got.imgIDs = draftID, mainID, imgIDs got.snapID, got.mainID, got.imgIDs = snapID, mainID, imgIDs
return nil return nil
}} }}
svc := newGroupService(repo) svc := newGroupService(repo)
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil { if err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
t.Fatalf("AttachDraftImages 失败: %v", err) t.Fatalf("AttachImages 失败: %v", err)
} }
if got.draftID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 { if got.snapID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: draftID=%d mainID=%d imgIDs=%v", got.draftID, got.mainID, got.imgIDs) t.Fatalf("参数透传错误: snapID=%d mainID=%d imgIDs=%v", got.snapID, got.mainID, got.imgIDs)
} }
} }
@ -106,22 +106,22 @@ func TestStreetGroupAttachDelegates(t *testing.T) {
func TestStreetGroupNonStreetRejected(t *testing.T) { func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{} repo := &fakeGroupRepo{}
svc := newGroupService(repo) svc := newGroupService(repo)
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) { if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err) t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
} }
} }
// TestStreetGroupPreventCrossDraft 仓储拦截跨草稿主图后,service 翻成中文用户错误。 // TestStreetGroupPreventCrossRecord 仓储拦截跨记录主图后,service 翻成中文用户错误。
func TestStreetGroupPreventCrossDraft(t *testing.T) { func TestStreetGroupPreventCrossRecord(t *testing.T) {
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error { repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
return repository.ErrInvalidImageGroup return repository.ErrInvalidImageGroup
}} }}
svc := newGroupService(repo) svc := newGroupService(repo)
err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}) err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
if err == nil { if err == nil {
t.Fatal("跨草稿应报错") t.Fatal("跨记录应报错")
} }
if err.Error() != "主图无效:它必须属于本草稿、未被删除,且自身是主图" { if err.Error() != "主图无效:它必须属于本条记录、未被删除,且自身是主图" {
t.Fatalf("错误文案不符: %q", err.Error()) t.Fatalf("错误文案不符: %q", err.Error())
} }
} }

View File

@ -17,7 +17,11 @@ import (
// StreetSnapService 街拍业务接口。 // StreetSnapService 街拍业务接口。
type StreetSnapService interface { type StreetSnapService interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。 // GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
@ -62,17 +66,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
return nil, 0, internalErr(err.Error()) return nil, 0, internalErr(err.Error())
} }
list := make([]dto.PublicStreetSnap, 0, len(rows))
ids := make([]uint32, 0, len(rows)) ids := make([]uint32, 0, len(rows))
for _, r := range rows { for _, r := range rows {
ids = append(ids, r.ID) ids = append(ids, r.ID)
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
ImageCount: r.ImageCount,
})
} }
list := s.toPublicSnaps(rows)
if q.Images > 0 && len(list) > 0 { if q.Images > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.Images); err != nil { if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
@ -82,6 +80,36 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
return list, total, nil return list, total, nil
} }
// AdminList 后台街拍列表:读基表,包含 pending / rejected。
//
// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段,
// 与走秀后台 AdminList 的口径一致。
func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
q.Normalize()
rows, total, err := s.repo.ListAdmin(ctx, q)
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicSnaps(rows), 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 查询为列表批量补齐图片,避免前端逐条请求详情。 // attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error { func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids) grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
@ -119,27 +147,52 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
return kept return kept
} }
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。 // Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) { func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByID(ctx, id) snap, err := s.repo.FindByID(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, repository.ErrNotFound) { return nil, s.detailErr(err)
return nil, ErrStreetSnapNotFound
} }
return nil, internalErr(err.Error())
}
imgs, err := s.repo.ListImages(ctx, id) imgs, err := s.repo.ListImages(ctx, id)
if err != nil { if err != nil {
return nil, internalErr(err.Error()) return nil, internalErr(err.Error())
} }
return s.buildSnapDetail(snap, imgs), 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{ return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title, Title: snap.Title,
Cover: s.img.Compose(snap.Cover), Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs), Images: s.buildSnapImages(imgs),
}, nil }
} }
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形): // buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
@ -207,7 +260,6 @@ func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.
return out return out
} }
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
rows, err := s.repo.Popular(ctx, limit) rows, err := s.repo.Popular(ctx, limit)