Compare commits
14 Commits
e8dd5b56ac
...
d908036621
| Author | SHA1 | Date | |
|---|---|---|---|
| d908036621 | |||
| 21dce13bde | |||
| b8d66df5a1 | |||
| 0d4000bba5 | |||
| 9807752e4e | |||
| 85ad3a3f68 | |||
| 49bd7b74c6 | |||
| 3b60e6fdea | |||
| 3428a9823b | |||
| be73e595ca | |||
| ed92b475d1 | |||
| 96858aa85f | |||
| 9fe51036e6 | |||
| 16e024561d |
@ -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)
|
||||||
|
|||||||
21
db/migrations/2026-09-21-01-street-main-detail.sql
Normal file
21
db/migrations/2026-09-21-01-street-main-detail.sql
Normal 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;
|
||||||
49
db/migrations/2026-09-21-02-duplicate-review.sql
Normal file
49
db/migrations/2026-09-21-02-duplicate-review.sql
Normal 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 <> '';
|
||||||
27
db/migrations/2026-09-21-03-ingest-source-idempotency.sql
Normal file
27
db/migrations/2026-09-21-03-ingest-source-idempotency.sql
Normal 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 <> '';
|
||||||
1446
docs/superpowers/plans/2026-09-21-street-main-detail.md
Normal file
1446
docs/superpowers/plans/2026-09-21-street-main-detail.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
|||||||
@ -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 改键
|
||||||
|
|||||||
@ -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` 顺序推导(主图顺序即组序)。
|
||||||
|
|||||||
@ -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 级)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 指定图片明细表名。
|
||||||
|
|||||||
@ -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 指定草稿图片表名。
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
192
internal/repository/street_main_detail_integration_test.go
Normal file
192
internal/repository/street_main_detail_integration_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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("不应再出现「并入上一张」")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
internal/service/review_street_group_test.go
Normal file
127
internal/service/review_street_group_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/service/street_snap_group_test.go
Normal file
67
internal/service/street_snap_group_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user