Compare commits

...

14 Commits

Author SHA1 Message Date
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
21 changed files with 2679 additions and 121 deletions

View File

@ -4,7 +4,7 @@
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。 // **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
// //
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出 // dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
// dbtool import [-config <yml>] [-in <file.sql>] 导入 // dbtool import [-config <yml>] [-in <file.sql>] [-clean] 导入
// //
// 导入(目标机器,二选一): // 导入(目标机器,二选一):
// //
@ -30,9 +30,17 @@
// 目标库为空还是已有数据: // 目标库为空还是已有数据:
// //
// 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空 // 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空
// (对已有数据的库会主键冲突)。想让同一份文件直接覆盖一个已有数据的库, // (对已有数据的库会主键冲突)。要覆盖一个已有数据的库,用 -clean:
// 导出时加 -clean —— 它会在结构段前输出 DROP TABLE IF EXISTS ... CASCADE, //
// 且只针对本次 dump 里的表(与 pg_dump --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) 列建不出来。 // 前置条件:目标库必须已启用 pgvector 扩展,否则 vector(64) 列建不出来。
// 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的 // 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的
@ -105,15 +113,17 @@ func usage() {
用法: 用法:
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean] dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件 导出「结构 + 主键 / 索引 + 数据」为单个纯 SQL 文件
dbtool import [-config <yml>] [-in <file>] dbtool import [-config <yml>] [-in <file>] [-clean]
把 dump 出来的 SQL 文件灌入目标库 把 dump 出来的 SQL 文件灌入目标库
说明: 说明:
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。 连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
-with-extension 在文件开头加 CREATE EXTENSION IF NOT EXISTS vector; -with-extension(dump)在文件开头加 CREATE EXTENSION IF NOT EXISTS vector;
默认不加,此时目标库须已启用 pgvector,否则 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;整个脚本在一个隐式事务里执行,出错整体回滚。`) import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
} }
@ -231,6 +241,7 @@ func runImport(args []string) {
fs := flag.NewFlagSet("import", flag.ExitOnError) fs := flag.NewFlagSet("import", flag.ExitOnError)
in := fs.String("in", "db_dump.sql", "导入文件路径") in := fs.String("in", "db_dump.sql", "导入文件路径")
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)") cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
clean := fs.Bool("clean", false, "导入前先 DROP public 下全部表(覆盖式还原;目标库这些数据会丢失)")
_ = fs.Parse(args) _ = fs.Parse(args)
script, err := os.ReadFile(*in) script, err := os.ReadFile(*in)
@ -244,6 +255,12 @@ func runImport(args []string) {
} }
defer db.Close() 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()) log.Printf("→ 导入 %s(%d KB)-> %s ...", *in, len(script)/1024, dcfg.Addr())
if err := execScript(context.Background(), db, string(script)); err != nil { if err := execScript(context.Background(), db, string(script)); err != nil {
log.Fatalf("✗ 导入失败(已整体回滚): %v", err) log.Fatalf("✗ 导入失败(已整体回滚): %v", err)
@ -251,6 +268,24 @@ func runImport(args []string) {
log.Printf("✓ 导入完成(%d KB)", len(script)/1024) log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
} }
// 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 简单查询协议执行整段脚本。 // execScript 用 pgx 简单查询协议执行整段脚本。
// //
// 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。 // 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。
@ -479,6 +514,10 @@ func writeConstraints(w *bufio.Writer, db *sql.DB, table string) error {
if err := rows.Scan(&name, &def); err != nil { if err := rows.Scan(&name, &def); err != nil {
return err 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.Fprintf(w, "ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;\n", qname(table), qi(name), def)
} }
fmt.Fprintln(w) fmt.Fprintln(w)

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

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 '' 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 ```sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source 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`)。避免标题被改后出现陈旧副本。 **不冗余存对手的标题**:读时按 `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 写入时机与算法 ### 3.2 写入时机与算法
@ -226,7 +228,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source
``` ```
internal/model/image_duplicate.go 新增(模型) 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/dup_repository.go 新增:写入 + 读取 + 回填
internal/repository/ingest_repository.go StreetSnapIDByEntity 改键 internal/repository/ingest_repository.go StreetSnapIDByEntity 改键
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 改键 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` 的行。 **不变量**:`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)。 - **写入侧**保证该不变量(并入操作先校验主图存在且属于同一 owner)。
- **读侧**容错:父行不存在(或已被软删)时,把该副图**按主图渲染**,不隐藏、不报错。 - **读侧**容错:父行不存在(或已被软删)时,把该副图**按主图渲染**,不隐藏、不报错。
### 3.2 审核页交互(零 JS) ### 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/attach` | 表单含单选 `main=<主图行id>` + 多个勾选 `img=<副图行id>` → **合并选中为一组** |
| `POST /admin/reviews/:kind/:id/images/:img/attach-prev` | **并入上一张**(相邻快捷,零勾选)。覆盖「连拍同一个人」这一最常见场景 |
| `POST /admin/reviews/:kind/:id/images/:img/detach` | **拆出**:把副图恢复为主图(`is_detail=0, parent_image_id=0`) | | `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 详情页渲染(按组折叠) ### 3.3 详情页渲染(按组折叠)
- 主图区:逐个渲染主图卡片(大图 + 名称) **审核页**:见 §3.2——统一图片网格;主图卡片徽标显示「N 副图」,副图卡片显示所属主图。
- 每个主图卡片下方:一条**窄的副图缩略条**,副图带「拆出」按钮
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**) **公开详情页**:`Images` 只含主图,副图挂在每张主图的 `Detail` 子数组下(与走秀详情同形,见 §3.5)。
- 主图卡片上显示「N 张副图」计数
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**)。
### 3.4 晋升到正式表:必须重建父引用 ### 3.4 晋升到正式表:必须重建父引用
@ -88,31 +97,29 @@ parent_image_id integer not null default 0 -- 副图指向所属主图的「
漏掉这一步会导致:副图的父引用指向一个不存在(或属于别的图)的 id,详情页折叠结构错乱。 漏掉这一步会导致:副图的父引用指向一个不存在(或属于别的图)的 id,详情页折叠结构错乱。
### 3.5 公开 API(保守扩展) ### 3.5 公开 API(与走秀详情同形)
**保留** `PublicStreetSnapDetail.Images`(扁平、按 `sort_order` 升序)不变——现有前端不受影响。 **决策(2026-09-21 修订)**:采用与走秀详情**完全一致**的嵌套结构(`images` 只含主图 + 每张主图自带 `detail`),**不采用**「保留扁平 `images` + 新增平行 `groups`」的保守方案。
**新增**分组字段:
```go ```go
type PublicStreetSnapGroup struct {
Image PublicArticleImage `json:"image"` // 主图
Details []PublicArticleImage `json:"details"` // 该主图下的副图(可为空)
}
type PublicStreetSnapDetail struct { type PublicStreetSnapDetail struct {
UID string UID string
Title string Title string
Cover string Cover string // 封面:独立字段,与主/副图分组无关
Images []PublicArticleImage // 保持原样(扁平全部) Images []PublicArticleImage // 只含主图(is_detail=0);每张主图的 .Detail 挂其副图
Groups []PublicStreetSnapGroup // 新增;未分组时为每张主图一个 group、details 为空
Favorited bool 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 明确不做 ### 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 attach / attach-prev / detach 三个写方法
internal/repository/review_repository.go SaveStreetSnapFromDraft 重建父引用 internal/repository/review_repository.go SaveStreetSnapFromDraft 重建父引用
internal/service/review_service.go DraftImageRef 加分组信息;分组写入的校验 internal/service/review_service.go DraftImageRef 加分组信息;分组写入的校验
internal/handler/backstage_handler.go 详情页折叠渲染 + 常驻主图条 + 三个表单 internal/handler/backstage_handler.go 审核页「图片流」渲染 + 常驻主图条 + 三个表单
internal/router/backstage.go 三个新路由 internal/router/backstage.go 两个新路由(attach 合并 / detach 拆出)
internal/dto/street_snap.go +Groups(保守扩展) internal/dto/street_snap.go 详情改为嵌套(images 只含主图,复用 PublicArticleImage)
internal/service/street_snap_service.go 详情组装 Groups internal/service/street_snap_service.go 详情组装嵌套 images(buildSnapImages)
``` ```
--- ---
@ -156,6 +163,6 @@ internal/service/street_snap_service.go 详情组装 Groups
## 6. 待确认 ## 6. 待确认
1. 公开 API 采用「保留扁平 `images` + 新增 `groups`」的保守方案。若你希望**直接改成**只返回分组结构(更干净但会打断现有前端),需要另行确认。 1. ~~公开 API 保守方案~~ **已决(2026-09-21)**:改为与走秀同形的嵌套结构(`images` 只含主图、副图挂 `detail`),见 §3.5。
2. `image_count` 暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。 2. `image_count` 暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。
3. 分组的「组序号」不落库,由读时按 `sort_order` 顺序推导(主图顺序即组序)。 3. 分组的「组序号」不落库,由读时按 `sort_order` 顺序推导(主图顺序即组序)。

View File

@ -60,11 +60,13 @@ type PublicStreetSnap struct {
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图 Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
} }
// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。 // PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
type PublicStreetSnapDetail struct { type PublicStreetSnapDetail struct {
UID string `json:"id"` UID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"` // 封面:独立字段,与主/副图分组无关
Images []PublicArticleImage `json:"images"` // Images 只含主图(is_detail=0),默认展示;每张主图的副图挂在它自己的 Detail 子数组下。
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级) // 结构与走秀详情完全一致(见 PublicArticleImage),前端可复用同一套渲染。
Images []PublicArticleImage `json:"images"`
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
} }

View File

@ -42,7 +42,10 @@ func NewBackstageHandler(auth service.AuthService, brand service.BrandService, a
// pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。 // pagerTmpl 是所有后台列表页共享的翻页片段定义(用法:{{template "pager" .Pager}})。
// 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文, // 以「base」为根模板并只含一个 define;renderHTML 每次渲染时 Clone 它再灌入页面正文,
// 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。 // 这样页面模板即可直接引用 {{template "pager"}} 而无需各自复制翻页 HTML。
var pagerTmpl = template.Must(template.New("base").Parse(`{{define "pager"}}<div class="pager"> var pagerTmpl = template.Must(template.New("base").Funcs(template.FuncMap{
// reviewDetailURL 生成审核详情页地址。
"reviewDetailURL": reviewDetailURL,
}).Parse(`{{define "pager"}}<div class="pager">
{{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}} {{if .HasPrev}}<a href="{{.PrevURL}}">上一页</a>{{else}}<a class="off">上一页</a>{{end}}
{{if .Label}}<span class="muted">{{.Label}}</span>{{end}} {{if .Label}}<span class="muted">{{.Label}}</span>{{end}}
{{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}} {{if .HasNext}}<a href="{{.NextURL}}">下一页</a>{{else}}<a class="off">下一页</a>{{end}}
@ -465,6 +468,58 @@ func (h *BackstageHandler) ReviewDraftImageDelete(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id")) c.Redirect(http.StatusFound, "/admin/reviews/"+kind+"/"+c.Param("id"))
} }
// ReviewAttachDraftImages 把勾选的草稿图并入指定主图(街拍审核页「合并选中为一组」)。
// 主图由表单里的单选 name="main" 指定;待并入的图由多个 checkbox name="img" 提交。
func (h *BackstageHandler) ReviewAttachDraftImages(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
if id == 0 {
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"})
return
}
mainID := uint32(atoiDefault(c.PostForm("main"), 0))
imgIDs := make([]uint32, 0)
for _, s := range c.PostFormArray("img") {
if v := atoiDefault(s, 0); v > 0 {
imgIDs = append(imgIDs, uint32(v))
}
}
if len(imgIDs) == 0 {
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请先勾选要合并的图片"})
return
}
if mainID == 0 {
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "请在要作为主图的图片上点选「主图」"})
return
}
if err := h.review.AttachDraftImages(c.Request.Context(), kind, uint32(id), mainID, imgIDs); err != nil {
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
}
// ReviewDetachDraftImage 把副图拆出恢复为主图。
func (h *BackstageHandler) ReviewDetachDraftImage(c *gin.Context) {
kind := c.Param("kind")
id := atoiDefault(c.Param("id"), 0)
imgID := atoiDefault(c.Param("img"), 0)
if id == 0 || imgID == 0 {
renderHTML(c, http.StatusBadRequest, tplError, gin.H{"Error": "参数无效"})
return
}
if err := h.review.DetachDraftImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
renderHTML(c, http.StatusInternalServerError, tplError, gin.H{"Error": err.Error()})
return
}
c.Redirect(http.StatusFound, reviewDetailURL(kind, uint32(id)))
}
// reviewDetailURL 生成审核详情页跳转地址。
func reviewDetailURL(kind string, id uint32) string {
return "/admin/reviews/" + kind + "/" + strconv.FormatUint(uint64(id), 10)
}
// reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。 // reviewListURL 根据 kind 生成审核列表跳转地址(带 kind 段或裸 /admin/reviews)。
func reviewListURL(kind string) string { func reviewListURL(kind string) string {
if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet { if kind == dto.IngestKindRunway || kind == dto.IngestKindStreet {
@ -1252,35 +1307,59 @@ body{font:14px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;backgrou
.bar{background:#111;color:#fff;padding:12px 24px;display:flex;justify-content:space-between;align-items:center} .bar{background:#111;color:#fff;padding:12px 24px;display:flex;justify-content:space-between;align-items:center}
.bar a{color:#fff;text-decoration:none} .bar a{color:#fff;text-decoration:none}
.main{max-width:900px;margin:24px auto;padding:0 24px} .main{max-width:900px;margin:24px auto;padding:0 24px}
h1{font-size:20px} h2{font-size:16px;margin:22px 0 8px}
.muted{color:#777} .muted{color:#777}
label{display:block;margin:14px 0 4px;color:#555} label{display:block;margin:14px 0 4px;color:#555}
input,textarea{width:100%;padding:9px 10px;box-sizing:border-box;border:1px solid #ccc;border-radius:6px;font-size:14px;font-family:inherit} input,textarea{width:100%;padding:9px 10px;box-sizing:border-box;border:1px solid #ccc;border-radius:6px;font-size:14px;font-family:inherit}
textarea{min-height:72px} textarea{min-height:72px}
button{margin-top:16px;padding:10px 18px;background:#111;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:14px} button{margin-top:16px;padding:10px 18px;background:#111;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:14px}
button.reject{background:#c0392b} button.reject{background:#c0392b}
.row{display:flex;gap:14px}.row>div{flex:1} button.ghost{background:#fff;color:#111;border:1px solid #ccc}
.kind{font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5}
.kind.street{background:#eafaf0;color:#1a7f43}
.hint{position:sticky;top:0;background:#fffbe6;border:1px solid #f5e08a;color:#7a5b00;padding:8px 12px;border-radius:6px;margin:12px 0;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:14px} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:14px}
.cell{background:#fff;border:1px solid #eee;border-radius:8px;overflow:hidden} .cell{background:#fff;border:1px solid #eee;border-radius:8px;overflow:hidden}
.cell img{width:100%;display:block;aspect-ratio:2/3;object-fit:cover} .cell img{width:100%;display:block;aspect-ratio:2/3;object-fit:cover}
.cell .acts{padding:8px;display:flex;gap:6px} .cell .role{display:block;padding:6px 8px 0;font-size:12px;color:#1a7f43}
.cell .acts button{margin:0;flex:1;padding:7px;font-size:13px} .cell .nm{display:block;padding:2px 8px 6px;font-size:12px;color:#111;word-break:break-all}
.kind{font-size:12px;padding:1px 7px;border-radius:10px;background:#eef2ff;color:#3b4bb5} .cell.det{background:#f8faff;border-color:#c7d2fe}
.kind.street{background:#eafaf0;color:#1a7f43} .cell.det .role{color:#3b4bb5}
.cell .acts{padding:8px;display:flex;flex-wrap:wrap;gap:6px}
.cell .acts button{margin:0;flex:1 1 auto;padding:7px;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}
.chk{display:flex;align-items:center;gap:4px;flex:1 1 auto;font-size:12px;color:#333;white-space:nowrap;cursor:pointer}
.chk input{margin:0}
.batchbar{position:sticky;bottom:0;background:#fff;border-top:1px solid #eee;padding:10px 0;margin-top:16px;display:flex;align-items:center;gap:10px}
</style></head> </style></head>
<body> <body>
<div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="/admin/reviews{{if ne .Draft.Kind ""}}/{{.Draft.Kind}}{{end}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div> <div class="bar"><span>管理后台 · 草稿审核 #{{.Draft.ID}}</span><span><a href="{{reviewDetailURL .Draft.Kind .Draft.ID}}">返回列表</a> · <a href="/admin/logout">退出</a></span></div>
<div class="main"> <div class="main">
<p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p> <p class="muted">类型 <b>{{.Draft.Kind}}</b>{{if .BrandName}} · 品牌 {{.BrandName}}{{end}} · 状态 <b>{{.Draft.Status}}</b></p>
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve"> <form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/approve">
{{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}} {{range .Draft.Fields}}<div><label>{{.Label}}</label>{{if eq .Type "textarea"}}<textarea name="{{.Name}}">{{.Value}}</textarea>{{else}}<input name="{{.Name}}" value="{{.Value}}"{{if eq .Type "number"}} inputmode="numeric"{{end}}>{{end}}</div>{{end}}
<button type="submit">通过并发布</button> <button type="submit">通过并发布</button>
</form> </form>
<form method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/reject" style="margin-top:18px"> {{if eq .Draft.Kind "street"}}
<label>驳回理由(可选)</label><input name="reason" placeholder="如:图片缺失 / 信息有误"> <div class="hint">操作:勾选属于同一个人的图片 → 在<b>其中一张</b>上点「主图」→ 点底部「合并选中为一组」。副图上的「拆出」可恢复为独立主图。</div>
<button type="submit" class="reject">驳回</button> <h2>图片({{len .Draft.Images}} 张 · {{len .Draft.Groups}} 组)</h2>
</form> <form id="batch" method="post" action="/admin/reviews/{{.Draft.Kind}}/{{.Draft.ID}}/images/attach"></form>
<h2 style="margin-top:24px">图片管理({{len .Draft.Images}} 张)</h2> <div class="grid">
{{range .Draft.Images}}<div class="cell{{if eq .IsDetail 1}} det{{end}}">
<img src="{{.Image}}" loading="lazy">
<span class="role">{{if eq .IsDetail 1}}副图 → #{{.ParentImageID}}{{else}}主图{{if .DetailCount}} · {{.DetailCount}} 副图{{end}}{{end}}</span>
<span class="nm">{{.Name}}</span>
<div class="acts">
{{if eq .IsDetail 0}}<label class="chk"><input type="radio" form="batch" name="main" value="{{.ID}}"> 主图</label>{{end}}
<label class="chk"><input type="checkbox" form="batch" name="img" value="{{.ID}}"> 选入</label>
{{if eq .IsDetail 1}}<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/detach"><button type="submit">拆出</button></form>{{end}}
<form method="post" action="/admin/reviews/{{$.Draft.Kind}}/{{$.Draft.ID}}/images/{{.ID}}/delete"><button type="submit" class="reject" onclick="return confirm('确认删除这张图片?')">删除</button></form>
</div>
</div>{{end}}
</div>
<div class="batchbar"><button type="submit" form="batch">合并选中为一组</button><span class="muted">勾选同一人的图,并在其中一张主图上点「主图」</span></div>
{{else}}
<h2>图片管理({{len .Draft.Images}} 张)</h2>
<div class="grid"> <div class="grid">
{{range .Draft.Images}}<div class="cell"> {{range .Draft.Images}}<div class="cell">
<img src="{{.Image}}" loading="lazy"> <img src="{{.Image}}" loading="lazy">
@ -1290,6 +1369,7 @@ button.reject{background:#c0392b}
</div> </div>
</div>{{end}} </div>{{end}}
</div> </div>
{{end}}
</div></body></html>` </div></body></html>`
const tplRunwayEdit = `<!doctype html><html lang="zh"><head><meta charset="utf-8"> const tplRunwayEdit = `<!doctype html><html lang="zh"><head><meta charset="utf-8">

View File

@ -25,19 +25,24 @@ func (StreetSnap) TableName() string { return "street_snaps" }
// //
// 仿 brand_runway_images,但把 runway_id 改名为 snap_id,去掉 brand_id(街拍无品牌关联)。 // 仿 brand_runway_images,但把 runway_id 改名为 snap_id,去掉 brand_id(街拍无品牌关联)。
type StreetSnapImage struct { type StreetSnapImage struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"` ID uint32 `gorm:"primaryKey;column:id" json:"id"`
SnapID uint32 `gorm:"column:snap_id" json:"snap_id"` SnapID uint32 `gorm:"column:snap_id" json:"snap_id"`
Image string `gorm:"column:image" json:"image"` Image string `gorm:"column:image" json:"image"`
Name string `gorm:"column:name" json:"name"` Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。 // 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"` Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"` 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"` 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 指定图片明细表名。 // TableName 指定图片明细表名。

View File

@ -30,19 +30,25 @@ func (StreetSnapDraft) TableName() string { return "street_snap_drafts" }
// StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。 // StreetSnapDraftImage 街拍草稿图片(对应 street_snap_draft_images)。
type StreetSnapDraftImage struct { type StreetSnapDraftImage struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"` ID uint32 `gorm:"primaryKey;column:id" json:"id"`
DraftID uint32 `gorm:"column:draft_id" json:"draft_id"` DraftID uint32 `gorm:"column:draft_id" json:"draft_id"`
Image string `gorm:"column:image" json:"image"` Image string `gorm:"column:image" json:"image"`
Name string `gorm:"column:name" json:"name"` Name string `gorm:"column:name" json:"name"`
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"`
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。 // 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"` Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"` 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"` DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
// 主副图分组(列由 db/migrations/2026-09-21-01-street-main-detail.sql 添加):
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同草稿内主图的行 id。
// 存行 id 而非序号:序号会因重排 / 插入 / 删除而失效。
IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"`
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"`
} }
// TableName 指定草稿图片表名。 // TableName 指定草稿图片表名。

View File

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

View File

@ -37,6 +37,15 @@ type ReviewRepository interface {
SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error SoftDeleteRunwayDraftImage(ctx context.Context, draftID, imgID uint32) error
// SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。 // SoftDeleteStreetDraftImage 软删单张街拍草稿图片,并同步 image_count。
SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error SoftDeleteStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
// mainImgID 必须属于同一草稿、未被软删、且自身是主图(is_detail=0),否则返回 ErrInvalidImageGroup。
// 若某个被并入的图自身还带着副图,则它原有的副图一并改挂到 mainImgID ——
// 避免出现「副图的副图」破坏不变量(见计划「实现裁决」第 1 条)。
// 幂等:已是 mainImgID 副图的行重复并入不报错。
AttachStreetDraftImages(ctx context.Context, draftID, mainImgID uint32, imgIDs []uint32) error
// DetachStreetDraftImage 把副图恢复为主图(is_detail=0, parent_image_id=0)。
// 对主图调用是幂等成功(无副作用)。
DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error
// GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。 // GetDraftImageKey 取单张草稿图片的S4 key(删除前清理用);不存在返回空串。
GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error) GetDraftImageKey(ctx context.Context, kind string, draftID, imgID uint32) (string, error)
UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error UpdateStreetDraft(ctx context.Context, id uint32, fields map[string]any) error
@ -244,20 +253,20 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
if len(imgs) > 0 { if len(imgs) > 0 {
rows := make([]model.BrandRunwayImage, 0, len(imgs)) rows := make([]model.BrandRunwayImage, 0, len(imgs))
for i, im := range imgs { for i, im := range imgs {
rows = append(rows, model.BrandRunwayImage{ rows = append(rows, model.BrandRunwayImage{
RunwayID: runwayID, RunwayID: runwayID,
BrandID: draft.BrandID, BrandID: draft.BrandID,
Image: im.Image, Image: im.Image,
Name: im.Name, Name: im.Name,
SortOrder: uint32(i + 1), SortOrder: uint32(i + 1),
LookIndex: im.LookIndex, LookIndex: im.LookIndex,
IsDetail: im.IsDetail, IsDetail: im.IsDetail,
Phash: im.Phash, Phash: im.Phash,
IsDuplicate: im.IsDuplicate, IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf, DupOf: im.DupOf,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
}) })
} }
if cErr := tx.Create(&rows).Error; cErr != nil { if cErr := tx.Create(&rows).Error; cErr != nil {
return cErr return cErr
@ -437,22 +446,74 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
return dErr return dErr
} }
if len(imgs) > 0 { if len(imgs) > 0 {
rows := make([]model.StreetSnapImage, 0, len(imgs)) // 第一遍:插入主图(is_detail=0),并记下「草稿行 id → 新正式行 id」的映射。
for i, im := range imgs { //
rows = append(rows, model.StreetSnapImage{ // 必须两遍插入:副图的 parent_image_id 存的是**草稿表**的行 id,
SnapID: snapID, // 而这里插入的正式行拿到的是全新 id。若先插副图,父引用就会指向草稿表的旧 id,
Image: im.Image, // 详情页折叠结构随之错乱(见计划「任务 2」标题下的说明)。
Name: im.Name, newIDByDraftID := make(map[uint32]uint32, len(imgs))
SortOrder: uint32(i + 1), mains := make([]model.StreetSnapImage, 0, len(imgs))
Phash: im.Phash, for _, im := range imgs {
IsDuplicate: im.IsDuplicate, if im.IsDetail == 1 {
DupOf: im.DupOf, continue
CreatedAt: now, }
UpdatedAt: now, mains = append(mains, model.StreetSnapImage{
}) SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(len(mains) + 1),
IsDetail: 0,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
CreatedAt: now,
UpdatedAt: now,
})
} }
if cErr := tx.Create(&rows).Error; cErr != nil { for i := range mains {
return cErr if cErr := tx.Create(&mains[i]).Error; cErr != nil {
return cErr
}
}
mainIdx := 0
for _, im := range imgs {
if im.IsDetail == 1 {
continue
}
newIDByDraftID[im.ID] = mains[mainIdx].ID
mainIdx++
}
// 第二遍:插入副图,把父引用改写成新主图行 id。
details := make([]model.StreetSnapImage, 0, len(imgs)-len(mains))
for _, im := range imgs {
if im.IsDetail != 1 {
continue
}
newParent, ok := newIDByDraftID[im.ParentImageID]
row := model.StreetSnapImage{
SnapID: snapID,
Image: im.Image,
Name: im.Name,
SortOrder: uint32(len(mains) + len(details) + 1),
IsDetail: 1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
CreatedAt: now,
UpdatedAt: now,
}
if ok {
row.ParentImageID = newParent
} else {
row.IsDetail = 0 // 父行缺失:按主图渲染,与读侧容错口径一致
}
details = append(details, row)
}
if len(details) > 0 {
if cErr := tx.Create(&details).Error; cErr != nil {
return cErr
}
} }
} }
return nil return nil
@ -599,3 +660,57 @@ func (r *reviewRepository) GetDraftImageKey(ctx context.Context, kind string, dr
return "", ErrNotFound return "", ErrNotFound
} }
} }
// ── 街拍草稿图片:主副图分组 ──────────────────────────────────────────────
// AttachStreetDraftImages 把 imgIDs 变成 mainImgID 的副图(批量并入)。
//
// 事务内三步:1) 校验主图存在、未软删、本身是主图;2) 先「改挂孙辈」——被并入图原有的副图
// 改挂到新主图(避免副图的副图);3) 再把被并入图自身标为副图。第 2 步必须在第 3 步之前。
func (r *reviewRepository) AttachStreetDraftImages(ctx context.Context, draftID, 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.StreetSnapDraftImage
if err := tx.Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 0", mainImgID, draftID).
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.StreetSnapDraftImage{}).
Where("draft_id = ? AND is_deleted = 0 AND parent_image_id IN ?", draftID, ids).
Updates(map[string]any{"parent_image_id": mainImgID, "updated_at": now}).Error; err != nil {
return err
}
return tx.Model(&model.StreetSnapDraftImage{}).
Where("draft_id = ? AND is_deleted = 0 AND id IN ?", draftID, ids).
Updates(map[string]any{"is_detail": 1, "parent_image_id": mainImgID, "updated_at": now}).Error
})
}
// DetachStreetDraftImage 把副图恢复为主图。对主图调用为幂等成功。
// 拆出的图若自身带着副图,那些副图保持挂在它下面——它重新成为主图,不变量依然成立。
func (r *reviewRepository) DetachStreetDraftImage(ctx context.Context, draftID, imgID uint32) error {
return r.db.WithContext(ctx).
Model(&model.StreetSnapDraftImage{}).
Where("id = ? AND draft_id = ? AND is_deleted = 0 AND is_detail = 1", imgID, draftID).
Updates(map[string]any{"is_detail": 0, "parent_image_id": 0, "updated_at": uint32(time.Now().Unix())}).
Error
}

View File

@ -0,0 +1,192 @@
//go:build integration
// 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。
//
// 运行方式(先起库并确保结构已就绪,见 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"
"testing"
"fashionapi/internal/model"
)
// seedStreetDraft 插入一条街拍草稿并返回其 id。
func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 {
t.Helper()
ctx := context.Background()
db := repo.(*reviewRepository).db
d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending}
if err := db.WithContext(ctx).Create(&d).Error; err != nil {
t.Fatalf("插入草稿失败: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID)
db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID)
})
return d.ID
}
// seedStreetDraftImage 插入一张草稿图并返回其行 id。
func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 {
t.Helper()
img := model.StreetSnapDraftImage{DraftID: draftID, 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
}
// reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。
func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage {
t.Helper()
imgs, err := repo.StreetDraftImages(context.Background(), draftID)
if err != nil {
t.Fatalf("读草稿图失败: %v", err)
}
return imgs
}
// TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。
func TestStreetMainDetail(t *testing.T) {
repo := NewReviewRepository(testDB(t))
ctx := context.Background()
draftID := seedStreetDraft(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "a", 1)
d2 := seedStreetDraftImage(t, repo, draftID, "b", 2)
d3 := seedStreetDraftImage(t, repo, draftID, "c", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("并入失败: %v", err)
}
for _, im := range reloadStreetImages(t, repo, draftID) {
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.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("重复并入应幂等,实际报错: %v", err)
}
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil {
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
}
for _, im := range reloadStreetImages(t, repo, draftID) {
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("主图被自身并入后不应变化")
}
}
// 拆出:把 d3 恢复为主图。
if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil {
t.Fatalf("拆出失败: %v", err)
}
for _, im := range reloadStreetImages(t, repo, draftID) {
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
}
}
if err := repo.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功
t.Fatalf("对主图拆出应幂等成功: %v", err)
}
// 跨草稿拦截:空集合 + 跨草稿主图。
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
}
otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1)
if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("跨草稿主图应被拦截,实际 %v", err)
}
}
// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。
func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
repo := NewReviewRepository(testDB(t))
ctx := context.Background()
draftID := seedStreetDraft(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1)
m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2)
child := seedStreetDraftImage(t, repo, draftID, "child", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil {
t.Fatalf("预置副图失败: %v", err)
}
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil {
t.Fatalf("并入整组失败: %v", err)
}
for _, im := range reloadStreetImages(t, repo, draftID) {
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)
}
}
}
// TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。
func TestStreetPromoteRebuildsParentRef(t *testing.T) {
repo := NewReviewRepository(testDB(t))
ctx := context.Background()
draftID := seedStreetDraft(t, repo)
m1 := seedStreetDraftImage(t, repo, draftID, "main", 1)
d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2)
d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3)
if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil {
t.Fatalf("预置主副图失败: %v", err)
}
snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID)
if err != nil {
t.Fatalf("晋升失败: %v", err)
}
t.Cleanup(func() {
db := repo.(*reviewRepository).db
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID)
db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID)
})
db := repo.(*reviewRepository).db
var rows []model.StreetSnapImage
if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID).
Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
t.Fatalf("读正式图失败: %v", err)
}
if len(rows) != 3 {
t.Fatalf("期望 3 行正式图,实际 %d", len(rows))
}
mainRow := rows[0]
if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 {
t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID)
}
for _, row := range rows[1:] {
if row.IsDetail != 1 {
t.Fatalf("第 %d 行应是副图", row.ID)
}
if row.ParentImageID != mainRow.ID {
t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID)
}
}
}

View File

@ -94,6 +94,9 @@ func NewBackstage(opt BackstageOptions) *gin.Engine {
admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove) admin.POST("/reviews/:kind/:id/approve", opt.Backstage.ReviewApprove)
admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject) admin.POST("/reviews/:kind/:id/reject", opt.Backstage.ReviewReject)
admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete) admin.POST("/reviews/:kind/:id/images/:img/delete", opt.Backstage.ReviewDraftImageDelete)
// 街拍主副图分组操作(零 JS:同步表单 POST,成功 302 回详情页)。
admin.POST("/reviews/:kind/:id/images/attach", opt.Backstage.ReviewAttachDraftImages)
admin.POST("/reviews/:kind/:id/images/:img/detach", opt.Backstage.ReviewDetachDraftImage)
// 爬虫入库任务监控:列出处理进度 + 失败重试 // 爬虫入库任务监控:列出处理进度 + 失败重试
admin.GET("/ingest-jobs", opt.Backstage.IngestJobs) admin.GET("/ingest-jobs", opt.Backstage.IngestJobs)
admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry) admin.POST("/ingest-jobs/:id/retry", opt.Backstage.IngestJobRetry)

View File

@ -14,6 +14,8 @@ import (
"fashionapi/internal/model" "fashionapi/internal/model"
"fashionapi/internal/pkg/jwt" "fashionapi/internal/pkg/jwt"
"fashionapi/internal/service" "fashionapi/internal/service"
"github.com/gin-gonic/gin"
) )
// ── 测试用假实现(不连库)────────────────────────────────────────────── // ── 测试用假实现(不连库)──────────────────────────────────────────────
@ -175,7 +177,7 @@ func TestBackstage(t *testing.T) {
m := jwt.NewManager("test-secret", 168) m := jwt.NewManager("test-secret", 168)
auth := &fakeAuth{jwt: m} auth := &fakeAuth{jwt: m}
brand := &fakeBrand{} brand := &fakeBrand{}
h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, nil, nil) h := handler.NewBackstageHandler(auth, brand, &fakeArticle{}, &fakeStreet{}, &fakeReview{attachOK: true}, nil)
r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h}) r := NewBackstage(BackstageOptions{Config: &config.Config{}, JWT: m, Backstage: h})
// 1) 登录页可访问 // 1) 登录页可访问
@ -288,3 +290,175 @@ func TestBackstage(t *testing.T) {
t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String()) t.Fatalf("街拍详情异常: %d %s", w.Code, w.Body.String())
} }
} }
// ── 街拍主副图分组:路由 / 重定向 / 渲染(无需真实数据库)─────────────────
// fakeReview 实现审核服务接口;嵌入接口兜底未用到的方法,仅覆盖分组相关 4 个。
type fakeReview struct {
service.ReviewService
attachOK bool
last struct {
kind string
draftID uint32
mainID uint32
imgIDs []uint32
imgID uint32
op string
}
view *service.DraftDetailView
}
var _ service.ReviewService = (*fakeReview)(nil)
func (f *fakeReview) DraftDetail(_ context.Context, _ string, _ uint32) (*service.DraftDetailView, error) {
if f.view != nil {
return f.view, nil
}
return &service.DraftDetailView{Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 2, Status: "pending"}, nil
}
func (f *fakeReview) AttachDraftImages(_ context.Context, kind string, draftID, mainID uint32, imgIDs []uint32) error {
f.last.kind, f.last.draftID, f.last.mainID, f.last.imgIDs, f.last.op = kind, draftID, mainID, imgIDs, "attach"
if !f.attachOK {
return errors.New("主图无效")
}
return nil
}
func (f *fakeReview) DetachDraftImage(_ context.Context, kind string, draftID, imgID uint32) error {
f.last.kind, f.last.draftID, f.last.imgID, f.last.op = kind, draftID, imgID, "detach"
if !f.attachOK {
return errors.New("主图无效")
}
return 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)
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/reviews/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/reviews/street/7") {
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/reviews/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/reviews/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/reviews/street/7") {
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) {
r, rev := newReviewRouter(t)
tok := reviewToken(t, r)
rev.view = &service.DraftDetailView{
Kind: "street", ID: 7, Title: "T", Year: 2026, City: "Beijing", ImageCount: 3, Status: "pending",
Images: []service.DraftImageRef{
{ID: 1, Image: "main.jpg", Name: "main", IsDetail: 0, DetailCount: 1},
{ID: 2, Image: "det.jpg", Name: "detail-a", IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", IsDetail: 0},
},
Groups: []service.DraftImageGroup{
{Image: service.DraftImageRef{ID: 1, Image: "main.jpg", Name: "main"}, Details: []service.DraftImageRef{{ID: 2, Image: "det.jpg", Name: "detail-a"}}},
{Image: service.DraftImageRef{ID: 3, Image: "solo.jpg", Name: "solo"}},
},
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/reviews/street/7", 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("不应再出现「并入上一张」")
}
}

View File

@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"sort" "sort"
"strconv" "strconv"
@ -26,11 +27,17 @@ type DraftCard struct {
} }
// DraftImageRef 草稿图片引用(统一结构)。 // DraftImageRef 草稿图片引用(统一结构)。
//
// IsDetail / ParentImageID 只有街拍模块会填(走秀的主/细节图走 LookIndex 那套,语义不同,不复用);
// DetailCount 让模板不用自己数副图。
type DraftImageRef struct { type DraftImageRef struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Image string `json:"image"` Image string `json:"image"`
Name string `json:"name"` Name string `json:"name"`
SortOrder uint32 `json:"sort_order"` 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 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框, // EditField 审核详情页里可编辑字段的通用描述;详情页按此循环渲染输入框,
@ -63,8 +70,21 @@ type DraftDetailView struct {
Images []DraftImageRef `json:"images"` Images []DraftImageRef `json:"images"`
// Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。 // Fields 是审核前可微调的字段列表(按模块声明顺序渲染),替代模板里的 IsStreet 分支。
Fields []EditField `json:"fields"` Fields []EditField `json:"fields"`
// Groups 街拍详情的「主图 → 其副图」分组视图(按 sort_order 顺序)。走秀模块留空。
// 与 Images 同源同序:Images 保持扁平(既有渲染 / runway 仍用),Groups 供按组折叠。
Groups []DraftImageGroup `json:"groups,omitempty"`
} }
// DraftImageGroup 一张主图及其副图,供审核页按组折叠渲染。
type DraftImageGroup struct {
Image DraftImageRef `json:"image"` // 主图
Details []DraftImageRef `json:"details"` // 该主图名下的副图(可为空)
}
// ErrInvalidImageGroup 主副图分组操作不合法:主图不存在 / 不属于本草稿 / 自身不是主图 / 空集合。
// 这里是给用户看的文案,handler 直接渲染 err.Error()。
var ErrInvalidImageGroup = errors.New("主图无效:它必须属于本草稿、未被删除,且自身是主图")
// KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。 // KindTab 审核列表页顶部「类型」标签;Pending 为该模块待审数(驱动大审核列表概览)。
type KindTab struct { type KindTab struct {
Kind string `json:"kind"` // 空串表示「全部」 Kind string `json:"kind"` // 空串表示「全部」
@ -102,6 +122,10 @@ type ReviewService interface {
Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error Reject(ctx context.Context, kind string, id uint32, reviewer, reason string) error
// DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。 // DeleteDraftImage 软删审核草稿里的单张图片(审核阶段剔除坏图,不删S4文件)。
DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error DeleteDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
// AttachDraftImages 把若干张草稿图并入指定主图(街拍专用;走秀模块返回 ErrInvalidImageGroup)。
AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error
// DetachDraftImage 把副图拆出恢复为主图(街拍专用)。
DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error
// KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。 // KindTabs 返回类型标签(含「全部」与每个模块的待审数),驱动大审核列表概览。
KindTabs(ctx context.Context) []KindTab KindTabs(ctx context.Context) []KindTab
} }
@ -260,6 +284,34 @@ func (s *reviewService) DeleteDraftImage(ctx context.Context, kind string, draft
return nil return nil
} }
func (s *reviewService) AttachDraftImages(ctx context.Context, kind string, draftID, mainImgID uint32, imgIDs []uint32) error {
if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup
}
if len(imgIDs) == 0 {
return ErrInvalidImageGroup
}
return translateGroupErr(s.repo.AttachStreetDraftImages(ctx, draftID, mainImgID, imgIDs))
}
func (s *reviewService) DetachDraftImage(ctx context.Context, kind string, draftID, imgID uint32) error {
if kind != dto.IngestKindStreet {
return ErrInvalidImageGroup
}
return translateGroupErr(s.repo.DetachStreetDraftImage(ctx, draftID, imgID))
}
// translateGroupErr 把仓储层的哨兵错误翻成给用户看的中文文案;其余错误原样透传。
func translateGroupErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, repository.ErrInvalidImageGroup) {
return ErrInvalidImageGroup
}
return err
}
// ── runway 模块 ──────────────────────────────────────────────────────────────── // ── runway 模块 ────────────────────────────────────────────────────────────────
type runwayModule struct { type runwayModule struct {
@ -385,9 +437,75 @@ func (m *streetModule) DraftDetail(ctx context.Context, id uint32) (*DraftDetail
{Name: "city", Label: "城市", Value: d.City, Type: "text"}, {Name: "city", Label: "城市", Value: d.City, Type: "text"},
}, },
} }
// 按 sort_order 顺序成组:主图各成一组;副图归入其(存活)主图组,父引用失效则自成一主图组。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图(保证与公开 API 同源同序)。
byID := make(map[uint32]model.StreetSnapDraftImage, len(imgs))
for _, im := range imgs { for _, im := range imgs {
v.Images = append(v.Images, DraftImageRef{ID: im.ID, Image: m.img.Compose(im.Image), Name: im.Name, SortOrder: im.SortOrder}) 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([]DraftImageGroup, 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 := DraftImageRef{
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, DraftImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1
continue
}
if gi, ok := groupIndex[root]; ok {
groups[gi].Details = append(groups[gi].Details, ref)
} else { // 兜底(理论不发生):主图未先出现,自成一组
groups = append(groups, DraftImageGroup{Image: ref})
groupIndex[im.ID] = len(groups) - 1
}
}
v.Groups = groups
return v, nil return v, nil
} }

View File

@ -0,0 +1,127 @@
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.StreetSnapDraftImage
detail *model.StreetSnapDraft
attachFn func(draftID, mainID uint32, imgIDs []uint32) error
}
func (f *fakeGroupRepo) StreetDraftDetail(_ context.Context, _ uint32) (*model.StreetSnapDraft, error) {
return f.detail, nil
}
func (f *fakeGroupRepo) StreetDraftImages(_ context.Context, _ uint32) ([]model.StreetSnapDraftImage, error) {
return f.images, nil
}
func (f *fakeGroupRepo) AttachStreetDraftImages(_ context.Context, draftID, mainID uint32, imgIDs []uint32) error {
if f.attachFn != nil {
return f.attachFn(draftID, mainID, imgIDs)
}
return nil
}
func (f *fakeGroupRepo) DetachStreetDraftImage(_ context.Context, _ uint32, _ uint32) error {
if f.attachFn != nil {
return f.attachFn(0, 0, nil)
}
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.StreetSnapDraft{ID: 7, Title: "T", Year: 2026, City: "Beijing", Status: model.DraftStatusPending},
images: []model.StreetSnapDraftImage{
{ID: 1, Image: "main.jpg", Name: "main", SortOrder: 1, IsDetail: 0, ParentImageID: 0},
{ID: 2, Image: "det.jpg", Name: "det", SortOrder: 2, IsDetail: 1, ParentImageID: 1},
{ID: 3, Image: "solo.jpg", Name: "solo", SortOrder: 3, IsDetail: 0, ParentImageID: 0},
},
}
svc := newGroupService(repo)
v, err := svc.DraftDetail(context.Background(), dto.IngestKindStreet, 7)
if err != nil {
t.Fatalf("DraftDetail 失败: %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 {
draftID uint32
mainID uint32
imgIDs []uint32
}
repo := &fakeGroupRepo{attachFn: func(draftID, mainID uint32, imgIDs []uint32) error {
got.draftID, got.mainID, got.imgIDs = draftID, mainID, imgIDs
return nil
}}
svc := newGroupService(repo)
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3}); err != nil {
t.Fatalf("AttachDraftImages 失败: %v", err)
}
if got.draftID != 7 || got.mainID != 1 || len(got.imgIDs) != 2 {
t.Fatalf("参数透传错误: draftID=%d mainID=%d imgIDs=%v", got.draftID, got.mainID, got.imgIDs)
}
}
// TestStreetGroupNonStreetRejected 走秀模块不允许主副图分组操作。
func TestStreetGroupNonStreetRejected(t *testing.T) {
repo := &fakeGroupRepo{}
svc := newGroupService(repo)
if err := svc.AttachDraftImages(context.Background(), dto.IngestKindRunway, 7, 1, []uint32{2}); !errors.Is(err, ErrInvalidImageGroup) {
t.Fatalf("走秀并入应返回 ErrInvalidImageGroup,实际 %v", err)
}
}
// TestStreetGroupPreventCrossDraft 仓储拦截跨草稿主图后,service 翻成中文用户错误。
func TestStreetGroupPreventCrossDraft(t *testing.T) {
repo := &fakeGroupRepo{attachFn: func(_, _ uint32, _ []uint32) error {
return repository.ErrInvalidImageGroup
}}
svc := newGroupService(repo)
err := svc.AttachDraftImages(context.Background(), dto.IngestKindStreet, 7, 1, []uint32{2, 3})
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], LookIndex=1)、C(Detail=[D], LookIndex=2)。
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 || a.LookIndex != 1 {
t.Fatalf("第 1 张应为 A 主图 LookIndex=1,实际 name=%s is_detail=%d look=%d", a.Name, a.IsDetail, a.LookIndex)
}
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 || d.LookIndex != 1 {
t.Fatalf("副图 %s 的 is_detail/look_index 应为 1/1,实际 %d/%d", d.Name, d.IsDetail, d.LookIndex)
}
}
c := out[1]
if c.Name != "C" || c.LookIndex != 2 {
t.Fatalf("第 2 张应为 C 主图 LookIndex=2,实际 name=%s look=%d", c.Name, c.LookIndex)
}
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

@ -138,10 +138,75 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap),
Title: snap.Title, Title: snap.Title,
Cover: s.img.Compose(snap.Cover), Cover: s.img.Compose(snap.Cover),
Images: s.toPublicSnapImages(imgs), Images: s.buildSnapImages(imgs),
}, nil }, nil
} }
// buildSnapImages 把街拍图片组装成「主图 + 其副图」的嵌套结构(与走秀详情同形):
// Images 只含主图(is_detail=0),每张主图的副图挂在其 Detail 子数组下。
// 父引用指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环则把该图按主图渲染(不丢图)。
// LookIndex 是合成的 1-based 主图序号(街拍不落库组序号)。
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)
pub.LookIndex = uint32(len(out) + 1) // 合成的 1-based 主图序号
index[im.ID] = len(out)
out = append(out, pub)
}
// 第二遍:把副图挂到所属主图的 Detail 下(LookIndex 取所属主图的值)。
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
pub.LookIndex = out[gi].LookIndex
out[gi].Detail = append(out[gi].Detail, pub)
}
return out
}
// IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 // IDs 返回全部街拍的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。
// Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。 // Popular 返回热度前 N 个街拍(按图片数降序),供 SSG 首页热门街拍展示。
func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) { func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.PublicStreetSnap, error) {
@ -171,16 +236,21 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []d
} }
out := make([]dto.PublicArticleImage, 0, len(imgs)) out := make([]dto.PublicArticleImage, 0, len(imgs))
for _, im := range imgs { for _, im := range imgs {
out = append(out, dto.PublicArticleImage{ out = append(out, s.toPublicSnapImage(im))
ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage),
Image: s.img.Compose(im.Image),
Thumb: s.img.ComposeThumb(im.Image),
Name: im.Name,
})
} }
return out 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), // toThumbSnapImages 与 toPublicSnapImages 同构,但图片地址走「列表缩略图」样式(ComposeThumb),
// 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。 // 仅供列表卡片 / 图条等小尺寸场景使用;详情页与大图灯箱仍用 toPublicSnapImages(展示样式)。
func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage {