Compare commits

...

40 Commits

Author SHA1 Message Date
d15d2a4701 update 2026-09-28 10:52:50 +08:00
aaeab41d67 update 2026-09-25 19:59:06 +08:00
42f6316125 update 2026-09-25 11:31:52 +08:00
4f4c11d508 update 2026-09-25 11:29:17 +08:00
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
bfe890bd56 update 2026-09-23 00:19:13 +08:00
7a069fe293 update 2026-09-22 21:44:58 +08:00
c3a6baa419 update 2026-09-22 21:40:04 +08:00
c32347ed3c update 2026-09-22 12:14:05 +08:00
d908036621 update 2026-09-22 11:12:23 +08:00
21dce13bde style(street): 审核页卡片「主图」单选移到「选入」前面 2026-09-22 00:07:23 +08:00
b8d66df5a1 docs(street): 审核页规格改为统一网格+勾选成组,记录并入上一张废弃 2026-09-22 00:00:16 +08:00
0d4000bba5 refactor(street): 审核页改为统一网格+勾选成组;移除并入上一张 2026-09-21 23:59:59 +08:00
9807752e4e docs(street): 规格改为与走秀同形嵌套结构并记录审核页图片流 2026-09-21 23:24:29 +08:00
85ad3a3f68 feat(street): 公开详情改为runway同形嵌套;审核页合并为图片流 2026-09-21 23:24:00 +08:00
49bd7b74c6 fix(street): 审核页「设为主图」改为GET链接并支持勾选批量并入 2026-09-21 23:04:38 +08:00
3b60e6fdea style(street): gofmt 规范化街拍主副图相关文件 2026-09-21 22:36:07 +08:00
3428a9823b fix(street): 补齐 errors.go 的 ErrInvalidImageGroup 哨兵 2026-09-21 22:36:07 +08:00
be73e595ca feat(street): 公开 API 详情新增主副图分组字段 2026-09-21 22:35:28 +08:00
ed92b475d1 feat(street): 后台审核页街拍主副图分组渲染与操作路由 2026-09-21 22:32:27 +08:00
96858aa85f feat(street): 审核 service 暴露主副图分组操作与详情分组视图 2026-09-21 22:23:43 +08:00
9fe51036e6 fix(street): 晋升时重建副图父引用(草稿行 id 到 正式行 id) 2026-09-21 22:21:44 +08:00
16e024561d feat(street): 草稿图支持主副分组(并入/并入上一张/拆出) 2026-09-21 22:20:48 +08:00
98 changed files with 34771 additions and 41999 deletions

6
.gitignore vendored
View File

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

View File

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

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,10 +1,10 @@
// Command dbtool 是后台管理用的数据库导出 / 导入脚本。
//
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 数据」导出为
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
//
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
// dbtool import [-config <yml>] [-in <file.sql>] 导入
// dbtool import [-config <yml>] [-in <file.sql>] [-clean] 导入
//
// 导入(目标机器,二选一):
//
@ -23,16 +23,28 @@
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
//
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
// 数据、序列当前值。
// 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。
//
// 视图 / 触发器 / 外键 / 注释 / 权限不导出(当前库中也不存在)。
// 触发器 / 外键 / 注释 / 权限不导出。
//
// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01),
// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图,
// 公开查询直接报 relation does not exist。
//
// 目标库为空还是已有数据:
//
// 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空
// (对已有数据的库会主键冲突)。想让同一份文件直接覆盖一个已有数据的库,
// 导出时加 -clean —— 它会在结构段前输出 DROP TABLE IF EXISTS ... CASCADE,
// 且只针对本次 dump 里的表(与 pg_dump --clean 口径一致,目标库多出来的表不动)。
// (对已有数据的库会主键冲突)。要覆盖一个已有数据的库,用 -clean:
//
// dump -clean 在结构段前输出 DROP TABLE IF EXISTS ... CASCADE,只针对本次
// dump 里的表(与 pg_dump --clean 口径一致),清理语义写在文件里,
// 因此 psql -f 同样能灌进脏库。
// import -clean 导入前 DROP public 下全部表,不必重新导出文件;适合「手上只有
// 一份旧文件、目标库状态不明」的情况。
//
// 约束段另有一层幂等保护:先 DROP CONSTRAINT IF EXISTS 再 ADD。这样即使目标库里
// 已存在同名主键(例如早先被 AutoMigrate 建过、或上一次导入中断留下的),
// 也不会报 relation "<name>" already exists。
//
// 前置条件:目标库必须已启用 pgvector 扩展,否则 vector(64) 列建不出来。
// 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的
@ -45,10 +57,12 @@ import (
"bufio"
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
"time"
@ -93,6 +107,10 @@ func main() {
runDump(os.Args[2:])
case "import":
runImport(os.Args[2:])
case "fixdup":
runFixDup(os.Args[2:])
case "purge-rejected":
runPurgeRejected(os.Args[2:])
default:
usage()
os.Exit(2)
@ -104,16 +122,24 @@ func usage() {
用法:
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件
dbtool import [-config <yml>] [-in <file>]
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
dbtool import [-config <yml>] [-in <file>] [-clean]
把 dump 出来的 SQL 文件灌入目标库
dbtool fixdup [-config <yml>]
清理「悬空」重复标记(is_duplicate=1 但其源图已删/软删),修编辑页误显「重复」
dbtool purge-rejected [-config <yml>]
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理)
说明:
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
-with-extension 在文件开头加 CREATE EXTENSION IF NOT EXISTS vector;
-with-extension(dump)在文件开头加 CREATE EXTENSION IF NOT EXISTS vector;
默认不加,此时目标库须已启用 pgvector,否则 vector 列建不出来。
-clean 在结构段前加 DROP TABLE IF EXISTS,使脚本可灌进**已有数据的库**
(目标库中这些表的既有数据会丢失)。不加时目标库必须是空库,否则主键冲突。
-clean 有两个位置,都会丢弃目标库中对应表的既有数据:
dump -clean 在结构段前写 DROP TABLE IF EXISTS,使文件可灌进已有数据的库
import -clean 导入前先 DROP public 下全部表,适合「只有旧文件、目标库状态不明」
不加 -clean 时要求目标库为空(或至少同名表为空),否则会主键冲突。
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
}
@ -168,13 +194,17 @@ func runDump(args []string) {
if len(tables) == 0 {
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
}
views, err := listViews(db)
if err != nil {
log.Fatalf("✗ 列举视图失败: %v", err)
}
cols := make(map[string][]column, len(tables))
// ① 结构(-clean 时先输出清理段)
fmt.Fprintln(w, "-- ==================== 结构 ====================")
if *clean {
writeClean(w, tables)
writeClean(w, tables, views)
}
for _, t := range tables {
cs, err := listColumns(db, t)
@ -184,7 +214,11 @@ func runDump(args []string) {
cols[t] = cs
writeCreateTable(w, t, cs)
}
log.Printf("→ 结构:%d 张表", len(tables))
// 视图放在全部基表之后建:视图可能引用基表,也可能引用另一个视图(listViews 已按依赖排序)。
for _, v := range views {
writeCreateView(w, v)
}
log.Printf("→ 结构:%d 张表、%d 个视图", len(tables), len(views))
// ② 数据
fmt.Fprintln(w, "-- ==================== 数据 ====================")
@ -231,6 +265,7 @@ func runImport(args []string) {
fs := flag.NewFlagSet("import", flag.ExitOnError)
in := fs.String("in", "db_dump.sql", "导入文件路径")
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
clean := fs.Bool("clean", false, "导入前先 DROP public 下全部表(覆盖式还原;目标库这些数据会丢失)")
_ = fs.Parse(args)
script, err := os.ReadFile(*in)
@ -244,6 +279,12 @@ func runImport(args []string) {
}
defer db.Close()
if *clean {
// 与脚本拼成一段再执行:两者落在同一个隐式事务里,要么都成功、要么都回滚。
script = append([]byte(dropAllTablesSQL+"\n"), script...)
log.Printf("→ 导入前先清空 public 下全部表(-clean)")
}
log.Printf("→ 导入 %s(%d KB)-> %s ...", *in, len(script)/1024, dcfg.Addr())
if err := execScript(context.Background(), db, string(script)); err != nil {
log.Fatalf("✗ 导入失败(已整体回滚): %v", err)
@ -251,6 +292,182 @@ func runImport(args []string) {
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
}
// runFixDup 清理「悬空」重复标记:is_duplicate=1 但 dup_of 指向的源图行已不存在或已被软删
// (is_deleted=1)。这类陈旧标记会让编辑页误显「重复」且源图打不开。仅清悬空项,真实近重复
// (源图仍在且存活)不受影响。等价于 DeleteImage 触发 ReresolveRunwayDedup 的重算效果之一,
// 但可一次性全局修历史遗留数据,无需逐张删图触发。
func runFixDup(args []string) {
fs := flag.NewFlagSet("fixdup", flag.ExitOnError)
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
_ = fs.Parse(args)
db, _, err := openDB(*cfgPath)
if err != nil {
log.Fatalf("✗ %v", err)
}
defer db.Close()
for _, t := range []string{"brand_runway_images", "street_snap_images"} {
q := fmt.Sprintf(`
UPDATE public.%[1]s
SET is_duplicate = 0, dup_of = ''
WHERE is_duplicate = 1
AND (dup_of IS NULL OR dup_of = '' OR dup_of NOT IN (
SELECT CAST(id AS text) FROM public.%[1]s WHERE is_deleted = 0));`, qi(t))
res, err := db.Exec(q)
if err != nil {
log.Fatalf("✗ 清理 %s 失败: %v", t, err)
}
n, _ := res.RowsAffected()
log.Printf("✓ %s: 清理悬空重复标记 %d 行", t, n)
}
}
// runPurgeRejected 把现存「已驳回(rejected)」且未下架的记录转删除:
// 级联软删其全部图片、记录自身置 is_deleted=1(后台 legWhere 强制 is_deleted=0,记录即从列表消失),
// 并把图片 key 作为 media_cleanup 任务入队,由运行中的 worker 按引用计数真删 S4 孤儿文件。
//
// 与后台点「删除(清空 S4)」完全等价(后者走 articleService/streetSnapService.SetDeleted →
// PurgeGallery),但本命令一次性全局处理历史遗留的 rejected 数据,无需逐条手动操作。
func runPurgeRejected(args []string) {
fs := flag.NewFlagSet("purge-rejected", flag.ExitOnError)
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
_ = fs.Parse(args)
db, _, err := openDB(*cfgPath)
if err != nil {
log.Fatalf("✗ %v", err)
}
defer db.Close()
// 两种实体:记录主表 / 图片明细表 / 图片表外键列。
kinds := []struct {
recTable, imgTable, fk string
}{
{"brand_runways", "brand_runway_images", "runway_id"},
{"street_snaps", "street_snap_images", "snap_id"},
}
now := uint32(time.Now().Unix())
totalRecs, totalKeys := 0, 0
var allKeys []string
for _, k := range kinds {
ids, err := selectUint32s(db, fmt.Sprintf(
"SELECT id FROM public.%s WHERE status = 'rejected' AND is_deleted = 0", qi(k.recTable)))
if err != nil {
log.Fatalf("✗ 查询 %s 失败: %v", k.recTable, err)
}
if len(ids) == 0 {
log.Printf("→ %s: 无 rejected 记录", k.recTable)
continue
}
for _, id := range ids {
keys, err := selectStrings(db, fmt.Sprintf(
"SELECT image FROM public.%s WHERE %s = $1 AND is_deleted = 0", qi(k.imgTable), k.fk), id)
if err != nil {
log.Fatalf("✗ 查询 %s 图片失败: %v", k.imgTable, err)
}
if _, err := db.Exec(fmt.Sprintf(
"UPDATE public.%s SET is_deleted = 1, updated_at = $1 WHERE %s = $2 AND is_deleted = 0",
qi(k.imgTable), k.fk), now, id); err != nil {
log.Fatalf("✗ 软删 %s 图片失败: %v", k.imgTable, err)
}
if _, err := db.Exec(fmt.Sprintf(
"UPDATE public.%s SET is_deleted = 1, updated_at = $1 WHERE id = $2",
qi(k.recTable)), now, id); err != nil {
log.Fatalf("✗ 置 %s 下架失败: %v", k.recTable, err)
}
totalRecs++
allKeys = append(allKeys, keys...)
totalKeys += len(keys)
log.Printf("✓ %s #%d: 软删图片 %d 张", k.recTable, id, len(keys))
}
}
// 把 key 分批入队 media_cleanup(与 EnqueueMediaCleanup 同格式),worker 按引用计数清 S4。
if len(allKeys) > 0 {
const batch = 500
for start := 0; start < len(allKeys); start += batch {
end := start + batch
if end > len(allKeys) {
end = len(allKeys)
}
payload, err := json.Marshal(map[string][]string{"keys": allKeys[start:end]})
if err != nil {
log.Fatalf("✗ 构造 payload 失败: %v", err)
}
if err := insertMediaCleanup(db, now, string(payload)); err != nil {
log.Fatalf("✗ 入队 media_cleanup 失败: %v", err)
}
}
}
log.Printf("✓ 完成:转删除记录 %d 条、图片 %d 张,已入队 media_cleanup(运行中 worker 将按引用计数清理 S4)", totalRecs, totalKeys)
}
// selectUint32s 执行返回单列 uint32 的查询。
func selectUint32s(db *sql.DB, q string, args ...any) ([]uint32, error) {
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []uint32
for rows.Next() {
var v uint32
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// selectStrings 执行返回单列 string 的查询。
func selectStrings(db *sql.DB, q string, args ...any) ([]string, error) {
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// insertMediaCleanup 写入一条「清理S4孤儿图」任务(与 repository.EnqueueMediaCleanup 同格式)。
// payload 为 {"keys":[...]},由 worker 的 processMediaCleanup 按引用计数判定真孤儿后删除。
func insertMediaCleanup(db *sql.DB, now uint32, payload string) error {
_, err := db.Exec(
"INSERT INTO public.ingest_jobs (kind, payload, status, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)",
"media_cleanup", payload, "pending", now, now)
return err
}
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
//
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
// DROP TABLE 之后扩展仍然可用,无需重新 CREATE EXTENSION。
const dropAllTablesSQL = `
DO $$
DECLARE
r record;
BEGIN
FOR r IN
SELECT c.relname FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
LOOP
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.relname);
END LOOP;
END $$;`
// execScript 用 pgx 简单查询协议执行整段脚本。
//
// 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。
@ -271,19 +488,175 @@ func execScript(ctx context.Context, db *sql.DB, script string) error {
})
}
// writeClean 输出清理段:DROP 掉本次要重建的表,让脚本可以灌进「已有数据的库」。
// writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。
//
// 只 DROP 本次 dump 里出现的表(与 pg_dump --clean 口径一致)——目标库中多出来的表保持不动。
// 用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
// 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。
// 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。
// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
func writeClean(w *bufio.Writer, tables []string) {
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列表,目标库中这些表的既有数据将丢失")
func writeClean(w *bufio.Writer, tables []string, views []viewDef) {
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失")
for i := len(views) - 1; i >= 0; i-- {
fmt.Fprintf(w, "DROP VIEW IF EXISTS %s;\n", qname(views[i].Name))
}
for _, t := range tables {
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
}
fmt.Fprintln(w)
}
// viewDef 单个视图的名字与定义(定义取自 pg_get_viewdef,已去掉前导空白)。
type viewDef struct {
Name string
Def string
}
// listViews 返回 public 下全部视图,按**依赖顺序**排列(被依赖者在前)。
//
// 视图可以依赖基表,也可以依赖另一个视图;重建时必须先建被依赖者,否则 CREATE VIEW 会报
// relation does not exist。顺序由 pg_depend 推出的「视图→视图」依赖做拓扑排序得到。
func listViews(db *sql.DB) ([]viewDef, error) {
rows, err := db.Query(`
SELECT c.relname, pg_get_viewdef(c.oid, true)
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'v'
ORDER BY c.relname`)
if err != nil {
return nil, err
}
defer rows.Close()
defs := map[string]string{}
var names []string
for rows.Next() {
var name, def string
if err := rows.Scan(&name, &def); err != nil {
return nil, err
}
defs[name] = def
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(names) == 0 {
return nil, nil
}
deps, err := listViewDeps(db)
if err != nil {
return nil, err
}
ordered, err := topoSortViews(names, deps)
if err != nil {
return nil, err
}
out := make([]viewDef, 0, len(ordered))
for _, name := range ordered {
// pg_get_viewdef 末尾自带分号,去掉后由 writeCreateView 统一补,避免出现 ";;"。
body := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(defs[name]), ";"))
out = append(out, viewDef{Name: name, Def: body})
}
return out, nil
}
// listViewDeps 返回「视图 → 它直接引用的另一个视图」的依赖边(忽略对基表的引用)。
func listViewDeps(db *sql.DB) (map[string][]string, error) {
rows, err := db.Query(`
SELECT DISTINCT v.relname, d.relname
FROM pg_depend dep
JOIN pg_rewrite rw ON rw.oid = dep.objid
JOIN pg_class v ON v.oid = rw.ev_class
JOIN pg_class d ON d.oid = dep.refobjid
JOIN pg_namespace n ON n.oid = v.relnamespace
WHERE dep.classid = 'pg_rewrite'::regclass
AND dep.refclassid = 'pg_class'::regclass
AND dep.deptype = 'n'
AND v.relkind = 'v' AND d.relkind = 'v'
AND v.oid <> d.oid
AND n.nspname = 'public'`)
if err != nil {
return nil, err
}
defer rows.Close()
deps := map[string][]string{}
for rows.Next() {
var v, d string
if err := rows.Scan(&v, &d); err != nil {
return nil, err
}
deps[v] = append(deps[v], d)
}
return deps, rows.Err()
}
// topoSortViews 对视图做拓扑排序:被依赖者在前。每步用名字排序兜底,保证导出可复现。
func topoSortViews(names []string, deps map[string][]string) ([]string, error) {
known := make(map[string]bool, len(names))
indeg := make(map[string]int, len(names))
for _, n := range names {
known[n] = true
indeg[n] = 0
}
adj := map[string][]string{}
for v, ds := range deps {
if !known[v] {
continue
}
for _, d := range ds {
if !known[d] || d == v {
continue
}
adj[d] = append(adj[d], v) // d 必须先建,建完 d 才轮到 v
indeg[v]++
}
}
ready := make([]string, 0, len(names))
for _, n := range names {
if indeg[n] == 0 {
ready = append(ready, n)
}
}
sort.Strings(ready)
out := make([]string, 0, len(names))
for len(ready) > 0 {
n := ready[0]
ready = ready[1:]
out = append(out, n)
next := append([]string(nil), adj[n]...)
sort.Strings(next)
for _, m := range next {
indeg[m]--
if indeg[m] == 0 {
ready = append(ready, m)
}
}
sort.Strings(ready)
}
if len(out) != len(names) {
return nil, fmt.Errorf("视图依赖存在环,无法排序(已完成 %d/%d)", len(out), len(names))
}
return out, nil
}
// writeCreateView 输出一个视图的 CREATE OR REPLACE VIEW。
// 定义来自 pg_get_viewdef(形如「 SELECT ...」),去掉前导空白后接到 AS 之后即可。
//
// 用 OR REPLACE 而非裸 CREATE:非 -clean 产物里其余语句都幂等(表 IF NOT EXISTS、
// 约束/索引 DROP ... IF EXISTS、索引 IF NOT EXISTS),视图也应对齐,重复灌入不报
// relation already exists。
// 注意 OR REPLACE 不允许改变已有视图的列名 / 列序 / 类型 —— 视图列集被 SELECT * 冻结,
// 给基表加列必须同批重建视图(见 db/migrations/2026-09-22-01 的备注)。
func writeCreateView(w *bufio.Writer, v viewDef) {
fmt.Fprintf(w, "CREATE OR REPLACE VIEW %s AS\n%s;\n\n", qname(v.Name), v.Def)
}
// writeHeader 写文件头与几个会话设置。
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())
@ -479,6 +852,10 @@ func writeConstraints(w *bufio.Writer, db *sql.DB, table string) error {
if err := rows.Scan(&name, &def); err != nil {
return err
}
// 先 DROP CONSTRAINT IF EXISTS 再 ADD,让这一段幂等:
// 目标库若已存在同名约束(例如早先被 AutoMigrate 建过、或导入中断留下的),
// 不先删就会报 relation "<name>" already exists —— 约束底层的索引名在 schema 内唯一。
fmt.Fprintf(w, "ALTER TABLE ONLY %s DROP CONSTRAINT IF EXISTS %s;\n", qname(table), qi(name))
fmt.Fprintf(w, "ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;\n", qname(table), qi(name), def)
}
fmt.Fprintln(w)

363
cmd/diagdup/main.go Normal file
View File

@ -0,0 +1,363 @@
package main
import (
"bytes"
"database/sql"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"math"
"net/http"
"os"
"strings"
"time"
"fashionapi/internal/pkg/phash"
_ "github.com/jackc/pgx/v5/stdlib"
)
const (
dsn = "host=127.0.0.1 port=5432 user=fashion password=fashion_dev_2026 dbname=fashion sslmode=disable"
base = "https://toomstudio.s3.bitiful.net/"
)
type row struct {
ID int64
Image string
Phash sql.NullString
IsDuplicate int16
DupOf string
RunwayID int64
BrandID int64
}
func queryRow(db *sql.DB, like string) *row {
r := &row{}
q := `SELECT id, image, phash, is_duplicate, dup_of, runway_id, brand_id
FROM brand_runway_images WHERE image LIKE $1 ORDER BY id LIMIT 1`
err := db.QueryRow(q, like).Scan(&r.ID, &r.Image, &r.Phash, &r.IsDuplicate, &r.DupOf, &r.RunwayID, &r.BrandID)
if err != nil {
fmt.Printf(" 查询 %s 失败: %v\n", like, err)
return nil
}
return r
}
// vectorToUint64 把 "[0,1,...,1]" 转回 uint64(与 phash.ToVectorBits 互逆)。
func vectorToUint64(s string) uint64 {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
parts := strings.Split(s, ",")
var h uint64
for i, p := range parts {
if i >= 64 {
break
}
if strings.TrimSpace(p) == "1" {
h |= 1 << uint(i)
}
}
return h
}
func hamming(a, b uint64) int {
c := 0
x := a ^ b
for x != 0 {
x &= x - 1
c++
}
return c
}
// fetchImage 下载原图,返回解码后的 image 与原始字节。
func fetchImage(key string) (image.Image, []byte, bool) {
url := base + strings.TrimPrefix(key, "/")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
fmt.Printf(" 下载 %s 失败: %v\n", url, err)
return nil, nil, false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf(" 下载 %s 状态 %d\n", url, resp.StatusCode)
return nil, nil, false
}
data, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf(" 读 %s 失败: %v\n", url, err)
return nil, nil, false
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
fmt.Printf(" 解码 %s 失败: %v\n", url, err)
return nil, nil, false
}
return img, data, true
}
// aHash 平均哈希:缩到 8x8 灰度,与整图均值比大小,得 64-bit。比 dHash 更关注整体明暗分布。
func aHash(img image.Image) uint64 {
const n = 8
gray := make([][]float64, n)
for i := range gray {
gray[i] = make([]float64, n)
}
b := img.Bounds()
var total float64
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
sx := b.Min.X + (x*b.Dx())/n + b.Dx()/(2*n)
sy := b.Min.Y + (y*b.Dy())/n + b.Dy()/(2*n)
r, g, bl, _ := img.At(sx, sy).RGBA()
l := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
gray[y][x] = l
total += l
}
}
mean := total / float64(n*n)
var h uint64
i := 0
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
if gray[y][x] > mean {
h |= 1 << uint(i)
}
i++
}
}
return h
}
// pHash DCT 感知哈希:缩到 32x32 灰度 → 2D DCT → 取左上 8x8 低频系数 → 与中位数比大小得 64-bit。
func pHash(img image.Image) uint64 {
const N = 32
g := [N][N]float64{}
b := img.Bounds()
for cy := 0; cy < N; cy++ {
for cx := 0; cx < N; cx++ {
x0 := b.Min.X + cx*b.Dx()/N
x1 := b.Min.X + (cx+1)*b.Dx()/N
y0 := b.Min.Y + cy*b.Dy()/N
y1 := b.Min.Y + (cy+1)*b.Dy()/N
if x1 <= x0 {
x1 = x0 + 1
}
if y1 <= y0 {
y1 = y0 + 1
}
var sum float64
var n int
for y := y0; y < y1 && y < b.Max.Y; y++ {
for x := x0; x < x1 && x < b.Max.X; x++ {
r, gg, bl, _ := img.At(x, y).RGBA()
sum += 0.299*float64(r>>8) + 0.587*float64(gg>>8) + 0.114*float64(bl>>8)
n++
}
}
if n > 0 {
g[cy][cx] = sum / float64(n)
}
}
}
// 行 DCT-II
var rows [N][N]float64
for y := 0; y < N; y++ {
for u := 0; u < N; u++ {
var s float64
for x := 0; x < N; x++ {
s += g[y][x] * math.Cos(math.Pi*float64(u)*(float64(x)+0.5)/float64(N))
}
rows[y][u] = s
}
}
// 列 DCT-II
var dct [N][N]float64
for x := 0; x < N; x++ {
for v := 0; v < N; v++ {
var s float64
for y := 0; y < N; y++ {
s += rows[y][x] * math.Cos(math.Pi*float64(v)*(float64(y)+0.5)/float64(N))
}
dct[v][x] = s
}
}
// 取左上 8x8 低频,与中位数比大小
var coeffs [64]float64
i := 0
for v := 0; v < 8; v++ {
for u := 0; u < 8; u++ {
coeffs[i] = dct[v][u]
i++
}
}
sorted := append([]float64{}, coeffs[:]...)
for a := 0; a < len(sorted); a++ {
for c := a + 1; c < len(sorted); c++ {
if sorted[c] < sorted[a] {
sorted[a], sorted[c] = sorted[c], sorted[a]
}
}
}
median := sorted[len(sorted)/2]
var h uint64
for k := 0; k < 64; k++ {
if coeffs[k] > median {
h |= 1 << uint(k)
}
}
return h
}
// luminanceStats 算亮度均值/标准差,并用 phash.Of 复算指纹。
func luminanceStats(img image.Image, data []byte) (mean, std float64, recomputed uint64, ok bool) {
recomputed = phash.Of(data)
b := img.Bounds()
var sum, sum2 float64
var n int
step := 1
if b.Dx() > 300 || b.Dy() > 300 {
step = int(math.Max(1, float64(b.Dx())/300))
}
for y := b.Min.Y; y < b.Max.Y; y += step {
for x := b.Min.X; x < b.Max.X; x += step {
r, g, bl, _ := img.At(x, y).RGBA()
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
sum += lum
sum2 += lum * lum
n++
}
}
if n == 0 {
return
}
mean = sum / float64(n)
variance := sum2/float64(n) - mean*mean
if variance < 0 {
variance = 0
}
std = math.Sqrt(variance)
ok = true
return
}
func dumpBits(h uint64) string {
var sb strings.Builder
for i := 0; i < 64; i++ {
if (h>>uint(i))&1 == 1 {
sb.WriteByte('1')
} else {
sb.WriteByte('0')
}
}
return sb.String()
}
func main() {
db, err := sql.Open("pgx", dsn)
if err != nil {
fmt.Println("open db:", err)
os.Exit(1)
}
defer db.Close()
k1 := "6837152087e6b9849368860811920d98d6b986dc"
k2 := "53f47b14a8799e682864fb7ffc1f942caaeeb56b"
if len(os.Args) >= 3 {
k1, k2 = os.Args[1], os.Args[2]
}
r1 := queryRow(db, "%"+k1+"%")
r2 := queryRow(db, "%"+k2+"%")
if r1 == nil || r2 == nil {
fmt.Println("未能取到两行,退出")
return
}
fmt.Println("==== 行 1 ====")
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
r1.ID, r1.Image, r1.IsDuplicate, r1.DupOf, r1.RunwayID, r1.BrandID)
fmt.Println("==== 行 2 ====")
fmt.Printf("id=%d image=%s is_duplicate=%d dup_of=%s runway_id=%d brand_id=%d\n",
r2.ID, r2.Image, r2.IsDuplicate, r2.DupOf, r2.RunwayID, r2.BrandID)
var h1, h2 uint64
if r1.Phash.Valid {
h1 = vectorToUint64(r1.Phash.String)
fmt.Printf("phash1=%s\nbits1=%s\n", r1.Phash.String, dumpBits(h1))
} else {
fmt.Println("phash1=NULL")
}
if r2.Phash.Valid {
h2 = vectorToUint64(r2.Phash.String)
fmt.Printf("phash2=%s\nbits2=%s\n", r2.Phash.String, dumpBits(h2))
} else {
fmt.Println("phash2=NULL")
}
if r1.Phash.Valid && r2.Phash.Valid {
ham := hamming(h1, h2)
fmt.Printf("\n>>> 两张图之间的真实汉明距离 = %d (系统阈值 DefaultThreshold=4)\n", ham)
if ham <= 4 {
fmt.Println(">>> 系统会把这两者判为近重复(≤4)。")
} else {
fmt.Println(">>> 这两张彼此并不在 ≤4 内;若被标重,必是各自 dup_of 指向了不同的第三者。")
}
}
// 亮度标准差 + 指纹复算(验证“平淡图哈希塌缩”假说)+ aHash 区分度
fmt.Println("\n==== 像素核验(原图,无 style)====")
var imgs [2]image.Image
var ahs [2]uint64
var phs [2]uint64
for i, r := range []*row{r1, r2} {
img, data, ok := fetchImage(r.Image)
if !ok {
continue
}
imgs[i] = img
ahs[i] = aHash(img)
phs[i] = pHash(img)
mean, std, recomputed, ok2 := luminanceStats(img, data)
if !ok2 {
continue
}
fmt.Printf("image=%s\n 亮度均值=%.1f 标准差=%.1f (标准差越低越“平”,哈希越易塌缩)\n",
r.Image, mean, std)
stored := vectorToUint64(r.Phash.String)
fmt.Printf(" 复算phash与库存phash一致=%v 复算bits=%s\n", recomputed == stored, dumpBits(recomputed))
fmt.Printf(" aHash bits=%s\n", dumpBits(ahs[i]))
fmt.Printf(" pHash bits=%s\n", dumpBits(phs[i]))
}
if imgs[0] != nil && imgs[1] != nil {
dh := 0
if r1.Phash.Valid && r2.Phash.Valid {
dh = hamming(vectorToUint64(r1.Phash.String), vectorToUint64(r2.Phash.String))
}
fmt.Printf("\n>>> dHash 汉明距离 = %d(库存判定,≤4 判重)\n", dh)
fmt.Printf(">>> aHash 汉明距离 = %d\n", hamming(ahs[0], ahs[1]))
fmt.Printf(">>> pHash 汉明距离 = %d (pHash 常规判重阈值约 10~15,远超 dHash 的 4)\n", hamming(phs[0], phs[1]))
}
// 若某行 dup_of 指向第三者,把它也拉出来看
for _, r := range []*row{r1, r2} {
if r.DupOf != "" && r.DupOf != "0" {
tgt := &row{}
err := db.QueryRow(`SELECT id, image, phash, runway_id, brand_id FROM brand_runway_images WHERE id=$1`, r.DupOf).
Scan(&tgt.ID, &tgt.Image, &tgt.Phash, &tgt.RunwayID, &tgt.BrandID)
if err == nil {
fmt.Printf("\n>>> 行 %d 的 dup_of=%s 指向:\n id=%d image=%s runway_id=%d brand_id=%d\n",
r.ID, r.DupOf, tgt.ID, tgt.Image, tgt.RunwayID, tgt.BrandID)
if tgt.Phash.Valid {
th := vectorToUint64(tgt.Phash.String)
fmt.Printf(" 与该目标汉明距离=%d\n", hamming(vectorToUint64(r.Phash.String), th))
}
}
}
}
}

View File

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

View File

@ -63,6 +63,7 @@ cors:
- X-Sign
- X-Sign-Ts
- X-Sign-Nonce
- X-Locale
max_age: 86400 # 预检请求缓存秒数
# 公开接口「前端 JS 签名」:给 /runway-looks(列表)与 /brands 两个批量接口加请求签名校验,
@ -84,6 +85,11 @@ client_sign:
ingest:
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
# 本机下载代理(仅本地开发用,不提交):worker 下载图片(如 assets.vogue.com)经此代理访问源站。
# 支持 http:// 或 socks5://,例如 socks5://127.0.0.1:51831 或 http://127.0.0.1:58591。
# 留空直连;环境变量 INGEST_DOWNLOAD_PROXY 可覆盖本值(二者均只作用于「下载」,不影响 S4 上传与数据库)。
# 实际值建议写 configs/config.local.yml(已被 .gitignore 忽略),避免进入共享配置。
download_proxy: "" # env: INGEST_DOWNLOAD_PROXY
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)

View File

@ -0,0 +1,21 @@
-- 2026-09-21-01 街拍主副图:草稿表 / 正式表的图片表各加两列。
--
-- 对应规格:docs/superpowers/specs/2026-09-21-street-main-detail-design.md §3.1
-- is_detail 0=主图 1=副图
-- parent_image_id 副图指向所属主图的「行 id」;主图为 0
-- (存行 id 而非序号:序号会因重排 / 插入 / 删除而失效)
--
-- 幂等:全部 ADD COLUMN IF NOT EXISTS,可重复执行。
-- 存量行为 0/0,即「全部按主图渲染」,无需数据迁移。
--
-- 应用后请重新导出结构,否则目标库拿不到新列:
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
--
-- 说明:规格未给 parent_image_id 建索引。图片表当前规模为千行级(street_snap_draft_images
-- 1052 行),按 (draft_id) 过滤走顺序扫描即可;若日后量级变大需要,再按读侧实际查询补索引。
ALTER TABLE street_snap_draft_images ADD COLUMN IF NOT EXISTS is_detail smallint NOT NULL DEFAULT 0;
ALTER TABLE street_snap_draft_images ADD COLUMN IF NOT EXISTS parent_image_id integer NOT NULL DEFAULT 0;
ALTER TABLE street_snap_images ADD COLUMN IF NOT EXISTS is_detail smallint NOT NULL DEFAULT 0;
ALTER TABLE street_snap_images ADD COLUMN IF NOT EXISTS parent_image_id integer NOT NULL DEFAULT 0;

View File

@ -0,0 +1,49 @@
-- 2026-09-21-02 重复检测与审核展示:新建 image_duplicates 表 + 街拍正式表按文章做键。
--
-- 对应规格:docs/superpowers/specs/2026-09-21-duplicate-review-design.md §3.1 / §3.7
--
-- 幂等:CREATE TABLE / CREATE INDEX 全带 IF NOT EXISTS,ADD COLUMN 带 IF NOT EXISTS,
-- 可重复执行。
--
-- 应用后请重新导出结构:
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
-- ---------------------------------------------------------------------------
-- ① 重复图对表(读时预计算并落库,替代不可反查的 is_duplicate / dup_of 两列)
-- ---------------------------------------------------------------------------
-- kind / owner_kind 等字符串列控制长度而不做 CHECK 约束:与项目既有表风格一致
-- (brand_runway_drafts.status 等也是裸 varchar),避免新增约束带来的迁移负担。
CREATE TABLE IF NOT EXISTS image_duplicates (
id BIGSERIAL PRIMARY KEY,
kind varchar(16) NOT NULL, -- runway | street
-- 本方(「这张图」)
image_id integer NOT NULL, -- 图片行 id
owner_kind varchar(8) NOT NULL, -- draft | official
owner_id integer NOT NULL, -- 草稿 id 或正式表主键
-- 对手(「和它重复的那张」)
peer_image_id integer NOT NULL,
peer_owner_kind varchar(8) NOT NULL,
peer_owner_id integer NOT NULL,
created_at integer NOT NULL DEFAULT 0
);
-- 唯一索引:保证重复配对计算幂等(写入侧用 ON CONFLICT ... DO NOTHING)
CREATE UNIQUE INDEX IF NOT EXISTS uq_image_dups_pair
ON image_duplicates (kind, owner_kind, image_id, peer_owner_kind, peer_image_id);
-- 辅助索引:读侧按草稿 / 专辑批量取数(审核详情页)
CREATE INDEX IF NOT EXISTS idx_image_dups_owner
ON image_duplicates (kind, owner_kind, owner_id);
-- ---------------------------------------------------------------------------
-- ② 街拍正式表:新增 source / source_url,供「一篇文章一张专辑」的实体键使用
-- ---------------------------------------------------------------------------
-- 用 not null default '' 而不是可空:NULL 在唯一索引里不参与比较,
-- 会造成「同键可重复插入」的漏洞。
ALTER TABLE street_snaps ADD COLUMN IF NOT EXISTS source varchar(32) NOT NULL DEFAULT '';
ALTER TABLE street_snaps ADD COLUMN IF NOT EXISTS source_url varchar(512) NOT NULL DEFAULT '';
-- 必须是**部分**索引:存量行 source_url 为空串,全量唯一索引会因大量 ('','') 冲突
-- 而建索引失败。
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source
ON street_snaps (source, source_url) WHERE source_url <> '';

View File

@ -0,0 +1,27 @@
-- 2026-09-21-03 来源幂等:两张草稿表新增 source / source_url,并建部分唯一索引。
--
-- 对应规格:docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md §3.1–3.2
--
-- 幂等:ADD COLUMN / CREATE INDEX 全带 IF NOT EXISTS,可重复执行。
--
-- 应用后请重新导出结构:
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
--
-- 注意:同规格 §3.6(month 列与「实体键并入 month」)**已作废**——
-- 由 docs/superpowers/specs/2026-09-21-duplicate-review-design.md §3.7 取代
-- (街拍实体键改为 (source, source_url),不再按月区分)。故本脚本**不建 month 列**。
-- 用 not null default '' 而不是可空:NULL 在唯一索引里不参与比较,
-- 会造成「同键可重复插入」的漏洞。
ALTER TABLE brand_runway_drafts ADD COLUMN IF NOT EXISTS source varchar(32) NOT NULL DEFAULT '';
ALTER TABLE brand_runway_drafts ADD COLUMN IF NOT EXISTS source_url varchar(512) NOT NULL DEFAULT '';
ALTER TABLE street_snap_drafts ADD COLUMN IF NOT EXISTS source varchar(32) NOT NULL DEFAULT '';
ALTER TABLE street_snap_drafts ADD COLUMN IF NOT EXISTS source_url varchar(512) NOT NULL DEFAULT '';
-- 两个部分唯一索引(全量唯一索引会因存量大量 ('','') 冲突而建索引失败)。
-- 向后兼容:老爬虫不上送 source_url 时该列为空串,自动被索引豁免 —— 行为与改动前一致。
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source
ON brand_runway_drafts (source, source_url) WHERE source_url <> '';
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_draft_source
ON street_snap_drafts (source, source_url) WHERE source_url <> '';

View File

@ -0,0 +1,67 @@
-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。
--
-- 本文件只做两件事:加列 / 建视图。幂等,可重复执行。
-- 存量行置已发布不在本文件里(它是只能执行一次的数据变更,见 2026-09-22-01b-publish-existing-rows.sql)。
-- 数据搬迁由一次性脚本 scripts/migrate_single_table/main.go 完成。该脚本已随本次改造从工作树删除
--(它 import 已删除的草稿模型,无法在改造后的代码树上编译);取回方式与执行顺序见本目录 README.md。
-- 删草稿表见 2026-09-22-05-drop-draft-tables.sql。
-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed):
-- 任何漏赋值的行默认不可见,而不是意外对外发布。
ALTER TABLE brand_runways
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
ALTER TABLE street_snaps
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '',
-- 街拍标题中文化:保留既有 title 作英文源,仅加 title_cn 供后台人工翻译、前端按 locale 取用。
-- 与 brand_runways 用 title_en/title_cn 不同:street_snaps 上有 uq_ss_entity(city, year, COALESCE(title,'')),
-- 改名成本过高;pickLocale 在中文为空时回落英文,无需数据回灌。
ADD COLUMN IF NOT EXISTS title_cn text NOT NULL DEFAULT '';
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
-- 2026-09-22-01b-publish-existing-rows.sql。
-- 为什么必须拆开:数据搬迁(scripts/migrate_single_table/main.go)会把 pending 草稿连原 created_at
-- 一起搬进正式表;若本文件含那条 UPDATE 且被重复执行(测试每次都会跑),
-- 这些待审内容会被误刷成已发布 —— 恰好是本设计要防的泄漏。
-- 3) 状态索引:审核列表与公开视图都按 status 过滤。
CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status);
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
-- 视图列集显式枚举(非 SELECT *):只暴露对外需要的列,刻意排除审核/溯源列
-- (job_id / reviewer / reject_reason),避免敏感列随基表加列被无意暴露到公开读路径。
-- 图片子视图仍取 i.*(图片表无敏感列);改动基表列集时按本文件同批重建视图。
DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps;
CREATE VIEW public_brand_runways AS
SELECT
id, brand_id, title_en, title_cn, description_en, description_cn,
cover, image_count, year, collection_type, season, season_code,
status, is_deleted, created_at, updated_at
FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i
JOIN brand_runways r ON r.id = i.runway_id
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
CREATE VIEW public_street_snaps AS
SELECT
id, title, title_cn, year, city, cover, image_count,
status, is_deleted, created_at, updated_at
FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_street_snap_images AS
SELECT i.* FROM street_snap_images i
JOIN street_snaps s ON s.id = i.snap_id
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;

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,50 @@
-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。
--
-- 为什么必须有:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈)。
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
--
-- 部分索引(WHERE is_deleted = 0):仅约束活着行,保证「同一实体至多一行活着」。
-- 注:入库服务层 RunwayEntityState / StreetSnapEntityState 现已**不再**以 is_deleted = 0 过滤,
-- 软删实体也会被查到并由 worker 跳过重爬(「删除即永久」)。本索引仍保持部分索引是为了
-- 兼容存量「软删行同键共存」的数据、避免建全量唯一索引时因历史重复而失败;结构性唯一性靠它兜底,
-- 行为上的「删除即永久」由服务层查重保证。
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
DO $$
DECLARE dup int;
BEGIN
SELECT count(*) INTO dup FROM (
SELECT brand_id, season_code, collection_type
FROM brand_runways WHERE is_deleted = 0
GROUP BY 1, 2, 3 HAVING count(*) > 1
) t;
IF dup > 0 THEN
RAISE EXCEPTION 'brand_runways 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
END IF;
-- 口径已随任务 3c 的实体键细化同步为 (city, year, title):否则本文件在「同城同年多专题」
-- 的合法数据上不再幂等(重复执行会误报重复而中止)。
SELECT count(*) INTO dup FROM (
SELECT city, year, COALESCE(title, '')
FROM street_snaps WHERE is_deleted = 0
GROUP BY 1, 2, 3 HAVING count(*) > 1
) t;
IF dup > 0 THEN
RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_entity
ON brand_runways (brand_id, season_code, collection_type) WHERE is_deleted = 0;
-- 索引表达式必须与上面的体检、以及 StreetSnapEntityState 的查重口径**逐字一致**:
-- (city, year, COALESCE(title, ''))。早期版本这里误建成 (city, year) —— 比体检更窄,
-- 于是体检放行「同城同年不同专题」的合法数据后,建索引才抛原生 23505
-- (could not create unique index ... duplicate key)。COALESCE(title, '') 的取舍见 2026-09-22-04。
--
-- ⚠️ CREATE UNIQUE INDEX IF NOT EXISTS 只检查**索引名**是否存在:若库里已有一个同名的
-- 旧 (city, year) 索引,本句会静默跳过、留下错误结构(这正是它一度掩盖问题的原因)。
-- 纠正这类存量库由 2026-09-22-04 承担;全新库 / 重复执行由本文件承载最终表达式。
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0;

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;

View File

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

View File

@ -0,0 +1,7 @@
-- 移除「驳回理由」:审核流程不再要求/存储理由,删除 reject_reason 列。
-- 视图 public_brand_runways / public_street_snaps 本就显式排除该列,无需改动。
ALTER TABLE brand_runways
DROP COLUMN IF EXISTS reject_reason;
ALTER TABLE street_snaps
DROP COLUMN IF EXISTS reject_reason;

View File

@ -0,0 +1,12 @@
-- 新增走秀发布日期列 published_at:从 Vogue 详情页 application/ld+json 的 datePublished 抽取,
-- 以 Unix 时间戳(秒,bigint)存储,0 表示未抽取/未知,与 created_at / updated_at 的约定一致。
--
-- 用途:
-- 1) 作为走秀的权威发布时间存下来(替代 created_at/updated_at 这类入库时间)。
-- 2) 标题缺年份(year=0、season_code 为空)的秀,用发布日期的年份补全 year / season_code,
-- 使这些秀重新可被「按年份/季节筛选」与热门排序(按 year + season_code)命中。
--
-- 回填为一次性操作:重跑 spider vogue 时,已存在实体走跳过分支按主键 id 写回 published_at
-- (及缺失年份),不重下图片。
ALTER TABLE brand_runways
ADD COLUMN IF NOT EXISTS published_at bigint NOT NULL DEFAULT 0;

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

17172
db_dump.sql Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -118,9 +118,14 @@ source varchar(32) not null default ''
source_url varchar(512) not null default ''
```
对应模型:`internal/model/runway_draft.go` 的 `BrandRunwayDraft`、`internal/model/street_snap_draft.go` 的 `StreetSnapDraft`。列由 `AutoMigrate` 托管,**模型上不加 `uniqueIndex` tag**(GORM tag 无法表达部分索引条件)。
对应模型:`internal/model/runway_draft.go` 的 `BrandRunwayDraft`、`internal/model/street_snap_draft.go` 的 `StreetSnapDraft`。**模型上不加 `uniqueIndex` tag**(GORM tag 无法表达部分索引条件)。
索引写在 `internal/database/postgres.go` 的 `EnsureDedupSchema` 中——该函数本就是「`AutoMigrate` 之外的索引补充」的既定位置,且全部 `IF NOT EXISTS` 幂等:
> **落点已变更(2026-09-21)**:服务启动时的自动迁移(`AutoMigrate` / `EnsureDedupSchema`)已移除,
> 全库结构改由 `cmd/dbtool` 的 dump 维护。加列 / 加索引现在走 `db/migrations/` 下的一次性脚本:
> 对开发库执行 → 再 `dbtool dump -clean` 重新导出。本规格的列与索引已落在
> `db/migrations/2026-09-21-03-ingest-source-idempotency.sql`(已应用)。
索引定义(部分唯一索引——全量唯一索引会因存量大量 `('','')` 冲突而建索引失败):
```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source

View File

@ -117,7 +117,9 @@ CREATE INDEX IF NOT EXISTS idx_image_dups_owner
**不冗余存对手的标题**:读时按 `peer_owner_kind` join 对应表取(草稿 → `*_drafts.title_en` / `.title`;正式 → `brand_runways.title_en` / `street_snaps.title`)。避免标题被改后出现陈旧副本。
建表放在 `internal/database/postgres.go` 的 `EnsureDedupSchema`(该函数本就是「`AutoMigrate` 之外的索引与表补充」的既定位置,且全部幂等)。
> **落点已变更(2026-09-21)**:`AutoMigrate` / `EnsureDedupSchema` 已移除,结构改由 `cmd/dbtool` 的 dump 维护。
> 本节的表与索引已落在 `db/migrations/2026-09-21-02-duplicate-review.sql`(已应用)。
> 今后加表 / 加索引一律走 `db/migrations/` 的一次性脚本 → 对开发库执行 → `dbtool dump -clean` 重新导出。
### 3.2 写入时机与算法
@ -226,7 +228,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source
```
internal/model/image_duplicate.go 新增(模型)
internal/database/postgres.go EnsureDedupSchema 建表与索引
db/migrations/2026-09-21-02-duplicate-review.sql 建表与索引(已应用)
internal/repository/dup_repository.go 新增:写入 + 读取 + 回填
internal/repository/ingest_repository.go StreetSnapIDByEntity 改键
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 改键

View File

@ -54,31 +54,40 @@ parent_image_id integer not null default 0 -- 副图指向所属主图的「
**不变量**:`is_detail = 1` 的行,其 `parent_image_id` 必须指向**同一草稿 / 同一专辑内**一张 `is_detail = 0` 的行。
> **落点已变更(2026-09-21)**:`AutoMigrate` / `EnsureDedupSchema` 已移除,结构改由 `cmd/dbtool` 的 dump 维护。
> 上述两列已落在 `db/migrations/2026-09-21-01-street-main-detail.sql`(已应用)。
- **写入侧**保证该不变量(并入操作先校验主图存在且属于同一 owner)。
- **读侧**容错:父行不存在(或已被软删)时,把该副图**按主图渲染**,不隐藏、不报错。
### 3.2 审核页交互(零 JS)
**当前主图**:由 URL 查询参数 `?main=<imgID>` 承载。页面顶部渲染一条常驻提示条「当前主图:#12 · 伦敦 Day 3 第 12 张」,直接在页面上可见,刷新后仍在。
**三个同步表单 POST 路由**(全部返回 302 回列表/详情,与后台现有风格一致):
**两个同步表单 POST 路由**(全部返回 302 回详情页,与后台现有风格一致):
| 路由 | 作用 |
| --- | --- |
| `POST /admin/reviews/:kind/:id/images/attach` | 表单含 `main=<主图行id>` + 多个 `img=<副图行id>`(checkbox)→ **批量并入**。这是主路径 |
| `POST /admin/reviews/:kind/:id/images/:img/attach-prev` | **并入上一张**(相邻快捷,零勾选)。覆盖「连拍同一个人」这一最常见场景 |
| `POST /admin/reviews/:kind/:id/images/attach` | 表单含单选 `main=<主图行id>` + 多个勾选 `img=<副图行id>` → **合并选中为一组** |
| `POST /admin/reviews/:kind/:id/images/:img/detach` | **拆出**:把副图恢复为主图(`is_detail=0, parent_image_id=0`) |
**不单设「设为主图」路由**:选定主图 = 在页面上点击某张图的「设为主图」链接,它只是把 URL 变成 `?main=<imgID>`(一个普通 GET 链接,无需服务端写入)。随后其他图的 checkbox 表单通过**隐藏字段**携带该 `main` 值。
**审核页布局**:街拍草稿详情页渲染为**一个统一图片网格**(与列表页同款 `.grid` / `.cell` 卡片样式,不再有"分组大卡片 + 小网格"两套风格):
这样「选定主图 → 滚到任意位置勾选 → 并入」全程**不需要同屏**,且天然支持批量。
- 每张图片一张卡片:缩略图 + 角色徽标 + 文件名。
- **主图**卡片:徽标「主图 · N 副图」+ 勾选框「选入」+ 单选「主图」。
- **副图**卡片:徽标「副图 → #主图」(显示沿链归并后的**实际**所属主图,非原始父 id)+ 勾选框「选入」+ 按钮「拆出」。
- 每张卡片都带「删除」。
- 底部常驻(sticky)按钮条:`合并选中为一组`。
**操作只有一件事**:勾选属于同一个人的图片 → 在**其中一张主图**上点「主图」(单选)→ 点底部「合并选中为一组」(`main`=单选值,`img`=全部勾选值)。一次请求成组,无需刷新页面选主图。
> **历史(已废弃)**:早期版本用 URL 参数 `?main=` 承载"当前主图",并有一个按 `sort_order` 找"上一行"的 `attach-prev` 快捷。实践中发现**同一人的图往往不相邻**,该快捷用不上;且"先刷新选主图 → 再勾选 → 再提交"步骤过多。已改为本节的**单选主图 + 一次合并**,并删除 `attach-prev` 路由、`AttachPrevStreetDraftImage`、`?main=` 参数与 `DraftDetailView.MainID`。
### 3.3 详情页渲染(按组折叠)
- 主图区:逐个渲染主图卡片(大图 + 名称)
- 每个主图卡片下方:一条**窄的副图缩略条**,副图带「拆出」按钮
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**)
- 主图卡片上显示「N 张副图」计数
**审核页**:见 §3.2——统一图片网格;主图卡片徽标显示「N 副图」,副图卡片显示所属主图。
**公开详情页**:`Images` 只含主图,副图挂在每张主图的 `Detail` 子数组下(与走秀详情同形,见 §3.5)。
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**)。
### 3.4 晋升到正式表:必须重建父引用
@ -88,31 +97,29 @@ parent_image_id integer not null default 0 -- 副图指向所属主图的「
漏掉这一步会导致:副图的父引用指向一个不存在(或属于别的图)的 id,详情页折叠结构错乱。
### 3.5 公开 API(保守扩展)
### 3.5 公开 API(与走秀详情同形)
**保留** `PublicStreetSnapDetail.Images`(扁平、按 `sort_order` 升序)不变——现有前端不受影响。
**新增**分组字段:
**决策(2026-09-21 修订)**:采用与走秀详情**完全一致**的嵌套结构(`images` 只含主图 + 每张主图自带 `detail`),**不采用**「保留扁平 `images` + 新增平行 `groups`」的保守方案。
```go
type PublicStreetSnapGroup struct {
Image PublicArticleImage `json:"image"` // 主图
Details []PublicArticleImage `json:"details"` // 该主图下的副图(可为空)
}
type PublicStreetSnapDetail struct {
UID string
Title string
Cover string
Images []PublicArticleImage // 保持原样(扁平全部)
Groups []PublicStreetSnapGroup // 新增;未分组时为每张主图一个 group、details 为空
UID string
Title string
Cover string // 封面:独立字段,与主/副图分组无关
Images []PublicArticleImage // 只含主图(is_detail=0);每张主图的 .Detail 挂其副图
Favorited bool
}
// 复用走秀的 PublicArticleImage:
// IsDetail 0=主图 / 1=副图
// LookIndex 该图归属的主图序号(街拍侧合成:主图按 sort_order 的 1-based 序;街拍不落库组序号)
// Detail []PublicArticleImage —— 该主图下的副图
```
`Groups` 与 `Images` **同源同序**:`Groups` 只是 `Images` 按主副关系重排后的视图,不引入新的数据来源。
- 与走秀详情(`PublicArticleService.Detail`)同形,前端可复用同一套渲染组件。
- `parent_image_id` 指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环 → 该图按主图渲染(**不丢图**)。这是与走秀读侧的刻意区别:走秀会丢弃「找不到主图的细节图」,街拍不丢。
- **列表**(`PublicStreetSnap`)不动;`image_count` 语义不变(仍计全部图)。
**列表**(`PublicStreetSnap`)不动。
> ⚠️ **破坏性变更**:`Images` 含义从「全部图(扁平)」变为「只含主图」。前端若原样遍历 `Images` 只会看到主图,需改读每张主图的 `.Detail`(跨仓库,本次只保证后端结构)。
### 3.6 明确不做
@ -130,10 +137,10 @@ internal/model/street_snap.go +is_detail / +parent_image_id
internal/repository/review_repository.go attach / attach-prev / detach 三个写方法
internal/repository/review_repository.go SaveStreetSnapFromDraft 重建父引用
internal/service/review_service.go DraftImageRef 加分组信息;分组写入的校验
internal/handler/backstage_handler.go 详情页折叠渲染 + 常驻主图条 + 三个表单
internal/router/backstage.go 三个新路由
internal/dto/street_snap.go +Groups(保守扩展)
internal/service/street_snap_service.go 详情组装 Groups
internal/handler/backstage_handler.go 审核页「图片流」渲染 + 常驻主图条 + 三个表单
internal/router/backstage.go 两个新路由(attach 合并 / detach 拆出)
internal/dto/street_snap.go 详情改为嵌套(images 只含主图,复用 PublicArticleImage)
internal/service/street_snap_service.go 详情组装嵌套 images(buildSnapImages)
```
---
@ -156,6 +163,6 @@ internal/service/street_snap_service.go 详情组装 Groups
## 6. 待确认
1. 公开 API 采用「保留扁平 `images` + 新增 `groups`」的保守方案。若你希望**直接改成**只返回分组结构(更干净但会打断现有前端),需要另行确认。
1. ~~公开 API 保守方案~~ **已决(2026-09-21)**:改为与走秀同形的嵌套结构(`images` 只含主图、副图挂 `detail`),见 §3.5。
2. `image_count` 暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。
3. 分组的「组序号」不落库,由读时按 `sort_order` 顺序推导(主图顺序即组序)。

View File

@ -0,0 +1,260 @@
# 单表发布模型(取消草稿表)设计
- 日期:2026-09-22
- 替代关系:本设计**取代** `2026-09-20-ingest-idempotency-design.md` 中"草稿表持有 source/source_url 做来源幂等"的部分(该能力从未落到代码,列与索引为空转);并**吸收** `2026-09-21-street-main-detail-design.md` 的主副图能力(从草稿图片迁移到正式图片表,父引用不再需要重映射)。
---
## 1. 背景与问题
现状是「草稿表 → 人工审核 → 晋升写入正式表」的两套表结构:
| 模块 | 草稿 | 正式 |
|---|---|---|
| 走秀 / article | `brand_runway_drafts` + `brand_runway_draft_images` | `brand_runways` + `brand_runway_images` |
| 街拍 | `street_snap_drafts` + `street_snap_draft_images` | `street_snaps` + `street_snap_images` |
由此产生的复杂度:
1. **结构重复**:内容字段(标题/描述/年份/季节/cover/image_count)在两张表各写一份;正式图片表与草稿图片表字段几乎相同(`is_detail`/`look_index`/`phash`/`is_duplicate`/`dup_of` 是双份)。
2. **晋升即重建**:`SaveRunwayFromDraft` / `SaveStreetSnapFromDraft` 会软删正式图片再整批重建,**图片行 id 每次都变** —— 外层业已存在"重复发布打断单图收藏"的隐患(`favorites.target_uid` 存的是图片的 hashid)。
3. **父引用重映射**:街拍副图的 `parent_image_id` 存的是草稿表行 id,晋升时要两遍插入并改写为新正式行 id,父行缺失还要降级。
4. **多来源聚合**:`sibling` 图片聚合(按实体键把多个已通过草稿的图并成一条正式记录)只在晋升时发生,逻辑绕。
5. **死物**:`brand_runway_drafts.source/source_url`、`street_snaps.source/source_url`、`image_duplicates`、`image_embeddings` 全仓无代码读写。
核心判断:**草稿表承担的两件事(审核态、来源追溯)都只是一个字段,不值得一整张表。**
## 2. 目标 / 非目标
### 目标
- 每个模块**只剩一张记录表 + 一张图片表**。
- 审核态由 `status` 字段承载,**审核阶段保留**(不是取消审核,是取消"另一张表")。
- 公开可见性从"每个公开查询各自记得过滤"升级为**结构性保证**(只读视图)。
- 图片行 id 从入库起**终身不变**(收藏不再被打断)。
- 删除晋升/聚合/父引用重映射的全部代码。
### 非目标(本次不做)
- 不改公开 API 的响应结构(`images` / `groups` / 列表字段全部保持)。
- 不改前台网站(独立前端仓库)。
- 不合并"审核页"与"正式编辑页"两个后台入口(后续可再收敛)。
- 不统一 `image_count` 口径(runway 计主图 / street 计全部图,现状保留,见 §12)。
- 不修 `favorites` 的唯一索引问题(见 §12)。
## 3. 数据模型
### 3.1 保留 / 删除的表
保留:`brand_runways`、`brand_runway_images`、`street_snaps`、`street_snap_images`、`brands`(品牌本就没有草稿表,不参与本次改造)。
删除:`brand_runway_drafts`、`brand_runway_draft_images`、`street_snap_drafts`、`street_snap_draft_images`。
### 3.2 新增列(从草稿表搬到正式表)
```sql
ALTER TABLE brand_runways
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
ALTER TABLE street_snaps
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
```
**默认值刻意用 `pending`(fail-closed)**:任何新增/遗漏赋值的行都会默认"不可见",而不是默认"已发布"。
图片表无需改动:
- runway 的细节图归属用 `look_index`;
- 街拍的副图归属用 `is_detail` + `parent_image_id`,单表之后它天然指向**本表**行 id,**永远有效,不需要任何重映射**。
### 3.3 状态取值
```go
// internal/model/content_status.go(新增)
const (
StatusPending = "pending" // 待审核(入库默认;公开不可见)
StatusPublished = "published" // 已发布(公开可见)
StatusRejected = "rejected" // 已驳回(公开不可见,留在库中备查)
)
```
审核列表的「已通过」tab 取值由 `approved` 改为 `published`(模板 `review-list.html` 的 tab 链接同步改)。
## 4. 状态机
```
ingest 入库
│
▼
┌─────────┐ 通过(写 reviewer,应用字段编辑) ┌───────────┐
│ pending │ ────────────────────────────────► │ published │
└─────────┘ └───────────┘
│ │
│ 驳回(写 reviewer + reject_reason) │ 驳回(下架)
▼ │
┌──────────┐ ◄──────────────────────────────────────┘
│ rejected │
└──────────┘
```
- **通过**:`UPDATE ... SET status='published', reviewer=?` + 应用表单里的字段编辑。**不再触碰图片行**。
- **驳回**:`UPDATE ... SET status='rejected', reviewer=?, reject_reason=?`。行保留(备查)。
- **驳回后重爬**:`rejected → pending`(复用同一行,见 §5 第 1 条第 2 支)。驳回不是永久黑名单。
- 每条记录**只有一个实体键行**(runway: `brand_id+season_code+collection_type`;street: `city+year`),不存在"同实体的多条 pending 并存"。
## 5. 写入链路(ingest 直写正式表)
`internal/service/ingest_service.go` 的 `processRunway` / `processStreet`:
1. **实体键查重**(`RunwayIDByEntity` / `StreetSnapIDByEntity`,`WHERE 实体键 AND is_deleted=0`)。单表之后该条件会命中任意状态的行,因此必须**按命中行的状态分三支**:
- 命中 `pending` / `published` → 整任务 `MarkDone`,**直接放弃**(用户裁定)。语义:该实体已收录或正在审核,不重复入库。
- 命中 `rejected` → **复用该行**:覆盖内容字段(标题/描述/年份/季节/collection_type/season_code/cover)、软删其旧图片、写入本次抓取的图片、置回 `status='pending'` 并清空 `reviewer`/`reject_reason`。语义:驳回不是永久黑名单,重爬即重新送审。
> ⚠️ 这一支是**必需的修正**:查重条件不带 status,若不特判 `rejected`,一条被驳回(甚至误驳)的记录会**永久挡住重爬**;而现状(草稿表)不会——查重只查正式表,驳回的草稿不挡路。
- 未命中 → 新建 `pending` 记录。
"同一实体只有一行"因此始终成立。
2. **建记录**:`CreateRunwayDraft` → `CreateRunway`(写 `brand_runways`,`status='pending'`、`job_id`);`CreateStreetSnapDraft` → `CreateStreetSnap`(写 `street_snaps`)。
3. **建图片**:把图直接写 `brand_runway_images` / `street_snap_images`。
4. **图片去重**:`FindNearDuplicateImage` 的比对表从"草稿图 + 正式图"两张收敛为**一张正式图表**;`phash` / `is_duplicate` / `dup_of` 的写入位置不变。
失效的旧代码(删除):`CreateRunwayDraft`、`CreateStreetSnapDraft`、草稿相关的 `RunwayIDByEntity` 之外的草稿查询、`MarkDraftDone` 一类的草稿态更新。
## 6. 公开可见性:只读视图
```sql
DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps;
CREATE VIEW public_brand_runways AS
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_brand_runway_images AS
SELECT i.* FROM brand_runway_images i
JOIN brand_runways r ON r.id = i.runway_id
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
CREATE VIEW public_street_snaps AS
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
CREATE VIEW public_street_snap_images AS
SELECT i.* FROM street_snap_images i
JOIN street_snaps s ON s.id = i.snap_id
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
```
**分层规则(硬约束)**
| 层 | 读 | 写 |
|---|---|---|
| 公开(public API / SSG) | 只读 `public_*` 视图 | 从不写 |
| 后台 | 读基表 | 读写基表 |
公开侧需要改读视图的方法(现状均为裸 `is_deleted=0`):
- `article_repository.go`:`List`、`FindByID`、`ListImages`、`ImagesByRunwayIDs`
- `street_snap_repository.go`:`List`、`FindByID`、`ListImages`、`ImagesBySnapIDs`、`Popular`
- `brand_repository.go`:`List` 的 `hasArticlesSubQuery`("有档案品牌")、`FeaturedIDs`、`PopularWithCover`(这两个直接查 `brand_runways`,必须改读 `public_brand_runways`,否则**待审走秀会让品牌提前出现在前台**)
- `index_service.go` / `article_service.go` / `street_snap_service.go` / `brand_service.go` 的公开映射方法本身不过滤,只跟着仓储走。
后台侧不改(`ListAdmin`、`GetForEdit` 等继续读基表)。
**GORM 用法**:视图无主键/无关联,统一以 `.Table("public_brand_runways")` + `.Find(&[]model.BrandRunway{})` 形式查询;`Preload` 不可用(现有公开查询本就是显式 join/分步查询,不受影响)。
**维护规则**:视图用 `SELECT *` 冻结列集,因此**任何给这 4 张基表加列的迁移,必须同批重建视图**(`DROP VIEW` + `CREATE VIEW`,本文件 §3.2 的加列即需要)。
## 7. 后台
- **审核列表**:`review_repository` 的 `ListDrafts(kind, status)` 改为直接查基表 `status`;「全部」tab = 不过滤。
- **审核详情**:`DraftDetailView` 由记录行组装(逻辑不变,只是数据源从草稿表换成正式表)。街拍分组视图(`Groups`)继续用 `is_detail`/`parent_image_id` 组装,`resolveRoot` 的链式上浮与孤儿容错**保持不变**。
- **通过 / 驳回**:`Approve` 不再调用 `SaveXxxFromDraft`,改为「应用字段编辑 + 置 `status`」;`Reject` 置 `status='rejected'` + 理由。
- **图片操作**:`AttachStreetDraftImages`→`AttachImages`、`AttachPrevStreetDraftImage`、`DetachStreetDraftImage`、`SoftDeleteXxxDraftImage` 全部改为操作**正式图片表**(逻辑不变,事务与不变量保证不变)。
- **正式列表页**(`/admin/runways`、`/admin/street-snaps`):读基表,默认展示 `published`,加一列状态。
- **命名收敛**:`Draft*` 前缀统一改名(`DraftDetailView`→`RecordDetailView`、`DraftImageRef`→`RecordImageRef`、`DraftImageGroup`→`ImageGroup`、`ListDrafts`→`ListRecords` 等),模板数据键 `.Draft` → `.Record`(模板不受编译器保护,由 `backstage_handler_test.go` 的"逐页渲染"测试兜底:缺键会导致模板执行报错→500→测试红)。
## 8. 迁移
分三步,**顺序不可颠倒**。
### 第 1 步:DDL + 视图(`db/migrations/2026-09-22-01-single-table-publish.sql`)
1. §3.2 加列。
2. 建 §6 的 4 个视图(视图定义必须幂等:`DROP VIEW IF EXISTS` 后重建)。
**存量行置已发布拆成独立的一次性文件**(`db/migrations/2026-09-22-01b-publish-existing-rows.sql`):
```sql
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
```
为什么必须拆开:数据搬迁(第 2 步)会把 pending 草稿**连同旧的 `created_at`** 搬进正式表。若这条 UPDATE 留在可重复执行的迁移文件里(集成测试每次都会执行它),这些待审内容会被误刷成 `published` —— 恰好是本设计要防的泄漏。因此:结构 DDL 可重复执行,数据变更只执行一次,且**必须在第 2 步之前**执行。
> 本步必须在部署新代码**之前**应用(新代码开始写 `status`)。
### 第 2 步:数据搬迁(`scripts/migrate_single_table/main.go`,一次性命令,支持 `-dry-run`)
1. 建 id 映射:`map[draftID]newRecordID`、`map[draftImageID]newImageID`。
2. `pending` / `rejected` 草稿 → 插入正式表(保留 `status`/`job_id`/`reviewer`/`reject_reason`/`is_deleted`),草稿图片 → 插入正式图片表(保留 `look_index`/`is_detail`/`phash`/`is_duplicate`/`dup_of`/`sort_order`)。
3. **街拍副图**:第二遍把 `parent_image_id` 从"草稿图片 id"改写为"新正式图片 id"(用第 1 步的映射);映射缺失则降级 `is_detail=0`(与读侧容错一致)。
4. **校验(不通过则中止,不删表)**:
- 每个 `approved` 草稿都能在正式表找到对应实体行(否则说明有"只存在于草稿"的内容,需人工处理);
- 待审/驳回草稿的记录数与图片数在迁移前后守恒;
- `street_snap_images` 中不存在指向不存在行的 `parent_image_id`。
5. `-dry-run` 打印上述统计与差异,不写库。
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
### 第 3 步:删表(`db/migrations/2026-09-22-05-drop-draft-tables.sql`,校验通过后再执行)
```sql
DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
street_snap_draft_images, street_snap_drafts;
```
## 9. 受影响文件(预估)
| 层 | 文件 |
|---|---|
| model | `runway_draft.go`(删)、`street_snap_draft.go`(删)、`runway.go`、`street_snap.go`(加列)、`content_status.go`(新) |
| repository | `ingest_repository.go`(建记录/查重)、`review_repository.go`(最大改动:列表/详情/状态/图片操作/删除晋升)、`article_repository.go`、`street_snap_repository.go`、`brand_repository.go`(改读视图) |
| service | `ingest_service.go`、`review_service.go`、`article_service.go`、`street_snap_service.go`、`brand_service.go`、`index_service.go` |
| handler | `backstage_handler.go`(去掉 `?main=` 之外基本不动)、`article_handler.go`/`street_snap_handler.go`/`brand_handler.go`/`ssg_handler.go`(应无需改动,公开过滤下沉到仓储) |
| templates | `review-list.html`(status 值)、`review-detail.html`(`.Draft`→`.Record`)、`runways.html`、`street-snaps.html`(状态列) |
| DDL / 脚本 | `db/migrations/2026-09-22-01-*.sql`、`db/migrations/2026-09-22-05-*.sql`、`scripts/migrate_single_table/main.go` |
| 测试 | `street_main_detail_integration_test.go`、`dedup_integration_test.go`、`ingest_repository_test.go`、`backstage_handler_test.go`、`router/backstage_test.go`、service 层单测 |
## 10. 测试策略
1. **公开可见性(关键,新建)**:插入一条 `status='pending'` 的记录 + 图片,断言
- 公开列表/详情/热门/SSG 全部**查不到**它;
- 置为 `published` 后能查到;
- 置回 `rejected` 后再次查不到。
这是"视图方案"真正要拿下的证据。
2. **实体键唯一**:同一实体键重复入库 → 只有一行,且第二次整任务被跳过。
3. **图片 id 稳定(新)**:通过 → 编辑字段 → 再通过,断言图片行 id 不变(旧实现会变)。
4. **街拍主副图不变量(现有集成测试改造)**:并入/并入上一张/拆出在正式图片表上仍满足"无副图的副图";父引用失效时按主图渲染。
5. **模板逐页渲染**:`backstage_handler_test.go` 现有"每个 page 渲染 200"继续兜住 `.Draft`→`.Record` 改名的遗漏。
6. **迁移脚本**:对拍迁移前后记录数/图片数;`-dry-run` 可重复执行。
## 11. 风险与回滚
| 风险 | 缓解 |
|---|---|
| 视图漏建/漏改,导致公开读到未审内容 | 视图是唯一公开入口 + §10.1 的专项测试;视图 DDL 与加列迁移同文件 |
| 加了列忘了重建视图(列集冻结) | §6 维护规则 + 迁移文件里 `DROP VIEW` 后重建 |
| 迁移把内容搬丢 | 第 2 步先 `-dry-run` + 三项守恒校验,**校验不过不执行第 3 步删表** |
| 存量 `approved` 草稿无对应正式行 | 校验会中止并列出,人工决定(补插或忽略) |
| 审核期间已发布专辑"消失" | 本设计下**不会发生**:重复入库直接放弃,不会把 published 行改回 pending |
**回滚**:第 1 步可逆(删列删视图),第 2 步之前数据零损失;第 3 步删表后不可逆(需从备份恢复,`db/backups/` 与 `db_dump.sql` 可用)。
## 12. 已知不一致(本次明确不处理,仅记录)
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题做**措辞**微调,入库查重会当作新实体,从而多出一行(内容重复)。
- ✅ 已处理(残留项 延后-4):纯**空白**差异(首尾空白、内部多空格 / 制表 / 换行)已通过 `repository.NormalizeStreetTitle`(空白折叠)在落库与查重两侧统一消除,不会再因空白微调产生重复行;措辞差异无法机械归一,仍按不同实体处理(符合预期)。
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
- 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。

View File

@ -119,6 +119,10 @@ type IngestConfig struct {
Secret string `yaml:"secret"`
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
TTLSeconds int `yaml:"ttl_seconds"`
// DownloadProxy 本机下载代理(仅本地开发用):worker 下载图片经此代理访问源站,
// 支持 http:// 或 socks5://(如 socks5://127.0.0.1:51831、http://127.0.0.1:58591)。
// 仅作用于图片下载客户端,不影响 S4 上传与数据库连接;留空直连。env: INGEST_DOWNLOAD_PROXY
DownloadProxy string `yaml:"download_proxy"`
}
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
@ -212,8 +216,9 @@ func defaultConfig() *Config {
TTLSeconds: 30,
},
Ingest: IngestConfig{
Secret: "",
TTLSeconds: 300,
Secret: "",
TTLSeconds: 300,
DownloadProxy: "",
},
S4: S4Config{
Enabled: false,
@ -268,11 +273,33 @@ func Load(explicit string) (*Config, error) {
}
cfg.loadedFrom = loadedFrom
// 本机私有覆盖(config.local.yml,已被 .gitignore 忽略):叠加在 config.yml 之上,
// 用于放本机相关、不应进版本库的设置(如本地代理 download_proxy)。环境变量仍拥有最高优先级。
overlayLocalConfig(cfg)
cfg.applyEnv()
cfg.normalize()
return cfg, nil
}
// overlayLocalConfig 把本机私有覆盖文件 config.local.yml(若存在)叠加到配置上。
// 仅用于本机相关、不应进版本库的设置(如本地代理 download_proxy);文件不存在则静默跳过。
// 与 config.yml 同样走 goccy/go-yaml 反序列化,仅覆盖文件中出现的键;环境变量仍在 applyEnv 中拥有最高优先级。
func overlayLocalConfig(cfg *Config) {
for _, p := range []string{
"configs/config.local.yml",
"config.local.yml",
filepath.Join("..", "..", "configs", "config.local.yml"),
} {
data, err := os.ReadFile(p)
if err != nil {
continue
}
_ = yaml.Unmarshal(data, cfg) // 解析失败静默跳过:本地覆盖属尽力而为,不应阻断启动
break
}
}
// LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。
func (c *Config) LoadedFrom() string { return c.loadedFrom }
@ -310,6 +337,7 @@ func (c *Config) applyEnv() {
envStr("INGEST_SECRET", &c.Ingest.Secret)
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
envStr("INGEST_DOWNLOAD_PROXY", &c.Ingest.DownloadProxy)
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。

View File

@ -79,12 +79,10 @@ type PublicArticleImage struct {
Thumb string `json:"thumb,omitempty"`
Name string `json:"name"`
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
// Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。
// 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份
// 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。
// Detail 仅详情接口填充:该主图下的细节图子集,元素形状与 Images 递归同构。
// 前端直接读它即可,无需再维护一份分组映射表。列表接口不填(omitempty 后不出现)。
Detail []PublicArticleImage `json:"detail,omitempty"`
}
@ -132,4 +130,7 @@ type AdminRunway struct {
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
ImageCount uint16 `json:"image_count"`
// Status 审核态(pending / published / rejected),供后台列表展示状态列。
// 后台专用 DTO,不外泄到公开接口。
Status string `json:"status"`
}

View File

@ -0,0 +1,10 @@
package dto
// DupSource 编辑页反查的「重复来源」上下文:某张被标记为重复的图所指向的源图,
// 及其所属走秀/街拍(对外统称"文章")的基本信息,供编辑员核对重复归属。
type DupSource struct {
Image string // 源图存储值(S4 key / 相对路径 / 外链),由模板经 imgURL 拼成可访问 URL
RecordID uint32 // 源图所属走秀/街拍的数字主键
Title string // 源图所属记录的标题(英文优先,回落中文)
Brand string // 源图所属走秀的品牌名(街拍无品牌关联,为空)
}

View File

@ -28,6 +28,7 @@ type RunwayIngest struct {
Season string `json:"season"` // spring / fall
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
City string `json:"city"` // 地区/城市(street 用)
PublishedAt uint32 `json:"published_at"` // Vogue 详情页 datePublished 的 Unix 时间戳(秒);0=未抽取。标题缺年份时用于矫正 year
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。

View File

@ -58,13 +58,20 @@ type PublicStreetSnap struct {
Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
// Status 审核态(pending / published / rejected)。
// 后台街拍列表复用本 DTO 渲染状态列,但公开列表接口**不返回**它:
// json:"-" 保证公开 API 响应结构与既有约定一致。
Status string `json:"-"`
}
// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
type PublicStreetSnapDetail struct {
UID string `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
Images []PublicArticleImage `json:"images"`
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
UID string `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"` // 封面:独立字段,与主/副图分组无关
// Images 只含主图(is_detail=0),默认展示;每张主图的副图挂在它自己的 Detail 子数组下。
// 结构与走秀详情完全一致(见 PublicArticleImage),前端可复用同一套渲染。
Images []PublicArticleImage `json:"images"`
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
}

View File

@ -0,0 +1,522 @@
/* ============================================================================
* 管理后台统一样式(合并原先散在 14 个内联模板里的 12 份重复 <style>)。
*
* 设计基调沿用原后台:黑白、直角感、系统字体、无外部依赖。
* 统一后只此一份,改样式不必再逐页改 14 处。
*
* 约定:
* 1) 布局三件套 —— body(页面底色)/ .bar(顶部导航)/ .main(内容容器)
* 2) 组件化 —— .btn / .badge / .card / .cell / .pager / .flash 等按语义命名,
* 页面只写结构,不再写内联 style
* 3) 缩略图统一 2:3 —— 与站内图片素材比例一致(后端素材为 240×360 / 720×1080)
* ==========================================================================*/
/* ---------- 基础 ---------- */
* { box-sizing: border-box; }
/* 让 hidden 属性始终生效:下面给 .batchbar/.flash/.chk 设了 display,
作者样式会盖过浏览器默认的 [hidden]{display:none},故这里强制抬高。 */
[hidden] { display: none !important; }
body {
margin: 0;
font: 14px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #111;
background: #f4f4f5;
-webkit-font-smoothing: antialiased;
}
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 26px 0 10px; color: #333; }
a { color: #111; }
a.view { text-decoration: underline; }
.muted { color: #777; }
code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
background: #f6f6f7;
padding: 1px 5px;
border-radius: 4px;
}
.break { word-break: break-all; }
:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
/* ---------- 顶部导航 ---------- */
.bar {
background: #111;
color: #fff;
padding: 10px 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 20;
}
.bar a { color: #fff; text-decoration: none; }
.bar-title { font-weight: 600; white-space: nowrap; }
.bar-nav { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.bar-nav a {
padding: 4px 9px;
border-radius: 6px;
font-size: 13px;
opacity: .78;
transition: background .15s, opacity .15s;
white-space: nowrap;
}
.bar-nav a:hover { opacity: 1; background: rgba(255, 255, 255, .14); }
.bar-nav a.on { opacity: 1; background: rgba(255, 255, 255, .2); }
.bar-nav .sep { width: 1px; height: 14px; background: rgba(255, 255, 255, .25); margin: 0 6px; }
.bar-badge {
display: inline-block;
min-width: 16px;
padding: 0 5px;
margin-left: 5px;
border-radius: 9px;
background: #f59e0b;
color: #111;
font-size: 11px;
font-weight: 700;
text-align: center;
vertical-align: 1px;
}
/* ---------- 内容容器 ---------- */
.main { max-width: 1100px; margin: 24px auto 60px; padding: 0 24px; }
.main.narrow { max-width: 620px; margin-top: 32px; }
.main.mid { max-width: 900px; }
/* 登录页固定宽卡片 */
.wrap {
max-width: 360px;
margin: 80px auto;
background: #fff;
padding: 28px 28px 32px;
border: 1px solid #e2e2e3;
border-radius: 10px;
}
.wrap h1 { margin-bottom: 18px; }
.wrap button { width: 100%; margin-top: 18px; }
/* ---------- 提示条 ---------- */
.flash {
padding: 10px 14px;
border-radius: 8px;
margin: 14px 0;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.flash.ok { background: #e6f7ec; border: 1px solid #a9dcbd; color: #1a7f43; }
.flash.err, .err { color: #c0392b; }
.flash.err { background: #fdeaea; border: 1px solid #f0b7b3; }
/* 收起按钮(提示条 / 顶部提示里的小 ✕):必须是「无底无边」的小图标,
否则会继承下面 button 的黑底样式,变成一个突兀的黑方块。 */
.hint .x, .flash .x {
background: none;
border: 0;
color: inherit;
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 2px 6px;
margin-left: auto;
}
/* hint 是 sticky(已定位),收起按钮绝对定位到右上角,不参与文字排版 */
.hint { padding-right: 34px; }
.hint .x { position: absolute; top: 6px; right: 8px; }
.hint {
position: sticky;
top: 52px;
background: #fffbe6;
border: 1px solid #f5e08a;
color: #7a5b00;
padding: 8px 12px;
border-radius: 6px;
margin: 12px 0;
font-size: 13px;
z-index: 10;
}
/* ---------- 表单 ---------- */
label { display: block; margin: 14px 0 4px; color: #555; }
input, textarea, select {
width: 100%;
padding: 9px 10px;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
background: #fff;
}
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
textarea { min-height: 72px; }
.row { display: flex; gap: 14px; }
.row > div { flex: 1; min-width: 0; }
button {
padding: 9px 16px;
background: #111;
color: #fff;
border: 1px solid #111;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-family: inherit;
}
button:hover { opacity: .88; }
button.reject, button.danger { background: #c0392b; border-color: #c0392b; }
button.ghost { background: #fff; color: #111; border-color: #ccc; }
button.retry {
font-size: 12px;
padding: 4px 10px;
border-color: #c0392b;
color: #c0392b;
background: #fff;
}
button.retry:hover { background: #c0392b; color: #fff; opacity: 1; }
button:disabled { opacity: .45; cursor: default; }
/* ---------- 工具栏 / 筛选 ---------- */
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin: 16px 0;
}
.toolbar input, .toolbar select { width: auto; padding: 8px 10px; }
.toolbar a { color: #555; text-decoration: none; font-size: 13px; }
.tabs a, .kinds a {
display: inline-block;
margin: 0 8px 8px 0;
color: #111;
text-decoration: none;
font-size: 13px;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid transparent;
}
.kinds a { border-color: #ddd; }
.tabs a.on, .kinds a.on { background: #111; color: #fff; border-color: #111; }
.idx { line-height: 2.2; }
.idx a { margin-right: 8px; text-decoration: none; font-size: 13px; }
.idx a.on { font-weight: 700; text-decoration: underline; }
/* ---------- 表格 ---------- */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
background: #fff;
margin-top: 12px;
border: 1px solid #e6e6e7;
border-radius: 8px;
}
th, td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid #eee;
font-size: 13px;
vertical-align: top;
}
th { background: #fafafa; color: #555; font-weight: 600; white-space: nowrap; }
tbody tr:hover { background: #fbfbfc; }
tbody tr:last-child td { border-bottom: 0; }
/* ---------- 徽标 ---------- */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
white-space: nowrap;
}
.badge.pending { background: #fff4e0; color: #a85b00; }
.badge.processing { background: #eef2ff; color: #3b4bb5; }
/* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
.badge.dup { background: #f3e8ff; color: #6b21a8; }
.badge.free { background: #eef2ff; color: #3b4bb5; }
.badge.vip { background: #fff4e0; color: #a85b00; }
.kind {
display: inline-block;
font-size: 12px;
padding: 1px 7px;
border-radius: 10px;
background: #eef2ff;
color: #3b4bb5;
}
.kind.street { background: #eafaf0; color: #1a7f43; }
.down { display: inline-block; padding: 2px 8px; background: #fdeaea; color: #c0392b; border-radius: 10px; font-size: 12px; }
/* ---------- 卡片(首页入口) ---------- */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
.card {
background: #fff;
border: 1px solid #e2e2e3;
border-radius: 10px;
padding: 18px;
text-decoration: none;
color: #111;
display: block;
transition: border-color .15s, transform .15s;
}
.card:hover { border-color: #111; transform: translateY(-1px); }
.card .t { font-size: 15px; font-weight: 600; }
.card .d { color: #777; font-size: 13px; margin-top: 6px; }
.chips { margin-top: 10px; }
.chip { display: inline-block; background: #111; color: #fff; font-size: 12px; padding: 3px 9px; border-radius: 10px; margin: 0 6px 6px 0; }
/* ---------- 图片网格(审核 / 图集) ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px;
margin-top: 14px;
}
.cell {
background: #fff;
border: 1px solid #e6e6e7;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 细节图 / 副图:明显的靛蓝边框 + 淡蓝底,和主图一眼区分开 */
.cell.det { background: #eef2ff; border: 2px solid #818cf8; }
/* 副图默认隐藏,点对应主图上的「显示副图」才展开(逐组,不会一次全开) */
.cell.det { display: none; }
.cell.det.shown { display: flex; }
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
.cell .thumb {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: 0;
background: #f1f1f2;
cursor: zoom-in;
}
.cell .thumb img, .cell > img { width: 100%; display: block; aspect-ratio: 2 / 3; object-fit: cover; }
.cell > img { cursor: zoom-in; }
.cell .thumb::after {
content: "点击看大图";
position: absolute;
right: 6px;
bottom: 6px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, .62);
color: #fff;
font-size: 11px;
opacity: 0;
transition: opacity .15s;
}
.cell .thumb:hover::after { opacity: 1; }
/* 角色标签统一做成胶囊徽标:主图绿、细节图靛蓝,颜色编码一眼可分 */
.cell .role {
align-self: flex-start;
margin: 6px 8px 0;
padding: 2px 9px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
color: #1a7f43;
background: #e6f7ec;
}
.cell.det .role { color: #fff; background: #4338ca; }
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
.btn {
display: inline-block;
flex: 1 1 auto;
margin: 0;
padding: 7px 10px;
background: #fff;
color: #111;
border: 1px solid #ccc;
border-radius: 6px;
text-decoration: none;
font-size: 13px;
text-align: center;
cursor: pointer;
}
.btn:hover { border-color: #111; }
.toggle-det { background: #eef2ff; border: 1px solid #818cf8; color: #4338ca; }
/* ---------- 行内操作按钮组(列表行 / 审核操作) ---------- */
/* 把原先「编辑 · 通过 · 驳回 · 下架」那种用中点分隔的散落按钮,
收敛成一组紧凑胶囊按钮:编辑是描边链接,通过=绿、驳回=红、下架=琥珀描边。 */
.ops { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ops .inline { display: inline-flex; margin: 0; padding: 0; }
.ops .view {
display: inline-block;
padding: 5px 10px;
border: 1px solid #ccc;
border-radius: 6px;
text-decoration: none;
font-size: 13px;
color: #111;
background: #fff;
}
.ops .view:hover { border-color: #111; }
.op {
margin: 0;
padding: 5px 11px;
font-size: 13px;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
font-family: inherit;
line-height: 1.4;
white-space: nowrap;
}
.op-primary { background: #1a7f43; border-color: #1a7f43; color: #fff; }
.op-danger { background: #c0392b; border-color: #c0392b; color: #fff; }
.op-warn { background: #fff; border-color: #e0a800; color: #8a6d00; }
.op-primary:hover, .op-danger:hover { opacity: .9; }
.op-warn:hover { background: #fff8e6; }
/* 审核操作区(编辑页底部的通过 / 驳回)同款胶囊按钮组 */
.review-actions { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0; }
.review-actions .inline { display: inline-flex; margin: 0; padding: 0; }
.chk {
display: flex;
align-items: center;
gap: 5px;
flex: 1 1 auto;
font-size: 12px;
color: #333;
white-space: nowrap;
cursor: pointer;
margin: 0;
padding: 5px 6px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
}
.chk:hover { border-color: #111; }
.chk input { margin: 0; }
/* ---------- 看大图弹层(<dialog>,initZoom 依赖 #zoom-modal / #zoom-img) ---------- */
#zoom-modal {
padding: 0;
border: 0;
background: transparent;
max-width: 94vw;
max-height: 94vh;
}
#zoom-modal::backdrop { background: rgba(0, 0, 0, .82); }
#zoom-img {
display: block;
max-width: 94vw;
max-height: 94vh;
width: auto;
height: auto;
object-fit: contain;
cursor: zoom-out;
}
/* ---------- 底部批量操作条 ---------- */
.batchbar {
position: sticky;
bottom: 0;
background: #fff;
border: 1px solid #e6e6e7;
border-radius: 8px;
padding: 10px 12px;
margin-top: 18px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
box-shadow: 0 -2px 12px rgba(0, 0, 0, .06);
z-index: 15;
}
.batchbar button { margin: 0; }
.batchbar .spacer { flex: 1; }
/* ---------- 分页 ---------- */
.pager { margin: 18px 0; display: flex; gap: 12px; align-items: center; }
.pager a { padding: 7px 14px; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; background: #fff; }
.pager a:hover { border-color: #111; }
.pager a.off { color: #bbb; border-color: #eee; pointer-events: none; }
/* ---------- 大图查看层(admin.js 用) ---------- */
.lightbox {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, .88);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.lightbox[hidden] { display: none; }
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
.lightbox .lb-close,
.lightbox .lb-prev,
.lightbox .lb-next {
position: absolute;
background: rgba(255, 255, 255, .92);
color: #111;
border: 0;
border-radius: 8px;
width: 40px;
height: 40px;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.lightbox .lb-close { top: 18px; right: 18px; }
.lightbox .lb-prev { left: 18px; top: 50%; transform: translateY(-50%); }
.lightbox .lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
.lightbox .lb-cap {
position: absolute;
left: 0;
right: 0;
bottom: 16px;
text-align: center;
color: #eee;
font-size: 12px;
}
/* ---------- 窄屏 ---------- */
@media (max-width: 720px) {
.bar { padding: 10px 14px; }
.bar-title { font-size: 13px; }
.main { padding: 0 14px; margin-top: 16px; }
.row { flex-direction: column; gap: 0; }
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}
/* ---------- 编辑页重复图标记 ---------- */
.cell .badge.dup { margin-left: 8px; vertical-align: middle; }
.cell .dup-src { margin: 0 8px 6px; font-size: 12px; }
.cell .dup-src > summary { cursor: pointer; color: #6b21a8; user-select: none; outline: none; }
.cell .dup-src-body {
display: flex;
gap: 8px;
align-items: center;
margin-top: 6px;
padding: 6px;
background: #faf5ff;
border: 1px solid #e9d5ff;
border-radius: 6px;
}
.cell .dup-src-body img { width: 56px; height: 84px; object-fit: cover; border-radius: 4px; flex: 0 0 auto; background: #eee; }
.cell .dup-title { margin-bottom: 2px; color: #333; }
.cell .dup-link { color: #6b21a8; font-weight: 600; text-decoration: none; }
.cell .dup-link:hover { text-decoration: underline; }

View File

@ -0,0 +1,215 @@
// 后台交互脚本(原生 JS,无构建步骤)。data-admin-js
// 两个入口页面:
// - 文章管理混合列表(/admin/articles):批量通过
// - 街拍编辑页(/admin/street-snaps/:id/edit):主副图合并/拆出(街拍分组)
(function () {
// 表单动作里的 /admin/articles/ 换成 /admin/api/articles/,得到对应的 JSON 接口地址。
function apiOf(action) {
return action.replace('/admin/articles/', '/admin/api/articles/');
}
// ── 看大图弹层(所有页面通用)────────────────────────────────────────────
function initZoom() {
var modal = document.getElementById('zoom-modal');
if (!modal) return;
var img = document.getElementById('zoom-img');
document.querySelectorAll('.thumb[data-zoom]').forEach(function (btn) {
btn.addEventListener('click', function () {
img.src = btn.querySelector('img').src;
modal.showModal();
});
});
modal.addEventListener('click', function (e) {
if (e.target === modal) modal.close();
});
}
// ── 文章管理列表:批量通过 ──────────────────────────────────────
function initArticleList() {
var selAll = document.getElementById('sel-all');
var picks = function () { return Array.prototype.slice.call(document.querySelectorAll('.row-pick')); };
var bar = document.getElementById('batchbar');
var count = document.getElementById('sel-count');
function refresh() {
var n = picks().filter(function (c) { return c.checked; }).length;
if (bar) bar.hidden = n === 0;
if (count) count.textContent = '已选 ' + n + ' 条';
}
if (selAll) selAll.addEventListener('change', function () {
picks().forEach(function (c) { c.checked = selAll.checked; });
refresh();
});
picks().forEach(function (c) { c.addEventListener('change', refresh); });
var clear = document.getElementById('batch-clear');
if (clear) clear.addEventListener('click', function () {
picks().forEach(function (c) { c.checked = false; });
if (selAll) selAll.checked = false;
refresh();
});
function postBatch(action) {
var items = picks().filter(function (c) {
return c.checked;
}).map(function (c) {
var tr = c.closest('tr');
return { kind: tr.getAttribute('data-kind'), id: parseInt(tr.getAttribute('data-id'), 10) };
});
if (items.length === 0) { alert('请先勾选要操作的记录'); return; }
if (!confirm('确认通过选中的 ' + items.length + ' 条?')) return;
fetch('/admin/api/articles/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, items: items })
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.failed && d.failed.length) {
alert('成功 ' + d.ok + ' 条,失败 ' + d.failed.length + ' 条:' + d.failed[0].error);
}
location.reload();
}).catch(function (e) { alert('请求失败:' + e); });
}
var approve = document.getElementById('batch-approve');
if (approve) approve.addEventListener('click', function () { postBatch('approve'); });
}
// ── 街拍编辑页:主副图分组(合并 / 拆出)──────────────────────────────
function initArticleDetail() {
var batchForm = document.getElementById('batch');
var stat = document.getElementById('img-stat');
var hint = document.getElementById('hint-dismiss');
if (hint) hint.addEventListener('click', function () {
var h = hint.closest('.hint'); if (h) h.style.display = 'none';
});
// 每个主图各自的「显示副图」开关:点一下只展开/收起该主图紧邻的副图(不会一次全开)。
var grid = document.querySelector('.grid');
if (grid) {
grid.addEventListener('click', function (e) {
var btn = e.target.closest('.toggle-det');
if (!btn) return;
var pid = btn.getAttribute('data-parent');
var dets = grid.querySelectorAll('.cell.det[data-parent="' + pid + '"]');
var expand = false;
dets.forEach(function (d) { if (!d.classList.contains('shown')) expand = true; });
dets.forEach(function (d) { d.classList.toggle('shown', expand); });
btn.textContent = expand
? ('隐藏副图(' + dets.length + ' 张)')
: ('显示副图(' + dets.length + ' 张)');
});
}
// 合并选中为一组(AJAX,成功后就地更新标签)
if (batchForm) batchForm.addEventListener('submit', function (e) {
e.preventDefault();
// 注意:主图 radio / 选入 checkbox 用 form="batch" 关联到批量表单,但 DOM 上并不在
// <form id="batch"> 内部(包在 .cell 里)。它们虽能随表单正确提交,却不在 form 子树中,
// 故必须用 document 级查询(而非 batchForm.querySelector)才能取到已勾选的项。
var main = document.querySelector('input[name="main"]:checked');
var picks = document.querySelectorAll('input[name="img"]:checked');
if (!main) { alert('请先在其中一张主图上点「主图」'); return; }
if (!picks.length) { alert('请勾选要合并的图片'); return; }
// 接口 ArticleAttachImagesAPI 收 JSON(main + imgs),不能发 FormData:
// multipart 会让 ShouldBindJSON 失败、返回「请求格式错误」。
var data = {
main: parseInt(main.value, 10),
imgs: Array.prototype.slice.call(picks).map(function (c) { return parseInt(c.value, 10); })
};
fetch(apiOf(batchForm.getAttribute('action')), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function (r) { return r.json(); })
.then(function (d) {
applyImages(d.images, kindOf(batchForm.getAttribute('action')), main.value);
if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组';
clearSelection();
})
.catch(function (err) { alert('合并失败:' + err); });
});
// 单张拆出(AJAX)
document.querySelectorAll('form[data-only="detail"]').forEach(function (f) {
f.addEventListener('submit', function (e) {
e.preventDefault();
fetch(apiOf(f.getAttribute('action')), { method: 'POST', body: new FormData(f) })
.then(function (r) { return r.json(); })
.then(function (d) { applyImages(d.images, kindOf(f.getAttribute('action'))); if (stat) stat.textContent = d.count + ' 张 · ' + d.groups + ' 组'; })
.catch(function (err) { alert('拆出失败:' + err); });
});
});
}
// 从 /admin/articles/<kind>/... 形式的 action 取出实体类型(street / runway)。
function kindOf(action) {
var m = /^\/admin\/articles\/(\w+)\//.exec(action || '');
return m ? m[1] : 'street';
}
function clearSelection() {
document.querySelectorAll('input[name="main"]:checked, input[name="img"]:checked').forEach(function (c) { c.checked = false; });
}
// 更新某个主图的「显示副图」开关:按最新副图数创建/更新/移除按钮,文案随展开态变化。
function updateToggle(mainId, detailCount) {
var cell = document.querySelector('.cell[data-img-id="' + mainId + '"]');
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
if (detailCount > 0) {
if (!btn && cell) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'toggle-det';
btn.setAttribute('data-parent', mainId);
var acts = cell.querySelector('.acts');
if (acts) acts.appendChild(btn);
}
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
var expanded = dets.length > 0 && dets[0].classList.contains('shown');
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + detailCount + ' 张)';
} else if (btn) {
btn.remove();
}
}
// 展开/收起某主图的全部副图,并同步按钮文案。
function setExpanded(mainId, expanded) {
var dets = document.querySelectorAll('.cell.det[data-parent="' + mainId + '"]');
dets.forEach(function (d) { d.classList.toggle('shown', expanded); });
var btn = document.querySelector('.toggle-det[data-parent="' + mainId + '"]');
if (btn) btn.textContent = (expanded ? '隐藏副图(' : '显示副图(') + dets.length + ' 张)';
}
// 用服务端返回的最新图片投影,重写网格里的角色标签、副图开关与统计。
// kind 决定细节图文案;focusMain 为合并操作的目标主图 id,合并后自动展开其副图。
function applyImages(images, kind, focusMain) {
if (!images) return;
images.forEach(function (im) {
var cell = document.querySelector('.cell[data-img-id="' + im.id + '"]');
if (!cell) return;
cell.classList.toggle('det', im.isDetail === 1);
if (im.isDetail === 1) {
cell.setAttribute('data-parent', im.parentId);
} else {
cell.removeAttribute('data-parent');
updateToggle(im.id, im.detailCount);
}
var role = cell.querySelector('.role');
if (role) {
if (im.isDetail === 1) {
role.textContent = (kind === 'runway' ? '细节图' : '副图') + ' → #' + im.parentId;
} else {
role.textContent = '主图' + (im.detailCount ? (' · ' + im.detailCount + ' 副图') : '');
}
}
});
if (focusMain) setExpanded(focusMain, true);
}
document.addEventListener('DOMContentLoaded', function () {
initZoom();
if (document.getElementById('sel-all') || document.getElementById('batchbar')) initArticleList();
if (document.getElementById('batch')) initArticleDetail();
});
})();

View File

@ -0,0 +1,175 @@
// 审核页的 AJAX 接口(返回 JSON,不整页跳转)。
//
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
// 同时表单式路由(/admin/articles/... 下的 approve/attach/detach)原样保留:关掉 JS 后台依然可用。
//
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
package handler
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"fashionapi/internal/middleware"
)
// maxReviewBatch 单次批量审核的条数上限,避免一次请求打到几百条。
const maxReviewBatch = 200
type reviewBatchItem struct {
Kind string `json:"kind"`
ID uint32 `json:"id"`
}
type reviewBatchReq struct {
// Action: approve
Action string `json:"action"`
Items []reviewBatchItem `json:"items"`
}
// ArticleBatch 文章管理批量通过(替代原审核批量接口)。
//
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
// 响应: {"ok":2,"failed":[{"kind":"street","id":9,"error":"..."}],"pending":17}
//
// 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。
func (h *BackstageHandler) ArticleBatch(c *gin.Context) {
var req reviewBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
return
}
if req.Action != "approve" {
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve"})
return
}
if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何记录"})
return
}
if len(req.Items) > maxReviewBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
return
}
ctx := c.Request.Context()
registered := h.registeredKinds(ctx)
nameVal, _ := c.Get(middleware.ContextUsername)
reviewer := usernameString(nameVal)
okCount := 0
failed := make([]gin.H, 0)
for _, item := range req.Items {
if item.ID == 0 || !registered[item.Kind] {
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": "类型或 ID 无效"})
continue
}
var err error
if req.Action == "approve" {
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
}
if err != nil {
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
continue
}
okCount++
}
c.JSON(http.StatusOK, gin.H{
"ok": okCount,
"failed": failed,
"pending": h.PendingReviews(ctx),
})
}
type reviewAttachReq struct {
Main uint32 `json:"main"`
Imgs []uint32 `json:"imgs"`
}
// ArticleAttachImagesAPI 把多张图并入指定主图(街拍「合并选中为一组」的 AJAX 版)。
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
// 加 API 后缀以区别于表单版 ArticleAttachImages:本方法收发 JSON,表单版返回 302。
func (h *BackstageHandler) ArticleAttachImagesAPI(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
var req reviewAttachReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
return
}
if req.Main == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先指定哪张是主图"})
return
}
if len(req.Imgs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
return
}
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.respondRecordImages(c, kind, uint32(id))
}
// ArticleDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ArticleAttachImagesAPI。
func (h *BackstageHandler) ArticleDetachImageAPI(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)
if id == 0 || imgID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.respondRecordImages(c, kind, uint32(id))
}
// respondRecordImages 重新读取记录并把「图片分组状态」按前端需要的形状返回。
//
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
func (h *BackstageHandler) respondRecordImages(c *gin.Context, kind string, id uint32) {
view, err := h.review.RecordDetail(c.Request.Context(), kind, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
images := make([]gin.H, 0, len(view.Images))
for _, im := range view.Images {
images = append(images, gin.H{
"id": im.ID,
"isDetail": im.IsDetail,
"parentId": im.ParentImageID,
"detailCount": im.DetailCount,
})
}
c.JSON(http.StatusOK, gin.H{
"images": images,
"imageCount": view.ImageCount,
"groups": len(view.Groups),
"pending": h.PendingReviews(c.Request.Context()),
})
}
// registeredKinds 已注册的审核类型集合(与 ReviewList 的校验口径一致)。
func (h *BackstageHandler) registeredKinds(ctx context.Context) map[string]bool {
registered := map[string]bool{}
for _, tab := range h.review.KindTabs(ctx) {
if tab.Kind != "" {
registered[tab.Kind] = true
}
}
return registered
}

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,131 @@
package handler
import "testing"
import (
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
// TestBackstageTemplatesParse 所有内嵌后台模板必须能解析。
"github.com/gin-gonic/gin"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/pagination"
"fashionapi/internal/service"
)
// TestBackstageTemplatesParsed 后台页面模板必须能解析,且集合齐全。
//
// 模板是 Go 字符串常量,语法错误不会在编译期暴露——只会在用户打开页面时 500;
// 而审核页又有登录中间件挡着(curl 只能打到 302),冒烟测试覆盖不到,故用解析测试兜住。
func TestBackstageTemplatesParse(t *testing.T) {
for name, tpl := range map[string]string{
"login": tplLogin,
"reviewList": tplReviewList,
"reviewDetail": tplReviewDetail,
} {
t.Run(name, func(t *testing.T) {
base, err := pagerTmpl.Clone()
if err != nil {
t.Fatalf("clone pager 模板失败: %v", err)
}
if _, err := base.Parse(tpl); err != nil {
t.Fatalf("模板 %s 解析失败: %v", name, err)
}
})
// 模板从「Go 字符串常量」改成 templates/ 下的独立文件后:
// - 语法错误会在包 init(template.ParseFS)时直接 panic,go test 立刻暴露,
// 不再出现「编得过、点到那个页面才 500」;
// - 本测试额外断言页面集合齐全,防止加了 handler 却忘了加模板文件
// (renderPage 会 500 "unknown backstage page: xxx")。
func TestBackstageTemplatesParsed(t *testing.T) {
want := []string{
"articles",
"brand-form",
"brands",
"dashboard",
"error",
"ingest-jobs",
"login",
"runway-detail",
"runway-edit",
"snap-edit",
"street-snap-detail",
"users",
}
got := PageNames()
sort.Strings(got)
if len(got) != len(want) {
t.Fatalf("页面模板数量不一致:\n got %v\n want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("页面模板集合不一致:\n got %v\n want %v", got, want)
}
}
}
// TestBackstageTemplatesRender 每个页面都要能真正渲染出来(不只是能解析)。
//
// 解析通过 ≠ 渲染通过:{{template "barRight" .}}、{{template "pager" .Pager}} 这类引用
// 是**执行期**才解析的,某个页面漏了 barLabel / barRight 定义时,只有渲染那一步才会报错
// —— 而审核页又被登录中间件挡着(curl 只能打到 302),所以用这个测试兜住。
//
// 数据用一份「尽量齐全」的假数据;有分支的页面(审核详情按 kind 分流)分别渲染。
func TestBackstageTemplatesRender(t *testing.T) {
gin.SetMode(gin.TestMode)
base := gin.H{
"Username": "admin",
"Error": "",
"Total": 1,
"Pager": pagination.Pager{HasPrev: true, HasNext: true, PrevURL: "/p/1", NextURL: "/p/3", Label: "第 2 页"},
// 各筛选/表单页用到的零值字段
"Keyword": "", "Letter": "", "Status": "", "Kind": "", "ListBase": "",
"Collection": "", "Season": "", "Year": 2026, "City": "", "Sort": "",
"UID": "abc123", "NameEn": "Acne", "NameCn": "", "Account": "",
"Images": []any{},
// 列表数据
"Items": []any{}, "Users": []any{}, "Kinds": []any{}, "Records": []any{}, "TabCounts": map[string]int64{},
// 详情页实体(.Snap 在两个页面里类型不同,见下面的 perPage 覆盖)
"Article": &dto.PublicArticleDetail{UID: "abc123"},
"Snap": &dto.PublicStreetSnapDetail{UID: "abc123"},
}
// 同名 key 在不同页面指向不同模型(历史遗留命名):按页覆盖,顺带把这份契约固化下来。
perPage := map[string]gin.H{
"snap-edit": {"Snap": &model.StreetSnap{}, "Images": []any{}},
"runway-edit": {"Runway": &model.BrandRunway{}, "Images": []any{}},
}
for _, page := range PageNames() {
for _, kind := range []string{"street", "runway"} {
name := page + "/" + kind
t.Run(name, func(t *testing.T) {
data := cloneData(base)
for k, v := range perPage[page] {
data[k] = v
}
data["Record"] = &service.RecordDetailView{Kind: kind, ID: 7, Title: "T", Status: "pending"}
// 图片管理块已抽到 imageGrid 共用片段,需要 handler 实际注入的字段:
data["Kind"] = kind
data["RecordID"] = uint32(7)
data["UID"] = "abc123"
data["Status"] = "pending"
data["Cells"] = []gin.H{}
data["BrandName"] = "Brand"
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/admin/", nil)
renderPage(c, http.StatusOK, page, data)
if w.Code != http.StatusOK {
t.Fatalf("页面 %s 渲染失败(%d):%s", name, w.Code, w.Body.String())
}
body := w.Body.String()
if body == "" {
t.Fatalf("页面 %s 渲染为空", name)
}
// 共享外壳必须挂上统一样式与渐进增强脚本(改回内联样式时这条会失败,是有意的)
if !strings.Contains(body, "/admin/assets/admin.css") || !strings.Contains(body, "/admin/assets/admin.js") {
t.Fatalf("页面 %s 未引用后台统一样式/脚本", name)
}
})
}
}
}
// cloneData 复制一份页面数据:renderPage 会往里注入 NavKey / PendingReviews,不能跨子测试共享。
func cloneData(src gin.H) gin.H {
dst := make(gin.H, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,172 @@
// 后台模板的加载与渲染。
//
// 为什么把模板从 Go 字符串常量搬到独立 .html 文件:
// 1. IDE 支持 —— .html 有语法高亮、补全、格式化、Emmet;Go 反引号字符串里没有;
// 2. 可维护 —— 改样式 / 加字段只动一个小文件,diff 干净;公共部分(顶部导航、翻页器)
// 抽成 partials 后不再 14 份复制粘贴;样式与脚本独立成文件后可被浏览器缓存;
// 3. 零构建 —— 用 go:embed 打进二进制,部署仍然只是一个可执行文件,无外部依赖。
//
// 模板分层:
// layout.html 外壳(<head> + 共享 css/js + 内容插槽)
// partials.html 共享片段(bar 顶部导航 / pager 翻页器)
// pages/*.html 每个页面一个文件,文件名即 renderPage 的 page 参数
//
// 基模板(layout + partials)在启动时解析一次,之后为每个页面 Clone + Parse 一次并缓存,
// 因此请求期不再重复解析模板(原实现每次渲染都 Clone+Parse 一遍)。
package handler
import (
"bytes"
"context"
"embed"
"html/template"
"io/fs"
"net/http"
"strings"
"fashionapi/internal/pkg/imgurl"
"github.com/gin-gonic/gin"
)
// imgComposer 图片 URL 拼装器(库里只存 S4 key / 本地 /uploads 路径,渲染时再拼 base_url 与样式)。
// 默认按本地兜底初始化;main 装配期会调用 SetImgComposer 注入真实配置(含 S4 BaseURL)。
var imgComposer = imgurl.New("", "high", "thumb")
// SetImgComposer 由 main 在装配期注入图片 URL 拼装器(S4 启用时含对外访问域名)。
// 后台模板通过 imgURL / imgThumb 函数渲染图片,避免直接裸拼存储值导致 S4 模式下打不开。
func SetImgComposer(c *imgurl.Composer) {
if c != nil {
imgComposer = c
}
}
//go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
var backstageFS embed.FS
// ctxNavStatsKey 是「导航统计」在 gin.Context 里的键;由 BackstageHandler.NavStats 中间件写入,
// renderPage 渲染前合并进页面数据,页面模板即可无条件引用 .PendingReviews。
const ctxNavStatsKey = "backstageNavStats"
// pageTemplates 页面名(= pages/ 下的文件名去扩展名)-> 已注入该页 define 的完整模板。
var pageTemplates = map[string]*template.Template{}
// assetFS 后台静态资源子树(admin.css / admin.js),经 BackstageAssetFS 交给路由挂载。
var assetFS fs.FS
func init() {
funcs := template.FuncMap{
// imgURL 把数据库里的图片存储值(S4 key / /uploads 路径 / 外链)拼成可访问完整 URL;
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
"imgURL": func(stored string) string { return imgComposer.Compose(stored) },
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
}
base, err := template.New("layout.html").Funcs(funcs).
ParseFS(backstageFS, "templates/layout.html", "templates/partials.html")
if err != nil {
panic("后台基模板解析失败: " + err.Error())
}
entries, err := fs.ReadDir(backstageFS, "templates/pages")
if err != nil {
panic("后台页面模板目录读取失败: " + err.Error())
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".html") {
continue
}
clone, err := base.Clone()
if err != nil {
panic("克隆后台基模板失败: " + err.Error())
}
page, err := clone.ParseFS(backstageFS, "templates/pages/"+entry.Name())
if err != nil {
// 模板语法错误在启动时直接 panic:go test / 起服务立刻暴露,
// 而不是等用户点到那个页面才 500(模板以前是字符串常量,编译期查不出来)。
panic("后台页面模板解析失败 " + entry.Name() + ": " + err.Error())
}
pageTemplates[strings.TrimSuffix(entry.Name(), ".html")] = page
}
assets, err := fs.Sub(backstageFS, "assets")
if err != nil {
panic("后台静态资源子树初始化失败: " + err.Error())
}
assetFS = assets
}
// BackstageAssetFS 返回后台静态资源子树,供路由以 /admin/assets/ 挂载。
func BackstageAssetFS() fs.FS { return assetFS }
// PageNames 返回已注册的页面名(供测试断言模板集合齐全)。
func PageNames() []string {
names := make([]string, 0, len(pageTemplates))
for name := range pageTemplates {
names = append(names, name)
}
return names
}
// renderPage 渲染后台页面:layout + 该页的 title / barLabel / barRight / content。
//
// page 取 pages/ 下的文件名(如 "articles");data 为页面数据,renderPage 会补上
// NavKey(导航高亮,值即 page)与 PendingReviews(导航角标,来自 NavStats 中间件)。
func renderPage(c *gin.Context, status int, page string, data gin.H) {
tpl, ok := pageTemplates[page]
if !ok {
c.String(http.StatusInternalServerError, "unknown backstage page: "+page)
return
}
if data == nil {
data = gin.H{}
}
if _, exists := data["NavKey"]; !exists {
data["NavKey"] = page
}
if _, exists := data["PendingReviews"]; !exists {
data["PendingReviews"] = 0
}
if v, ok := c.Get(ctxNavStatsKey); ok {
if stats, ok := v.(gin.H); ok {
for k, val := range stats {
data[k] = val
}
}
}
var buf bytes.Buffer
if err := tpl.ExecuteTemplate(&buf, "layout", data); err != nil {
c.String(http.StatusInternalServerError, "render error: "+err.Error())
return
}
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
}
// NavStats 返回一个中间件:为后台每个页面预备导航统计(目前是待审总数)。
//
// 放在中间件而不是逐个 handler 里,是为了「任意页面都能看到待审队列深度」——
// 后台是内部工具、页面访问量小,每页多一次 count 查询可以接受。
func (h *BackstageHandler) NavStats() gin.HandlerFunc {
return func(c *gin.Context) {
if h != nil && h.review != nil {
c.Set(ctxNavStatsKey, gin.H{"PendingReviews": h.PendingReviews(c.Request.Context())})
}
c.Next()
}
}
// PendingReviews 待审草稿总数。
//
// ⚠️ KindTabs() 的第 0 项是「全部」标签,它的 Pending 已经是各模块之和(见 ReviewService.KindTabs)。
// 因此不能对所有项无脑累加 —— 那会把待审数算成两倍。这里以「全部」项为准,退化为逐项累加时跳过空 Kind。
func (h *BackstageHandler) PendingReviews(ctx context.Context) int64 {
var total int64
for _, k := range h.review.KindTabs(ctx) {
if k.Kind == "" {
return k.Pending
}
total += k.Pending
}
return total
}

View File

@ -0,0 +1,27 @@
{{/*
后台页面外壳:<head> + 共享样式/脚本 + 页面内容插槽。
页面模板只需提供三个 define:
{{define "title"}} 浏览器标题
{{define "content"}} 正文(含自己的 .bar / .main 容器)
需要顶部导航时,在 content 里写 {{template "bar" .}}(登录页不需要,故不引用)。
样式与脚本走 /admin/assets/(同样是 embed 进二进制,零外部依赖、零构建):
CSS 独立成文件后浏览器可跨页缓存,也才有真正的 CSS 语法高亮与补全。
*/}}
{{define "layout"}}<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{template "title" .}}</title>
<link rel="stylesheet" href="/admin/assets/admin.css">
</head>
<body>
{{template "content" .}}
<div id="toast" class="flash ok" hidden></div>
<dialog id="zoom-modal"><img id="zoom-img" alt=""></dialog>
<script src="/admin/assets/admin.js" defer></script>
</body>
</html>
{{end}}

View File

@ -0,0 +1,56 @@
{{define "title"}}文章管理 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 文章管理{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>文章管理</h1>
<p class="muted">走秀与街拍合并管理:通过 / 编辑 / 删除(下架,清空 S4)都在此处发起。待审核优先排在前面。</p>
<div class="kinds">
<a href="/admin/articles?status={{.Status}}" class="{{if eq .Kind ""}}on{{end}}">全部类型</a>
<a href="/admin/articles?status={{.Status}}&kind=runway" class="{{if eq .Kind "runway"}}on{{end}}">走秀</a>
<a href="/admin/articles?status={{.Status}}&kind=street" class="{{if eq .Kind "street"}}on{{end}}">街拍</a>
</div>
<div class="tabs">
<a href="/admin/articles?status=&kind={{.Kind}}" class="{{if eq .Status ""}}on{{end}}">全部{{if gt (index .TabCounts "") 0}}({{index .TabCounts ""}}){{end}}</a>
<a href="/admin/articles?status=pending&kind={{.Kind}}" class="{{if eq .Status "pending"}}on{{end}}">待审核{{if gt (index .TabCounts "pending") 0}}({{index .TabCounts "pending"}}){{end}}</a>
<a href="/admin/articles?status=published&kind={{.Kind}}" class="{{if eq .Status "published"}}on{{end}}">已发布{{if gt (index .TabCounts "published") 0}}({{index .TabCounts "published"}}){{end}}</a>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>重复率</th><th>状态</th><th>操作</th>
</tr></thead>
<tbody>
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
<td><input type="checkbox" class="row-pick" aria-label="选择这条"></td>
<td>{{if eq .Kind "street"}}<span class="kind street">{{.Kind}}</span>{{else}}<span class="kind">{{.Kind}}</span>{{end}}</td>
<td><code>{{.ID}}</code></td>
<td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td>
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>
<div class="ops">
{{if eq .Kind "runway"}}<a class="view" href="/admin/runways/{{.UID}}/edit">编辑</a>{{else}}<a class="view" href="/admin/street-snaps/{{.UID}}/edit">编辑</a>{{end}}
{{if eq .Status "pending"}}<form class="inline" method="post" action="/admin/articles/{{.Kind}}/{{.ID}}/approve"><button type="submit" class="op op-primary">通过</button></form>{{end}}
{{if eq .Kind "runway"}}<form class="inline" method="post" action="/admin/runways/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认删除(清空 S4)?')">删除</button></form>{{else}}<form class="inline" method="post" action="/admin/street-snaps/{{.UID}}/toggle"><input type="hidden" name="deleted" value="1"><button type="submit" class="op op-warn" onclick="return confirm('确认删除(清空 S4)?')">删除</button></form>{{end}}
</div>
</td>
</tr>{{end}}
</tbody>
</table>
</div>
{{template "pager" .Pager}}
<div class="batchbar" id="batchbar" hidden>
<span class="muted" id="sel-count">已选 0 条</span>
<span class="spacer"></span>
<button type="button" id="batch-approve">批量通过</button>
<button type="button" id="batch-clear" class="ghost">清空选择</button>
</div>
</div>
{{end}}

View File

@ -0,0 +1,15 @@
{{define "title"}}{{if .UID}}编辑品牌{{else}}新建品牌{{end}} · 管理后台{{end}}
{{define "barLabel"}}管理后台 · {{if .UID}}编辑品牌{{else}}新建品牌{{end}}{{end}}
{{define "barRight"}}<a href="/admin/brands">返回列表</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>{{if .UID}}编辑品牌{{else}}新建品牌{{end}}</h1>
<form method="post" action="{{if .UID}}/admin/brands/{{.UID}}{{else}}/admin/brands{{end}}">
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<label>英文名(name_en)</label><input name="name_en" value="{{.NameEn}}" placeholder="如 Acne Studios">
<label>中文名(name_cn)</label><input name="name_cn" value="{{.NameCn}}" placeholder="如 艾克妮">
<button type="submit">{{if .UID}}保存{{else}}创建{{end}}</button>
</form>
</div>
{{end}}

View File

@ -0,0 +1,51 @@
{{define "title"}}品牌管理 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 品牌管理{{end}}
{{define "barRight"}}<a href="/admin/brands/new">+ 新建</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>品牌列表</h1>
<form class="toolbar" method="get" action="/admin/brands">
<input name="keyword" placeholder="搜索品牌名…" value="{{.Keyword}}">
<button type="submit">搜索</button>
{{if .Keyword}}<a href="/admin/brands">清除</a>{{end}}
</form>
<div class="idx">
<a href="/admin/brands?keyword={{.Keyword}}" class="{{if eq .Letter ""}}on{{end}}">全部</a>
<a href="/admin/brands?letter=OTHER&keyword={{.Keyword}}" class="{{if eq .Letter "OTHER"}}on{{end}}">中文</a>
<a href="/admin/brands?letter=A&keyword={{.Keyword}}" class="{{if eq .Letter "A"}}on{{end}}">A</a>
<a href="/admin/brands?letter=B&keyword={{.Keyword}}" class="{{if eq .Letter "B"}}on{{end}}">B</a>
<a href="/admin/brands?letter=C&keyword={{.Keyword}}" class="{{if eq .Letter "C"}}on{{end}}">C</a>
<a href="/admin/brands?letter=D&keyword={{.Keyword}}" class="{{if eq .Letter "D"}}on{{end}}">D</a>
<a href="/admin/brands?letter=E&keyword={{.Keyword}}" class="{{if eq .Letter "E"}}on{{end}}">E</a>
<a href="/admin/brands?letter=F&keyword={{.Keyword}}" class="{{if eq .Letter "F"}}on{{end}}">F</a>
<a href="/admin/brands?letter=G&keyword={{.Keyword}}" class="{{if eq .Letter "G"}}on{{end}}">G</a>
<a href="/admin/brands?letter=H&keyword={{.Keyword}}" class="{{if eq .Letter "H"}}on{{end}}">H</a>
<a href="/admin/brands?letter=I&keyword={{.Keyword}}" class="{{if eq .Letter "I"}}on{{end}}">I</a>
<a href="/admin/brands?letter=J&keyword={{.Keyword}}" class="{{if eq .Letter "J"}}on{{end}}">J</a>
<a href="/admin/brands?letter=K&keyword={{.Keyword}}" class="{{if eq .Letter "K"}}on{{end}}">K</a>
<a href="/admin/brands?letter=L&keyword={{.Keyword}}" class="{{if eq .Letter "L"}}on{{end}}">L</a>
<a href="/admin/brands?letter=M&keyword={{.Keyword}}" class="{{if eq .Letter "M"}}on{{end}}">M</a>
<a href="/admin/brands?letter=N&keyword={{.Keyword}}" class="{{if eq .Letter "N"}}on{{end}}">N</a>
<a href="/admin/brands?letter=O&keyword={{.Keyword}}" class="{{if eq .Letter "O"}}on{{end}}">O</a>
<a href="/admin/brands?letter=P&keyword={{.Keyword}}" class="{{if eq .Letter "P"}}on{{end}}">P</a>
<a href="/admin/brands?letter=Q&keyword={{.Keyword}}" class="{{if eq .Letter "Q"}}on{{end}}">Q</a>
<a href="/admin/brands?letter=R&keyword={{.Keyword}}" class="{{if eq .Letter "R"}}on{{end}}">R</a>
<a href="/admin/brands?letter=S&keyword={{.Keyword}}" class="{{if eq .Letter "S"}}on{{end}}">S</a>
<a href="/admin/brands?letter=T&keyword={{.Keyword}}" class="{{if eq .Letter "T"}}on{{end}}">T</a>
<a href="/admin/brands?letter=U&keyword={{.Keyword}}" class="{{if eq .Letter "U"}}on{{end}}">U</a>
<a href="/admin/brands?letter=V&keyword={{.Keyword}}" class="{{if eq .Letter "V"}}on{{end}}">V</a>
<a href="/admin/brands?letter=W&keyword={{.Keyword}}" class="{{if eq .Letter "W"}}on{{end}}">W</a>
<a href="/admin/brands?letter=X&keyword={{.Keyword}}" class="{{if eq .Letter "X"}}on{{end}}">X</a>
<a href="/admin/brands?letter=Y&keyword={{.Keyword}}" class="{{if eq .Letter "Y"}}on{{end}}">Y</a>
<a href="/admin/brands?letter=Z&keyword={{.Keyword}}" class="{{if eq .Letter "Z"}}on{{end}}">Z</a>
</div>
<table>
<thead><tr><th>编码 ID</th><th>英文名</th><th>中文名</th></tr></thead>
<tbody>
{{range .Items}}<tr><td><code>{{.UID}}</code></td><td>{{.NameEn}}</td><td>{{.NameCn}}</td><td><a class="view" href="/admin/brands/{{.UID}}">编辑</a></td></tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -0,0 +1,16 @@
{{define "title"}}管理后台{{end}}
{{define "barLabel"}}管理后台 · Fashion Archive{{end}}
{{define "barRight"}}<a href="/admin/logout">退出({{.Username}})</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>欢迎,{{.Username}}</h1>
{{if .Kinds}}<div class="chips">{{range .Kinds}}{{if ne .Kind ""}}<span class="chip">{{.Label}}:待审 {{.Pending}}</span>{{end}}{{end}}</div>{{end}}
<div class="cards">
<a class="card" href="/admin/brands"><div class="t">品牌管理</div><div class="d">查看 / 搜索全部品牌</div></a>
<a class="card" href="/admin/articles"><div class="t">文章管理{{if gt .PendingReviews 0}}(待审 {{.PendingReviews}}){{end}}</div><div class="d">走秀 / 街拍:通过 · 编辑 · 删除(下架)</div></a>
<a class="card" href="/admin/ingest-jobs"><div class="t">入库任务</div><div class="d">爬虫上报处理进度 / 失败重试</div></a>
<a class="card" href="/admin/users"><div class="t">用户管理</div><div class="d">账号等级 · 提级 VIP</div></a>
</div>
</div>
{{end}}

View File

@ -0,0 +1,5 @@
{{define "title"}}错误{{end}}
{{define "content"}}
<h1>出错了</h1><p style="color:#c0392b">{{.Error}}</p>
<p><a href="/admin/">返回首页</a></p>
{{end}}

View File

@ -0,0 +1,24 @@
{{define "title"}}入库任务 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 爬虫入库任务{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>爬虫入库任务</h1>
<p class="muted">worker 每 3 秒扫描一次 pending 任务并处理;failed 可一键重新入队。共 {{.Total}} 条,当前第 {{.Page}} / {{if eq .TotalPages 0}}1{{else}}{{.TotalPages}}{{end}} 页(每页 {{.PageSize}} 条)。</p>
<table>
<thead><tr><th>ID</th><th>状态</th><th>重试</th><th>错误信息</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody>
{{range .Jobs}}<tr>
<td><code>{{.ID}}</code></td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>{{.Attempts}}</td>
<td class="err break">{{.LastError}}</td>
<td class="muted">{{.UpdatedAt}}</td>
<td>{{if eq .Status "failed"}}<form class="retry" method="post" action="/admin/ingest-jobs/{{.ID}}/retry"><button class="retry" type="submit">重试</button></form>{{else}}<span class="muted">—</span>{{end}}</td>
</tr>{{end}}
</tbody>
</table>
{{template "pager" .Pager}}
</div>
{{end}}

View File

@ -0,0 +1,10 @@
{{define "title"}}管理后台 · 登录{{end}}
{{define "content"}}
<div class="wrap"><h1>管理后台登录</h1>
<form method="post" action="/admin/login">
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<label>账号</label><input name="account" value="{{.Account}}" autofocus>
<label>密码</label><input name="password" type="password">
<button type="submit">登录</button>
</form></div>
{{end}}

View File

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

View File

@ -0,0 +1,29 @@
{{define "title"}}编辑走秀 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 编辑走秀 {{.UID}}{{end}}
{{define "barRight"}}<a href="/admin/runways/{{.UID}}">返回详情</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
<form method="post" action="/admin/runways/{{.UID}}/edit">
<div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div>
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Runway.Year}}"></div><div><label>季节</label><input name="season" value="{{.Runway.Season}}"></div></div>
<div class="row"><div><label>系列 (collection_type)</label><input name="collection_type" value="{{.Runway.CollectionType}}"></div><div><label>季节码 (season_code)</label><input name="season_code" value="{{.Runway.SeasonCode}}"></div></div>
<button type="submit">保存字段</button>
</form>
{{template "imageGrid" .}}
{{if eq .Runway.Status "pending"}}
<div class="review-actions">
<form method="post" action="/admin/articles/runway/{{.Runway.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
</div>
{{end}}
<form method="post" action="/admin/runways/{{.UID}}/toggle" style="margin-top:22px">
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
</form>
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
</div>
{{end}}

View File

@ -0,0 +1,27 @@
{{define "title"}}编辑街拍 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 编辑街拍 {{.UID}}{{end}}
{{define "barRight"}}<a href="/admin/street-snaps/{{.UID}}">返回详情</a> · <a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
<form method="post" action="/admin/street-snaps/{{.UID}}/edit">
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>
<button type="submit">保存字段</button>
</form>
{{template "imageGrid" .}}
{{if eq .Snap.Status "pending"}}
<div class="review-actions">
<form method="post" action="/admin/articles/street/{{.Snap.ID}}/approve"><button type="submit" class="op op-primary">通过并发布</button></form>
</div>
{{end}}
<form method="post" action="/admin/street-snaps/{{.UID}}/toggle" style="margin-top:22px">
{{if .IsDown}}<input type="hidden" name="deleted" value="0"><button type="submit">重新上架</button>{{else}}<input type="hidden" name="deleted" value="1"><button type="submit" class="reject">删除(清空 S4)</button>{{end}}
</form>
<p class="muted" style="margin-top:10px">下架会级联软删全部图片,并异步把S4孤儿图清理任务入队(按引用计数判定,共享图不误删)。</p>
</div>
{{end}}

View File

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

View File

@ -0,0 +1,30 @@
{{define "title"}}用户管理 · 管理后台{{end}}
{{define "barLabel"}}管理后台 · 用户管理{{end}}
{{define "barRight"}}<a href="/admin/logout">退出</a>{{end}}
{{define "content"}}
{{template "bar" .}}
<div class="main">
<h1>用户管理({{len .Users}})</h1>
<p class="muted">图片质量模型已统一:所有用户(含未登录)看图集详情均走同一高清样式,不再按等级分级。</p>
<table>
<thead><tr><th>ID</th><th>用户名</th><th>邮箱</th><th>等级</th><th>操作</th></tr></thead>
<tbody>
{{range .Users}}<tr>
<td><code>{{.ID}}</code></td>
<td>{{.Username}}</td>
<td class="muted">{{.Email}}</td>
<td>{{if eq .Tier "vip"}}<span class="badge vip">VIP</span>{{else}}<span class="badge free">FREE</span>{{end}}</td>
<td>
<form class="tier" method="post" action="/admin/users/{{.ID}}/tier">
<select name="tier">
<option value="free" {{if eq .Tier "free"}}selected{{end}}>free(普通)</option>
<option value="vip" {{if eq .Tier "vip"}}selected{{end}}>vip</option>
</select>
<button type="submit">保存</button>
</form>
</td>
</tr>{{end}}
</tbody>
</table>
</div>
{{end}}

View File

@ -0,0 +1,82 @@
{{/*
共享片段:所有后台页面通用的顶部导航与翻页器。
由 templates.go 在启动时与 layout.html 一起解析进「基模板」,各页面模板只需
{{template "bar" .}} / {{template "pager" .Pager}} 引用,改一处即全局生效
—— 这也是把导航从 14 份复制粘贴里提出来、补全模块入口的原因。
页面需要提供的数据:
.NavKey 由 renderPage 自动注入(= 当前页面名),用于导航高亮
.PendingReviews 由 backstageNavStats 中间件注入(待审总数,导航角标)
barLabel/barRight 两个 define 由各页面模板自己提供(见 pages/*.html)
*/}}
{{define "bar"}}
<div class="bar">
<span class="bar-title">{{template "barLabel" .}}</span>
<nav class="bar-nav">
<a href="/admin/" class="{{if eq .NavKey "dashboard"}}on{{end}}">首页</a>
<a href="/admin/articles" class="{{if or (eq .NavKey "articles") (eq .NavKey "runway-edit") (eq .NavKey "snap-edit") (eq .NavKey "runway-detail") (eq .NavKey "street-snap-detail")}}on{{end}}">文章{{if gt .PendingReviews 0}}<span class="bar-badge">{{.PendingReviews}}</span>{{end}}</a>
<a href="/admin/brands" class="{{if or (eq .NavKey "brands") (eq .NavKey "brand-form")}}on{{end}}">品牌</a>
<a href="/admin/ingest-jobs" class="{{if eq .NavKey "ingest-jobs"}}on{{end}}">入库任务</a>
<a href="/admin/users" class="{{if eq .NavKey "users"}}on{{end}}">用户</a>
<span class="sep"></span>
{{template "barRight" .}}
</nav>
</div>
{{end}}
{{define "pager"}}
<div class="pager">
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
</div>
{{end}}
{{/*
共享片段:街拍 / 走秀编辑页共用的「图片管理」块(网格 + 合并为一组 + 拆出)。
两个后台编辑页之前各写一份几乎相同的图片管理 HTML,现已抽到这里,改一处全局生效。
页面需要提供的字段:
.Kind "street" | "runway"(决定封面/删除路由前缀与主/副图文案)
.UID 记录的 hashid(用于 /admin/{street-snaps|runways}/<UID>/... 路由)
.RecordID 记录数字 id(用于 /admin/articles/<kind>/<id>/... 路由)
.Status 记录状态(pending 时显示待审角标)
.Cells []gin.H,每项 {id, image, name, isDetail, role}
*/}}
{{define "imageGrid"}}
<h2>图片管理(<span id="img-stat">{{len .Cells}} 张</span>{{if eq .Status "pending"}} · <span class="badge pending">待审核</span>{{end}}{{if .DupCount}} · 重复率 <span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}} 张)</span>{{end}})</h2>
<div class="hint">
操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。<br>
{{if eq .Kind "runway"}}走秀图按 Look 分组:同一 Look 的主图 + 其细节图(副图)。{{end}}
<button type="button" class="x" id="hint-dismiss" aria-label="收起提示">✕</button>
</div>
{{$kind := .Kind}}{{$uid := .UID}}{{$rid := .RecordID}}{{$dupSources := .DupSources}}
<form id="batch" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/attach"></form>
<div class="grid">
{{range .Cells}}<div class="cell{{if eq .isDetail 1}} det{{if .orphan}} shown{{end}}{{end}}" data-img-id="{{.id}}"{{if eq .isDetail 1}} data-parent="{{.parent}}"{{end}}>
<button class="thumb" type="button" data-zoom><img src="{{imgURL .image}}" loading="lazy"></button>
<span class="role">{{.role}}</span>
<span class="nm">{{.name}}</span>
{{if eq .isDuplicate 1}}<span class="badge dup">重复</span>
{{with index $dupSources .dupOf}}{{if .srcTitle}}<details class="dup-src"><summary>查看重复来源</summary><div class="dup-src-body"><img src="{{imgURL .srcImage}}" loading="lazy" alt="源图缩略图"><div><div class="dup-title">属于:<b>{{.srcTitle}}</b>{{if .srcBrand}} · {{.srcBrand}}{{end}}</div><a class="dup-link" href="/admin/{{if eq .srcKind "runway"}}runways{{else}}street-snaps{{end}}/{{.srcUID}}/edit" target="_blank">打开所属{{if eq .srcKind "runway"}}走秀{{else}}街拍{{end}} →</a></div></div></details>{{end}}{{end}}
{{end}}
<div class="acts">
{{if eq $kind "runway"}}<form method="post" action="/admin/runways/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/runways/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
{{else}}<form method="post" action="/admin/street-snaps/{{$uid}}/cover"><input type="hidden" name="image" value="{{.image}}"><button type="submit">设封面</button></form>
<form method="post" action="/admin/street-snaps/{{$uid}}/images/{{.id}}/delete"><button type="submit" class="reject">删除</button></form>
{{end}}
{{if eq .isDetail 0}}<label class="chk" data-only="main"><input type="radio" form="batch" name="main" value="{{.id}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.id}}"> 选入</label>
{{if and (eq .isDetail 0) .DetCount}}<button type="button" class="toggle-det" data-parent="{{.id}}">显示副图({{.DetCount}} 张)</button>{{end}}
{{if eq .isDetail 1}}<form data-only="detail" method="post" action="/admin/articles/{{$kind}}/{{$rid}}/images/{{.id}}/detach"><button type="submit">拆出</button></form>{{end}}
</div>
</div>{{end}}
</div>
<div class="batchbar">
<button type="submit" form="batch">合并选中为一组</button>
<span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span>
</div>
{{end}}

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
Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null
SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25
PublishedAt uint32 `gorm:"column:published_at" json:"published_at"` // 发布时间 Unix 时间戳(秒);0=未抽取。Vogue datePublished;标题缺年份时用于矫正 year
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"` // 关联 ingest_jobs.id(重跑溯源)
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"` // 审核人(admin 账号)
}
// TableName 指定表名。
@ -43,6 +49,7 @@ type RunwayRow struct {
CollectionType string `gorm:"column:collection_type"`
Season string `gorm:"column:season"`
SeasonCode string `gorm:"column:season_code"`
Status string `gorm:"column:status"`
CreatedAt uint32 `gorm:"column:created_at"`
BrandNameEn string `gorm:"column:brand_name_en"`
BrandNameCn string `gorm:"column:brand_name_cn"`

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

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

View File

@ -8,14 +8,21 @@ import "database/sql"
// 与 brand_runways 双表同构,但**无 brand_id**(街拍不关联品牌,模块自治)。
type StreetSnap struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
Title string `gorm:"column:title" json:"title"`
Title string `gorm:"column:title" json:"title"` // 英文标题(既有源,改动前唯一标题列)
TitleCn string `gorm:"column:title_cn" json:"title_cn"` // 中文标题:后台人工翻译;为空时前端/接口回落英文
Year uint16 `gorm:"column:year" json:"year"`
City string `gorm:"column:city" json:"city"` // 地区/城市(从 title 第一段 \t 解析回填),'' 表示未归类
Cover string `gorm:"column:cover" json:"cover"`
ImageCount uint16 `gorm:"column:image_count" json:"image_count"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
// 审核态与溯源(单表发布模型;列由 db/migrations/2026-09-22-01-single-table-publish.sql 添加)。
Status string `gorm:"column:status;size:16;not null;default:'pending'" json:"status"`
JobID uint32 `gorm:"column:job_id;not null;default:0" json:"job_id"`
Reviewer string `gorm:"column:reviewer;size:64;not null;default:''" json:"reviewer"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
}
// TableName 指定主表名。
@ -25,19 +32,24 @@ func (StreetSnap) TableName() string { return "street_snaps" }
//
// 仿 brand_runway_images,但把 runway_id 改名为 snap_id,去掉 brand_id(街拍无品牌关联)。
type StreetSnapImage struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
SnapID uint32 `gorm:"column:snap_id" json:"snap_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"`
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
SnapID uint32 `gorm:"column:snap_id" json:"snap_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。
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 指定图片明细表名。

View File

@ -1,49 +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"`
}
// TableName 指定草稿图片表名。
func (StreetSnapDraftImage) TableName() string { return "street_snap_draft_images" }

View File

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

View File

@ -90,7 +90,7 @@ func TestOfDifferent(t *testing.T) {
// TestToVectorBits 验证向量串格式与维度(64 位、方括号包裹、0/1 组成)。
func TestToVectorBits(t *testing.T) {
s := ToVectorBits(0b1) // 仅最低位为 1
s := ToVectorBits(0b1) // 仅最低位为 1
if len(s) != 2*Size+1 { // 64 个 0/1 + 63 个逗号 + 2 个方括号 = 129
t.Fatalf("向量串长度应为 %d,got %d(%s)", 2*Size+1, len(s), s)
}

View File

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

View File

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

View File

@ -3,11 +3,13 @@ package repository
import (
"context"
"errors"
"math"
"strconv"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -18,20 +20,32 @@ type ArticleRepository interface {
List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// ListAdmin 后台管理列表:投影列含 season/year/collection_type/season_code 等管理字段。
ListAdmin(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error)
// FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
// FindByID 公开读:查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。
FindByID(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 查询某篇文章的全部图片,按排序值升序。
// FindByIDBase 后台读基表:与 FindByID 同投影/同 JOIN,但能看到 pending / rejected。
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error)
// ListImages 公开读:查询某篇文章的全部图片,按排序值升序(未发布图片不可见)。
ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error)
// ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。
ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error)
// GetForEdit 取原始走秀记录(含全部 en/cn 字段)与图片,供后台编辑表单预填。
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
// UpdateRunway 审核/编辑后更新字段(白名单,杜绝任意列注入)。
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
// DeleteRunwayImage 软删单张走秀图片。
// DeleteRunwayImage 软删单张走秀图片,并按实际存活主图行数重算 image_count。
DeleteRunwayImage(ctx context.Context, imageID uint32) error
// GetRunwayImageKey 取单张走秀图片的S4 key(删除前清理用);不存在返回空串。
GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error)
// GetRunwayDupSources 批量反查重复源:给定一组走秀图片 id(即 dup_of 指向的源图 id),
// 返回每张源图及其所属走秀(标题、品牌)的上下文,供编辑页标记重复图时展示来源。
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
// ReresolveRunwayDedup 删图后重算本画廊去重,消除陈旧 dup_of(见方法注释)。
ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用),返回被删图片的 key 列表。
SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error)
// SetRunwayCover 更新走秀封面(同时回到图片列表首张)。
@ -62,11 +76,12 @@ const articleDetailColumns = `brand_runways.id, brand_runways.title_en, brand_ru
brand_runways.cover, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// articleAdminColumns 后台管理列表投影列:在列表列基础上补回 season / year /
// collection_type / season_code 等管理字段(前端列表不展示这些)。
// collection_type / season_code / status 等管理字段(前端列表不展示这些)。
const articleAdminColumns = `brand_runways.id, brand_runways.brand_id,
brand_runways.title_en, brand_runways.title_cn,
brand_runways.cover, brand_runways.image_count, brand_runways.year,
brand_runways.collection_type, brand_runways.season, brand_runways.season_code,
brand_runways.status,
b.name_en AS brand_name_en, b.name_cn AS brand_name_cn`
// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。
@ -142,7 +157,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
var total int64
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Table(publicRunways).
Scopes(scope).
Count(&total).Error; err != nil {
return nil, 0, err
@ -153,7 +168,7 @@ func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]mod
var rows []model.RunwayRow
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Table(publicRunways).
Scopes(scope).
Select(articleListColumns).
Joins(brandJoin).
@ -196,11 +211,20 @@ func (r *articleRepository) ListAdmin(ctx context.Context, q dto.ArticleQuery) (
return rows, total, nil
}
// FindByID 公开读:读公开视图,pending / rejected 视同不存在(返回 ErrNotFound)。
func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) {
return r.findByID(r.db.WithContext(ctx).Table(publicRunways), id)
}
// FindByIDBase 后台读基表:同投影同 JOIN,但不过滤 status(后台详情需渲染待审内容)。
func (r *articleRepository) FindByIDBase(ctx context.Context, id string) (*model.RunwayRow, error) {
return r.findByID(r.db.WithContext(ctx).Model(&model.BrandRunway{}), id)
}
// findByID 用给定查询(视图或基表)取单行;投影与 JOIN 两处保持一致,避免公开/后台口径漂移。
func (r *articleRepository) findByID(q *gorm.DB, id string) (*model.RunwayRow, error) {
var row model.RunwayRow
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select(articleDetailColumns).
err := q.Select(articleDetailColumns).
Joins(brandJoin).
Where("brand_runways.id = ? AND brand_runways.is_deleted = 0", id).
First(&row).Error
@ -213,7 +237,19 @@ func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.Run
return &row, nil
}
// ListImages 公开读:读公开视图,未发布走秀的图片不可见(视图已含 is_deleted 过滤)。
func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Table(publicRunwayImages).
Where("runway_id = ?", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
func (r *articleRepository) ListImagesBase(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
@ -230,8 +266,9 @@ func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32)
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Table(publicRunwayImages).
Select("runway_id, image, name, sort_order").
Where("runway_id IN ? AND is_deleted = 0 AND is_detail = 0", ids).
Where("runway_id IN ? AND is_detail = 0", ids).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
if err != nil {
@ -265,7 +302,7 @@ func (r *articleRepository) GetForEdit(ctx context.Context, id uint32) (*model.B
}
return nil, nil, err
}
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil {
return nil, nil, err
}
@ -289,11 +326,64 @@ func (r *articleRepository) UpdateRunway(ctx context.Context, id uint32, fields
Updates(clean).Error
}
// DeleteRunwayImage 软删单张走秀图片(后台正式编辑页删图),并同步重算 image_count。
//
// 与审核侧 SoftDeleteRunwayImage 口径一致:删完按**实际存活主图行数**重算
// (`is_deleted = 0 AND is_detail = 0`,细节图不计入)。
//
// 为什么必须重算而不是「递减 1」:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径,
// 编辑页删图若只递减,过期页面 / 重复提交 / 跨记录重放(命中 0 行仍递减)造成的漂移会永久固化,
// 并直接显示在公开列表卡片的「N 张」角标里。
//
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 runway_id → 软删 → 重算。
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
func (r *articleRepository) DeleteRunwayImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.BrandRunwayImage
err := tx.Select("runway_id, image").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
var rw model.BrandRunway
if err := tx.Select("cover").Where("id = ?", img.RunwayID).First(&rw).Error; err != nil {
return err
}
if err := tx.Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
if err := tx.Exec(`UPDATE brand_runways SET image_count =
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
WHERE id = ?`, img.RunwayID, img.RunwayID).Error; err != nil {
return err
}
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
if rw.Cover == img.Image {
cover := ""
var survivor struct {
Image string `gorm:"column:image"`
}
serr := tx.Raw(
"SELECT image FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
img.RunwayID,
).Scan(&survivor).Error
if serr == nil {
cover = survivor.Image
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
return serr
}
if err := tx.Model(&model.BrandRunway{}).Where("id = ?", img.RunwayID).
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
}
return nil
})
}
func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint32) (string, error) {
@ -308,6 +398,128 @@ func (r *articleRepository) GetRunwayImageKey(ctx context.Context, imageID uint3
return img.Image, nil
}
// GetRunwayImageRunwayID 取图片所属 runway_id(不过滤 is_deleted,软删后仍可取,
// 供编辑页删图后重算去重时定位所属画廊)。
func (r *articleRepository) GetRunwayImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
var img model.BrandRunwayImage
err := r.db.WithContext(ctx).Select("runway_id").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return img.RunwayID, nil
}
// nearDupExceptRunway 在 brand_runway_images 内做全局近重复检索(is_deleted=0),
// 但排除自身与「同 runway 且 sort_order 在后的图」——后者防止重算时后入者反成源导致互指环。
// 命中返回源图 id,无则 0。
func nearDupExceptRunway(db *gorm.DB, phashBits string, threshold int, excludeID, runwayID, sortOrder uint32) (uint32, error) {
l2Limit := math.Sqrt(float64(threshold))
var row struct {
ID uint32 `gorm:"column:id"`
}
if err := db.Table("brand_runway_images").
Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Where("is_deleted = 0").
Where("id <> ?", excludeID).
Where("(runway_id <> ? OR sort_order < ?)", runwayID, sortOrder).
Order("dist ASC").
Limit(1).
Scan(&row).Error; err != nil {
return 0, err
}
return row.ID, nil
}
// ReresolveRunwayDedup 删除图片后重解本画廊去重:先清空全部存活图的 is_duplicate/dup_of,
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
//
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;
// 删除「源图」后,剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」,且「查看重复来源」指向已删
// 源图(其 S4 文件可能已被清理)而打不开。重算后,被删源图不再作为参照,剩余近重复图重新确立
// 「先入者为准」的源/副本关系,使「重复」标记始终反映当前真实图片。
func (r *articleRepository) ReresolveRunwayDedup(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
if len(imgs) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
return err
}
for _, im := range imgs {
if !im.Phash.Valid || im.Phash.String == "" {
continue
}
src, err := nearDupExceptRunway(tx, im.Phash.String, phash.DefaultThreshold, im.ID, runwayID, im.SortOrder)
if err != nil {
return err
}
if src != 0 && src != im.ID {
if err := tx.Model(&model.BrandRunwayImage{}).
Where("id = ? AND is_deleted = 0", im.ID).
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
return err
}
}
}
return nil
})
}
// GetRunwayDupSources 批量反查重复源(见接口注释)。一次 IN 查询 + JOIN 走秀与品牌,
// 避免逐图 N+1;源图可能已被删(查不到则跳过,不报错)。
func (r *articleRepository) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
out := make(map[uint32]dto.DupSource, len(imageIDs))
if len(imageIDs) == 0 {
return out, nil
}
type srcRow struct {
ImageID uint32
Image string
RunwayID uint32
TitleEn string
TitleCn string
BrandEn string
BrandCn string
}
var rows []srcRow
err := r.db.WithContext(ctx).
Table("brand_runway_images bri").
Select("bri.id AS image_id, bri.image AS image, bri.runway_id AS runway_id, br.title_en AS title_en, br.title_cn AS title_cn, b.name_en AS brand_en, b.name_cn AS brand_cn").
Joins("JOIN brand_runways br ON br.id = bri.runway_id").
Joins("LEFT JOIN brands b ON b.id = br.brand_id").
Where("bri.id IN ?", imageIDs).
Where("bri.is_deleted = 0").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, rw := range rows {
title := rw.TitleEn
if title == "" {
title = rw.TitleCn
}
brand := rw.BrandEn
if brand == "" {
brand = rw.BrandCn
}
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.RunwayID, Title: title, Brand: brand}
}
return out, nil
}
// SoftDeleteRunwayImages 软删某走秀的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID uint32) ([]string, error) {
@ -330,6 +542,13 @@ func (r *articleRepository) SoftDeleteRunwayImages(ctx context.Context, runwayID
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", runwayID).
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
return keys, nil
}

View File

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

View File

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

View File

@ -0,0 +1,410 @@
//go:build integration
// 集成测试:后台正式编辑页(ArticleRepository / StreetSnapRepository)删图后,
// image_count 必须按实际存活行**重算** —— 与审核侧 SoftDelete*Image 口径完全一致。
//
// 为什么必须有这条:单表发布模型删掉了「晋升时按实际图片行重算」的自愈路径。
// 旧流程下编辑页删图造成的计数漂移会在下次发布被抹平;现在不会了,漂移会永久留在公开列表
// 卡片的「N 张」角标上。入口链路:templates/pages/*-edit.html 删除按钮 →
// backstage_handler.go → article_service/street_snap_service.DeleteImage → 本仓储方法。
//
// 运行:go test -tags integration ./internal/repository/ -run TestEditorDeleteImage -v
package repository
import (
"context"
"database/sql"
"fmt"
"strconv"
"testing"
"time"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
// editorSeedSeq 让每次 seed 出来的实体键互不相同(避开 uq_br_entity / uq_ss_entity 部分唯一索引)。
var editorSeedSeq int
// seedEditorRunway 插入一条走秀记录(image_count 预置为入参,便于观察是否被重算)并返回其 id。
func seedEditorRunway(t *testing.T, db *gorm.DB, imageCount uint16) uint32 {
t.Helper()
editorSeedSeq++
now := uint32(time.Now().Unix())
rw := model.BrandRunway{
BrandID: 1, TitleEn: "editor-delete-test", Year: 2026,
SeasonCode: fmt.Sprintf("ED%04d", editorSeedSeq), CollectionType: "rtw",
Status: model.StatusPending, ImageCount: imageCount, CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&rw).Error; err != nil {
t.Fatalf("插入走秀记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
})
return rw.ID
}
// seedEditorRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回行 id。
func seedEditorRunwayImage(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8) uint32 {
t.Helper()
img := model.BrandRunwayImage{
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
SortOrder: order, IsDetail: isDetail,
}
if err := db.Create(&img).Error; err != nil {
t.Fatalf("插入走秀图片失败: %v", err)
}
return img.ID
}
// seedEditorSnap 插入一条街拍记录(image_count 预置为入参)并返回其 id。
func seedEditorSnap(t *testing.T, db *gorm.DB, imageCount uint16) uint32 {
t.Helper()
editorSeedSeq++
now := uint32(time.Now().Unix())
snap := model.StreetSnap{
Title: "editor-delete-test", City: fmt.Sprintf("EditorCity-%d", editorSeedSeq),
Year: 2026, Status: model.StatusPending, ImageCount: imageCount,
CreatedAt: now, UpdatedAt: now,
}
if err := db.Create(&snap).Error; err != nil {
t.Fatalf("插入街拍记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
})
return snap.ID
}
// seedEditorSnapImage 插入一张街拍图(isDetail=1 表示副图)并返回行 id。
func seedEditorSnapImage(t *testing.T, db *gorm.DB, snapID uint32, name string, order uint32, isDetail uint8, parent uint32) uint32 {
t.Helper()
img := model.StreetSnapImage{
SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order,
IsDetail: isDetail, ParentImageID: parent,
}
if err := db.Create(&img).Error; err != nil {
t.Fatalf("插入街拍图片失败: %v", err)
}
return img.ID
}
// seedEditorRunwayImagePhash 插入一张带 phash 的走秀图(isDetail 表示主/细节),返回行 id。
func seedEditorRunwayImagePhash(t *testing.T, db *gorm.DB, runwayID uint32, name string, order uint32, isDetail uint8, phashBits string) uint32 {
t.Helper()
var ph sql.NullString
if phashBits != "" {
ph = sql.NullString{String: phashBits, Valid: true}
}
img := model.BrandRunwayImage{
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
SortOrder: order, IsDetail: isDetail, Phash: ph,
}
if err := db.Create(&img).Error; err != nil {
t.Fatalf("插入带 phash 走秀图片失败: %v", err)
}
return img.ID
}
// editorImageFlag 读某图片的 is_duplicate / dup_of(验证去重重算结果)。
func editorImageFlag(t *testing.T, db *gorm.DB, id uint32) (uint8, string) {
t.Helper()
var row struct {
IsDuplicate uint8 `gorm:"column:is_duplicate"`
DupOf string `gorm:"column:dup_of"`
}
if err := db.Table("brand_runway_images").Select("is_duplicate, dup_of").Where("id = ?", id).Scan(&row).Error; err != nil {
t.Fatalf("读去重标记失败: %v", err)
}
return row.IsDuplicate, row.DupOf
}
// TestEditorReresolveRunwayDedupAfterSourceDeleted 模拟「删掉源图后」重算去重应消除悬空 dup_of:
// A 为源(被删),B、C 曾 dup_of=A;重算后 A 退出参照,B 成为新源(dup=0),C 重指 B(dup=1),
// 不再有任何图指向已删的 A —— 对应线上「删了源图后仍显示重复、源图查看重复来源打不开」。
func TestEditorReresolveRunwayDedupAfterSourceDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 3)
// A=全1 向量;B 翻转 1 位(距 A=1);C 翻转 2 位(距 A=2)—— 三者互为近重复。
allOne := phash.ToVectorBits(^uint64(0))
bVec := phash.ToVectorBits(^uint64(0) &^ (uint64(1) << 0))
cVec := phash.ToVectorBits(^uint64(0) &^ ((uint64(1) << 0) | (uint64(1) << 1)))
a := seedEditorRunwayImagePhash(t, db, id, "A", 1, 0, allOne)
b := seedEditorRunwayImagePhash(t, db, id, "B", 2, 0, bVec)
c := seedEditorRunwayImagePhash(t, db, id, "C", 3, 0, cVec)
// 预置入库去重后的陈旧态:B、C 都 dup_of=A。
if err := db.Model(&model.BrandRunwayImage{}).Where("id IN ?", []uint32{b, c}).
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(a), 10)}).Error; err != nil {
t.Fatalf("预置陈旧去重标记失败: %v", err)
}
// 删掉源图 A(软删,模拟编辑页删除)。
if err := repo.DeleteRunwayImage(ctx, a); err != nil {
t.Fatalf("删源图失败: %v", err)
}
// 重算去重(编辑页删图会自动调用,这里直接验证仓储方法)。
if err := repo.ReresolveRunwayDedup(ctx, id); err != nil {
t.Fatalf("重算去重失败: %v", err)
}
diB, ofB := editorImageFlag(t, db, b)
diC, ofC := editorImageFlag(t, db, c)
if diB != 0 {
t.Fatalf("B 应成为新源(is_duplicate=0),实际 %d", diB)
}
if ofB != "" {
t.Fatalf("B 的 dup_of 应为空,实际 %q", ofB)
}
if diC != 1 {
t.Fatalf("C 应标记为重复(is_duplicate=1),实际 %d", diC)
}
if ofC != strconv.FormatUint(uint64(b), 10) {
t.Fatalf("C 的 dup_of 应指向 B(%d),实际 %q", b, ofC)
}
}
// editorImageCount 读某记录的 image_count(table / idCol 由测试常量指定,无注入面)。
func editorImageCount(t *testing.T, db *gorm.DB, table, idCol string, id uint32) uint16 {
t.Helper()
var row struct {
ImageCount uint16 `gorm:"column:image_count"`
}
if err := db.Table(table).Select("image_count").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
t.Fatalf("读 %s.image_count 失败: %v", table, err)
}
return row.ImageCount
}
// TestEditorDeleteRunwayImageRecomputesCount 走秀编辑页删图:口径 = 存活主图行数(is_detail=0)。
func TestEditorDeleteRunwayImageRecomputesCount(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2) // 与「2 张主图」一致的初始值
keep := seedEditorRunwayImage(t, db, id, "keep", 1, 0)
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
detail := seedEditorRunwayImage(t, db, id, "detail", 3, 1)
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 2 {
t.Fatalf("前置 image_count 应为 2,实际 %d", got)
}
// 删一张主图:应按存活主图行数重算为 1。若只置 is_deleted 不重算,会停在 2。
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
t.Fatalf("编辑页删图失败: %v", err)
}
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
t.Fatalf("删一张主图后 image_count 应重算为 1,实际 %d(只置 is_deleted 不重算会停在 2)", got)
}
// 删细节图:口径只计主图,计数不变(锁住 is_detail = 0 过滤)。
if err := repo.DeleteRunwayImage(ctx, detail); err != nil {
t.Fatalf("删细节图失败: %v", err)
}
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
t.Fatalf("删细节图不应改变主图计数,实际 %d", got)
}
// 重复删同一张(已软删):重放幂等,计数不变。
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
t.Fatalf("重复删同一张应幂等,实际报错: %v", err)
}
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got)
}
// 不存在的 imageID:静默无操作,不得影响计数。
if err := repo.DeleteRunwayImage(ctx, 4_294_967_290); err != nil {
t.Fatalf("不存在的 imageID 不应报错: %v", err)
}
if got := editorImageCount(t, db, "brand_runways", "id", id); got != 1 {
t.Fatalf("不存在的 imageID 不应改变 image_count,实际 %d", got)
}
var alive []uint32
if err := db.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0", id).Order("id ASC").
Pluck("id", &alive).Error; err != nil {
t.Fatalf("读存活图失败: %v", err)
}
if len(alive) != 1 || alive[0] != keep {
t.Fatalf("应只剩 keep 一行,实际 %v", alive)
}
}
// TestEditorDeleteSnapImageRecomputesCount 街拍编辑页删图:口径 = 全部存活图片行(主图 + 副图)。
func TestEditorDeleteSnapImageRecomputesCount(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
main := seedEditorSnapImage(t, db, id, "main", 1, 0, 0)
detail := seedEditorSnapImage(t, db, id, "detail", 2, 1, main)
// 删主图:副图仍存活,街拍口径计全部存活行 → 应重算为 1。
// 若误用走秀的「只计主图」口径,会得 0 —— 本断言锁得住。
if err := repo.DeleteSnapImage(ctx, main); err != nil {
t.Fatalf("编辑页删图失败: %v", err)
}
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 {
t.Fatalf("删主图后 image_count 应按存活行重算为 1(副图也计入),实际 %d", got)
}
// 重复删同一张(已软删):重放幂等,计数不变。
if err := repo.DeleteSnapImage(ctx, main); err != nil {
t.Fatalf("重复删同一张应幂等,实际报错: %v", err)
}
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 1 {
t.Fatalf("重复删同一张不应改变 image_count,实际 %d", got)
}
// 删副图:全部删完 → 0。
if err := repo.DeleteSnapImage(ctx, detail); err != nil {
t.Fatalf("删副图失败: %v", err)
}
if got := editorImageCount(t, db, "street_snaps", "id", id); got != 0 {
t.Fatalf("删完所有图后 image_count 应为 0,实际 %d", got)
}
}
// editorCover 读某记录的 cover 列(用于封面悬空防护断言)。
func editorCover(t *testing.T, db *gorm.DB, table, idCol string, id uint32) string {
t.Helper()
var row struct{ Cover string `gorm:"column:cover"` }
if err := db.Table(table).Select("cover").Where(idCol+" = ?", id).Scan(&row).Error; err != nil {
t.Fatalf("读 %s.cover 失败: %v", table, err)
}
return row.Cover
}
// TestEditorDeleteRunwayCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
func TestEditorDeleteRunwayCoverRepointsWhenDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2)
seedEditorRunwayImage(t, db, id, "keep", 1, 0)
drop := seedEditorRunwayImage(t, db, id, "drop", 2, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteRunwayImage(ctx, drop); err != nil {
t.Fatalf("删封面图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "keep.jpg" {
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
}
}
// TestEditorDeleteRunwayCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
func TestEditorDeleteRunwayCoverClearsWhenLastImage(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 1)
only := seedEditorRunwayImage(t, db, id, "only", 1, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteRunwayImage(ctx, only); err != nil {
t.Fatalf("删最后一张图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
}
}
// TestEditorPurgeRunwayGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
func TestEditorPurgeRunwayGalleryClearsCover(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewArticleRepository(db)
id := seedEditorRunway(t, db, 2)
seedEditorRunwayImage(t, db, id, "a", 1, 0)
seedEditorRunwayImage(t, db, id, "b", 2, 0)
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if _, err := repo.SoftDeleteRunwayImages(ctx, id); err != nil {
t.Fatalf("下架删图失败: %v", err)
}
if got := editorCover(t, db, "brand_runways", "id", id); got != "" {
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
}
}
// TestEditorDeleteSnapCoverRepointsWhenDeleted 删掉当前封面图时,封面应重指向另一张存活图。
func TestEditorDeleteSnapCoverRepointsWhenDeleted(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
seedEditorSnapImage(t, db, id, "keep", 1, 0, 0)
drop := seedEditorSnapImage(t, db, id, "drop", 2, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "drop.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteSnapImage(ctx, drop); err != nil {
t.Fatalf("删封面图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "keep.jpg" {
t.Fatalf("删掉封面图后封面应重指向 keep.jpg,实际 %q", got)
}
}
// TestEditorDeleteSnapCoverClearsWhenLastImage 删掉最后一张图(即封面)时,封面应清空而非悬空。
func TestEditorDeleteSnapCoverClearsWhenLastImage(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 1)
only := seedEditorSnapImage(t, db, id, "only", 1, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "only.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if err := repo.DeleteSnapImage(ctx, only); err != nil {
t.Fatalf("删最后一张图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
t.Fatalf("删掉最后一张图后封面应清空,实际 %q", got)
}
}
// TestEditorPurgeSnapGalleryClearsCover 下架清空全部图片时,封面应清空(避免悬空指针)。
func TestEditorPurgeSnapGalleryClearsCover(t *testing.T) {
db := testDB(t)
ctx := context.Background()
repo := NewStreetSnapRepository(db)
id := seedEditorSnap(t, db, 2)
seedEditorSnapImage(t, db, id, "a", 1, 0, 0)
seedEditorSnapImage(t, db, id, "b", 2, 0, 0)
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("cover", "a.jpg").Error; err != nil {
t.Fatalf("设封面失败: %v", err)
}
if _, err := repo.SoftDeleteSnapImages(ctx, id); err != nil {
t.Fatalf("下架删图失败: %v", err)
}
if got := editorCover(t, db, "street_snaps", "id", id); got != "" {
t.Fatalf("下架清空图片后封面应清空,实际 %q", got)
}
}

View File

@ -0,0 +1,180 @@
//go:build integration
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行活着」。
//
// 注意:部分索引(WHERE is_deleted = 0)在 DB 层仍允许「软删后再插入同键」;
// 但入库服务层(processRunway / processStreet)现已在查重阶段跳过重爬已软删的实体,
// 从而达成「删除即永久」。本测试只验证索引这一层的行为。
package repository
import (
"context"
"testing"
"fashionapi/internal/model"
)
// TestRunwayEntityKeyUnique 同一实体键的第二行应被拒绝;软删后可以再插入。
func TestRunwayEntityKeyUnique(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "SS95"
rw := newRunwayForIngest(1, 1, season, "rtw", "uniq-a.jpg")
id, err := repo.CreateRunwayWithImages(ctx, rw, newRunwayImages("uniq-a.jpg"))
if err != nil {
t.Fatalf("首行应能插入: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
})
dup := newRunwayForIngest(1, 1, season, "rtw", "uniq-b.jpg")
if _, err := repo.CreateRunwayWithImages(ctx, dup, newRunwayImages("uniq-b.jpg")); err == nil {
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
}
// 软删首行后,部分索引(WHERE is_deleted = 0)不再占用该键,DB 层仍允许再插入。
// 「删除即永久」由入库服务层(RunwayEntityState 命中 isDeleted 跳过重爬)保证,而非本索引。
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
t.Fatalf("软删失败: %v", err)
}
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
if err != nil {
t.Fatalf("部分索引下软删后仍可再插入同实体键(服务层才拦截): %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", newID)
})
}
// TestStreetEntityKeyUnique 街拍实体键 = city + year + title(2026-09-22-04 细化后)。
//
// 为什么键里要有 title:同城同年可以有多个专题(开发库实例 London 2027 Day 2 / Day 3),
// title 是数据里唯一能区分它们的字段。因此:
// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性);
// ② 同城同年**不同标题**的第二行必须允许(对应「Day 2 / Day 3 分成两个文章」)。
func TestStreetEntityKeyUnique(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
// 04 是替换索引:先 DROP 掉 03 建的 (city, year),再按 (city, year, title) 重建同名索引。
// 两个都执行才真实还原「迁移链走完」的库结构,也才能验证旧键确实不再生效。
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const city = "UniqTestCity"
const year = 1994
const titleDay2 = "UniqTest Spring 1994 Day 2"
const titleDay3 = "UniqTest Spring 1994 Day 3"
snap := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
id, err := repo.CreateStreetSnapWithImages(ctx, snap, []model.StreetSnapImage{{Image: "uniq-a.jpg", SortOrder: 1}})
if err != nil {
t.Fatalf("首行应能插入: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id)
db.Exec("DELETE FROM street_snaps WHERE id = ?", id)
})
// ① 同城同年同标题:实体键命中,唯一索引必须拒绝。
dup := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil {
t.Fatalf("同城同年同标题的第二行应被唯一索引拒绝,实际插入成功")
}
// ② 同城同年不同标题:不同专题,必须允许共存。
// 这一条是本任务的核心:若还原成 (city, year) 的旧键,这里会插入失败。
other := &model.StreetSnap{JobID: 1, Title: titleDay3, Year: year, City: city, Status: model.StatusPending}
otherID, err := repo.CreateStreetSnapWithImages(ctx, other, []model.StreetSnapImage{{Image: "uniq-c.jpg", SortOrder: 1}})
if err != nil {
t.Fatalf("同城同年不同标题应可插入,实际失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", otherID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", otherID)
})
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
t.Fatalf("软删失败: %v", err)
}
again := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
newID, err := repo.CreateStreetSnapWithImages(ctx, again, []model.StreetSnapImage{{Image: "uniq-d.jpg", SortOrder: 1}})
if err != nil {
t.Fatalf("软删后应可再插入同实体键: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", newID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", newID)
})
}
// TestStreetEntityStateMatchesIndexKey 覆盖 StreetSnapEntityState 的查重口径:
// 它必须与唯一索引 uq_ss_entity 的表达式 COALESCE(title, '') 完全一致,
// 否则查重会比索引更严(漏判 → 写入注定被索引拒绝的行)或更松(重复内容被当成新实体)。
func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const city = "EntityStateTestCity"
const year = 1996
// 种子行:title = Day 2(经 ORM 写入,title 为普通空串语义)。
snap := &model.StreetSnap{JobID: 1, Title: "EntityState Day 2", Year: year, City: city, Status: model.StatusPending}
id, err := repo.CreateStreetSnapWithImages(ctx, snap, nil)
if err != nil {
t.Fatalf("种子行应能插入: %v", err)
}
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
if _, _, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
t.Fatalf("查重出错: %v", err)
} else if found {
t.Fatalf("不同 title 不应命中既有行,实际命中")
}
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
hitID, status, found, _, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
if err != nil {
t.Fatalf("查重出错: %v", err)
}
if !found || hitID != id || status != model.StatusPending {
t.Fatalf("同 title 应命中 id=%d status=pending,实际 found=%v id=%d status=%q", id, found, hitID, status)
}
// ③ title 为 NULL 的历史行 + 传入空串 title → 必须命中。
// 这是 COALESCE(title, '') 与裸 title = ? 唯一的分歧点:裸写法下 NULL 永不等于空串,
// 这里会误判「未命中」并插入一行与 NULL 行同键(COALESCE 后同为 '')的记录,被索引拒绝。
// 用原生 SQL 插入,因为 GORM 的 string 字段会写空串而非 NULL。
var nullID uint32
if err := db.Raw(
"INSERT INTO street_snaps (title, year, city, is_deleted, status, created_at, updated_at) "+
"VALUES (NULL, ?, ?, 0, ?, 0, 0) RETURNING id",
year, city, model.StatusPending,
).Scan(&nullID).Error; err != nil {
t.Fatalf("插入 NULL title 行失败: %v", err)
}
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
nid, _, nfound, _, err := repo.StreetSnapEntityState(ctx, city, year, "")
if err != nil {
t.Fatalf("查重出错: %v", err)
}
if !nfound || nid != nullID {
t.Fatalf("NULL title 行 + 空串应命中 id=%d,实际 found=%v id=%d", nullID, nfound, nid)
}
}

View File

@ -10,3 +10,6 @@ import "errors"
// ErrNotFound 记录不存在。
var ErrNotFound = errors.New("record not found")
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该草稿 / 自身不是主图 / 空集合。
var ErrInvalidImageGroup = errors.New("invalid image group")

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,7 +3,9 @@ package repository
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
"fashionapi/internal/model"
@ -14,9 +16,10 @@ import (
// IngestRepository 爬虫入库管线专属仓储:任务队列(ingest_jobs)+ nonce 防重放
// (ingest_nonces)+ 走秀正式表写入(brand_runways / brand_runway_images)。
//
// 去重由 worker 按「实体键」完成:走秀 = brand_id + season_code + collection_type,
// 街拍 = city + year(与 review_repository 的 SaveRunwayFromDraft / SaveStreetSnapFromDraft
// 晋升时的幂等键一致),因此一个秀/街拍只会在正式表留一行,多来源图片在晋升阶段聚合。
// 实体键:走秀 = brand_id + season_code + collection_type,街拍 = city + year + title
// (同城同年可有多个专题,title 是数据里区分专题的字段)。
// 单表发布模型下入库直写正式表(status=pending),实体键命中后按既有行状态分三支:
// pending/published → 放弃本次入库;rejected → 复用该行重审。因此一个秀/街拍始终只留一行。
//
// 入队与领取用同一张 ingest_jobs 表,领取靠 PostgreSQL 的 FOR UPDATE SKIP LOCKED
// 实现「多 worker 安全并发」——同一条任务只会被一个 worker 拿到,其它 worker 跳过它。
@ -32,19 +35,34 @@ type IngestRepository interface {
MarkFailed(ctx context.Context, id uint32, errMsg string) error
// ReserveNonce 写入一次性随机串;若已存在(重放)返回 ok=false。
ReserveNonce(ctx context.Context, nonce string) (ok bool, err error)
// RunwayIDByEntity 按实体键(brand_id + season_code + collection_type)查是否已存在走秀正式表;
// 返回 (id, found)。worker 据此跳过已晋升秀的重爬,避免重复下载。
RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error)
// CreateRunwayDraft 插入走秀草稿行(status=pending),返回自增主键。
CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error)
// CreateRunwayDraftImages 批量插入草稿图片行。
CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error
// StreetSnapIDByEntity 按实体键(city + year)查是否已存在街拍正式表;返回 (id, found)。
StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error)
// CreateStreetSnapDraft 插入街拍草稿行(status=pending),返回自增主键。
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行,
// 返回其状态与是否已被软删(isDeleted)。worker 据此分四支:
// - 命中 pending/published → 直接放弃本次入库(ingestSkip)
// - 命中 rejected → 复用该行重审(ingestReuseRejected)
// - 命中但已软删 → 跳过重爬(删除即永久,不再因重爬复活)
// - 未命中 → 新建(ingestCreate)
// 注意:本查询不再以 is_deleted = 0 为过滤条件(旧版口径),否则软删行会被当成「未命中」
// 而重新入库,违背「删除即永久」。优先取活着行(ORDER BY is_deleted ASC, id DESC)。
RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (id uint32, status string, found bool, isDeleted bool, err error)
// CreateRunwayWithImages 新建走秀正式行(status=pending)并写入图片,返回记录主键(事务内完成)。
CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error)
// ReuseRejectedRunway 复用一条 rejected 走秀:覆盖内容字段、软删旧图、写入新图、置回 pending(事务内完成)。
// 驳回因此不是永久黑名单:重爬同一实体即重新送审,且始终「一个实体一行」。
ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error
// BackfillRunwayPublishedAt 临时回填:给已存在走秀补 published_at,并在标题缺年份时
// 用发布日期年份补全 year / season_code(不重下图片)。回填为一次性操作,按主键 id 更新。
// publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error
// RepairRunwayDetailParents 入库后按 sort_order 回填走秀细节图的 parent_image_id(细节图挂到最近主图)。
RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
// 与 RunwayEntityState 同源:不再以 is_deleted = 0 过滤,软删实体跳过重爬(删除即永久)。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致。
StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (id uint32, status string, found bool, isDeleted bool, err error)
// CreateStreetSnapWithImages 新建街拍正式行(status=pending)并写入图片(事务内完成)。
CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error)
// ReuseRejectedStreetSnap 复用一条 rejected 街拍记录(事务内完成)。
ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
@ -239,94 +257,277 @@ func (r *ingestRepository) ReserveNonce(ctx context.Context, nonce string) (bool
return true, nil
}
// RunwayIDByEntity 按实体键查走秀正式表是否已存在(与 SaveRunwayFromDraft 晋升键一致)。
func (r *ingestRepository) RunwayIDByEntity(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, bool, error) {
// ---- 入库直写正式表(单表发布模型)----
// RunwayEntityState 按实体键(brand_id + season_code + collection_type)查正式表既有行。
// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。
// 优先取活着行(is_deleted ASC),保证「活着行 + 软删行同键共存」的存量数据下也返回活行。
func (r *ingestRepository) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
var row struct {
ID uint32 `gorm:"column:id"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
IsDeleted uint8 `gorm:"column:is_deleted"`
}
err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Select("id").
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND is_deleted = 0", brandID, seasonCode, collectionType).
Select("id, status, is_deleted").
Where("brand_id = ? AND season_code = ? AND collection_type = ?", brandID, seasonCode, collectionType).
Order("is_deleted ASC, id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
return 0, "", false, false, err
}
if row.ID == 0 {
return 0, false, nil
return 0, "", false, false, nil
}
return row.ID, true, nil
return row.ID, row.Status, true, row.IsDeleted == 1, nil
}
func (r *ingestRepository) CreateRunwayDraft(ctx context.Context, d *model.BrandRunwayDraft) (uint32, error) {
func (r *ingestRepository) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
now := uint32(time.Now().Unix())
d.CreatedAt = now
d.UpdatedAt = now
if d.Status == "" {
d.Status = model.DraftStatusPending
rw.CreatedAt, rw.UpdatedAt = now, now
if rw.Status == "" {
rw.Status = model.StatusPending
}
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if cErr := tx.Create(rw).Error; cErr != nil {
return cErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].RunwayID = rw.ID
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
if err != nil {
return 0, err
}
return d.ID, nil
return rw.ID, nil
}
func (r *ingestRepository) CreateRunwayDraftImages(ctx context.Context, imgs []model.BrandRunwayDraftImage) error {
if len(imgs) == 0 {
func (r *ingestRepository) ReuseRejectedRunway(ctx context.Context, id uint32, rw *model.BrandRunway, imgs []model.BrandRunwayImage) error {
now := uint32(time.Now().Unix())
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// WHERE 带 status=rejected 守卫:状态是读阶段拿到的,而写库发生在整批图片下载/上传之后
// (可能数十秒窗口)。若无条件覆盖,期间已被改成 published 的行会被置回 pending,
// 等于把已发布内容从公开视图上撤下。
upd := tx.Model(&model.BrandRunway{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected)
if uErr := upd.Updates(map[string]any{
"title_en": rw.TitleEn,
"title_cn": rw.TitleCn,
"description_en": rw.DescriptionEn,
"description_cn": rw.DescriptionCn,
"year": rw.Year,
"season": rw.Season,
"collection_type": rw.CollectionType,
"season_code": rw.SeasonCode,
"cover": rw.Cover,
"image_count": rw.ImageCount,
"job_id": rw.JobID,
"status": model.StatusPending,
"reviewer": "",
"reject_reason": "",
"updated_at": now,
}).Error; uErr != nil {
return uErr
}
if upd.RowsAffected == 0 {
return fmt.Errorf("runway %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id)
}
if dErr := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0", id).
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
return dErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].RunwayID = id
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
}
// RepairRunwayDetailParents 入库后按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
func (r *ingestRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}
// BackfillRunwayPublishedAt 临时回填(一次性):按主键 id 给已存在走秀补 published_at;
// 并在标题缺年份(year=0)时,用发布日期年份补全 year / season_code。
//
// 设计要点:
// - published_at 总是写入(按 id,幂等,不会触犯唯一索引)。
// - year / season_code 仅在「当前 year=0」且「目标 season_code 不与同品牌其它行冲突」时补全,
// 避免触犯 (brand_id, season_code, collection_type) 唯一索引;冲突时只补 published_at,留待人工处理。
// - 不重下图片。
// - publishedAt 为 Unix 时间戳(秒),0 表示未抽取。
func (r *ingestRepository) BackfillRunwayPublishedAt(ctx context.Context, runwayID uint32, publishedAt uint32, year uint16, targetSeasonCode string) error {
now := time.Now().Unix()
if err := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ? AND is_deleted = 0", runwayID).
Updates(map[string]any{"published_at": publishedAt, "updated_at": now}).Error; err != nil {
return err
}
if year == 0 {
return nil
}
now := uint32(time.Now().Unix())
for i := range imgs {
imgs[i].CreatedAt = now
imgs[i].UpdatedAt = now
}
return r.db.WithContext(ctx).Create(&imgs).Error
// 仅当目标 season_code 不与同品牌其它行冲突时才补全 year / season_code。
return r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ? AND year = 0 AND is_deleted = 0 "+
"AND NOT EXISTS (SELECT 1 FROM brand_runways b "+
"WHERE b.brand_id = brand_runways.brand_id AND b.season_code = ? "+
"AND b.collection_type = brand_runways.collection_type AND b.id <> brand_runways.id AND b.is_deleted = 0)",
targetSeasonCode).
Updates(map[string]any{
"year": year,
"season_code": targetSeasonCode,
"updated_at": now,
}).Error
}
// StreetSnapIDByEntity 按实体键(city + year)查街拍正式表是否已存在(与 SaveStreetSnapFromDraft 晋升键一致)。
func (r *ingestRepository) StreetSnapIDByEntity(ctx context.Context, city string, year uint16) (uint32, bool, error) {
// StreetSnapEntityState 按实体键(city + year + title)查正式表既有行,返回其状态与是否已被软删。
// title 用 COALESCE 与唯一索引 uq_ss_entity 的表达式保持一致:唯一索引里 NULL 互不冲突,
// 若这里写成裸 title,查重口径会比索引更严,导致重爬同城同年(title 为 NULL 的历史行)时误判未命中、
// 进而插入一行注定被索引拒绝的记录。
//
// 入参 title 会先经 NormalizeStreetTitle 归一化:与落库口径(processStreet 同样归一化)保持一致,
// 否则「空白 / 多空格」微调会被判为新实体,同城同年多出重复行(见设计规格 §12.4)。
//
// 不再过滤 is_deleted:软删行也会被查到,由调用方据此跳过重爬(删除即永久)。
// 与 RunwayEntityState 同源,优先取活着行(ORDER BY is_deleted ASC, id DESC)。
func (r *ingestRepository) StreetSnapEntityState(ctx context.Context, city string, year uint16, title string) (uint32, string, bool, bool, error) {
title = NormalizeStreetTitle(title)
var row struct {
ID uint32 `gorm:"column:id"`
ID uint32 `gorm:"column:id"`
Status string `gorm:"column:status"`
IsDeleted uint8 `gorm:"column:is_deleted"`
}
err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Select("id").
Where("city = ? AND year = ? AND is_deleted = 0", city, year).
Select("id, status, is_deleted").
Where("city = ? AND year = ? AND COALESCE(title, '') = ?", city, year, title).
Order("is_deleted ASC, id DESC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
return 0, "", false, false, err
}
if row.ID == 0 {
return 0, false, nil
return 0, "", false, false, nil
}
return row.ID, true, nil
return row.ID, row.Status, true, row.IsDeleted == 1, nil
}
func (r *ingestRepository) CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error) {
// NormalizeStreetTitle 街拍实体键标题归一化:去除首尾空白,并把内部任意空白(空格 / 制表 / 换行)
// 连续段折叠为单个空格。
//
// 为什么需要:实体键含 title((city, year, COALESCE(title, ''))),同一专题标题只做空白 / 大小写 / 措辞
// 微调会被判为新实体(设计规格 §12.4)。本函数消除「纯空白」这一类可机械归一的差异,避免重复行。
// 措辞差异无法机械归一,仍按不同实体处理(符合预期)。调用方在落库与查重两侧都必须用同一函数,
// 才能保证索引(COALESCE(title, ''))与查重口径逐字一致。
func NormalizeStreetTitle(title string) string {
return strings.Join(strings.Fields(title), " ")
}
func (r *ingestRepository) CreateStreetSnapWithImages(ctx context.Context, snap *model.StreetSnap, imgs []model.StreetSnapImage) (uint32, error) {
now := uint32(time.Now().Unix())
d.CreatedAt = now
d.UpdatedAt = now
if d.Status == "" {
d.Status = model.DraftStatusPending
snap.CreatedAt, snap.UpdatedAt = now, now
if snap.Status == "" {
snap.Status = model.StatusPending
}
if err := r.db.WithContext(ctx).Create(d).Error; err != nil {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if cErr := tx.Create(snap).Error; cErr != nil {
return cErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].SnapID = snap.ID
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
if err != nil {
return 0, err
}
return d.ID, nil
return snap.ID, nil
}
func (r *ingestRepository) CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error {
if len(imgs) == 0 {
return nil
}
func (r *ingestRepository) ReuseRejectedStreetSnap(ctx context.Context, id uint32, snap *model.StreetSnap, imgs []model.StreetSnapImage) error {
now := uint32(time.Now().Unix())
for i := range imgs {
imgs[i].CreatedAt = now
imgs[i].UpdatedAt = now
}
return r.db.WithContext(ctx).Create(&imgs).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 同 runway:带 status=rejected 守卫 + 行数校验,避免把期间已发布的记录撤下公开视图。
upd := tx.Model(&model.StreetSnap{}).Where("id = ? AND status = ? AND is_deleted = 0", id, model.StatusRejected)
if uErr := upd.Updates(map[string]any{
"title": snap.Title,
"year": snap.Year,
"city": snap.City,
"cover": snap.Cover,
"image_count": snap.ImageCount,
"job_id": snap.JobID,
"status": model.StatusPending,
"reviewer": "",
"reject_reason": "",
"updated_at": now,
}).Error; uErr != nil {
return uErr
}
if upd.RowsAffected == 0 {
return fmt.Errorf("street snap %d 复用前置条件失效:该行已不是 rejected 状态或已被软删", id)
}
if dErr := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0", id).
Updates(map[string]any{"is_deleted": 1, "updated_at": now}).Error; dErr != nil {
return dErr
}
if len(imgs) == 0 {
return nil
}
for i := range imgs {
imgs[i].SnapID = id
imgs[i].CreatedAt, imgs[i].UpdatedAt = now, now
}
return tx.Create(&imgs).Error
})
}
// ListJobs 按 id 倒序分页列出入库任务(后台监控页用)。offset/limit 控制分页区间。
@ -369,11 +570,14 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
}).Error
}
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,取距离 ≤ threshold 的最近一条。
// 算子用 pgvector 的 L2(<->);因 phash 是 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(threshold)。
// phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
//
// 只比对未软删的行(is_deleted = 0):已被软删(审核剔除 / 复用重审)的旧图不应再作为
// 「重复」的参照。注意复用驳回行的时序 —— 去重发生在软删旧图**之前**,那一刻旧行仍是
// is_deleted = 0、会被正常命中;dup_of 只是人工留痕,不承担可见性语义。
// 本方法被 runway / street 两条入库路径共用,过滤对两者语义一致。
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
// 汉明阈值转 L2 阈值:phash 为 {0,1}^64 向量,L2² == 汉明距离,故 L2 阈值 = sqrt(汉明阈值)。
l2Limit := math.Sqrt(float64(threshold))
@ -385,6 +589,7 @@ func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []
err := r.db.WithContext(ctx).Table(t).
Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Where("is_deleted = 0").
Order("dist ASC").
Limit(1).
Scan(&row).Error

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}}
}
// TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。
func TestIngestCreatesPendingRecord(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "SS97"
_, _, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
if found {
t.Fatalf("测试前置:实体键 %s 不应已存在", season)
}
id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(101, 1, season, "rtw", "ingest-ss97.jpg"), newRunwayImages("ingest-ss97.jpg"))
if err != nil {
t.Fatalf("CreateRunwayWithImages 出错: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
})
var rw model.BrandRunway
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusPending {
t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status)
}
gotID, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil {
t.Fatalf("RunwayEntityState 出错: %v", err)
}
if !found || gotID != id || status != model.StatusPending {
t.Fatalf("实体键应命中刚建的行,实际 found=%v id=%d status=%s", found, gotID, status)
}
publicCount := int64(0)
db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount)
if publicCount != 0 {
t.Fatalf("pending 记录不应出现在公开视图,实际 %d", publicCount)
}
}
// TestIngestReusesRejectedRecord 复用分支:实体键命中 rejected → 覆盖内容、软删旧图、写新图、置回 pending。
func TestIngestReusesRejectedRecord(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "FW96"
oldID, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(90, 1, season, "rtw", "old.jpg"), newRunwayImages("old.jpg"))
if err != nil {
t.Fatalf("预置记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", oldID)
db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID)
})
if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID).
Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin"}).Error; err != nil {
t.Fatalf("置为已驳回失败: %v", err)
}
_, status, found, _, err := repo.RunwayEntityState(ctx, 1, season, "rtw")
if err != nil || !found || status != model.StatusRejected {
t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err)
}
newRW := newRunwayForIngest(91, 1, season, "rtw", "new.jpg")
newRW.TitleEn = "ingest-test-reused"
if err := repo.ReuseRejectedRunway(ctx, oldID, newRW, newRunwayImages("new.jpg")); err != nil {
t.Fatalf("ReuseRejectedRunway 出错: %v", err)
}
var rw model.BrandRunway
if err := db.Where("id = ?", oldID).First(&rw).Error; err != nil {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusPending {
t.Fatalf("复用后应回到 pending,实际 %s", rw.Status)
}
if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" {
t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
}
if rw.Reviewer != "" {
t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s", rw.Reviewer)
}
var alive []model.BrandRunwayImage
if err := db.Where("runway_id = ? AND is_deleted = 0", oldID).Find(&alive).Error; err != nil {
t.Fatalf("读图片失败: %v", err)
}
if len(alive) != 1 || alive[0].Image != "new.jpg" {
t.Fatalf("旧图应被软删、只留新图,实际 %d 张", len(alive))
}
var oldAlive int64
db.Model(&model.BrandRunwayImage{}).Where("runway_id = ? AND image = ? AND is_deleted = 0", oldID, "old.jpg").Count(&oldAlive)
if oldAlive != 0 {
t.Fatalf("旧图应被软删")
}
}
// TestIngestReuseRejectedRefusesPublished 复用守卫:实体键状态是读阶段拿到的,而写库发生在整批
// 图片下载/上传之后(可能数十秒窗口)。若该行在这段窗口内已被改成 published,复用必须失败,
// 绝不能把它置回 pending——那等于把已发布内容从公开视图上撤下。
func TestIngestReuseRejectedRefusesPublished(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "SS95"
id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(80, 1, season, "rtw", "pub.jpg"), newRunwayImages("pub.jpg"))
if err != nil {
t.Fatalf("预置记录失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
})
// 模拟「读阶段之后、写库之前」该行被审核通过。
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).
Update("status", model.StatusPublished).Error; err != nil {
t.Fatalf("置为已发布失败: %v", err)
}
newRW := newRunwayForIngest(81, 1, season, "rtw", "pub2.jpg")
newRW.TitleEn = "ingest-test-should-not-apply"
if err := repo.ReuseRejectedRunway(ctx, id, newRW, newRunwayImages("pub2.jpg")); err == nil {
t.Fatalf("对已发布行复用应报错,实际返回 nil")
}
var rw model.BrandRunway
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusPublished {
t.Fatalf("已发布行不得被复用改回 pending,实际 %s", rw.Status)
}
if rw.TitleEn != "ingest-test" || rw.Cover != "pub.jpg" {
t.Fatalf("复用失败时不应覆盖内容,实际 title=%s cover=%s", rw.TitleEn, rw.Cover)
}
publicCount := int64(0)
db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount)
if publicCount != 1 {
t.Fatalf("已发布行应仍在公开视图,实际 %d", publicCount)
}
}
// TestDedupIgnoresSoftDeletedImage 去重只比对**未软删**的行(is_deleted = 0)。
//
// 别把它误读成「复用驳回行时不会自我命中」:复用路径里去重发生在软删旧图**之前**,那一刻
// 旧行仍是 is_deleted = 0、仍会被命中——dup_of 只是给人工看的留痕,不参与任何可见性判定。
// 本用例锁的是另一条独立保证:**已经**软删的行不再被当作重复参照。
//
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证它若被纳入就必然是「最近一条」,
// 从而确定性地验证 is_deleted = 0 过滤生效,与库中其它数据无关。
func TestDedupIgnoresSoftDeletedImage(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
bits := phash.ToVectorBits(^uint64(0))
now := uint32(time.Now().Unix())
row := model.BrandRunwayImage{
RunwayID: 1, BrandID: 1, Image: "ingest/soft-deleted.jpg", Name: "look",
SortOrder: 1, IsDeleted: 1, CreatedAt: now, UpdatedAt: now,
Phash: sql.NullString{String: bits, Valid: true},
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("插入软删图片失败: %v", err)
}
t.Cleanup(func() { db.Where("id = ?", row.ID).Delete(&model.BrandRunwayImage{}) })
dupID, found, err := repo.FindNearDuplicateImage(ctx, []string{"brand_runway_images"}, bits, phash.DefaultThreshold)
if err != nil {
t.Fatalf("FindNearDuplicateImage 出错: %v", err)
}
if found && dupID == row.ID {
t.Fatalf("已软删的行(id=%d)不应参与近重复比对", row.ID)
}
}
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
//
// 与 service.processRunway 的 imageCount = countMainImages(images) 同口径,且与审核期
// SoftDeleteRunwayImage 的重算口径一致:三处必须同源,否则公开卡片「N 张」角标与
// FeaturedIDs 的 SUM(image_count) 热度会漂移(单表发布删掉了晋升重算的自愈路径)。
func TestIngestImageCountMatchesAliveMainRows(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewIngestRepository(db)
ctx := context.Background()
const season = "IC71"
rw := newRunwayForIngest(70, 1, season, "rtw", "ic-main-a.jpg")
rw.ImageCount = 2 // 2 张主图
imgs := []model.BrandRunwayImage{
{BrandID: 1, Image: "ic-main-a.jpg", Name: "Look 1", SortOrder: 1, IsDetail: 0},
{BrandID: 1, Image: "ic-detail-a1.jpg", Name: "Look 1 — Detail 1", SortOrder: 2, IsDetail: 1},
{BrandID: 1, Image: "ic-main-b.jpg", Name: "Look 2", SortOrder: 3, IsDetail: 0},
}
id, err := repo.CreateRunwayWithImages(ctx, rw, imgs)
if err != nil {
t.Fatalf("CreateRunwayWithImages 出错: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id)
db.Exec("DELETE FROM brand_runways WHERE id = ?", id)
})
var got struct {
ImageCount uint16 `gorm:"column:image_count"`
AliveMain int64 `gorm:"column:alive_main"`
}
if err := db.Raw(`SELECT r.image_count,
(SELECT COUNT(*) FROM brand_runway_images i
WHERE i.runway_id = r.id AND i.is_deleted = 0 AND i.is_detail = 0) AS alive_main
FROM brand_runways r WHERE r.id = ?`, id).Scan(&got).Error; err != nil {
t.Fatalf("读回计数失败: %v", err)
}
if int64(got.ImageCount) != got.AliveMain {
t.Fatalf("入库后 image_count=%d 必须等于存活主图行数 %d", got.ImageCount, got.AliveMain)
}
}

View File

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

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

View File

@ -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,40 +11,50 @@ import (
"gorm.io/gorm"
)
// ReviewRepository 审核草稿的数据访问接口。
// ReviewRepository 审核数据访问接口。
//
// 草稿由爬虫 worker 写入(见 IngestRepository),本接口负责「读 + 晋升」:
// 列出待审草稿、查看详情、审核前微调字段、通过时晋升到正式表、驳回时标记状态。
// 单表发布模型下,审核对象就是正式表里的记录本身(不再有草稿表):
// 列出待审记录、查看详情、审核前微调字段、通过(置 published)/ 驳回(置 rejected)。
type ReviewRepository interface {
// ListDrafts 分页列出草稿;status 为空表示全部,否则按状态过滤。
// 排序:pending 优先,其次按 id 倒序(最新在前)。
ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error)
// DraftDetail 查单条草稿(不含图片)。
DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error)
// DraftImages 查草稿的全部图片,按排序值升序。
DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error)
// UpdateDraft 审核前微调草稿字段(白名单,杜绝任意列注入)。
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
// SetDraftStatus 设置草稿状态(通过/驳回)与审核人/驳回理由。
SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
// SaveRunwayFromDraft 晋升:按实体键(brand_id + season_code + collection_type)幂等 upsert 到 brand_runways 正式表并替换图片,返回正式表主键。
SaveRunwayFromDraft(ctx context.Context, draftID uint32) (uint32, error)
// 街拍草稿镜像方法(与 runway 同构,但对象为 street_snap_drafts / street_snaps)。
ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error)
StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error)
StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error)
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步 image_count。
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error
SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error)
// CountDraftPending 统计某草稿表(dest 传表对应的零值指针,如 &model.BrandRunwayDraft{})中指定状态的草稿数。
// 供各 ReviewModule 复用,新增模块无需再单独写计数方法。
CountDraftPending(ctx context.Context, status string, dest any) (int64, error)
// ListRecords 分页列出记录;status 为空表示全部,否则按状态过滤。排序:pending 优先,其次 id 倒序。
ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error)
// RecordDetail 查单条走秀记录(不含图片)。
RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error)
// RecordImages 查走秀记录的全部未删图片,按排序值升序。
RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error)
// UpdateRecord 审核前微调字段(白名单,杜绝任意列注入)。
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetRecordStatus 设置状态(通过/驳回)与审核人;记录不存在返回 ErrNotFound。
SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// 街拍镜像方法(对象为 street_snaps / street_snap_images)。
ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error)
StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error)
StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error)
UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error
// SetStreetRecordStatus 设置状态;记录不存在返回 ErrNotFound。
SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),并按实际存活**主图**行数重算 image_count。
SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error
// SoftDeleteStreetImage 软删单张街拍图片,并按实际存活行数重算 image_count。
SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// mainImgID 必须属于同一记录、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID —— 避免「副图的副图」。
// 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetSnapImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。对主图调用是幂等成功。
DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
// 与 AttachStreetSnapImages 同构(按 parent_image_id 分组):先改挂孙辈,再把被并入图标为副图。
AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error)
// CountByStatus 统计某表(dest 传表对应的零值指针,如 &model.BrandRunway{})中指定状态的行数。
CountByStatus(ctx context.Context, status string, dest any) (int64, error)
}
type reviewRepository struct {
@ -56,27 +66,40 @@ func NewReviewRepository(db *gorm.DB) ReviewRepository {
return &reviewRepository{db: db}
}
func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page, size int) ([]model.BrandRunwayDraft, int64, error) {
// reviewListScope 把「未软删 + 可选状态过滤」编译为 GORM Scope。
//
// 用 Scope 而非在 Count 之后复用同一个 *gorm.DB:GORM v2 中终结方法(Count 等)之后再复用
// 同一实例会带上残留的 Statement 状态,Scope 每次作用于全新查询即可杜绝该隐患
//(与 article_repository.filterScope 同一理由)。
func reviewListScope(status string) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
db = db.Where("is_deleted = 0")
if status != "" {
db = db.Where("status = ?", status)
}
return db
}
}
func (r *reviewRepository) ListRecords(ctx context.Context, status string, page, size int) ([]model.BrandRunway, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := r.db.WithContext(ctx).Model(&model.BrandRunwayDraft{}).Where("is_deleted = 0")
if status != "" {
q = q.Where("status = ?", status)
}
scope := reviewListScope(status)
var total int64
if err := q.Count(&total).Error; err != nil {
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.BrandRunwayDraft{}, 0, nil
return []model.BrandRunway{}, 0, nil
}
var rows []model.BrandRunwayDraft
if err := q.
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
var rows []model.BrandRunway
if err := r.db.WithContext(ctx).Model(&model.BrandRunway{}).Scopes(scope).
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
@ -85,8 +108,8 @@ func (r *reviewRepository) ListDrafts(ctx context.Context, status string, page,
return rows, total, nil
}
func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.BrandRunwayDraft, error) {
var d model.BrandRunwayDraft
func (r *reviewRepository) RecordDetail(ctx context.Context, id uint32) (*model.BrandRunway, error) {
var d model.BrandRunway
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
@ -96,17 +119,17 @@ func (r *reviewRepository) DraftDetail(ctx context.Context, id uint32) (*model.B
return &d, nil
}
func (r *reviewRepository) DraftImages(ctx context.Context, draftID uint32) ([]model.BrandRunwayDraftImage, error) {
var imgs []model.BrandRunwayDraftImage
func (r *reviewRepository) RecordImages(ctx context.Context, recordID uint32) ([]model.BrandRunwayImage, error) {
var imgs []model.BrandRunwayImage
err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID).
Where("runway_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// draftEditable 审核前可微调的字段白名单(键即列名)。
var draftEditable = map[string]bool{
// recordEditable 审核前可微调的字段白名单(键即列名)。
var recordEditable = map[string]bool{
"title_en": true,
"title_cn": true,
"description_en": true,
@ -117,11 +140,11 @@ var draftEditable = map[string]bool{
"season_code": true,
}
// UpdateDraft 只更新白名单内的字段,其余忽略,杜绝任意列注入。
func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
// UpdateRecord 只更新白名单内的字段,其余忽略,杜绝任意列注入。
func (r *reviewRepository) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{}
for k, v := range fields {
if draftEditable[k] {
if recordEditable[k] {
clean[k] = v
}
}
@ -130,149 +153,39 @@ func (r *reviewRepository) UpdateDraft(ctx context.Context, id uint32, fields ma
}
clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(clean).Error
}
func (r *reviewRepository) SetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayDraft{}).
// SetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound。
// 否则「对不存在的 id 通过 / 驳回」会被当作成功:handler 只校验 id != 0,id 又来自 URL,
// 竞态窗口(记录刚被删除)下会给出假成功 302。
//
// 有意**不校验前置状态**:允许把 rejected 直接置 published(或反向),不做状态机约束。
// 列表页只对 pending 显示通过 / 驳回按钮,但 POST 端点本身不设防 —— 这是已知取舍
//(幂等重放 / 管理端纠正误操作都依赖它);若日后要收紧,应改在 handler 或显式状态机里做,
// 而不是悄悄在这个仓储方法里加限制。
func (r *reviewRepository) SetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
res := r.db.WithContext(ctx).
Model(&model.BrandRunway{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
}).Error
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// 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 cErr := tx.Create(&rows).Error; cErr != nil {
return cErr
}
}
return nil
})
if err != nil {
return 0, err
}
return runwayID, nil
}
// CountDraftPending 统计传入表(dest 零值指针)中指定状态的草稿数;供各 ReviewModule 复用。
func (r *reviewRepository) CountDraftPending(ctx context.Context, status string, dest any) (int64, error) {
// CountByStatus 统计传入表(dest 零值指针)中指定状态的行数;供各 ReviewModule 复用。
func (r *reviewRepository) CountByStatus(ctx context.Context, status string, dest any) (int64, error) {
var n int64
if err := r.db.WithContext(ctx).
Model(dest).
@ -283,29 +196,27 @@ func (r *reviewRepository) CountDraftPending(ctx context.Context, status string,
return n, nil
}
// ---- 街拍草稿镜像实现(与 runway 同构) ----
// ---- 街拍镜像实现(与 runway 同构) ----
func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string, page, size int) ([]model.StreetSnapDraft, int64, error) {
func (r *reviewRepository) ListStreetRecords(ctx context.Context, status string, page, size int) ([]model.StreetSnap, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
q := r.db.WithContext(ctx).Model(&model.StreetSnapDraft{}).Where("is_deleted = 0")
if status != "" {
q = q.Where("status = ?", status)
}
scope := reviewListScope(status)
var total int64
if err := q.Count(&total).Error; err != nil {
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnapDraft{}, 0, nil
return []model.StreetSnap{}, 0, nil
}
var rows []model.StreetSnapDraft
if err := q.
Order("CASE status WHEN '" + model.DraftStatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
var rows []model.StreetSnap
if err := r.db.WithContext(ctx).Model(&model.StreetSnap{}).Scopes(scope).
Order("CASE status WHEN '" + model.StatusPending + "' THEN 0 ELSE 1 END ASC, id DESC").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error; err != nil {
@ -314,8 +225,8 @@ func (r *reviewRepository) ListStreetDrafts(ctx context.Context, status string,
return rows, total, nil
}
func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*model.StreetSnapDraft, error) {
var d model.StreetSnapDraft
func (r *reviewRepository) StreetRecordDetail(ctx context.Context, id uint32) (*model.StreetSnap, error) {
var d model.StreetSnap
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", id).First(&d).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
@ -325,26 +236,26 @@ func (r *reviewRepository) StreetDraftDetail(ctx context.Context, id uint32) (*m
return &d, nil
}
func (r *reviewRepository) StreetDraftImages(ctx context.Context, draftID uint32) ([]model.StreetSnapDraftImage, error) {
var imgs []model.StreetSnapDraftImage
func (r *reviewRepository) StreetRecordImages(ctx context.Context, recordID uint32) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Where("draft_id = ? AND is_deleted = 0", draftID).
Where("snap_id = ? AND is_deleted = 0", recordID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// streetDraftEditable 街拍草稿可微调字段白名单(title / year / city)。
var streetDraftEditable = map[string]bool{
// streetRecordEditable 街拍记录可微调字段白名单(title / year / city)。
var streetRecordEditable = map[string]bool{
"title": true,
"year": true,
"city": true,
}
func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error {
func (r *reviewRepository) UpdateStreetRecord(ctx context.Context, id uint32, fields map[string]any) error {
clean := map[string]any{}
for k, v := range fields {
if streetDraftEditable[k] {
if streetRecordEditable[k] {
clean[k] = v
}
}
@ -353,231 +264,82 @@ func (r *reviewRepository) UpdateStreetDraft(ctx context.Context, id uint32, fie
}
clean["updated_at"] = uint32(time.Now().Unix())
return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(clean).Error
}
func (r *reviewRepository) SetStreetDraftStatus(ctx context.Context, id uint32, status, reviewer, reason string) error {
return r.db.WithContext(ctx).
Model(&model.StreetSnapDraft{}).
// SetStreetRecordStatus 命中 0 行说明记录不存在,返回 ErrNotFound(理由同 SetRecordStatus)。
// 同样有意不校验前置状态:允许 rejected 直接置 published,属已知取舍(见 SetRecordStatus)。
func (r *reviewRepository) SetStreetRecordStatus(ctx context.Context, id uint32, status, reviewer string) error {
res := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", id).
Updates(map[string]any{
"status": status,
"reviewer": reviewer,
"reject_reason": reason,
"updated_at": uint32(time.Now().Unix()),
}).Error
"status": status,
"reviewer": reviewer,
"updated_at": uint32(time.Now().Unix()),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// SaveStreetSnapFromDraft 晋升街拍草稿到正式表 street_snaps(按实体键 city+year 幂等 upsert,替换图片)。
// 逻辑与 SaveRunwayFromDraft 同构,但无 brand_id、有 city,图片明细落到 street_snap_images。
// 在事务内完成,保证草稿与正式表一致性。
func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID uint32) (uint32, error) {
var draft model.StreetSnapDraft
if err := r.db.WithContext(ctx).Where("id = ? AND is_deleted = 0", draftID).First(&draft).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrNotFound
}
return 0, err
}
curImgs, err := r.StreetDraftImages(ctx, draftID)
if err != nil {
return 0, err
}
sibImgs, err := r.streetApprovedSiblingImages(ctx, draftID, draft.City, draft.Year)
if err != nil {
return 0, err
}
imgs := unionStreetDraftImages(curImgs, sibImgs)
// ── 图片软删 ────────────────────────────────────────────────────────────────
now := uint32(time.Now().Unix())
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 {
rows := make([]model.StreetSnapImage, 0, len(imgs))
for i, im := range imgs {
rows = append(rows, model.StreetSnapImage{
SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(i + 1),
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
CreatedAt: now,
UpdatedAt: now,
})
}
if cErr := tx.Create(&rows).Error; cErr != nil {
return cErr
}
}
return nil
})
if err != nil {
return 0, err
}
return snapID, nil
}
// runwayApprovedSiblingImages 返回同实体(品牌+季节码+系列)且已 approved 的其他草稿的图片,
// 用于多来源聚合:当前正被审核的草稿强制纳入,其余仅合并已审核来源,避免未审图泄露进正式表。
func (r *reviewRepository) runwayApprovedSiblingImages(ctx context.Context, excludeDraftID, brandID uint32, seasonCode, collectionType string) ([]model.BrandRunwayDraftImage, error) {
var drafts []model.BrandRunwayDraft
// SoftDeleteRunwayImage 软删单张走秀图片(审核阶段剔除坏图),按实际存活主图行数重算 image_count。
// 不删S4文件(与正式表既有删除行为一致:仅 DB 标记,避免误删被引用文件)。
//
// 为什么是「重算」而不是「递减 image_count - 1」:递减的前提是「本次调用必然删掉一行」,
// 而 imgID 完全来自 URL 路径、没有归属校验 —— 过期页面 / 重复提交 / 跨记录的重放都会命中 0 行,
// GORM 不报错,递减却照常执行,于是只减计数不删图(GREATEST 只钳到 0、永不回补)。
// 旧流程靠审核通过时按实际图片行重算能抹平这种漂移;单表发布模型删掉了晋升重建,
// 漂移会永久固化在已发布记录上,并直接显示在公开列表卡片的「N 张」角标里。
//
// 口径与入库一致:只计主图(is_detail = 0)——见 ingest_service.processRunway 的 imageCount = len(p.Looks),
// 以及旧 SaveRunwayFromDraft 的 mainCount。细节图不计入。
func (r *reviewRepository) SoftDeleteRunwayImage(ctx context.Context, recordID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Where("brand_id = ? AND season_code = ? AND collection_type = ? AND status = ? AND is_deleted = 0 AND id <> ?", brandID, seasonCode, collectionType, model.DraftStatusApproved, excludeDraftID).
Find(&drafts).Error; err != nil {
return nil, err
}
out := make([]model.BrandRunwayDraftImage, 0)
for _, d := range drafts {
imgs, err := r.DraftImages(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, imgs...)
}
return out, nil
}
// unionRunwayDraftImages 按 image URL 去重合并(S4 key 已是 sha1,同图 URL 必相同)。当前草稿图优先。
func unionRunwayDraftImages(a, b []model.BrandRunwayDraftImage) []model.BrandRunwayDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.BrandRunwayDraftImage, 0, len(a)+len(b))
for _, im := range a {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
for _, im := range b {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
return out
}
// streetApprovedSiblingImages 同 city+year 已 approved 的其他街拍草稿图(多来源聚合)。
func (r *reviewRepository) streetApprovedSiblingImages(ctx context.Context, excludeDraftID uint32, city string, year uint16) ([]model.StreetSnapDraftImage, error) {
var drafts []model.StreetSnapDraft
if err := r.db.WithContext(ctx).
Where("city = ? AND year = ? AND status = ? AND is_deleted = 0 AND id <> ?", city, year, model.DraftStatusApproved, excludeDraftID).
Find(&drafts).Error; err != nil {
return nil, err
}
out := make([]model.StreetSnapDraftImage, 0)
for _, d := range drafts {
imgs, err := r.StreetDraftImages(ctx, d.ID)
if err != nil {
return nil, err
}
out = append(out, imgs...)
}
return out, nil
}
// unionStreetDraftImages 按 image URL 去重合并。当前草稿图优先。
func unionStreetDraftImages(a, b []model.StreetSnapDraftImage) []model.StreetSnapDraftImage {
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]model.StreetSnapDraftImage, 0, len(a)+len(b))
for _, im := range a {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
for _, im := range b {
if _, ok := seen[im.Image]; ok {
continue
}
seen[im.Image] = struct{}{}
out = append(out, im)
}
return out
}
// ── 草稿图片删除 ────────────────────────────────────────────────────────────────
// SoftDeleteRunwayDraftImage 软删单张走秀草稿图片(审核阶段剔除坏图),并同步草稿 image_count。
// 不删S4文件(与正式表 DeleteRunwayImage 行为一致:仅 DB 标记,避免误删被引用文件)。
func (r *reviewRepository) SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayDraftImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", imgID, recordID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).
Exec("UPDATE brand_runway_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
Exec(`UPDATE brand_runways SET image_count =
(SELECT COUNT(*) FROM brand_runway_images WHERE runway_id = ? AND is_deleted = 0 AND is_detail = 0)
WHERE id = ?`, recordID, recordID).Error
}
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
func (r *reviewRepository) SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
// SoftDeleteStreetImage 软删单张街拍图片,按实际存活行数重算 image_count(理由同 SoftDeleteRunwayImage)。
//
// 口径与街拍正式表的历史行为一致:计全部存活图片行(主图 + 副图),
// 见旧 SaveStreetSnapFromDraft 的 image_count = len(imgs);街拍副图是审核期人工并入的,
// 与走秀「is_detail 是入库时的细节图」语义不同。
func (r *reviewRepository) SoftDeleteStreetImage(ctx context.Context, recordID, imgID uint32) error {
if err := r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0", imgID, draftID).
Model(&model.StreetSnapImage{}).
Where("id = ? AND snap_id = ? AND is_deleted = 0", imgID, recordID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
return r.db.WithContext(ctx).
Exec("UPDATE street_snap_drafts SET image_count = GREATEST(image_count - 1, 0) WHERE id = ?", draftID).Error
Exec(`UPDATE street_snaps SET image_count =
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
WHERE id = ?`, recordID, recordID).Error
}
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
// kind 决定查哪张草稿图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) {
// GetImageKey 取单张图片的S4 key(删除前清理用);不存在返回空串。
// kind 决定查哪张图片表(runway / street);未知 kind 返回 ErrNotFound。
func (r *reviewRepository) GetImageKey(ctx context.Context, kind string, recordID, imgID uint32) (string, error) {
switch kind {
case dto.IngestKindRunway:
var im model.BrandRunwayDraftImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
var im model.BrandRunwayImage
err := r.db.WithContext(ctx).Where("id = ? AND runway_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
@ -586,8 +348,8 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
}
return im.Image, nil
case dto.IngestKindStreet:
var im model.StreetSnapDraftImage
err := r.db.WithContext(ctx).Where("id = ? AND draft_id = ?", imgID, draftID).First(&im).Error
var im model.StreetSnapImage
err := r.db.WithContext(ctx).Where("id = ? AND snap_id = ?", imgID, recordID).First(&im).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
@ -599,3 +361,143 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
return "", ErrNotFound
}
}
// ── 街拍主副图分组 ──────────────────────────────────────────────────────────
// AttachStreetSnapImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
//
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
func (r *reviewRepository) AttachStreetSnapImages(ctx context.Context, snapID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs {
if id != 0 && id != mainImgID {
ids = append(ids, id)
}
}
if mainImgID == 0 || len(imgIDs) == 0 {
// 主图无效,或传入集合本身就是空的(调用方连一张待并入图都没给)。
return ErrInvalidImageGroup
}
if len(ids) == 0 {
// 集合里只有主图自身:视为无操作成功(忽略),不报错也不改动主图。
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.StreetSnapImage
if err := tx.Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, snapID).
First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup
}
return err
}
now := uint32(time.Now().Unix())
if err := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0 AND parent_image_id IN ?", snapID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0 AND id IN ?", snapID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachStreetSnapImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetSnapImage(ctx context.Context, snapID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.StreetSnapImage{}).
Where("id = ? AND snap_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, snapID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// AttachRunwayImages 走秀版「合并选中为一组」:把 imgIDs 变为主图 mainImgID 的明细图。
//
// 与 AttachStreetSnapImages 同构(均按 parent_image_id 分组):事务内先「改挂孙辈」
// (被并入图原有的副图 parent_image_id IN ids 改挂到新主图),再把被并入图自身标为副图并指向新主图。
func (r *reviewRepository) AttachRunwayImages(ctx context.Context, runwayID, mainImgID uint32, imgIDs []uint32) error {
ids := make([]uint32, 0, len(imgIDs))
for _, id := range imgIDs {
if id != 0 && id != mainImgID {
ids = append(ids, id)
}
}
if mainImgID == 0 || len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
if len(ids) == 0 {
return nil // 集合里只有主图自身:无操作成功
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var main model.BrandRunwayImage
if err := tx.Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, runwayID).
First(&main).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidImageGroup
}
return err
}
now := uint32(time.Now().Unix())
// 先改挂孙辈:被并入图原有的副图(parent_image_id IN ids)改挂到新主图。
if err := tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND parent_image_id IN ?", runwayID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
// 再把被并入图自身标为副图并指向新主图。
return tx.Model(&model.BrandRunwayImage{}).
Where("runway_id = ? AND is_deleted = 0 AND id IN ?", runwayID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachRunwayImage 走秀版「拆出」:把明细图恢复为独立主图(is_detail=0,parent_image_id 清零)。
// 同主图下其余明细图仍挂在该主图之下,不变量成立。
func (r *reviewRepository) DetachRunwayImage(ctx context.Context, runwayID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, runwayID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}
// RepairRunwayDetailParents 按 sort_order 顺序回填走秀细节图的 parent_image_id:
// 每张 is_detail=1 的图挂到它之前最近的一张 is_detail=0(主图)之下。
// 入库时细节图紧随其主图之后(同一 Look 先主图后细节),故「最近主图」即其归属主图。
// 供入库后调用,使新入库的走秀自动成组,无需人工合并。
func (r *reviewRepository) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
var imgs []model.BrandRunwayImage
if err := r.db.WithContext(ctx).
Where("runway_id = ? AND is_deleted = 0", runwayID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
lastMain := uint32(0)
type fix struct{ id, parent uint32 }
fixes := make([]fix, 0)
for _, im := range imgs {
if im.IsDetail == 0 {
lastMain = im.ID
continue
}
if im.IsDetail == 1 && im.ParentImageID == 0 && lastMain != 0 {
fixes = append(fixes, fix{im.ID, lastMain})
}
}
for _, f := range fixes {
if err := r.db.WithContext(ctx).
Model(&model.BrandRunwayImage{}).
Where("id = ? AND runway_id = ? AND is_deleted = 0", f.id, runwayID).
Update("parent_image_id", f.parent).Error; err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,103 @@
//go:build integration
// 集成测试:「通过 → 公开可见」这条单表发布主链路的**仓储级**证据。
//
// 背景:公开视图测试(TestPublicViewsHideUnpublished*)都是直接 `db.Model(...).Update("status", ...)`
// 改库、绕过了 `Approve` 走的 SetRecordStatus / SetStreetRecordStatus,这两个仓储方法在全仓零直接引用。
// 一旦它们改错列 / 漏写 status,那些测试仍会全绿、线上却无法通过审核。本文件补上这一段。
//
// 运行:go test -tags integration ./internal/repository/ -run TestSetRecordStatus -v
package repository
import (
"context"
"errors"
"testing"
"fashionapi/internal/model"
)
// TestSetRecordStatusDrivesPublicVisibility 走秀:pending 经仓储置 published 后在公开视图可见;
// 再置 rejected 后不可见;对不存在的 id 返回 ErrNotFound。
func TestSetRecordStatusDrivesPublicVisibility(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewReviewRepository(db)
ctx := context.Background()
id := seedRunway(t, db, model.StatusPending, 1991)
publicCount := func() int64 {
var n int64
db.Table("public_brand_runways").Where("id = ?", id).Count(&n)
return n
}
if got := publicCount(); got != 0 {
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
}
if err := repo.SetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
t.Fatalf("SetRecordStatus(published) 出错: %v", err)
}
if got := publicCount(); got != 1 {
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
}
if err := repo.SetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
t.Fatalf("SetRecordStatus(rejected) 出错: %v", err)
}
if got := publicCount(); got != 0 {
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
}
// 审核痕迹应落库,便于列表页展示。
var rw model.BrandRunway
if err := db.Where("id = ?", id).First(&rw).Error; err != nil {
t.Fatalf("读回记录失败: %v", err)
}
if rw.Status != model.StatusRejected || rw.Reviewer != "admin" {
t.Fatalf("审核态与审核人应落库,实际 status=%s reviewer=%s", rw.Status, rw.Reviewer)
}
// 不存在的 id:必须返回 ErrNotFound,而不是假成功。
if err := repo.SetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
}
}
// TestSetStreetRecordStatusDrivesPublicVisibility 街拍:与走秀同形,覆盖另一组方法与视图。
func TestSetStreetRecordStatusDrivesPublicVisibility(t *testing.T) {
db := testDB(t)
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
repo := NewReviewRepository(db)
ctx := context.Background()
id := seedStreetSnap(t, db, model.StatusPending, 1990)
publicCount := func() int64 {
var n int64
db.Table("public_street_snaps").Where("id = ?", id).Count(&n)
return n
}
if got := publicCount(); got != 0 {
t.Fatalf("pending 不应出现在公开视图,实际 %d", got)
}
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, "admin"); err != nil {
t.Fatalf("SetStreetRecordStatus(published) 出错: %v", err)
}
if got := publicCount(); got != 1 {
t.Fatalf("经仓储通过后应出现在公开视图,实际 %d(仓储可能写错列)", got)
}
if err := repo.SetStreetRecordStatus(ctx, id, model.StatusRejected, "admin"); err != nil {
t.Fatalf("SetStreetRecordStatus(rejected) 出错: %v", err)
}
if got := publicCount(); got != 0 {
t.Fatalf("经仓储驳回后不应出现在公开视图,实际 %d", got)
}
if err := repo.SetStreetRecordStatus(ctx, 4_294_967_290, model.StatusPublished, "admin"); !errors.Is(err, ErrNotFound) {
t.Fatalf("对不存在 id 应返回 ErrNotFound,实际 %v", err)
}
}

View File

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

View File

@ -3,11 +3,13 @@ package repository
import (
"context"
"errors"
"math"
"strconv"
"time"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/phash"
"gorm.io/gorm"
)
@ -16,10 +18,17 @@ import (
type StreetSnapRepository interface {
// List 按筛选条件分页查询街拍,同时返回符合条件的总数。
List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
// FindByID 查询单条街拍;不存在时返回 ErrNotFound。
// ListAdmin 后台管理列表:读基表(含 pending / rejected),与 article_repository.ListAdmin 对称。
ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error)
// FindByID 公开读:查询单条街拍;不存在时返回 ErrNotFound(pending / rejected 视为不存在)。
FindByID(ctx context.Context, id string) (*model.StreetSnap, error)
// ListImages 查询某条街拍的全部图片,按排序值升序。
// FindByIDBase 后台读基表:与 FindByID 同形,但能看到 pending / rejected。
// 后台列表故意展示待审行并链到详情页,详情必须走基表,否则待审项点进去 404。
FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error)
// ListImages 公开读:查询某条街拍的全部图片,按排序值升序(未发布图片不可见)。
ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
// ListImagesBase 后台读基表:与 ListImages 同形,但含未发布图片(后台详情/编辑需要)。
ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error)
// ImagesBySnapIDs 批量查询多条街拍的图片,单次 IN 查询避免 N+1。
ImagesBySnapIDs(ctx context.Context, ids []uint32) (map[uint32][]model.StreetSnapImage, error)
// Popular 按图片数降序返回热度前 N 个街拍,供 SSG 首页热门街拍使用。
@ -28,10 +37,17 @@ type StreetSnapRepository interface {
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
// UpdateSnap 编辑后更新字段(白名单,杜绝任意列注入)。
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
// DeleteSnapImage 软删单张街拍图片。
// DeleteSnapImage 软删单张街拍图片,并按实际存活图片行数重算 image_count。
DeleteSnapImage(ctx context.Context, imageID uint32) error
// GetSnapImageKey 取单张街拍图片的S4 key(删除前清理用);不存在返回空串。
GetSnapImageKey(ctx context.Context, imageID uint32) (string, error)
// GetStreetSnapDupSources 批量反查重复源:给定一组街拍图片 id(dup_of 指向的源图 id),
// 返回每张源图及其所属街拍的上下文,供编辑页标记重复图时展示来源。
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供删图后重算去重)。
GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error)
// ReresolveSnapDedup 删图后重算本专辑去重,消除陈旧 dup_of(见方法注释)。
ReresolveSnapDedup(ctx context.Context, snapID uint32) error
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用),返回被删图片的 key 列表。
SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error)
// SetSnapCover 更新街拍封面。
@ -75,9 +91,39 @@ func snapOrderBy(sort string) string {
}
}
// List 公开读:读公开视图,pending / rejected 街拍不可见。
func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
scope := snapFilterScope(q)
var total int64
if err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Scopes(scope).
Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []model.StreetSnap{}, 0, nil
}
var rows []model.StreetSnap
if err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Scopes(scope).
Order(snapOrderBy(q.Sort)).
Offset(q.Offset()).
Limit(q.Size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
// ListAdmin 后台管理列表:与 List 同构,但读基表 —— 后台必须看得到 pending / rejected,
// 否则审核员在列表页看不到待审内容(公开 List 读视图,会把这些行挡掉)。
func (r *streetSnapRepository) ListAdmin(ctx context.Context, q dto.StreetSnapQuery) ([]model.StreetSnap, int64, error) {
scope := snapFilterScope(q)
var total int64
if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
@ -102,11 +148,20 @@ func (r *streetSnapRepository) List(ctx context.Context, q dto.StreetSnapQuery)
return rows, total, nil
}
// FindByID 公开读:读公开视图,pending / rejected 街拍视同不存在(返回 ErrNotFound)。
func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.StreetSnap, error) {
return r.findByID(r.db.WithContext(ctx).Table(publicStreetSnaps), id)
}
// FindByIDBase 后台读基表:不过滤 status(后台详情需渲染待审内容)。
func (r *streetSnapRepository) FindByIDBase(ctx context.Context, id string) (*model.StreetSnap, error) {
return r.findByID(r.db.WithContext(ctx), id)
}
// findByID 用给定查询(视图或基表)取单行,公开/后台口径只在数据来源上不同。
func (r *streetSnapRepository) findByID(q *gorm.DB, id string) (*model.StreetSnap, error) {
var snap model.StreetSnap
err := r.db.WithContext(ctx).
Where("id = ? AND is_deleted = 0", id).
First(&snap).Error
err := q.Where("id = ? AND is_deleted = 0", id).First(&snap).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
@ -116,7 +171,19 @@ func (r *streetSnapRepository) FindByID(ctx context.Context, id string) (*model.
return &snap, nil
}
// ListImages 公开读:读公开视图,未发布街拍的图片不可见(视图已含 is_deleted 过滤)。
func (r *streetSnapRepository) ListImages(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Table(publicStreetSnapImages).
Where("snap_id = ?", snapID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
return imgs, err
}
// ListImagesBase 读基表(含未发布),供后台 GetForEdit / 后台详情使用 —— 否则待审内容看不到图。
func (r *streetSnapRepository) ListImagesBase(ctx context.Context, snapID string) ([]model.StreetSnapImage, error) {
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Where("snap_id = ? AND is_deleted = 0", snapID).
@ -133,8 +200,9 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
var imgs []model.StreetSnapImage
err := r.db.WithContext(ctx).
Table(publicStreetSnapImages).
Select("snap_id, image, name, sort_order").
Where("snap_id IN ? AND is_deleted = 0", ids).
Where("snap_id IN ?", ids).
Order("sort_order ASC, id ASC").
Find(&imgs).Error
if err != nil {
@ -146,11 +214,11 @@ func (r *streetSnapRepository) ImagesBySnapIDs(ctx context.Context, ids []uint32
return result, nil
}
// IDs 返回全部未删除街拍的 id(升序),供 SSG 构建期枚举详情页路径。
// Popular 按图片数降序返回热度前 N 个街拍(SSG 首页热门街拍用)。
func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.StreetSnap, error) {
var rows []model.StreetSnap
if err := r.db.WithContext(ctx).
Table(publicStreetSnaps).
Where("is_deleted = 0").
Order("image_count DESC, id DESC").
Limit(limit).
@ -164,10 +232,11 @@ func (r *streetSnapRepository) Popular(ctx context.Context, limit int) ([]model.
// snapEditable 后台可编辑的街拍字段白名单(键即列名)。
var snapEditable = map[string]bool{
"title": true,
"year": true,
"city": true,
"cover": true,
"title": true,
"title_cn": true,
"year": true,
"city": true,
"cover": true,
}
func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
@ -178,7 +247,7 @@ func (r *streetSnapRepository) GetForEdit(ctx context.Context, id uint32) (*mode
}
return nil, nil, err
}
imgs, err := r.ListImages(ctx, strconv.FormatUint(uint64(id), 10))
imgs, err := r.ListImagesBase(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil {
return nil, nil, err
}
@ -202,11 +271,63 @@ func (r *streetSnapRepository) UpdateSnap(ctx context.Context, id uint32, fields
Updates(clean).Error
}
// DeleteSnapImage 软删单张街拍图片(后台正式编辑页删图),并同步重算 image_count。
//
// 与审核侧 SoftDeleteStreetImage 口径一致:删完按**实际存活图片行数**重算
// (`is_deleted = 0`,主图 + 副图全部计入)。
//
// 为什么必须重算而不是「递减 1」:理由同 articleRepository.DeleteRunwayImage —— 单表发布模型
// 删掉了晋升重建,编辑页删图造成的计数漂移不再被抹平,会永久显示在公开列表卡片的「N 张」上。
//
// 入参只有 imageID(拿不到父记录 id),故在一个事务里三步走:取 snap_id → 软删 → 重算。
// 图片行不存在时与旧行为一致:静默无操作(不报错、不重算)。
func (r *streetSnapRepository) DeleteSnapImage(ctx context.Context, imageID uint32) error {
return r.db.WithContext(ctx).
Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var img model.StreetSnapImage
err := tx.Select("snap_id, image").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 取出父记录的封面,判断被删图是否正是封面(防止封面悬空指向已删图 —— 设计规格 §12 唯一真数据丢失窗口)。
var snap model.StreetSnap
if err := tx.Select("cover").Where("id = ?", img.SnapID).First(&snap).Error; err != nil {
return err
}
if err := tx.Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", imageID).
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
if err := tx.Exec(`UPDATE street_snaps SET image_count =
(SELECT COUNT(*) FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0)
WHERE id = ?`, img.SnapID, img.SnapID).Error; err != nil {
return err
}
// 被删图是封面:重指向另一张存活图(无存活图则清空),避免封面悬空。
if snap.Cover == img.Image {
cover := ""
var survivor struct {
Image string `gorm:"column:image"`
}
serr := tx.Raw(
"SELECT image FROM street_snap_images WHERE snap_id = ? AND is_deleted = 0 ORDER BY id ASC LIMIT 1",
img.SnapID,
).Scan(&survivor).Error
if serr == nil {
cover = survivor.Image
} else if !errors.Is(serr, gorm.ErrRecordNotFound) {
return serr
}
if err := tx.Model(&model.StreetSnap{}).Where("id = ?", img.SnapID).
Updates(map[string]any{"cover": cover, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return err
}
}
return nil
})
}
func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint32) (string, error) {
@ -221,6 +342,117 @@ func (r *streetSnapRepository) GetSnapImageKey(ctx context.Context, imageID uint
return img.Image, nil
}
// GetSnapImageRunwayID 取图片所属 snap_id(不过滤 is_deleted,软删后仍可取,供编辑页删图后重算去重)。
func (r *streetSnapRepository) GetSnapImageRunwayID(ctx context.Context, imageID uint32) (uint32, error) {
var img model.StreetSnapImage
err := r.db.WithContext(ctx).Select("snap_id").Where("id = ?", imageID).First(&img).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
return img.SnapID, nil
}
// nearDupExceptSnap 在 street_snap_images 内做全局近重复检索(is_deleted=0),但排除自身与
// 「同 snap 且 sort_order 在后的图」——防止重算时后入者反成源导致互指环。命中返回源图 id,无则 0。
func nearDupExceptSnap(db *gorm.DB, phashBits string, threshold int, excludeID, snapID, sortOrder uint32) (uint32, error) {
l2Limit := math.Sqrt(float64(threshold))
var row struct {
ID uint32 `gorm:"column:id"`
}
if err := db.Table("street_snap_images").
Select("id, (phash <-> ?::vector) AS dist", phashBits).
Where("(phash <-> ?::vector) <= ?", phashBits, l2Limit).
Where("is_deleted = 0").
Where("id <> ?", excludeID).
Where("(snap_id <> ? OR sort_order < ?)", snapID, sortOrder).
Order("dist ASC").
Limit(1).
Scan(&row).Error; err != nil {
return 0, err
}
return row.ID, nil
}
// ReresolveSnapDedup 删除图片后重解本专辑去重:先清空全部存活图的 is_duplicate/dup_of,
// 再按 sort_order 逐张以「全局近重复」重新判定(与入库 dedupImage 同口径:先入者为准)。
//
// 为什么需要:is_duplicate/dup_of 是入库时一次算死的存储标记,编辑页手动删图从不重算;删除「源图」
// 后剩余图仍挂陈旧 dup_of,导致编辑页误显「重复」且「查看重复来源」指向已删源图而打不开。重算后
// 被删源图不再作为参照,剩余近重复图重新确立「先入者为准」的源/副本关系。
func (r *streetSnapRepository) ReresolveSnapDedup(ctx context.Context, snapID uint32) error {
var imgs []model.StreetSnapImage
if err := r.db.WithContext(ctx).
Where("snap_id = ? AND is_deleted = 0", snapID).
Order("sort_order ASC, id ASC").
Find(&imgs).Error; err != nil {
return err
}
if len(imgs) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.StreetSnapImage{}).
Where("snap_id = ? AND is_deleted = 0", snapID).
Updates(map[string]any{"is_duplicate": 0, "dup_of": ""}).Error; err != nil {
return err
}
for _, im := range imgs {
if !im.Phash.Valid || im.Phash.String == "" {
continue
}
src, err := nearDupExceptSnap(tx, im.Phash.String, phash.DefaultThreshold, im.ID, snapID, im.SortOrder)
if err != nil {
return err
}
if src != 0 && src != im.ID {
if err := tx.Model(&model.StreetSnapImage{}).
Where("id = ? AND is_deleted = 0", im.ID).
Updates(map[string]any{"is_duplicate": 1, "dup_of": strconv.FormatUint(uint64(src), 10)}).Error; err != nil {
return err
}
}
}
return nil
})
}
// GetStreetSnapDupSources 批量反查重复源(见接口注释)。街拍无品牌关联;一次 IN 查询 + JOIN。
func (r *streetSnapRepository) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
out := make(map[uint32]dto.DupSource, len(imageIDs))
if len(imageIDs) == 0 {
return out, nil
}
type srcRow struct {
ImageID uint32
Image string
SnapID uint32
Title string
TitleCn string
}
var rows []srcRow
err := r.db.WithContext(ctx).
Table("street_snap_images ssi").
Select("ssi.id AS image_id, ssi.image AS image, ssi.snap_id AS snap_id, ss.title AS title, ss.title_cn AS title_cn").
Joins("JOIN street_snaps ss ON ss.id = ssi.snap_id").
Where("ssi.id IN ?", imageIDs).
Where("ssi.is_deleted = 0").
Find(&rows).Error
if err != nil {
return nil, err
}
for _, rw := range rows {
title := rw.Title
if title == "" {
title = rw.TitleCn
}
out[rw.ImageID] = dto.DupSource{Image: rw.Image, RecordID: rw.SnapID, Title: title}
}
return out, nil
}
// SoftDeleteSnapImages 软删某街拍的全部图片(级联下架用)。
// 先取非删除图片的 key 列表返回给调用方(用于后续S4孤儿清理),再统一置 is_deleted=1。
func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID uint32) ([]string, error) {
@ -243,6 +475,13 @@ func (r *streetSnapRepository) SoftDeleteSnapImages(ctx context.Context, snapID
Updates(map[string]any{"is_deleted": 1, "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
// 全部图片软删:封面必然悬空(封面本就指向被删图之一),直接清空避免悬空指针。
if err := r.db.WithContext(ctx).
Model(&model.StreetSnap{}).
Where("id = ?", snapID).
Updates(map[string]any{"cover": "", "updated_at": uint32(time.Now().Unix())}).Error; err != nil {
return nil, err
}
return keys, nil
}

View File

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

View File

@ -44,6 +44,15 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
r.Static(opt.UploadURLPrefix, opt.UploadDir)
}
// 后台自有静态资源(admin.css / admin.js):embed 进二进制,无 CDN、无构建产物依赖;
// 给一段缓存时间,避免每翻一页都重新下载。
assets := r.Group("/admin/assets")
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=600")
c.Next()
})
assets.StaticFS("/", http.FS(handler.BackstageAssetFS()))
// 公开:登录页 / 登录动作 / 登出(登出也要先有 cookie,但不需要先校验)
r.GET("/admin/login", opt.Backstage.LoginPage)
r.POST("/admin/login", opt.Backstage.Login)
@ -65,35 +74,43 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
// 受保护:后台页面,统一走 cookie 鉴权中间件
admin := r.Group("/admin")
admin.Use(requireBackstageLogin(opt.JWT))
// 导航统计(待审总数):放进 context,任意后台页面都能显示队列深度
admin.Use(opt.Backstage.NavStats())
{
// 文章管理页的 AJAX 接口(返回 JSON,不整页跳转):批量通过/驳回、主副图合并与拆出。
// 与下面的表单式路由并存 —— 表单路径保持「无 JS 也能用」,AJAX 只是更快的那条路。
admin.POST("/api/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/api/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImagesAPI)
admin.POST("/api/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImageAPI)
admin.GET("/", opt.Backstage.Dashboard)
admin.GET("/brands", opt.Backstage.Brands)
admin.GET("/brands/new", opt.Backstage.BrandNew)
admin.POST("/brands", opt.Backstage.BrandCreate)
admin.GET("/brands/:id", opt.Backstage.BrandEdit)
admin.POST("/brands/:id", opt.Backstage.BrandUpdate)
admin.GET("/runways", opt.Backstage.Runways)
// 文章管理:走秀 + 街拍混合列表,替代原「审核页 + 分类列表」,是编辑/通过/删除(下架)的统一入口。
admin.GET("/articles", opt.Backstage.Articles)
admin.POST("/articles/batch", opt.Backstage.ArticleBatch)
admin.POST("/articles/:kind/:id/approve", opt.Backstage.ArticleApprove)
// 街拍主副图分组(零 JS:同步表单 POST,成功 302 回编辑页;AJAX 版见路由上方)。
admin.POST("/articles/:kind/:id/images/attach", opt.Backstage.ArticleAttachImages)
admin.POST("/articles/:kind/:id/images/:img/detach", opt.Backstage.ArticleDetachImage)
// 分类型编辑/详情/删图/设封面/上下架:从文章管理列表跳入,按类型落到各自的编辑页。
// 列表本身已合并到 /admin/articles,这里只保留编辑链路,避免重复维护两套列表。
admin.GET("/runways/:id", opt.Backstage.RunwayDetail)
admin.GET("/runways/:id/edit", opt.Backstage.RunwayEdit)
admin.POST("/runways/:id/edit", opt.Backstage.RunwayUpdate)
admin.POST("/runways/:id/images/:img/delete", opt.Backstage.RunwayImageDelete)
admin.POST("/runways/:id/cover", opt.Backstage.RunwaySetCover)
admin.POST("/runways/:id/toggle", opt.Backstage.RunwayToggleDeleted)
admin.GET("/street-snaps", opt.Backstage.StreetSnaps)
admin.GET("/street-snaps/:id", opt.Backstage.StreetSnapDetail)
admin.GET("/street-snaps/:id/edit", opt.Backstage.StreetSnapEdit)
admin.POST("/street-snaps/:id/edit", opt.Backstage.StreetSnapUpdate)
admin.POST("/street-snaps/:id/images/:img/delete", opt.Backstage.StreetSnapImageDelete)
admin.POST("/street-snaps/:id/cover", opt.Backstage.StreetSnapSetCover)
admin.POST("/street-snaps/:id/toggle", opt.Backstage.StreetSnapToggleDeleted)
// 审核草稿:爬虫入库待人工审核 → 通过晋升正式表 / 驳回。
// 路由带 :kind 段(runway / street / 缺省=全部),详情/通过/驳回均按 kind 分流。
admin.GET("/reviews", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind", opt.Backstage.ReviewList)
admin.GET("/reviews/:kind/:id", opt.Backstage.ReviewDetail)
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete)
// 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -2,6 +2,7 @@ package router
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
@ -12,8 +13,12 @@ import (
"fashionapi/internal/dto"
"fashionapi/internal/handler"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/jwt"
"fashionapi/internal/repository"
"fashionapi/internal/service"
"github.com/gin-gonic/gin"
)
// ── 测试用假实现(不连库)──────────────────────────────────────────────
@ -47,6 +52,11 @@ type fakeBrand struct{}
func (f *fakeBrand) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return []dto.PublicBrand{{UID: "001DESke", NameEn: "Acne Studios", NameCn: "艾克妮"}}, 1, nil
}
// AdminList 后台品牌列表:假实现里与 List 同源(本假实现不区分走秀发布态)。
func (f *fakeBrand) AdminList(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) {
return f.List(ctx, q)
}
func (f *fakeBrand) Hot(ctx context.Context, limit int, locale string) ([]dto.PublicBrand, error) {
return nil, nil
}
@ -101,11 +111,19 @@ func (f *fakeArticle) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.
ImageCount: 12,
}}, 1, nil
}
// Detail 公开详情。本 fake 代表一条「待审」内容:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeArticle) Detail(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
return nil, service.ErrArticleNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeArticle) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicArticleDetail, error) {
return &dto.PublicArticleDetail{
UID: id,
BrandUID: "001DESke",
Title: "Acne Studios Spring 2024",
Title: "Acne Studios Spring 2024(待审)",
BrandName: "Acne Studios",
Images: []dto.PublicArticleImage{
{ID: "i1", Image: "/uploads/runway/1.jpg", Name: "Look 1"},
@ -129,6 +147,9 @@ func (f *fakeArticle) SetDeleted(ctx context.Context, id uint32, deleted uint8)
func (f *fakeArticle) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
func (f *fakeArticle) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
return map[uint32]dto.DupSource{}, nil
}
type fakeStreet struct{}
@ -140,20 +161,38 @@ func (f *fakeStreet) List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.Pub
ImageCount: 8,
}}, 1, nil
}
func (f *fakeStreet) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
// AdminList 后台列表读基表;假实现里与 List 同源即可(本假实现不区分可见性)。
func (f *fakeStreet) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
return f.List(ctx, q)
}
// Detail 公开详情。本 fake 代表一条「待审」街拍:公开路径读视图,读不到,
// 故固定返回 not found;后台详情只有走 DetailAdmin(读基表)才渲染得出来。
func (f *fakeStreet) Detail(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return nil, service.ErrStreetSnapNotFound
}
// DetailAdmin 后台详情:读基表,待审内容也能渲染(Title 带「待审」标记便于断言)。
func (f *fakeStreet) DetailAdmin(ctx context.Context, id, locale string) (*dto.PublicStreetSnapDetail, error) {
return &dto.PublicStreetSnapDetail{
UID: id,
Title: "Paris Fashion Week Street",
Title: "Paris Fashion Week Street(待审)",
Images: []dto.PublicArticleImage{
{ID: "j1", Image: "/uploads/snap/1.jpg", Name: "Snap 1"},
},
}, nil
}
func (f *fakeStreet) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (f *fakeStreet) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
return nil, nil
}
func (f *fakeStreet) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street"}, nil, nil
imgs := []model.StreetSnapImage{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
}
return &model.StreetSnap{ID: id, Title: "Paris Fashion Week Street", Status: "pending"}, imgs, nil
}
func (f *fakeStreet) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
return nil
@ -168,6 +207,62 @@ func (f *fakeStreet) SetDeleted(ctx context.Context, id uint32, deleted uint8) e
func (f *fakeStreet) PurgeGallery(ctx context.Context, id uint32) error {
return nil
}
func (f *fakeStreet) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
return map[uint32]dto.DupSource{}, nil
}
// fakeAdminRepo 实现文章管理仓储接口;返回固定的混合列表示例,供路由/模板渲染测试。
type fakeAdminRepo struct{}
func (f *fakeAdminRepo) ListMixed(_ context.Context, status, kind string, page, size int) ([]repository.AdminRecordCard, int64, error) {
all := []repository.AdminRecordCard{
{Kind: "runway", ID: 1, Title: "Acne Studios Spring 2024", Subtitle: "Acne Studios", ImageCount: 12, Status: "published"},
{Kind: "street", ID: 2, Title: "Paris Fashion Week Street", Subtitle: "Paris", ImageCount: 8, Status: "pending"},
}
if kind != "" {
kept := all[:0]
for _, c := range all {
if c.Kind == kind {
kept = append(kept, c)
}
}
all = kept
}
if status != "" {
kept := all[:0]
for _, c := range all {
if c.Status == status {
kept = append(kept, c)
}
}
all = kept
}
return all, int64(len(all)), nil
}
func (f *fakeAdminRepo) CountMixed(_ context.Context, status, kind string) (int64, error) {
if kind == "runway" {
if status == "" || status == "published" {
return 1, nil
}
return 0, nil
}
if kind == "street" {
if status == "" || status == "pending" {
return 1, nil
}
return 0, nil
}
if status == "" {
return 2, nil
}
if status == "pending" || status == "published" {
return 1, nil
}
return 0, nil
}
func (f *fakeAdminRepo) DupRateByRecords(_ context.Context, kind string, ids []uint32) (map[uint32]repository.DupStat, error) {
return map[uint32]repository.DupStat{}, nil
}
// ── 冒烟测试:验证路由 / 鉴权 / 模板渲染(无需真实数据库)─────────────────
@ -175,7 +270,7 @@ func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, nil, nil)
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问
@ -252,39 +347,381 @@ func TestBackstage(t *testing.T) {
t.Fatalf("品牌新建提交异常: %d %s", w.Code, w.Header().Get("Location"))
}
// 6) 走秀列表渲染(含后台管理字段:季节码 SS24 / 来源)
// 6) 文章管理混合列表渲染(走秀 + 街拍合并)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Acne Studios Spring 2024") || !strings.Contains(w.Body.String(), "SS24") {
t.Fatalf("走秀列表异常: %d %s", w.Code, w.Body.String())
body := w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Acne Studios Spring 2024") || !strings.Contains(body, "Paris Fashion Week Street") {
t.Fatalf("文章管理列表异常: %d %s", w.Code, body)
}
if !strings.Contains(body, "待审核") || !strings.Contains(body, "已发布") {
t.Fatalf("文章管理列表应含状态 tab: %s", body)
}
// 7) 走秀详情渲染图集
// 7) 后台走秀详情渲染图集。
// fakeArticle.Detail(公开路径)对这条「待审」内容固定返回 not found,
// 这里仍 200 且渲染出「待审」标记,即证明后台详情走的是 DetailAdmin(读基表)。
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/runways/r001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") {
t.Fatalf("走秀详情异常: %d %s", w.Code, w.Body.String())
if w.Code != 200 || !strings.Contains(w.Body.String(), "Look 1") || !strings.Contains(w.Body.String(), "(待审)") {
t.Fatalf("后台应能渲染待审走秀详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
// 8) 街拍列表渲染
// 8) 状态筛选:待审核视图只显示 pending 的街拍,不显示已发布的走秀
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps", nil)
req = httptest.NewRequest(http.MethodGet, "/admin/articles?status=pending", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Paris Fashion Week Street") {
t.Fatalf("街拍列表异常: %d %s", w.Code, w.Body.String())
body = w.Body.String()
if w.Code != 200 || !strings.Contains(body, "Paris Fashion Week Street") || strings.Contains(body, "Acne Studios Spring 2024") {
t.Fatalf("待审核筛选异常: %d %s", w.Code, body)
}
// 9) 街拍详情渲染图集
// 9) 后台街拍详情渲染图集。
// 同 7):fakeStreet.Detail(公开路径)返回 not found,此处 200 且渲染待审标记即证明走 DetailAdmin。
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/street-snaps/s001DESke", nil)
req.AddCookie(&http.Cookie{Name: adminCookie, Value: token})
r.ServeHTTP(w, req)
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
if w.Code != 200 || !strings.Contains(w.Body.String(), "Snap 1") || !strings.Contains(w.Body.String(), "(待审)") {
t.Fatalf("后台应能渲染待审街拍详情(走 DetailAdmin): %d %s", w.Code, w.Body.String())
}
}
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
// fakeReview 实现审核服务接口;
// ⚠️ 嵌入的 service.ReviewService 是 nil 接口,只对「确实实现的方法」安全 ——
// 任何未覆盖的方法被调用时都会 nil panic(曾经因为导航中间件调 KindTabs 而炸出来)。
// 因此凡是页面/接口会走到的服务方法,都必须在这里显式实现。
type fakeReview struct {
service.ReviewService
attachOK bool
approveErr error
last struct {
kind string
recordID uint32
mainID uint32
imgIDs []uint32
imgID uint32
op string
}
// calls 记录批量审核的逐条调用,供断言。
calls []struct {
Action string
Kind string
ID uint32
}
view *service.RecordDetailView
}
var _ service.ReviewService = (*fakeReview)(nil)
func (f *fakeReview) RecordDetail(_ context.Context, _ string, _ uint32) (*service.RecordDetailView, error) {
if f.view != nil {
return f.view, nil
}
return &service.RecordDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
}
// UpdateRecord 表单式通过路径会先应用字段微调;这里只做无操作桩(真正的白名单在仓储层)。
func (f *fakeReview) UpdateRecord(_ context.Context, _ string, _ uint32, _ map[string]any) error {
return nil
}
func (f *fakeReview) AttachImages(_ context.Context, kind string, recordID, mainID uint32, imgIDs []uint32) error {
f.last.kind, f.last.recordID, f.last.mainID, f.last.imgIDs, f.last.op = kind, recordID, mainID, imgIDs, "attach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
func (f *fakeReview) DetachImage(_ context.Context, kind string, recordID, imgID uint32) error {
f.last.kind, f.last.recordID, f.last.imgID, f.last.op = kind, recordID, imgID, "detach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
// KindTabs 是导航中间件在每个后台页面上都会调用的方法(用于显示待审计数角标),
// 必须显式实现,否则会落到嵌入的 nil 接口上 panic。
func (f *fakeReview) KindTabs(_ context.Context) []service.KindTab {
return []service.KindTab{
{Kind: "", Label: "全部", Pending: 2},
{Kind: "street", Label: "街拍", Pending: 1},
{Kind: "runway", Label: "走秀", Pending: 1},
}
}
func (f *fakeReview) Approve(_ context.Context, kind string, id uint32, reviewer string) (uint32, error) {
f.calls = append(f.calls, struct {
Action string
Kind string
ID uint32
}{"approve", kind, id})
// last 供「表单式通过」用例断言(批量接口用 calls)。
f.last.kind, f.last.recordID, f.last.op = kind, id, "approve"
if f.approveErr != nil {
return 0, f.approveErr
}
return id, nil
}
func newReviewRouter(t *testing.T) (*gin.Engine, *fakeReview) {
t.Helper()
m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m}
brand := &fakeBrand{}
rev := &fakeReview{attachOK: true}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, rev, nil, &fakeAdminRepo{})
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
return r, rev
}
func reviewToken(t *testing.T, r *gin.Engine) *http.Cookie {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("account=admin&password=secret"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(w, req)
cookies := w.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("登录未下发会话 cookie")
}
return cookies[0]
}
// TestBackstageStreetAttachRedirect 合并选中应 302 跳回详情页,且参数透传到 service。
func TestBackstageStreetAttachRedirect(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("main=1&img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("合并应 302,实际 %d %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应重定向回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "attach" || rev.last.mainID != 1 || len(rev.last.imgIDs) != 2 {
t.Fatalf("参数透传错误: %+v", rev.last)
}
}
// TestBackstageStreetAttachNeedsMain 未选主图时应 400,而不是静默失败。
func TestBackstageStreetAttachNeedsMain(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/attach", strings.NewReader("img=2&img=3"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("未选主图应 400,实际 %d", w.Code)
}
if rev.last.op == "attach" {
t.Fatalf("未选主图不应调用 service")
}
}
// TestBackstageStreetDetach 拆出应 302 跳回详情页。
func TestBackstageStreetDetach(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/images/2/detach", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("拆出应 302,实际 %d", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "/admin/street-snaps/") {
t.Fatalf("应跳回详情页,实际 %s", w.Header().Get("Location"))
}
if rev.last.op != "detach" || rev.last.imgID != 2 {
t.Fatalf("参数透传错误: %+v", rev.last)
}
}
// TestBackstageStreetRenderGroups 详情页渲染统一网格:复选框 + 主图单选 + 底部合并按钮,且不再有「并入上一张」。
func TestBackstageStreetRenderGroups(t *testing.T) {
hashid.Init("test-salt")
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
_ = rev // 分组渲染走的是 street 服务的 GetForEdit,不依赖 review 服务
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/street-snaps/"+hashid.EncodeWithType(7, hashid.TypeSnap)+"/edit", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("街拍编辑页应 200,实际 %d %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "detail-a") {
t.Fatalf("应渲染副图 detail-a")
}
// 副图徽标显示所属主图。
if !strings.Contains(body, "副图 → #1") {
t.Fatalf("副图应显示所属主图")
}
// 统一网格 + 勾选 + 主图单选 + 底部提交按钮,全部用 form="batch" 关联(零 JS、不嵌套表单)。
if !strings.Contains(body, `id="batch"`) || !strings.Contains(body, `form="batch"`) {
t.Fatalf("应有勾选合并的表单")
}
if !strings.Contains(body, `name="main"`) {
t.Fatalf("应有主图单选(name=main)")
}
if !strings.Contains(body, "合并选中为一组") {
t.Fatalf("底部应有「合并选中为一组」按钮")
}
// 「并入上一张」已废弃:不应再出现在页面或表单里。
if strings.Contains(body, "并入上一张") || strings.Contains(body, "attach-prev") {
t.Fatalf("不应再出现「并入上一张」")
}
}
// TestBackstageApprovePublishes 通过 = 只改状态,不再晋升重建。
// 断言表单式通过路径确实走到了 service.Approve(而不是别的方法)。
func TestBackstageApprovePublishes(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/articles/street/7/approve", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("通过应 302,实际 %d %s", w.Code, w.Body.String())
}
if rev.last.op != "approve" {
t.Fatalf("应调用 Approve,实际 op=%s", rev.last.op)
}
}
// ── 新增:静态资源、导航统计 & 批量审核 AJAX 接口 ───────────────────────────
// TestBackstageAssets 后台的样式与脚本由二进制内嵌提供:零外部依赖、零构建产物。
func TestBackstageAssets(t *testing.T) {
r, _ := newReviewRouter(t)
for _, tc := range []struct{ path, want string }{
{"/admin/assets/admin.css", ".bar-nav"},
{"/admin/assets/admin.js", "data-admin-js"},
} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
if w.Code != 200 {
t.Fatalf("%s 应 200,实际 %d", tc.path, w.Code)
}
if !strings.Contains(w.Body.String(), tc.want) {
t.Fatalf("%s 内容缺少 %q", tc.path, tc.want)
}
}
}
// TestBackstageNavStatsOnEveryPage 后台任意页面都带待审计数角标。
// 该角标由 NavStats 中间件提供;它同时是「所有页面都会调用 ReviewService.KindTabs」的证据。
func TestBackstageNavStatsOnEveryPage(t *testing.T) {
r, _ := newReviewRouter(t)
tok := reviewToken(t, r)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/brands", nil)
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("品牌页应 200: %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "bar-badge") || !strings.Contains(body, ">2<") {
t.Fatalf("品牌页导航应带待审计数角标(2):\n%s", body)
}
// 导航已在共享 partial 里,各模块入口都应在
for _, label := range []string{"文章", "品牌", "入库任务", "用户"} {
if !strings.Contains(body, ">"+label) {
t.Fatalf("导航应包含入口 %q", label)
}
}
}
// TestBackstageReviewBatchApprove 批量通过:一次请求处理多条,返回 ok 计数。
func TestBackstageReviewBatchApprove(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"runway","id":22}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("批量通过应 200: %d %s", w.Code, w.Body.String())
}
var res struct {
OK int `json:"ok"`
Pending int64 `json:"pending"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("响应不是合法 JSON: %v", err)
}
if res.OK != 2 {
t.Fatalf("应通过 2 条,实际 %d", res.OK)
}
if res.Pending != 2 {
t.Fatalf("应回带最新待审计数 2,实际 %d", res.Pending)
}
if len(rev.calls) != 2 || rev.calls[0].ID != 11 || rev.calls[1].Kind != "runway" {
t.Fatalf("逐条调用未透传到 service: %+v", rev.calls)
}
}
// TestBackstageReviewBatchPartialFailure 未注册类型只计失败,其余照常处理(不整体回滚)。
func TestBackstageReviewBatchPartialFailure(t *testing.T) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
body := `{"action":"approve","items":[{"kind":"street","id":11},{"kind":"nope","id":12}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/api/articles/batch", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(tok)
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("部分失败仍应 200: %d", w.Code)
}
var res struct {
OK int `json:"ok"`
Failed []struct {
ID uint32 `json:"id"`
Error string `json:"error"`
} `json:"failed"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("响应不是合法 JSON: %v", err)
}
if res.OK != 1 || len(res.Failed) != 1 || res.Failed[0].ID != 12 {
t.Fatalf("应 1 成功 1 失败(id=12): ok=%d failed=%+v", res.OK, res.Failed)
}
if len(rev.calls) != 1 {
t.Fatalf("未注册类型不应调用 service: %+v", rev.calls)
}
}

View File

@ -24,10 +24,14 @@ type MediaCleanupEnqueuer interface {
type ArticleService interface {
List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error)
// AdminList 后台管理列表:返回含季节 / 年份 / 系列等管理字段的走秀项。
AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error)
// GetForEdit 取原始走秀记录与图片,供后台编辑表单预填。
GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error)
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释),供编辑页标记重复图。
GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
// UpdateRunway 审核/编辑后更新字段(字段白名单在仓储层强制)。
UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error
// DeleteImage 软删单张走秀图片。
@ -87,7 +91,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
return list, total, nil
}
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 /
// AdminList 后台管理列表:复用同一套筛选与排序,但返回含季节 / 年份 / 系列 / 状态
// 管理字段(对外 PublicArticle 刻意不暴露这些)。不附带缩略图,列表更轻。
func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]dto.AdminRunway, int64, error) {
q.Normalize()
@ -107,6 +111,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d
CollectionType: r.CollectionType,
SeasonCode: r.SeasonCode,
ImageCount: r.ImageCount,
Status: r.Status,
})
}
return list, total, nil
@ -151,41 +156,62 @@ func excludeCover(imgs []dto.PublicArticleImage, cover string) []dto.PublicArtic
return kept
}
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言;
// 图片统一走展示样式(VIP 与免费同质量)。
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrArticleNotFound)。
// locale 决定标题/描述/品牌名的展示语言;图片统一走展示样式(VIP 与免费同质量)。
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
row, err := s.articles.FindByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, ErrArticleNotFound
}
return nil, NewError(http.StatusInternalServerError, err.Error())
return nil, s.detailErr(err)
}
imgs, err := s.articles.ListImages(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildDetail(row, imgs, locale), nil
}
// 主图(is_detail=0)进 Images,默认展示;细节图(is_detail=1)按 look_index
// 直接挂到所属主图的 Detail 子数组下 —— 「look → 细节」的层级与前端 UI 一致,
// 前端读 img.detail 即可,无需再单独维护一份「按 look_index 分组」的映射表。
// 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)按 parent_image_id
// 直接挂到所属主图的 Detail 子数组下 —— 「主图 → 细节」的层级与前端 UI 一致,前端读 img.detail 即可。
mains := make([]model.BrandRunwayImage, 0, len(imgs))
byLook := make(map[uint32][]model.BrandRunwayImage)
detailsByParent := make(map[uint32][]model.BrandRunwayImage)
for _, im := range imgs {
if im.IsDetail == 0 {
if im.IsDetail == 0 || im.ParentImageID == 0 {
mains = append(mains, im)
} else {
byLook[im.LookIndex] = append(byLook[im.LookIndex], im)
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
pubMains := s.toPublicImages(mains)
for i := range pubMains {
// 挂载后即从 byLook 摘除:主图 look_index 若有重复,只归属第一张,避免同一批
// 细节图被复制进多个主图(正常数据不会重复,此处仅作防御)。
if kids, ok := byLook[pubMains[i].LookIndex]; ok && len(kids) > 0 {
// 主图 id 对应的细节图子集挂到该主图下(正常数据每个主图一份,无需防御性摘除)。
if kids, ok := detailsByParent[mains[i].ID]; ok && len(kids) > 0 {
pubMains[i].Detail = s.toPublicImages(kids)
delete(byLook, pubMains[i].LookIndex)
}
}
@ -198,13 +224,56 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
// 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。
BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, "en"),
Images: pubMains,
}, nil
}
}
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
func (s *articleService) GetForEdit(ctx context.Context, id uint32) (*model.BrandRunway, []model.BrandRunwayImage, error) {
return s.articles.GetForEdit(ctx, id)
rw, imgs, err := s.articles.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 与街拍编辑页同构:把每张主图(is_detail=0)紧跟其细节图(同 parent_image_id、is_detail=1)重排,
// 避免细节图被甩到几百张图的最末尾、看起来像「没显示」。孤儿细节图(无主图)追加末尾。
return rw, orderRunwayImagesForEdit(imgs), nil
}
// GetRunwayDupSources 批量反查走秀重复源(见仓储注释)。
func (s *articleService) GetRunwayDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
return s.articles.GetRunwayDupSources(ctx, imageIDs)
}
// orderRunwayImagesForEdit 走秀图片按 parent_image_id 重排为「主图后紧跟其细节图」的扁平顺序。
// 同一主图内的细节图按 sort_order 升序;孤儿细节图(parent 缺失)追加末尾,不丢图。
func orderRunwayImagesForEdit(imgs []model.BrandRunwayImage) []model.BrandRunwayImage {
mains := make([]model.BrandRunwayImage, 0, len(imgs))
mainSeen := make(map[uint32]bool, len(imgs))
for _, im := range imgs {
isMain := im.IsDetail == 0 || im.ParentImageID == 0
if isMain && !mainSeen[im.ID] {
mainSeen[im.ID] = true
mains = append(mains, im)
}
}
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
out := make([]model.BrandRunwayImage, 0, len(imgs))
for _, main := range mains {
out = append(out, main)
out = append(out, detailsByParent[main.ID]...)
}
// 孤儿细节图(parent 缺失):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID == 0 && !mainSeen[im.ID] {
out = append(out, im)
}
}
return out
}
func (s *articleService) UpdateRunway(ctx context.Context, id uint32, fields map[string]any) error {
@ -223,6 +292,12 @@ func (s *articleService) DeleteImage(ctx context.Context, imageID uint32) error
if key != "" {
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
}
// 删图后重算本画廊去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
if runwayID, err := s.articles.GetRunwayImageRunwayID(ctx, imageID); err == nil && runwayID != 0 {
if err := s.articles.ReresolveRunwayDedup(ctx, runwayID); err != nil {
log.Printf("[warn] 重算走秀 %d 去重失败(重复标记可能陈旧): %v", runwayID, err)
}
}
return nil
}
@ -279,7 +354,6 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
})
}
return out
@ -298,7 +372,6 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ
Image: s.img.ComposeThumb(im.Image),
Name: im.Name,
IsDetail: im.IsDetail,
LookIndex: im.LookIndex,
})
}
return out

View File

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

View File

@ -0,0 +1,168 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/hashid"
"fashionapi/internal/pkg/storage"
"fashionapi/internal/repository"
)
// captureIngestRepo 捕获 processRunway 交给仓储的「待落库」记录与图片行。
// 只实现被测路径会用到的方法,其余由内嵌 nil 接口兜底(不应被调用)。
type captureIngestRepo struct {
repository.IngestRepository
createdRW *model.BrandRunway
createdImages []model.BrandRunwayImage
deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入)
}
func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) {
if c.deletedEntity {
return 1, model.StatusPending, true, true, nil // 命中但已软删
}
return 0, "", false, false, nil // 实体键未命中 → 新建分支
}
func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) {
c.createdRW = rw
c.createdImages = imgs
return 1, nil
}
func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
return 0, false, nil // 近重复不影响本用例
}
func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil }
// RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。
func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error {
return nil
}
// fakeBrandRepo 品牌校验恒通过。
type fakeBrandRepo struct{ repository.BrandRepository }
func (fakeBrandRepo) FindByID(ctx context.Context, id uint32) (*model.Brand, error) {
return &model.Brand{}, nil
}
// TestProcessRunwayImageCountEqualsAliveMainRows 回归 image_count 漂移:
//
// 旧实现取 imageCount = len(p.Looks),但 fetchLookImages 会跳过 look.Main == "" 的 look
// 与 sha1 重复的行 —— 实际落库的主图行可能更少。旧流程靠晋升按实际行重算自愈,单表发布后
// 该路径已删除,漂移会永久留在公开卡片的「N 张」角标与热度 SUM(image_count) 上。
//
// 本用例断言:入库时交给仓储的 image_count == 实际存活主图行数(is_detail = 0),
// 且在数据触发跳过时**严格小于** len(Looks)。旧实现下 image_count 会是 4,确定性失败。
func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) {
dir := t.TempDir()
local := storage.NewLocalUploader(dir, "/uploads")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
// 按路径返回不同内容:Content 不同 → sha1 不同;同路径 → sha1 相同(触发批次内去重)。
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
}))
defer srv.Close()
repo := &captureIngestRepo{}
s := &IngestService{
repo: repo,
brandRepo: fakeBrandRepo{},
uploader: local,
local: local,
httpClient: srv.Client(),
}
p := dto.RunwayIngest{
Kind: dto.IngestKindRunway,
BrandUID: hashid.Encode(1),
TitleEn: "image-count-test",
Year: 2027,
Season: "spring",
CollectionType: "rtw",
Looks: []dto.RunwayLook{
// look 1:主图 a + 细节图 a1 → 1 主图
{Main: srv.URL + "/img/a.jpg", Details: []string{srv.URL + "/img/a1.jpg"}},
// look 2:空主图,只有细节图 b1 → 0 主图(旧实现仍按 1 计)
{Details: []string{srv.URL + "/img/b1.jpg"}},
// look 3:主图与 look 1 内容相同(sha1 重复)→ 被跳过,0 主图(旧实现仍按 1 计)
{Main: srv.URL + "/img/a.jpg"},
// look 4:主图 c → 1 主图
{Main: srv.URL + "/img/c.jpg"},
},
}
job := model.IngestJob{ID: 1, Kind: model.IngestKindCrawl}
s.processRunway(context.Background(), job, p)
if repo.createdRW == nil {
t.Fatal("未写入走秀记录")
}
// 实际落库主图行:a、c → 2 张;细节图 a1、b1 → 2 张。
var actualMain uint16
for _, im := range repo.createdImages {
if im.IsDetail == 0 {
actualMain++
}
}
if actualMain != 2 {
t.Fatalf("前置:实际主图行应为 2,实际 %d(图片行=%+v)", actualMain, repo.createdImages)
}
if got := repo.createdRW.ImageCount; got != actualMain {
t.Fatalf("入库 image_count 应等于存活主图行数 %d,实际 %d", actualMain, got)
}
if repo.createdRW.ImageCount >= uint16(len(p.Looks)) {
t.Fatalf("本用例数据应让 image_count(%d) 严格小于 len(Looks)=%d,否则测不出漂移",
repo.createdRW.ImageCount, len(p.Looks))
}
}
// TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」:
// 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。
func TestProcessRunwaySkipsSoftDeletedEntity(t *testing.T) {
dir := t.TempDir()
local := storage.NewLocalUploader(dir, "/uploads")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path))
}))
defer srv.Close()
// deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。
repo := &captureIngestRepo{deletedEntity: true}
s := &IngestService{
repo: repo,
brandRepo: fakeBrandRepo{},
uploader: local,
local: local,
httpClient: srv.Client(),
}
p := dto.RunwayIngest{
Kind: dto.IngestKindRunway,
BrandUID: hashid.Encode(1),
TitleEn: "deleted-entity-test",
Year: 2027,
Season: "spring",
CollectionType: "rtw",
Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}},
}
job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl}
s.processRunway(context.Background(), job, p)
if repo.createdRW != nil {
t.Fatal("已软删的实体不应被重新写入正式表")
}
if len(repo.createdImages) != 0 {
t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages))
}
}

View File

@ -10,6 +10,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"path"
"strconv"
"sync"
@ -44,7 +45,28 @@ type IngestService struct {
// uploader: 主上传器(S4或本地)
// del: 删除器(S4或本地),用于 media_cleanup 真删S4/本地孤儿文件
// local: 本地兜底上传器(S4上传失败时回退,避免图片完全丢失)
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader) *IngestService {
func NewIngestService(repo repository.IngestRepository, brandRepo repository.BrandRepository, media repository.MediaRepository, uploader storage.Uploader, del storage.Deleter, local *storage.LocalUploader, downloadProxy string) *IngestService {
// 下载代理:download_proxy(或 INGEST_DOWNLOAD_PROXY)非空时,下载客户端经该代理访问源站;
// 仅作用于图片下载,S4 上传走独立 s3 客户端、pgx 数据库连接不走 http,均不受影响。
// 留空回退 http.ProxyFromEnvironment(默认直连,仍可被系统 HTTP(S)_PROXY 影响)。
dlTransport := &http.Transport{
MaxIdleConns: imageFetchConcurrency * 4,
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
if downloadProxy != "" {
if pu, perr := url.Parse(downloadProxy); perr == nil {
dlTransport.Proxy = http.ProxyURL(pu)
log.Printf("[ingest] 图片下载走代理: %s", downloadProxy)
} else {
log.Printf("[ingest] download_proxy 非法,已忽略并回退 ProxyFromEnvironment: %q", downloadProxy)
dlTransport.Proxy = http.ProxyFromEnvironment
}
} else {
dlTransport.Proxy = http.ProxyFromEnvironment
}
return &IngestService{
repo: repo,
brandRepo: brandRepo,
@ -57,14 +79,8 @@ func NewIngestService(repo repository.IngestRepository, brandRepo repository.Bra
httpClient: &http.Client{
// 超时须显著大于单张图的正常下载耗时(实测 4~22s,且随源站波动剧烈)。
// 原 30s 余量太薄:一次抖动就会让「单图失败=整任务失败」把整批打回重试。
Timeout: 60 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: imageFetchConcurrency * 4,
MaxIdleConnsPerHost: imageFetchConcurrency * 2,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
Timeout: 60 * time.Second,
Transport: dlTransport,
},
}
}
@ -229,7 +245,7 @@ func (s *IngestService) failOrRetry(ctx context.Context, id uint32, errMsg strin
}
}
// processRunway 走秀入库:品牌校验 → 去重 → 补季节码 → 下载图 → 写 brand_runway_drafts。
// processRunway 走秀入库:品牌校验 → 实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 brand_runways。
func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 品牌必须存在(爬虫负责先建/复用品牌)
brandID, err := hashid.Decode(p.BrandUID)
@ -242,42 +258,85 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return
}
// 2) 按实体键(brand_id + season_code + collection_type)去重:正式表已存在该秀则跳过,
// 避免重爬重复下载。注意:只判正式表、不判 pending 草稿——否则多来源(Vogue + theImpression)
// 爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
seasonCode := season.Derive(p.Year, p.CollectionType, p.Season)
dedupStart := time.Now()
if _, found, err := s.repo.RunwayIDByEntity(ctx, brandID, seasonCode, p.CollectionType); err == nil && found {
log.Printf("[ingest] job=%d runway 实体去重命中(season=%s type=%s),跳过 耗时=%v",
job.ID, seasonCode, p.CollectionType, time.Since(dedupStart).Round(time.Millisecond))
// 2) 实体键查重并决定分支(单表模型)
// 标题缺年份(p.Year=0)时,用发布日期的年份补全年份,使 year / season_code 不再为空,
// 避免这些秀在「按年份/季节筛选」与热门排序(按 year + season_code)中丢失。
effYear := p.Year
if effYear == 0 && p.PublishedAt != 0 {
effYear = uint16(time.Unix(int64(p.PublishedAt), 0).UTC().Year())
}
seasonCode := season.Derive(effYear, p.CollectionType, p.Season)
reuseID, existStatus, exist, isDeleted, err := s.repo.RunwayEntityState(ctx, brandID, seasonCode, p.CollectionType)
if err != nil {
// 读失败绝不能当成「未命中」:误判为新建会走一次注定冲突的写入——实体键唯一索引
//(2026-09-22-03)会把它拒成一次可重试的写库错误,重试时实体键已能查到该行(pending)
// 而直接跳过。但唯一索引是最后一道防线,不该靠它来替代这里的前置判断,
// 故仍 fail-closed + 退避重试,不做无谓的写尝试。
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
return
}
// 删除即永久:已软删的实体键不再因重爬复活(原设计靠部分唯一索引释放键、允许重爬,已改为禁止)。
if exist && isDeleted {
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
if !exist && p.Year == 0 {
// 标题无年份的历史行(year=0、season_code 为空)待回填:按旧键再查一次,
// 命中后走跳过分支按主键 id 回填(回填后season_code变为effYear码,后续重跑即可直接命中,幂等)。
var rid uint32
rid, existStatus, exist, isDeleted, err = s.repo.RunwayEntityState(ctx, brandID, "", p.CollectionType)
if err != nil {
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
return
}
reuseID = rid
if exist && isDeleted {
log.Printf("[ingest] job=%d runway 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
}
action := decideIngestAction(exist, existStatus)
if action == ingestSkip {
log.Printf("[ingest] job=%d runway 实体已存在(status=%s),跳过(回填 published_at)", job.ID, existStatus)
// 临时回填:仅更新发布时间与缺失年份,不重下图片(图片已在库)。
if p.PublishedAt != 0 {
if err := s.repo.BackfillRunwayPublishedAt(ctx, reuseID, p.PublishedAt, effYear, seasonCode); err != nil {
s.failOrRetry(ctx, job.ID, "backfill published_at: "+err.Error())
return
}
}
_ = s.repo.MarkDone(ctx, job.ID)
return
}
log.Printf("[ingest] job=%d runway 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
// 3) season_code 已在去重前补齐(vogue.go 历史漏填的 bug,统一在此兜底)
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
var cover string
var draftImages []model.BrandRunwayDraftImage
var images []model.BrandRunwayImage
var keys []string
var imgFailed bool
var imageCount uint16
var timing *fetchTiming
if len(p.Looks) > 0 {
cover, draftImages, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
imageCount = uint16(len(p.Looks))
cover, images, keys, imgFailed, timing = s.fetchLookImages(ctx, p.Looks, "runway")
// image_count 必须等于**实际落库的主图行数**,不能是 len(p.Looks):
// fetchLookImages 会跳过 look.Main == "" 的 look 与 sha1 重复的行,实际主图行可能更少。
// 旧流程靠晋升时按实际行重算自愈,该路径已随单表发布模型删除 —— 一旦用 len(Looks),
// 漂移会永久留在公开卡片的「N 张」角标与 FeaturedIDs 的 SUM(image_count) 热度上。
imageCount = countMainImages(images)
} else {
var fimgs []fetchedImage
cover, fimgs, keys, imgFailed, timing = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
imageCount = uint16(len(fimgs))
for i, fi := range fimgs {
draftImages = append(draftImages, model.BrandRunwayDraftImage{
images = append(images, model.BrandRunwayImage{
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
LookIndex: uint32(i + 1),
IsDetail: 0,
Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup,
@ -294,53 +353,65 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return
}
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
// 5) 直写正式表(status=pending),等待后台审核通过(审核只改状态,不重建图片)
writeStart := time.Now()
draft := &model.BrandRunwayDraft{
rw := &model.BrandRunway{
JobID: job.ID,
BrandID: brandID,
TitleEn: p.TitleEn,
TitleCn: p.TitleCn,
DescriptionEn: p.DescriptionEn,
DescriptionCn: p.DescriptionCn,
Year: p.Year,
Year: effYear,
Season: p.Season,
CollectionType: p.CollectionType,
SeasonCode: seasonCode,
PublishedAt: p.PublishedAt,
Cover: cover,
ImageCount: imageCount,
Status: model.DraftStatusPending,
Status: model.StatusPending,
}
id, err := s.repo.CreateRunwayDraft(ctx, draft)
if err != nil {
s.failOrRetry(ctx, job.ID, "create draft: "+err.Error())
for i := range images {
images[i].BrandID = brandID
}
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedRunway(ctx, reuseID, rw, images)
} else {
_, saveErr = s.repo.CreateRunwayWithImages(ctx, rw, images)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save runway: "+saveErr.Error())
return
}
for i := range draftImages {
draftImages[i].DraftID = id
// 入库后按 sort_order 回填细节图的 parent_image_id,使新走秀自动成组(与街拍一致)。
runwayID := rw.ID
if runwayID == 0 {
runwayID = reuseID
}
if err := s.repo.CreateRunwayDraftImages(ctx, draftImages); err != nil {
s.failOrRetry(ctx, job.ID, "create draft images: "+err.Error())
return
if rerr := s.repo.RepairRunwayDetailParents(ctx, runwayID); rerr != nil {
log.Printf("[ingest] job=%d runway 回填 parent 失败: %v", job.ID, rerr)
}
log.Printf("[ingest] job=%d runway 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(draftImages))
log.Printf("[ingest] job=%d runway 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(images), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
}
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落草稿的
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
// fetchLookImages 按 Looks 结构下载主图+细节图并上传到存储,返回可直接落正式图片表的
// BrandRunwayImage 行(带 parent_image_id / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
// cover 取首个成功下载的主图;image_count 由调用方按返回行中 is_detail == 0 的数量计
//(countMainImages),而不是 len(Looks):跳过空主图 / sha1 重复后实际行数可能更少。
// 每张图入库前做去重:命中 dHash 近重复则仍入库但标记留痕。
//
// 实现:先把全部「主图 + 细节图」按原始顺序摊平成任务列表,用 concurrentFetch 并发完成
// 「下载 → phash → 上传」这段 IO 密集操作;随后再按下标顺序串行去重与组装。
// 这样既拿到并发收益,又保证 cover / sort_order / 批次内去重与原串行实现一致。
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool, *fetchTiming) {
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayImage, []string, bool, *fetchTiming) {
start := time.Now()
timing := &fetchTiming{}
cover := ""
rows := make([]model.BrandRunwayDraftImage, 0)
rows := make([]model.BrandRunwayImage, 0)
keys := make([]string, 0)
seen := make(map[string]bool)
failed := false
@ -392,11 +463,10 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
name = fmt.Sprintf("Look %d — Detail %d", task.lookIdx, task.detailNo)
isDetail = 1
}
rows = append(rows, model.BrandRunwayDraftImage{
rows = append(rows, model.BrandRunwayImage{
Image: r.url,
Name: name,
SortOrder: uint32(order),
LookIndex: uint32(task.lookIdx),
IsDetail: isDetail,
Phash: sqlNull(r.phashBits),
IsDuplicate: isDup,
@ -408,18 +478,43 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
return cover, rows, keys, failed, timing
}
// processStreet 街拍入库:去重 → 下载图 → 写 street_snap_draft(无品牌)。
// countMainImages 数一批待落库图片行中的主图(is_detail = 0)数量,即 image_count 的口径。
// 与 SoftDeleteRunwayImage 审核期重算的口径一致(只计主图),保证入库值与后续重算值同源。
func countMainImages(images []model.BrandRunwayImage) uint16 {
var n uint16
for i := range images {
if images[i].IsDetail == 0 {
n++
}
}
return n
}
// processStreet 街拍入库:实体键分三支(新建 / 放弃 / 复用驳回)→ 下载图 → 直写 street_snaps(无品牌)。
func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, p dto.RunwayIngest) {
// 1) 按实体键(city + year)去重:正式表已存在该街拍则跳过,避免重爬重复下载。
// 只判正式表、不判 pending 草稿,保留多来源街拍图片在晋升阶段聚合。
dedupStart := time.Now()
if _, found, err := s.repo.StreetSnapIDByEntity(ctx, p.City, p.Year); err == nil && found {
log.Printf("[ingest] job=%d street 实体去重命中(city=%s year=%d),跳过 耗时=%v",
job.ID, p.City, p.Year, time.Since(dedupStart).Round(time.Millisecond))
// 1) 实体键查重并决定分支(单表模型,同 runway)。
// 实体键含 title:与下方 snap.Title 用的是同一个归一化后的 title,保证查重口径与落库内容一致,
// 同城同年的不同专题(Day 2 / Day 3)因此不会被误判为重复。
// 标题先经 NormalizeStreetTitle 归一化(空白折叠),消除「纯空白微调」造成的重复实体(设计规格 §12.4)。
title := repository.NormalizeStreetTitle(p.TitleEn)
reuseID, existStatus, exist, isDeleted, err := s.repo.StreetSnapEntityState(ctx, p.City, p.Year, title)
if err != nil {
// 同 runway:读失败不得当「未命中」,否则会给同一实体留两行。fail-closed + 退避重试。
s.failOrRetry(ctx, job.ID, "entity lookup: "+err.Error())
return
}
// 删除即永久:已软删的实体键不再因重爬复活(与 runway 同源)。
if exist && isDeleted {
log.Printf("[ingest] job=%d street 实体已删除(is_deleted=1),跳过重爬", job.ID)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
action := decideIngestAction(exist, existStatus)
if action == ingestSkip {
log.Printf("[ingest] job=%d street 实体已存在(status=%s),跳过", job.ID, existStatus)
_ = s.repo.MarkDone(ctx, job.ID)
return
}
log.Printf("[ingest] job=%d street 实体去重 耗时=%v(未命中,继续下载)", job.ID, time.Since(dedupStart).Round(time.Millisecond))
// 2) 下载图片并上传到存储(S4优先,失败兜底本地)。
cover, fimgs, keys, imgFailed, timing := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
@ -432,26 +527,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
return
}
// 3) 写草稿表(status=pending),等待后台审核通过后再晋升 street_snaps 正式表
// 3) 直写正式表(status=pending)
writeStart := time.Now()
draft := &model.StreetSnapDraft{
snap := &model.StreetSnap{
JobID: job.ID,
Title: p.TitleEn, // 街拍单标题,爬虫优先填 title_en
Title: title, // 街拍单标题,爬虫优先填 title_en(已归一化,与查重口径一致)
Year: p.Year,
City: p.City,
Cover: cover,
ImageCount: uint16(len(fimgs)),
Status: model.DraftStatusPending,
Status: model.StatusPending,
}
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
if err != nil {
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
return
}
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
rows := make([]model.StreetSnapImage, 0, len(fimgs))
for i, fi := range fimgs {
rows = append(rows, model.StreetSnapDraftImage{
DraftID: id,
rows = append(rows, model.StreetSnapImage{
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
@ -460,12 +549,18 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
})
}
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
var saveErr error
if action == ingestReuseRejected {
saveErr = s.repo.ReuseRejectedStreetSnap(ctx, reuseID, snap, rows)
} else {
_, saveErr = s.repo.CreateStreetSnapWithImages(ctx, snap, rows)
}
if saveErr != nil {
s.failOrRetry(ctx, job.ID, "save street snap: "+saveErr.Error())
return
}
log.Printf("[ingest] job=%d street 写草稿 耗时=%v 图片行=%d", job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows))
log.Printf("[ingest] job=%d street 写正式表 耗时=%v 图片行=%d 复用驳回=%v",
job.ID, time.Since(writeStart).Round(time.Millisecond), len(rows), action == ingestReuseRejected)
_ = s.repo.MarkDone(ctx, job.ID)
}
@ -606,9 +701,9 @@ func (s *IngestService) dedupImage(ctx context.Context, kind, phashBits string,
var tables []string
switch kind {
case dto.IngestKindRunway:
tables = []string{"brand_runway_draft_images", "brand_runway_images"}
tables = []string{"brand_runway_images"}
case dto.IngestKindStreet:
tables = []string{"street_snap_draft_images", "street_snap_images"}
tables = []string{"street_snap_images"}
default:
return 0, 0
}
@ -666,7 +761,7 @@ func concurrentFetch(ctx context.Context, n int, fn func(context.Context, int) d
}
// lookImageTask 是 fetchLookImages 摊平后的一张待下载任务:记录它属于哪个 look、
// 是主图还是第几张细节图,供并发下载完成后重建 Name / LookIndex / IsDetail。
// 是主图还是第几张细节图,供并发下载完成后重建 Name / 主副图归属 / IsDetail。
type lookImageTask struct {
url string
lookIdx int // 1-based look 序号
@ -742,3 +837,25 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
}
return res
}
// ingestActionKind 入库分支决策结果(单表发布模型)。
type ingestActionKind int
const (
ingestCreate ingestActionKind = iota // 实体键未命中 → 新建记录
ingestSkip // 命中 pending/published → 放弃本次入库
ingestReuseRejected // 命中 rejected → 复用该行重审
)
// decideIngestAction 按实体键命中情况与既有行状态决定入库分支。
// 抽成纯函数,是为了让「驳回不是永久黑名单」这条规则有独立的、不需要数据库的测试。
func decideIngestAction(found bool, status string) ingestActionKind {
switch {
case !found:
return ingestCreate
case status == model.StatusRejected:
return ingestReuseRejected
default:
return ingestSkip
}
}

View File

@ -44,7 +44,7 @@ func TestProcessMediaCleanup(t *testing.T) {
t.Fatalf("预置文件失败: %v", err)
}
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up)
svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "")
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`}
svc.processMediaCleanup(context.Background(), job)
@ -60,7 +60,7 @@ func TestProcessMediaCleanupSkipsReferenced(t *testing.T) {
del := &countingDeleter{LocalUploader: up}
// 引用计数恒为 1(仍被其他图集使用),不应真删。
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up)
svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "")
job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`}
svc.processMediaCleanup(context.Background(), job)
@ -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

@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"sort"
"strconv"
@ -12,8 +13,8 @@ import (
"fashionapi/internal/repository"
)
// DraftCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
type DraftCard struct {
// RecordCard 审核列表统一卡片(所有模块共用),供后台列表页渲染。
type RecordCard struct {
Kind string `json:"kind"` // runway | street | 未来其它模块
ID uint32 `json:"id"`
Title string `json:"title"` // 展示标题
@ -25,12 +26,18 @@ type DraftCard struct {
UpdatedAt uint32 `json:"updated_at"`
}
// DraftImageRef 草稿图片引用(统一结构)。
type DraftImageRef struct {
ID uint32 `json:"id"`
Image string `json:"image"`
Name string `json:"name"`
SortOrder uint32 `json:"sort_order"`
// RecordImageRef 记录图片引用(统一结构)。
//
// 街拍 / 走秀共用同一套主副图模型:is_detail=0 为主图(parent_image_id=0),
// =1 为副图(parent_image_id 指向同记录内主图的行 id)。DetailCount 让模板不用自己数副图。
type RecordImageRef struct {
ID uint32 `json:"id"`
Image string `json:"image"`
Name string `json:"name"`
SortOrder uint32 `json:"sort_order"`
IsDetail uint8 `json:"is_detail"` // 1=副图(挂在 ParentImageID 指向的主图下);0=主图
ParentImageID uint32 `json:"parent_image_id"` // 副图所属主图的行 id;主图为 0
DetailCount int `json:"detail_count"` // 仅主图有意义:名下(存活)副图数量
}
// EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框,
@ -42,29 +49,43 @@ type EditField struct {
Type string `json:"type"` // text | textarea | number
}
// DraftDetailView 审核详情统一视图(所有模块共用)。
type DraftDetailView struct {
Kind string `json:"kind"`
ID uint32 `json:"id"`
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
Title string `json:"title"` // 展示标题(runway=title_en,street=title)
TitleEn string `json:"title_en"`
TitleCn string `json:"title_cn"`
DescriptionEn string `json:"description_en"`
DescriptionCn string `json:"description_cn"`
Year uint16 `json:"year"`
Season string `json:"season"`
CollectionType string `json:"collection_type"`
SeasonCode string `json:"season_code"`
City string `json:"city"`
Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"`
Status string `json:"status"`
Images []DraftImageRef `json:"images"`
// RecordDetailView 审核详情统一视图(所有模块共用)。
type RecordDetailView struct {
Kind string `json:"kind"`
ID uint32 `json:"id"`
BrandID uint32 `json:"brand_id"` // 仅 runway 等有品牌的模块有,其余为 0
Title string `json:"title"` // 展示标题(runway=title_en,street=title)
TitleEn string `json:"title_en"`
TitleCn string `json:"title_cn"`
DescriptionEn string `json:"description_en"`
DescriptionCn string `json:"description_cn"`
Year uint16 `json:"year"`
Season string `json:"season"`
CollectionType string `json:"collection_type"`
SeasonCode string `json:"season_code"`
City string `json:"city"`
Cover string `json:"cover"`
ImageCount uint16 `json:"image_count"`
Status string `json:"status"`
Images []RecordImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"`
// Groups 详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。
// 街拍与走秀均按 parent_image_id 分组(见各模块 RecordDetail),结构同构。
// 与 Images 同源同序:Images 保持扁平(既有渲染),Groups 供按组折叠。
Groups []ImageGroup `json:"groups,omitempty"`
}
// ImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type ImageGroup struct {
Image RecordImageRef `json:"image"` // 主图
Details []RecordImageRef `json:"details"` // 该主图名下的副图(可为空)
}
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于该记录 / 自身不是主图 / 空集合。
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本条记录、未被删除,且自身是主图")
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
type KindTab struct {
Kind string `json:"kind"` // 空串表示「全部」
@ -72,36 +93,43 @@ type KindTab struct {
Pending int64 `json:"pending"`
}
// ReviewModule 一种待审草稿模块(runway / street / 未来任意爬虫数据类型)的统一契约。
// ReviewModule 一种待审记录模块(runway / street / 未来任意爬虫数据类型)的统一契约。
//
// 新增一种爬虫数据类型需要审核时,只需:
// 1. 建草稿表 + 迁移;
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/SaveFromDraft/CountPending;
// 1. 建正式表 + 迁移(记录自带 status 列);
// 2. 在仓储层实现 List/Detail/Images/Update/SetStatus/CountByStatus;
// 3. 在此文件实现本接口并注册到 NewReviewService。
//
// 列表、详情、通过、驳回、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
// 列表、详情、通过、待审计数、类型标签都会自动覆盖,无需改动 handler / 模板分支。
type ReviewModule interface {
Kind() string
Label() string
ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error)
DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error)
UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error
// Approve 晋升到正式表并把草稿置 approved;返回正式表主键。
ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error)
RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error)
UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error
// Approve 把正式记录置为 published;返回记录主键。
// 单表发布模型:通过只是改状态,不再晋升重建(图片在入库时已落到正式表)。
Approve(ctx context.Context, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, id uint32, reviewer, reason string) error
CountPending(ctx context.Context) (int64, error)
}
// ReviewService 审核草稿的业务逻辑:列出 / 查看 / 微调 / 通过(晋升)/ 驳回。
// ReviewService 审核的业务逻辑:列出 / 查看 / 微调 / 通过(置 published)。
// 全部按 kind 委派到对应 ReviewModule;kind 为空表示合并所有模块。
//
// 注意:审核不再提供「驳回」动作。不想发布的待审项直接走「下架 / 删除」
// (articleService / streetSnapService 的 SetDeleted → PurgeGallery),
// 会级联软删图片并异步入队清理 S4 孤儿图(按引用计数判定),而非停留在 rejected 态。
type ReviewService interface {
ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error)
DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error)
UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error
ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error)
RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error)
UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error
Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error)
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图,不删S4文件)。
DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error
// AttachImages 把若干张图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error
// DetachImage 把副图拆出恢复为主图(街拍专用)。
DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
KindTabs(ctx context.Context) []KindTab
}
@ -141,22 +169,22 @@ func (s *reviewService) module(kind string) (ReviewModule, bool) {
return m, ok
}
func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, page, size int) ([]DraftCard, int64, error) {
func (s *reviewService) ListRecords(ctx context.Context, kind, status string, page, size int) ([]RecordCard, int64, error) {
if kind == "all" {
kind = ""
}
if kind != "" {
m, ok := s.module(kind)
if !ok {
return []DraftCard{}, 0, nil
return []RecordCard{}, 0, nil
}
return m.ListDrafts(ctx, status, page, size)
return m.ListRecords(ctx, status, page, size)
}
// 合并所有模块:各取较多行后在内存按创建时间倒序,再分页(后台量小,足够准确)。
var all []DraftCard
var all []RecordCard
var total int64
for _, k := range s.order {
cards, t, err := s.modules[k].ListDrafts(ctx, status, 1, 1000)
cards, t, err := s.modules[k].ListRecords(ctx, status, 1, 1000)
if err != nil {
return nil, 0, err
}
@ -184,20 +212,20 @@ func (s *reviewService) ListDrafts(ctx context.Context, kind, status string, pag
return all[start:end], total, nil
}
func (s *reviewService) DraftDetail(ctx context.Context, kind string, id uint32) (*DraftDetailView, error) {
func (s *reviewService) RecordDetail(ctx context.Context, kind string, id uint32) (*RecordDetailView, error) {
m, ok := s.module(kind)
if !ok {
return nil, repository.ErrNotFound
}
return m.DraftDetail(ctx, id)
return m.RecordDetail(ctx, id)
}
func (s *reviewService) UpdateDraft(ctx context.Context, kind string, id uint32, fields map[string]any) error {
func (s *reviewService) UpdateRecord(ctx context.Context, kind string, id uint32, fields map[string]any) error {
m, ok := s.module(kind)
if !ok {
return repository.ErrNotFound
}
return m.UpdateDraft(ctx, id, fields)
return m.UpdateRecord(ctx, id, fields)
}
func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, reviewer string) (uint32, error) {
@ -208,14 +236,6 @@ func (s *reviewService) Approve(ctx context.Context, kind string, id uint32, rev
return m.Approve(ctx, id, reviewer)
}
func (s *reviewService) Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error {
m, ok := s.module(kind)
if !ok {
return repository.ErrNotFound
}
return m.Reject(ctx, id, reviewer, reason)
}
func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
tabs := []KindTab{{Kind: "", Label: "全部"}}
var all int64
@ -232,22 +252,22 @@ func (s *reviewService) KindTabs(ctx context.Context) []KindTab {
return tabs
}
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图)。
// 按 kind 委派到对应草稿图片表的软删,并同步草稿 image_count;
// DeleteImage 软删审核记录里的单张图片(审核阶段剔除坏图)。
// 按 kind 委派到对应图片表的软删,并同步记录 image_count;
// 若该 key 在全库已无其他引用(引用计数归零),则一并清理S4孤儿文件。
func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
func (s *reviewService) DeleteImage(ctx context.Context, kind string, recordID, imgID uint32) error {
if _, ok := s.module(kind); !ok {
return repository.ErrNotFound
}
key, err := s.repo.GetDraftImageKey(ctx, kind, draftID, imgID)
key, err := s.repo.GetImageKey(ctx, kind, recordID, imgID)
if err != nil {
return err
}
switch kind {
case dto.IngestKindRunway:
err = s.repo.SoftDeleteRunwayDraftImage(ctx, draftID, imgID)
err = s.repo.SoftDeleteRunwayImage(ctx, recordID, imgID)
case dto.IngestKindStreet:
err = s.repo.SoftDeleteStreetDraftImage(ctx, draftID, imgID)
err = s.repo.SoftDeleteStreetImage(ctx, recordID, imgID)
default:
return repository.ErrNotFound
}
@ -260,6 +280,42 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
return nil
}
func (s *reviewService) AttachImages(ctx context.Context, kind string, recordID, mainImgID uint32, imgIDs []uint32) error {
if len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
switch kind {
case dto.IngestKindStreet:
return translateGroupErr(s.repo.AttachStreetSnapImages(ctx, recordID, mainImgID, imgIDs))
case dto.IngestKindRunway:
return translateGroupErr(s.repo.AttachRunwayImages(ctx, recordID, mainImgID, imgIDs))
default:
return ErrInvalidImageGroup
}
}
func (s *reviewService) DetachImage(ctx context.Context, kind string, recordID, imgID uint32) error {
switch kind {
case dto.IngestKindStreet:
return translateGroupErr(s.repo.DetachStreetSnapImage(ctx, recordID, imgID))
case dto.IngestKindRunway:
return translateGroupErr(s.repo.DetachRunwayImage(ctx, recordID, imgID))
default:
return ErrInvalidImageGroup
}
}
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
func translateGroupErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, repository.ErrInvalidImageGroup) {
return ErrInvalidImageGroup
}
return err
}
// ── runway 模块 ────────────────────────────────────────────────────────────────
type runwayModule struct {
@ -270,24 +326,24 @@ type runwayModule struct {
func (m *runwayModule) Kind() string { return dto.IngestKindRunway }
func (m *runwayModule) Label() string { return "走秀" }
func (m *runwayModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
rows, total, err := m.repo.ListDrafts(ctx, status, page, size)
func (m *runwayModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
rows, total, err := m.repo.ListRecords(ctx, status, page, size)
if err != nil {
return nil, 0, err
}
return m.toRunwayCards(rows), total, nil
}
func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
d, err := m.repo.DraftDetail(ctx, id)
func (m *runwayModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
d, err := m.repo.RecordDetail(ctx, id)
if err != nil {
return nil, err
}
imgs, err := m.repo.DraftImages(ctx, id)
imgs, err := m.repo.RecordImages(ctx, id)
if err != nil {
return nil, err
}
v := &DraftDetailView{
v := &RecordDetailView{
Kind: dto.IngestKindRunway,
ID: d.ID,
BrandID: d.BrandID,
@ -314,33 +370,71 @@ func (m *runwayModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
{Name: "season_code", Label: "季节码", Value: d.SeasonCode, Type: "text"},
},
}
// 按 parent_image_id 成组(与街拍同构):is_detail=0 为主图;is_detail=1 且 parent_image_id>0
// 的是其副图;is_detail=1 且 parent_image_id=0 的孤儿细节图按主图渲染(自成一组,不丢图)。
// 这样走秀详情页(消费 Groups)与街拍同构,编辑页(消费 Images)也能按主图→细节图顺序渲染。
mains := make([]model.BrandRunwayImage, 0, len(imgs))
mainSeen := make(map[uint32]bool, len(imgs))
for _, im := range imgs {
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder})
isMain := im.IsDetail == 0 || im.ParentImageID == 0
if isMain && !mainSeen[im.ID] {
mainSeen[im.ID] = true
mains = append(mains, im)
}
}
detailsByParent := make(map[uint32][]model.BrandRunwayImage, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 && im.ParentImageID != 0 && !mainSeen[im.ID] {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], im)
}
}
detailCount := make(map[uint32]int, len(detailsByParent))
for p, ds := range detailsByParent {
detailCount[p] = len(ds)
}
for _, main := range mains {
ref := RecordImageRef{
ID: main.ID,
Image: m.img.Compose(main.Image),
Name: main.Name,
SortOrder: main.SortOrder,
IsDetail: 0,
ParentImageID: 0,
DetailCount: detailCount[main.ID],
}
v.Images = append(v.Images, ref)
grp := ImageGroup{Image: ref}
for _, d := range detailsByParent[main.ID] {
dref := RecordImageRef{
ID: d.ID,
Image: m.img.Compose(d.Image),
Name: d.Name,
SortOrder: d.SortOrder,
IsDetail: 1,
ParentImageID: d.ParentImageID,
}
grp.Details = append(grp.Details, dref)
v.Images = append(v.Images, dref)
}
v.Groups = append(v.Groups, grp)
}
return v, nil
}
func (m *runwayModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateDraft(ctx, id, fields)
func (m *runwayModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateRecord(ctx, id, fields)
}
func (m *runwayModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveRunwayFromDraft(ctx, id)
if err != nil {
if err := m.repo.SetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
return 0, err
}
if err := m.repo.SetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
return 0, err
}
return officialID, nil
}
func (m *runwayModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
return m.repo.SetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
return id, nil
}
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 模块 ────────────────────────────────────────────────────────────────
@ -353,24 +447,24 @@ type streetModule struct {
func (m *streetModule) Kind() string { return dto.IngestKindStreet }
func (m *streetModule) Label() string { return "街拍" }
func (m *streetModule) ListDrafts(ctx context.Context, status string, page, size int) ([]DraftCard, int64, error) {
rows, total, err := m.repo.ListStreetDrafts(ctx, status, page, size)
func (m *streetModule) ListRecords(ctx context.Context, status string, page, size int) ([]RecordCard, int64, error) {
rows, total, err := m.repo.ListStreetRecords(ctx, status, page, size)
if err != nil {
return nil, 0, err
}
return m.toStreetCards(rows), total, nil
}
func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetailView, error) {
d, err := m.repo.StreetDraftDetail(ctx, id)
func (m *streetModule) RecordDetail(ctx context.Context, id uint32) (*RecordDetailView, error) {
d, err := m.repo.StreetRecordDetail(ctx, id)
if err != nil {
return nil, err
}
imgs, err := m.repo.StreetDraftImages(ctx, id)
imgs, err := m.repo.StreetRecordImages(ctx, id)
if err != nil {
return nil, err
}
v := &DraftDetailView{
v := &RecordDetailView{
Kind: dto.IngestKindStreet,
ID: d.ID,
Title: d.Title,
@ -385,39 +479,97 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
{Name: "city", Label: "城市", Value: d.City, Type: "text"},
},
}
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
byID := make(map[uint32]model.StreetSnapImage, len(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})
byID[im.ID] = im
}
resolveRoot := func(start uint32) uint32 {
cur := start
seen := map[uint32]bool{}
for {
n, ok := byID[cur]
if !ok {
return start // 父行不存在:按主图渲染(返回自身)
}
if n.IsDetail == 0 {
return n.ID
}
if seen[cur] {
return start // 环:容错,自成主图
}
seen[cur] = true
cur = n.ParentImageID
}
}
detailCount := make(map[uint32]int, len(imgs))
for _, im := range imgs {
if im.IsDetail == 1 {
if root := resolveRoot(im.ID); root != im.ID {
detailCount[root]++
}
}
}
groups := make([]ImageGroup, 0, len(imgs))
groupIndex := make(map[uint32]int, len(imgs))
for _, im := range imgs {
root := resolveRoot(im.ID)
isDetail := root != im.ID
isDetailVal := uint8(0)
parent := uint32(0) // 副图徽标显示「所属主图」:取沿链归并后的根主图;主图恒为 0
if isDetail {
isDetailVal = 1
parent = root
}
ref := RecordImageRef{
ID: im.ID,
Image: m.img.Compose(im.Image),
Name: im.Name,
SortOrder: im.SortOrder,
IsDetail: isDetailVal,
ParentImageID: parent,
DetailCount: detailCount[im.ID],
}
v.Images = append(v.Images, ref)
if !isDetail {
groups = append(groups, ImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1
continue
}
if gi, ok := groupIndex[root]; ok {
groups[gi].Details = append(groups[gi].Details, ref)
} else { // 兜底(理论不发生):主图未先出现,自成一组
groups = append(groups, ImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1
}
}
v.Groups = groups
return v, nil
}
func (m *streetModule) UpdateDraft(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateStreetDraft(ctx, id, fields)
func (m *streetModule) UpdateRecord(ctx context.Context, id uint32, fields map[string]any) error {
return m.repo.UpdateStreetRecord(ctx, id, fields)
}
func (m *streetModule) Approve(ctx context.Context, id uint32, reviewer string) (uint32, error) {
officialID, err := m.repo.SaveStreetSnapFromDraft(ctx, id)
if err != nil {
if err := m.repo.SetStreetRecordStatus(ctx, id, model.StatusPublished, reviewer); err != nil {
return 0, err
}
if err := m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusApproved, reviewer, ""); err != nil {
return 0, err
}
return officialID, nil
}
func (m *streetModule) Reject(ctx context.Context, id uint32, reviewer, reason string) error {
return m.repo.SetStreetDraftStatus(ctx, id, model.DraftStatusRejected, reviewer, reason)
return id, nil
}
func (m *streetModule) CountPending(ctx context.Context) (int64, error) {
return m.repo.CountDraftPending(ctx, model.DraftStatusPending, &model.StreetSnapDraft{})
return m.repo.CountByStatus(ctx, model.StatusPending, &model.StreetSnap{})
}
// ── 卡片转换(保留原逻辑)──────────────────────────────────────────────────────
func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard {
cards := make([]DraftCard, 0, len(rows))
func (m *runwayModule) toRunwayCards(rows []model.BrandRunway) []RecordCard {
cards := make([]RecordCard, 0, len(rows))
for _, d := range rows {
sub := d.SeasonCode
if d.CollectionType != "" {
@ -426,7 +578,7 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
if sub == "" {
sub = "—"
}
cards = append(cards, DraftCard{
cards = append(cards, RecordCard{
Kind: dto.IngestKindRunway,
ID: d.ID,
Title: d.TitleEn,
@ -441,14 +593,14 @@ func (m *runwayModule) toRunwayCards(rows []model.BrandRunwayDraft) []DraftCard
return cards
}
func (m *streetModule) toStreetCards(rows []model.StreetSnapDraft) []DraftCard {
cards := make([]DraftCard, 0, len(rows))
func (m *streetModule) toStreetCards(rows []model.StreetSnap) []RecordCard {
cards := make([]RecordCard, 0, len(rows))
for _, d := range rows {
sub := d.City
if sub == "" {
sub = "—"
}
cards = append(cards, DraftCard{
cards = append(cards, RecordCard{
Kind: dto.IngestKindStreet,
ID: d.ID,
Title: d.Title,

View File

@ -0,0 +1,159 @@
package service
import (
"context"
"errors"
"testing"
"fashionapi/internal/dto"
"fashionapi/internal/model"
"fashionapi/internal/pkg/imgurl"
"fashionapi/internal/repository"
)
// fakeGroupRepo 只实现分组相关的仓储方法,其余由嵌入的接口兜底(测试中不会调到未实现的方法)。
type fakeGroupRepo struct {
repository.ReviewRepository
images []model.StreetSnapImage
detail *model.StreetSnap
attachFn func(snapID, mainID uint32, imgIDs []uint32) error
attachRunwayFn func(runwayID, mainID uint32, imgIDs []uint32) error
}
func (f *fakeGroupRepo) StreetRecordDetail(_ context.Context, _ uint32) (*model.StreetSnap, error) {
return f.detail, nil
}
func (f *fakeGroupRepo) StreetRecordImages(_ context.Context, _ uint32) ([]model.StreetSnapImage, error) {
return f.images, nil
}
func (f *fakeGroupRepo) AttachStreetSnapImages(_ context.Context, snapID, mainID uint32, imgIDs []uint32) error {
if f.attachFn != nil {
return f.attachFn(snapID, mainID, imgIDs)
}
return nil
}
func (f *fakeGroupRepo) DetachStreetSnapImage(_ context.Context, _ uint32, _ uint32) error {
if f.attachFn != nil {
return f.attachFn(0, 0, nil)
}
return nil
}
func (f *fakeGroupRepo) AttachRunwayImages(_ context.Context, runwayID, mainID uint32, imgIDs []uint32) error {
if f.attachRunwayFn != nil {
return f.attachRunwayFn(runwayID, mainID, imgIDs)
}
return nil
}
func (f *fakeGroupRepo) DetachRunwayImage(_ context.Context, _ uint32, _ uint32) error {
return nil
}
var _ repository.ReviewRepository = (*fakeGroupRepo)(nil)
func newGroupService(f *fakeGroupRepo) ReviewService {
return NewReviewService(f, nil, imgurl.New("", "", ""), nil)
}
// TestStreetGroupDetailExposesGrouping 详情必须输出可折叠分组:主图各成一组,副图归入主图。
func TestStreetGroupDetailExposesGrouping(t *testing.T) {
repo := &fakeGroupRepo{
detail: &model.StreetSnap{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.StatusPending},
images: []model.StreetSnapImage{
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
},
}
svc := newGroupService(repo)
v, err := svc.RecordDetail(context.Background(), dto.IngestKindStreet, 7)
if err != nil {
t.Fatalf("RecordDetail 失败: %v", err)
}
if len(v.Images) != 3 {
t.Fatalf("Images 应保留扁平 3 张,实际 %d", len(v.Images))
}
if len(v.Groups) != 2 {
t.Fatalf("Groups 应为 2 组,实际 %d", len(v.Groups))
}
if v.Groups[0].Image.ID != 1 || len(v.Groups[0].Details) != 1 {
t.Fatalf("第 0 组应为 1 张主图 + 1 副图,实际 main=%d details=%d", v.Groups[0].Image.ID, len(v.Groups[0].Details))
}
if v.Groups[0].Image.DetailCount != 1 {
t.Fatalf("主图 DetailCount 应为 1,实际 %d", v.Groups[0].Image.DetailCount)
}
if v.Groups[0].Details[0].ID != 2 {
t.Fatalf("副图应为 id=2,实际 %d", v.Groups[0].Details[0].ID)
}
if v.Groups[1].Image.ID != 3 {
t.Fatalf("第 1 组主图应为 id=3,实际 %d", v.Groups[1].Image.ID)
}
}
// TestStreetGroupAttachDelegates 并入必须透传参数到仓储。
func TestStreetGroupAttachDelegates(t *testing.T) {
var got struct {
snapID uint32
mainID uint32
imgIDs []uint32
}
repo := &fakeGroupRepo{attachFn: func(snapID, mainID uint32, imgIDs []uint32) error {
got.snapID, got.mainID, got.imgIDs = snapID, mainID, imgIDs
return nil
}}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
t.Fatalf("AttachImages 失败: %v", err)
}
if got.snapID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: snapID=%d mainID=%d imgIDs=%v", got.snapID, got.mainID, got.imgIDs)
}
}
// TestStreetGroupNonStreetRejected 既非 street 也非 runway 的实体类型不允许主副图分组操作。
func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), "bogus", 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("未知实体并入应返回 ErrInvalidImageGroup,实际 %v", err)
}
}
// TestRunwayGroupAttachDelegates 走秀并入必须透传参数到 runway 仓储。
func TestRunwayGroupAttachDelegates(t *testing.T) {
var got struct {
runwayID uint32
mainID uint32
imgIDs []uint32
}
repo := &fakeGroupRepo{attachRunwayFn: func(runwayID, mainID uint32, imgIDs []uint32) error {
got.runwayID, got.mainID, got.imgIDs = runwayID, mainID, imgIDs
return nil
}}
svc := newGroupService(repo)
if err := svc.AttachImages(context.Background(), dto.IngestKindRunway, 9, 4, []uint32{5, 6}); err != nil {
t.Fatalf("Runway AttachImages 失败: %v", err)
}
if got.runwayID != 9 || got.mainID != 4 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: runwayID=%d mainID=%d imgIDs=%v", got.runwayID, got.mainID, got.imgIDs)
}
}
// TestStreetGroupPreventCrossRecord 仓储拦截跨记录主图后,service 翻成中文用户错误。
func TestStreetGroupPreventCrossRecord(t *testing.T) {
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
return repository.ErrInvalidImageGroup
}}
svc := newGroupService(repo)
err := svc.AttachImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
if err == nil {
t.Fatal("跨记录应报错")
}
if err.Error() != "主图无效:它必须属于本条记录、未被删除,且自身是主图" {
t.Fatalf("错误文案不符: %q", err.Error())
}
}

View File

@ -0,0 +1,67 @@
package service
import (
"testing"
"fashionapi/internal/model"
"fashionapi/internal/pkg/imgurl"
)
// TestBuildSnapImages 验证公开详情的 image 只含主图、副图嵌套在其主图的 Detail 下,
// 且「副图的副图」上浮归并到该副图所在组的主图。
//
// 5 张图:A 主,B 副(挂 A);C 主;D 副(挂 C);E 副(挂 B → 应上浮归并到 A)
//
// 期望 images 只有 2 张主图:A(Detail=[B,E])、C(Detail=[D])。
func TestBuildSnapImages(t *testing.T) {
s := &streetSnapService{img: imgurl.New("", "", "")}
imgs := []model.StreetSnapImage{
{ID: 1, Image: "A", Name: "A", IsDetail: 0, ParentImageID: 0},
{ID: 2, Image: "B", Name: "B", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "C", Name: "C", IsDetail: 0, ParentImageID: 0},
{ID: 4, Image: "D", Name: "D", IsDetail: 1, ParentImageID: 3},
{ID: 5, Image: "E", Name: "E", IsDetail: 1, ParentImageID: 2}, // 2 是副图 → 上浮到 A
}
out := s.buildSnapImages(imgs)
if len(out) != 2 {
t.Fatalf("images 应只含 2 张主图,实际 %d", len(out))
}
a := out[0]
if a.Name != "A" || a.IsDetail != 0 {
t.Fatalf("第 1 张应为 A 主图,实际 name=%s is_detail=%d", a.Name, a.IsDetail)
}
if len(a.Detail) != 2 || a.Detail[0].Name != "B" || a.Detail[1].Name != "E" {
t.Fatalf("A 的副图应依序为 [B,E],实际 %d 张", len(a.Detail))
}
for _, d := range a.Detail {
if d.IsDetail != 1 {
t.Fatalf("副图 %s 的 is_detail 应为 1,实际 %d", d.Name, d.IsDetail)
}
}
c := out[1]
if c.Name != "C" {
t.Fatalf("第 2 张应为 C 主图,实际 name=%s", c.Name)
}
if len(c.Detail) != 1 || c.Detail[0].Name != "D" {
t.Fatalf("C 的副图应为 [D],实际 %d 张", len(c.Detail))
}
}
// TestBuildSnapImagesOrphan 父引用指向失效行(不存在)时,该图按主图渲染,不丢图。
func TestBuildSnapImagesOrphan(t *testing.T) {
s := &streetSnapService{img: imgurl.New("", "", "")}
imgs := []model.StreetSnapImage{
{ID: 1, Image: "A", Name: "A", IsDetail: 1, ParentImageID: 99}, // 父 99 不存在
{ID: 2, Image: "B", Name: "B", IsDetail: 0, ParentImageID: 0},
}
out := s.buildSnapImages(imgs)
if len(out) != 2 {
t.Fatalf("孤儿副图应自成主图:images=%d", len(out))
}
if out[0].Name != "A" || out[0].IsDetail != 0 || len(out[0].Detail) != 0 {
t.Fatalf("A 应作为无副图的主图,实际 name=%s is_detail=%d details=%d", out[0].Name, out[0].IsDetail, len(out[0].Detail))
}
}

View File

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

View File

@ -5,6 +5,7 @@ import (
"errors"
"log"
"net/http"
"sort"
"fashionapi/internal/dto"
"fashionapi/internal/model"
@ -17,11 +18,17 @@ import (
// StreetSnapService 街拍业务接口。
type StreetSnapService interface {
List(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error)
// AdminList 后台街拍列表:读基表,包含 pending / rejected(否则审核员看不到待审内容)。
AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error)
Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected(供后台详情页复用)。
DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error)
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error)
Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error)
// GetForEdit 取原始街拍记录与图片,供后台编辑表单预填。
GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error)
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释),供编辑页标记重复图。
GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error)
// UpdateSnap 编辑后更新字段(字段白名单在仓储层强制)。
UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error
// DeleteImage 软删单张街拍图片。
@ -62,17 +69,11 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
return nil, 0, internalErr(err.Error())
}
list := make([]dto.PublicStreetSnap, 0, len(rows))
ids := make([]uint32, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量)
ImageCount: r.ImageCount,
})
}
list := s.toPublicSnaps(rows, q.Locale)
if q.Images > 0 && len(list) > 0 {
if err := s.attachImages(ctx, list, ids, q.Images); err != nil {
@ -82,6 +83,36 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
return list, total, nil
}
// AdminList 后台街拍列表:读基表,包含 pending / rejected。
//
// 与 List 用同一个映射(toPublicSnaps),但列表不附图——后台列表只展示卡片字段,
// 与走秀后台 AdminList 的口径一致。
func (s *streetSnapService) AdminList(ctx context.Context, q dto.StreetSnapQuery) ([]dto.PublicStreetSnap, int64, error) {
q.Normalize()
rows, total, err := s.repo.ListAdmin(ctx, q)
if err != nil {
return nil, 0, internalErr(err.Error())
}
return s.toPublicSnaps(rows, q.Locale), total, nil
}
// toPublicSnaps 把街拍实体映射为对外列表结构(卡片字段:编码 id / 标题 / 缩略封面 / 图片数)。
// 公开 List 与后台 AdminList 共用,避免两处重复维护映射(封面统一走缩略图样式,小尺寸省流量)。
func (s *streetSnapService) toPublicSnaps(rows []model.StreetSnap, locale string) []dto.PublicStreetSnap {
list := make([]dto.PublicStreetSnap, 0, len(rows))
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.ComposeThumb(r.Cover),
ImageCount: r.ImageCount,
Status: r.Status, // 仅后台模板渲染状态列用(json:"-" 不进公开响应)
})
}
return list
}
// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐条请求详情。
func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicStreetSnap, ids []uint32, limit int) error {
grouped, err := s.repo.ImagesBySnapIDs(ctx, ids)
@ -119,32 +150,121 @@ func excludeCoverSnap(imgs []dto.PublicArticleImage, cover string) []dto.PublicA
return kept
}
// Detail 查询街拍详情,含完整图片集。图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
// Detail 公开详情:读公开视图,pending / rejected 视同不存在(ErrStreetSnapNotFound)。
// 图片统一走展示样式(VIP 与免费同质量)。
func (s *streetSnapService) Detail(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, ErrStreetSnapNotFound
}
return nil, internalErr(err.Error())
return nil, s.detailErr(err)
}
imgs, err := s.repo.ListImages(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title,
Cover: s.img.Compose(snap.Cover),
Images: s.toPublicSnapImages(imgs),
}, nil
return s.buildSnapDetail(snap, imgs, locale), nil
}
// DetailAdmin 后台详情:读基表,能渲染 pending / rejected。
//
// 后台列表(AdminList)故意把待审行链到详情页,且详情页是通往编辑页的唯一入口;
// 若后台详情也走公开视图,待审项会 404、审核无从下手。
func (s *streetSnapService) DetailAdmin(ctx context.Context, id string, locale string) (*dto.PublicStreetSnapDetail, error) {
snap, err := s.repo.FindByIDBase(ctx, id)
if err != nil {
return nil, s.detailErr(err)
}
imgs, err := s.repo.ListImagesBase(ctx, id)
if err != nil {
return nil, internalErr(err.Error())
}
return s.buildSnapDetail(snap, imgs, locale), nil
}
// detailErr 统一详情取数失败的错误映射(公开 Detail 与 DetailAdmin 共用)。
func (s *streetSnapService) detailErr(err error) error {
if errors.Is(err, repository.ErrNotFound) {
return ErrStreetSnapNotFound
}
return internalErr(err.Error())
}
// buildSnapDetail 把街拍行与图片集映射为详情 DTO(公开/后台共用,差异只在数据来源)。
func (s *streetSnapService) buildSnapDetail(snap *model.StreetSnap, imgs []model.StreetSnapImage, locale string) *dto.PublicStreetSnapDetail {
return &dto.PublicStreetSnapDetail{
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: pickLocale(snap.Title, snap.TitleCn, locale),
Cover: s.img.Compose(snap.Cover),
Images: s.buildSnapImages(imgs),
}
}
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// 主图 / 副图的归并完全靠 parent_image_id 表达(街拍不落库组序号)。
func (s *streetSnapService) buildSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
if len(imgs) == 0 {
return nil
}
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
for i := range imgs {
byID[imgs[i].ID] = &imgs[i]
}
// resolveRoot 返回 img 所属主图的行 id;自身是主图则返回自身;父行缺失 / 成环则按主图渲染(返回自身)。
resolveRoot := func(start uint32) uint32 {
cur := start
seen := map[uint32]bool{}
for {
n, ok := byID[cur]
if !ok {
return start // 父行不存在 → 按主图渲染(返回自身)
}
if n.IsDetail == 0 {
return n.ID
}
if seen[cur] {
return start // 环 → 容错,自成主图
}
seen[cur] = true
cur = n.ParentImageID
}
}
// 第一遍:按 sort_order 建立主图(根),并记录「主图行 id → 输出下标」,副图排在主图之前也能正确归位。
out := make([]dto.PublicArticleImage, 0, len(imgs))
index := make(map[uint32]int, len(imgs))
for _, im := range imgs {
if resolveRoot(im.ID) != im.ID {
continue
}
pub := s.toPublicSnapImage(im)
index[im.ID] = len(out)
out = append(out, pub)
}
// 第二遍:把副图挂到所属主图的 Detail 下(副图沿用所属主图的 id 作为分组键)。
for _, im := range imgs {
root := resolveRoot(im.ID)
if root == im.ID {
continue
}
gi, ok := index[root]
if !ok {
continue // 理论不发生(孤儿已按主图渲染)
}
pub := s.toPublicSnapImage(im)
pub.IsDetail = 1
out[gi].Detail = append(out[gi].Detail, pub)
}
return out
}
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
func (s *streetSnapService) Popular(ctx context.Context, limit int, locale string) ([]dto.PublicStreetSnap, error) {
rows, err := s.repo.Popular(ctx, limit)
if err != nil {
return nil, internalErr(err.Error())
@ -153,7 +273,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
for _, r := range rows {
list = append(list, dto.PublicStreetSnap{
UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap),
Title: r.Title,
Title: pickLocale(r.Title, r.TitleCn, locale),
Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high)
ImageCount: r.ImageCount,
})
@ -171,16 +291,21 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []d
}
out := make([]dto.PublicArticleImage, 0, len(imgs))
for _, im := range imgs {
out = append(out, dto.PublicArticleImage{
ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage),
Image: s.img.Compose(im.Image),
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
})
out = append(out, s.toPublicSnapImage(im))
}
return out
}
// toPublicSnapImage 把单张图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。
func (s *streetSnapService) toPublicSnapImage(im model.StreetSnapImage) dto.PublicArticleImage {
return dto.PublicArticleImage{
ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage),
Image: s.img.Compose(im.Image),
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
}
}
// toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。
func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {
@ -201,7 +326,55 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt
// ── 后台编辑支持 ──────────────────────────────────────────────────────────────
func (s *streetSnapService) GetForEdit(ctx context.Context, id uint32) (*model.StreetSnap, []model.StreetSnapImage, error) {
return s.repo.GetForEdit(ctx, id)
snap, imgs, err := s.repo.GetForEdit(ctx, id)
if err != nil {
return nil, nil, err
}
// 编辑页按「主图 → 紧邻其副图」重排:副图的 sort_order 通常排在全部主图之后,
// 若原样平铺,副图会被甩到几百张图的最末尾,看起来像「副图没显示」。
// 重排后主图与其副图相邻,分组一眼可见;孤儿副图(父不在本记录)追加末尾,不丢图。
return snap, orderStreetImagesForEdit(imgs), nil
}
// GetStreetSnapDupSources 批量反查街拍重复源(见仓储注释)。
func (s *streetSnapService) GetStreetSnapDupSources(ctx context.Context, imageIDs []uint32) (map[uint32]dto.DupSource, error) {
return s.repo.GetStreetSnapDupSources(ctx, imageIDs)
}
// orderStreetImagesForEdit 把街拍图片重排为「每张主图后紧跟其副图」的扁平顺序。
// 同一主图下的副图按 sort_order 升序;父引用指向副图(理论上不发生)的也兜住不丢。
func orderStreetImagesForEdit(imgs []model.StreetSnapImage) []model.StreetSnapImage {
byID := make(map[uint32]*model.StreetSnapImage, len(imgs))
detailsByParent := make(map[uint32][]model.StreetSnapImage)
var mains []model.StreetSnapImage
for i := range imgs {
im := &imgs[i]
byID[im.ID] = im
if im.IsDetail == 0 {
mains = append(mains, *im)
} else {
detailsByParent[im.ParentImageID] = append(detailsByParent[im.ParentImageID], *im)
}
}
out := make([]model.StreetSnapImage, 0, len(imgs))
emitted := make(map[uint32]bool, len(imgs))
for _, m := range mains {
out = append(out, m)
emitted[m.ID] = true
ds := detailsByParent[m.ID]
sort.Slice(ds, func(i, j int) bool { return ds[i].SortOrder < ds[j].SortOrder })
for _, d := range ds {
out = append(out, d)
emitted[d.ID] = true
}
}
// 未被上述主图认领的副图(父缺失 / 父本身是副图):追加末尾,保证不丢图。
for _, im := range imgs {
if im.IsDetail == 1 && !emitted[im.ID] {
out = append(out, im)
}
}
return out
}
func (s *streetSnapService) UpdateSnap(ctx context.Context, id uint32, fields map[string]any) error {
@ -220,6 +393,12 @@ func (s *streetSnapService) DeleteImage(ctx context.Context, imageID uint32) err
if key != "" {
purgeOrphanImages(ctx, s.media, s.deleter, []string{key})
}
// 删图后重算本专辑去重:避免删除源图后剩余图仍挂陈旧 dup_of,误显「重复」且源图打不开。
if snapID, err := s.repo.GetSnapImageRunwayID(ctx, imageID); err == nil && snapID != 0 {
if err := s.repo.ReresolveSnapDedup(ctx, snapID); err != nil {
log.Printf("[warn] 重算街拍 %d 去重失败(重复标记可能陈旧): %v", snapID, err)
}
}
return nil
}

2543
server_run.err Normal file

File diff suppressed because it is too large Load Diff

2480
server_run.out Normal file

File diff suppressed because it is too large Load Diff