update
This commit is contained in:
@ -181,28 +181,18 @@ SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, er
|
||||
|
||||
**冲突时无需回滚已上传的图**:两张草稿的图片 URL 完全相同,sha1 内容寻址会推出同一个对象 key,属覆盖写,不产生孤儿文件。此点须在代码注释中写明,避免后来人误加 `cleanupUploads`。
|
||||
|
||||
### 3.6 街拍实体键引入月份
|
||||
### 3.6 ~~街拍实体键引入月份~~(已作废)
|
||||
|
||||
街拍实体键由 `(city, year)` 改为 `(city, year, month)`。**该键共有 4 处落点,必须同时修改**,否则晋升与去重口径不一致会导致数据撕裂。
|
||||
|
||||
| # | 落点 | 改动 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `ingest_repository.StreetSnapIDByEntity` | 增加 `month` 参数,查询加 `AND month = ?`;接口注释同步 |
|
||||
| 2 | `ingest_service.processStreet` | 调用点传 `p.Month`;日志补上 month |
|
||||
| 3 | `review_repository.SaveStreetSnapFromDraft` | 正式行查找 `WHERE city = ? AND year = ?` 增加 month |
|
||||
| 4 | `review_repository.streetApprovedSiblingImages` | 兄弟草稿聚合查询增加 month 条件 |
|
||||
|
||||
**第 4 处是最容易漏、后果最脏的一处**:它是「同 city+year 已 approved 的其他街拍草稿图」的聚合查询。若只改第 3 处而不改第 4 处,会出现**「正式行按月分开了、图片却跨月混在一起」**——2 月 Copenhagen 专辑里混进 8 月的图。晋升与去重口径必须完全一致。
|
||||
|
||||
其余改动:
|
||||
|
||||
- **spider**:`Year` 与 `Month` 均取抓取时刻 `time.Now()`;删除 `(*TheImpressionSpider).parseYear`(失去调用方)。`yearRe` 被 `vogue.go` 共用,**保留**。
|
||||
- **写草稿**:`processStreet` 填 `Month: p.Month`。
|
||||
- **晋升写正式表**:`SaveStreetSnapFromDraft` 的 `common` map 增加 `"month": draft.Month`。
|
||||
- **后台可编辑**:`streetDraftEditable` 白名单放行 `month`;street 模块的 `Fields` 增加 `{month, 月份, number}`(与 `year` / `city` 一致——三者本就是键的一部分,后两者现已可编辑);列表卡片副标题由 `city` 改为 `city · YYYY-MM`。
|
||||
- **公开 API 不动**:`PublicStreetSnap` / `PublicStreetSnapDetail` 目前连 `year`、`city` 都未暴露,加 `month` 无意义。前端确有需要时再单独加字段。
|
||||
|
||||
**残留代价(明确接受)**:同城、同年、同月内的不同文章,仍会在「首条晋升之后」被判重跳过。但同一批抓取会一起进草稿、并在晋升时聚合,实际影响很小。这是键粒度换来的必然取舍,「爬过即终态」的既定语义不变。
|
||||
> **本节已作废(2026-09-21)**,由 `2026-09-21-duplicate-review-design.md` §3.7 取代。
|
||||
>
|
||||
> 原设计把街拍实体键改为 `(city, year, month)`(同城同月自动收敛成一张专辑)。用户随后取消了「按月区分」与「合并成一张专辑」两项需求,改为**一篇文章一张专辑**,实体键变为 `(source, source_url)`。
|
||||
>
|
||||
> 影响:
|
||||
>
|
||||
> - 本节描述的全部改动**不再执行**(原实现计划的任务 6 已删除)。
|
||||
> - 但 §3.1(契约新增 `source` / `source_url`)**仍然有效、且更关键**——这两个字段现在直接充当街拍实体键。
|
||||
> - `Month` 字段不再需要(其唯一用途是月度分组):`month` 列、spider 的 `Month` 填充、`streetDraftEditable` 放行 `month`、列表副标题加月份等一并取消。
|
||||
> - 街拍 `street_snaps` 正式表需新增 `source` / `source_url` 两列,见新规格 §3.7。
|
||||
|
||||
### 3.7 明确不动的部分
|
||||
|
||||
|
||||
274
docs/superpowers/specs/2026-09-21-duplicate-review-design.md
Normal file
274
docs/superpowers/specs/2026-09-21-duplicate-review-design.md
Normal file
@ -0,0 +1,274 @@
|
||||
# 重复检测与审核展示设计(含街拍实体键改为按文章)
|
||||
|
||||
- 日期:2026-09-21
|
||||
- 状态:设计已确认,待用户审阅规格
|
||||
- 范围:
|
||||
1. 「重复图对」表 `image_duplicates` + 审核页展示(列表给数量、详情给标记)
|
||||
2. 街拍实体键由 `(city, year)` 改为 `(source, source_url)`——一篇文章一张专辑
|
||||
- **不含**:街拍主副图(已确认设计与交互,另立规格)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
### 1.1 现有的 `is_duplicate` / `dup_of` 为什么不能直接用
|
||||
|
||||
两者在入库时写入,但有两个硬缺陷:
|
||||
|
||||
**缺陷一:`dup_of` 是「不带表名的裸行 id」,无法反查归属。**
|
||||
|
||||
```go
|
||||
DupOf: strconv.FormatUint(uint64(dupID), 10)
|
||||
```
|
||||
|
||||
`FindNearDuplicateImage` 按「草稿表 → 正式表」顺序遍历,**命中哪张表就返回哪张表的 id**。拿到 `dup_of=264`,无法判断它是 `street_snap_draft_images` 的 264 还是 `street_snap_images` 的 264,更推不出它属于哪篇草稿。
|
||||
|
||||
**缺陷二:篇内重复完全检不到(结构性缺陷)。**
|
||||
|
||||
草稿图片是**全部下载完之后一次性批量插入**的(`CreateStreetSnapDraftImages` 在 `fetchImages` 返回后才调用),而 `dedupImage` 是每张图下载后立刻查库——**那一刻本篇的行还不存在**,所以同一篇文章内部的近似重复根本没有比较对象。
|
||||
|
||||
**实测证据**(当前 5 篇街拍,1052 张图):
|
||||
|
||||
| 指标 | 值 |
|
||||
| --- | --- |
|
||||
| `is_duplicate = 1` 的行数 | **3** |
|
||||
| 实际 hamming ≤ 10 的对数 | **10**(其中**篇内 6 对,一条都没标**) |
|
||||
|
||||
篇内漏标的典型:行 id `1150`/`1151`(同一篇内前后相邻的两张,连拍),hamming = 5——这类最该被剔除的,入库标记完全看不见。
|
||||
|
||||
### 1.2 阈值实测(`phash.DefaultThreshold = 10` 明显过松)
|
||||
|
||||
对 1052 张图做两两汉明距离统计(`L2² == 汉明距离`):
|
||||
|
||||
| 阈值 | 命中对数 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| ≤4 | **0** | — |
|
||||
| ≤5 | 1 | 唯一的真候选(行 1150/1151,篇内相邻) |
|
||||
| ≤8 | 1 | 与 ≤5 相同 |
|
||||
| ≤9 | 4 | 开始混入噪声 |
|
||||
| **≤10(现值)** | **10** | 混入 9 对噪声 |
|
||||
|
||||
距离分布(低尾):
|
||||
|
||||
```
|
||||
hamming | pairs
|
||||
5 | 1
|
||||
9 | 3
|
||||
10 | 6
|
||||
11 | 7
|
||||
12 | 13
|
||||
13 | 40
|
||||
14 | 85
|
||||
...
|
||||
平均 31.45 ≈ 32(64 位随机期望)
|
||||
```
|
||||
|
||||
**关键判定**:9、10、11 三档的计数(3、6、7)平滑接进 12、13、40、85……**没有分离的簇**,说明 9~11 就是噪声分布的肩部。用户实际查看后确认:hamming = 10 的跨篇对(行 264 ↔ 1021)**目视完全不同**,是假阳性。
|
||||
|
||||
**根因**:dHash 只有 9×8 采样格、64 位,而这批图全是「竖构图的街头全身人像」,整体构图高度雷同,描述子被构图主导、内容差异被压缩。(描述子升级为更大网格 dHash / pHash-DCT 的方案已评估,另立规格处理。)
|
||||
|
||||
---
|
||||
|
||||
## 2. 决策记录
|
||||
|
||||
| 决策点 | 结论 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 重复检测机制 | **读时预计算并落库到 `image_duplicates` 表**,不依赖 `is_duplicate`/`dup_of` | 现有字段无法反查归属且漏篇内重复(见 1.1) |
|
||||
| 计算时机 | **草稿图片写完之后的 worker 里** | 入库每张图时本篇行还不存在,篇内比不到(见 1.1 缺陷二) |
|
||||
| 计算算法 | 对新草稿每张图,用现成的 `_phash_hnsw` 索引做 LATERAL 近邻查询 | 复杂度 O(新增图数 × log N),优于全表 O(n²) 自连接 |
|
||||
| 比对范围 | **同 kind 的「草稿表 + 正式表」都比** | 用户选定:要能知道「这张图已经上线过了」 |
|
||||
| 晋升镜像 | **跳过 `image` 相同的对** | 草稿晋升后同一张图在草稿表与正式表各存一行、phash 相同(hamming 0),那是镜像不是重复 |
|
||||
| 阈值 | **4** | 用户指定。写入时应用(见 3.3) |
|
||||
| 是否存汉明距离 | **不存** | 用户指定。代价:改阈值需重跑配对计算——但**只读库里的 phash,不需重新下图**,比换描述子便宜一个量级 |
|
||||
| 列表页展示 | **不展示**(用户取消了「列表显示重复数量」这一需求) | 用户指定 |
|
||||
| 详情页展示 | 给重复图**打标记** + 显示对手属于哪篇文章 | 用户指定 |
|
||||
| 街拍实体键 | **`(source, source_url)`**——一篇文章一张专辑 | 用户指定;月/年不再参与区分 |
|
||||
| 合并成一张专辑 | **取消** | 用户取消 |
|
||||
| `month` 进实体键 | **取消** | 用户取消按月区分 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 新表 `image_duplicates`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS image_duplicates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kind varchar(16) NOT NULL, -- runway | street
|
||||
-- 本方(「这张图」)
|
||||
image_id integer NOT NULL, -- 图片行 id(在 *_draft_images 或 *_images 中,由 owner_kind 决定)
|
||||
owner_kind varchar(8) NOT NULL, -- draft | official
|
||||
owner_id integer NOT NULL, -- 草稿 id 或正式表主键(brand_runways.id / street_snaps.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
|
||||
);
|
||||
|
||||
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);
|
||||
```
|
||||
|
||||
唯一索引保证重复计算幂等;辅助索引服务读侧(按草稿批量取数)。
|
||||
|
||||
**不冗余存对手的标题**:读时按 `peer_owner_kind` join 对应表取(草稿 → `*_drafts.title_en` / `.title`;正式 → `brand_runways.title_en` / `street_snaps.title`)。避免标题被改后出现陈旧副本。
|
||||
|
||||
建表放在 `internal/database/postgres.go` 的 `EnsureDedupSchema`(该函数本就是「`AutoMigrate` 之外的索引与表补充」的既定位置,且全部幂等)。
|
||||
|
||||
### 3.2 写入时机与算法
|
||||
|
||||
**时机**:`processRunway` / `processStreet` 内,`Create*DraftImages` **成功之后**。失败仅记录日志、**不影响任务状态**(重复信息是审核辅助,不该让入库任务失败);漏算可由回填命令补齐(3.6)。
|
||||
|
||||
**算法**:对本草稿的每一张图,用 `_phash_hnsw` 索引(`vector_l2_ops`)在对手池中取近邻。对手池 = 同 kind 的「草稿表 UNION 正式表」。
|
||||
|
||||
```
|
||||
L2 阈值 = sqrt(4) = 2.0 -- 与 phash 口径一致:phash 为 {0,1}^64,L2² == 汉明距离
|
||||
跳过条件:对手行的 image 与本方相同(晋升镜像)
|
||||
```
|
||||
|
||||
结果以 `INSERT ... ON CONFLICT (kind, owner_kind, image_id, peer_owner_kind, peer_image_id) DO NOTHING` 入库——表内不存汉明距离,故命中已存在的对时**无需更新**,DO NOTHING 即幂等。
|
||||
|
||||
### 3.3 阈值
|
||||
|
||||
`4`,作为**配置项**(不入库汉明距离,故阈值在写入时生效):
|
||||
|
||||
```
|
||||
dup.hamming_threshold: 4
|
||||
```
|
||||
|
||||
**改阈值的代价**:需重跑配对计算。因为只读库里的 `phash`、不需要重新下载图片,这是一条纯数据库批处理(3.6 的回填命令可直接复用)。
|
||||
|
||||
### 3.4 读侧:审核列表 —— **不做**
|
||||
|
||||
用户已取消「审核列表显示重复数量」这一需求:**列表页不加任何重复相关列**,`DraftCard` 也不增加字段。
|
||||
|
||||
因此 `image_duplicates` 的读侧只有**一处**消费方——审核详情页(见 3.5)。列表页保持原样。
|
||||
|
||||
> 说明:曾实现过一版读时自连接 + 列表列的过渡方案,因该需求取消而**整体移除**。详情页标记改由本规格的 `image_duplicates` 表实现。
|
||||
|
||||
### 3.5 读侧:审核详情
|
||||
|
||||
`DraftImageRef` 增加一个字段:
|
||||
|
||||
```go
|
||||
// DupPeers 与该图重复的对手;为空表示不重复。
|
||||
// 对手可能多于一个(同一张图在多篇文章里都出现过),故用切片而非 bool。
|
||||
type DupPeer struct {
|
||||
OwnerKind string // draft | official
|
||||
Title string // 对手所属文章 / 专辑标题
|
||||
}
|
||||
|
||||
type DraftImageRef struct {
|
||||
// ...既有字段...
|
||||
DupPeers []DupPeer
|
||||
}
|
||||
```
|
||||
|
||||
「是否重复」直接由 `len(DupPeers) > 0` 判定,**不单设 bool**(避免两者不一致)。
|
||||
|
||||
详情模板对有对手的图加角标(复用现有 `.badge` 视觉语言,新增 `.badge.dup`),角标含对手文章标题;对手多于一个时全部列出。
|
||||
|
||||
对手文章标题的取法:按 `peer_owner_kind` 分别 join,读时组装。
|
||||
|
||||
### 3.6 存量回填
|
||||
|
||||
现有需回填规模:
|
||||
|
||||
| 表 | 总行 | 存活行 |
|
||||
| --- | --- | --- |
|
||||
| `brand_runway_images` | 672 | 556 |
|
||||
| `brand_runway_draft_images` | 556 | 556 |
|
||||
| `street_snap_images` | 0 | 0 |
|
||||
| `street_snap_draft_images` | 1052 | 1052 |
|
||||
|
||||
去重后 **1608 张不同图**(走秀 556 张在两表重复出现)。
|
||||
|
||||
提供一个**可中断、可重入**的回填命令:按 kind 与 owner 分批处理,重复执行不产生重复行(靠唯一索引),支持从任意位置续跑。
|
||||
|
||||
### 3.7 街拍实体键改为按文章
|
||||
|
||||
街拍实体键由 `(city, year)` 改为 **`(source, source_url)`**,即一篇文章一张专辑。落点与 `2026-09-20-ingest-idempotency-design.md` §3.6 所列的 4 处一致,但键的构成改变:
|
||||
|
||||
| # | 落点 | 改动 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `ingest_repository.StreetSnapIDByEntity` | 改为按 `(source, source_url)` 查正式表 |
|
||||
| 2 | `ingest_service.processStreet` | 调用点同步 |
|
||||
| 3 | `review_repository.SaveStreetSnapFromDraft` | 正式行查找改为按 `(source, source_url)` |
|
||||
| 4 | `review_repository.streetApprovedSiblingImages` | **对街拍变为空操作**(per-article 后不存在「兄弟草稿」) |
|
||||
|
||||
⚠️ `street_snaps` 正式表需新增 `source` / `source_url` 两列(与草稿表同构),否则晋升时无法按该键 upsert。同时补一个与草稿表同形的**部分唯一索引**,从数据库层面保证同一篇文章不会产生两张正式专辑:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source
|
||||
ON street_snaps (source, source_url) WHERE source_url <> '';
|
||||
```
|
||||
|
||||
必须是部分索引,理由与 `2026-09-20-ingest-idempotency-design.md` §3.2 相同:存量行 `source_url` 为空串,全量唯一索引会因大量 `('','')` 冲突导致**建索引失败 → 启动崩溃**。
|
||||
|
||||
**关于 `month` 与 `year`**(用户只说「不按月区分」,未明确字段去留,此处为裁决):
|
||||
|
||||
- **不做 `month`**:它此前的唯一用途是月度分组,该需求已取消(YAGNI)。
|
||||
- **保留「year 取抓取年份」**(spider 侧 2 行改动):`year=0` 是错误数据,且公开 API 支持按 year 过滤,`year=0` 的条目无法被正常筛选。注意实体键改为按文章后,`year=0` 已**不再造成静默丢数据**(原 `(city, 0)` 吸附桶问题随键变更消失)。
|
||||
|
||||
### 3.8 明确不动的部分
|
||||
|
||||
- `is_duplicate` / `dup_of` 两列**保留写入但不读**(纯历史留痕)。不删列,避免破坏性迁移。
|
||||
- 入库时的 `dedupImage` 逻辑不变。
|
||||
- runway 的 `(brand_id, season_code, collection_type)` 实体键与 sibling union **不变**(多来源聚合对走秀仍有意)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 影响面
|
||||
|
||||
```
|
||||
internal/model/image_duplicate.go 新增(模型)
|
||||
internal/database/postgres.go EnsureDedupSchema 建表与索引
|
||||
internal/repository/dup_repository.go 新增:写入 + 读取 + 回填
|
||||
internal/repository/ingest_repository.go StreetSnapIDByEntity 改键
|
||||
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 改键
|
||||
internal/service/ingest_service.go 草稿写入后触发配对计算;processStreet 调用点
|
||||
internal/service/review_service.go DraftImageRef 加重复对手字段(仅详情用)
|
||||
internal/handler/backstage_handler.go 详情页加重复标记
|
||||
internal/model/street_snap.go +source / +source_url
|
||||
cmd/dupbackfill(新) 存量回填命令
|
||||
configs/config.yml +dup.hamming_threshold
|
||||
../spider/internal/spider/theimpression.go year 改取抓取年份
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试
|
||||
|
||||
### 5.1 单元测试(不依赖数据库)
|
||||
|
||||
- 配对写入会**跳过 `image` 相同的对**(晋升镜像)。
|
||||
- 详情渲染:重复图被标记,并显示对手文章标题;无对手的图**不显示标记**。
|
||||
|
||||
### 5.2 集成测试(需 PostgreSQL)
|
||||
|
||||
- 唯一索引生效:同一对重复写入只留一行。
|
||||
- 阈值语义:hamming 恰为 4 的对入表、为 5 的不入表。
|
||||
- 回填命令可重入:连跑两次行数不变。
|
||||
- 街拍实体键:不同 `source_url` 的两篇文章晋升后是**两张**正式专辑;相同 `source_url` 是**一张**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 对既有规格与计划的影响
|
||||
|
||||
| 对象 | 影响 |
|
||||
| --- | --- |
|
||||
| `2026-09-20-ingest-idempotency-design.md` §3.6 | **作废**(月/年进键的部分)。§3.1–3.5(来源幂等)**仍然有效**,且其 `source` / `source_url` 字段正好成为街拍新实体键 |
|
||||
| `2026-09-20-ingest-idempotency.md`(实现计划)**任务 6** | **删除**(实体键改动移入本规格) |
|
||||
| 同上,任务 5(spider 填 source/source_url) | **保留**,但 `Month` 字段的填充与 `parseYear` 删除需按本规格 §3.7 调整 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 待确认 / 开放问题
|
||||
|
||||
1. **阈值 4 会让当前数据命中 0 对**,审核页该列为空。用户已指定 4;因它是配置项,改成 5 只改一行配置 + 重跑回填。
|
||||
2. 街拍主副图(含公开 API 分组化)**另立规格**,不在本文档范围。
|
||||
3. `image_duplicates` 的生命周期:草稿被软删 / 单图被审核删除后,表中会残留指向失效行 id 的记录。当前设计**读时靠 join `is_deleted = 0` 过滤**,不做主动清理;若日后数据量增大再考虑级联清理。
|
||||
161
docs/superpowers/specs/2026-09-21-street-main-detail-design.md
Normal file
161
docs/superpowers/specs/2026-09-21-street-main-detail-design.md
Normal file
@ -0,0 +1,161 @@
|
||||
# 街拍主副图设计
|
||||
|
||||
- 日期:2026-09-21
|
||||
- 状态:设计已确认,待用户审阅规格
|
||||
- 范围:街拍图增加「主图 / 副图」分组——审核页人工指定、详情页按组折叠、公开 API 保守扩展
|
||||
- **不含**:重复检测(见 `2026-09-21-duplicate-review-design.md`)、来源幂等(见 `2026-09-20-ingest-idempotency-design.md`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**现状**:街拍图是扁平列表。`street_snap_draft_images` / `street_snap_images` 都**没有** `is_detail` 之类字段,而走秀侧(`brand_runway_draft_images` / `brand_runway_images`)**已经有**「主图 + 细节图」模型(`is_detail` + `look_index`)。
|
||||
|
||||
**动机**(用户原话):一个人可能被拍多张,平铺会很占位置。
|
||||
|
||||
**为什么只能人工指定**:源站不提供任何人物/分组信息。实测 theImpression 一篇文章:
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `<figure>` 数量 | 167(就是街拍图本身) |
|
||||
| `<figcaption>` 数量 | **0** |
|
||||
| 街拍图的 `alt` | **全空**(`alt=""`) |
|
||||
| 非空 `alt` | 仅 11 个,且全是侧栏广告与无关标题(如 `Mugler Fall 2026 Ad Campaign`) |
|
||||
|
||||
(文件名含摄影师帧号如 `milano-str-f26-0007-1`,帧号接近可能是连拍,但对「同一个人不同时间被拍」无效,不采用。)
|
||||
|
||||
---
|
||||
|
||||
## 2. 决策记录
|
||||
|
||||
| 决策点 | 结论 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 主副如何确定 | **人工在审核页指定** | 源站无人物信息;用户选择纯人工 |
|
||||
| 交互方式 | **不引入 JavaScript**:两阶段「先选定主图 → 勾选图片并入」+ 每张图的「并入上一张」快捷 | 后台目前**零 JS**(全仓 `internal/handler` 无 `<script>`/`fetch`,全是同步表单 POST);且拖拽需同屏看见起止点,无法处理「第 200 张是第 1 张的副图」这类跨屏场景(用户已指出该问题) |
|
||||
| 当前主图的保持方式 | 通过 **URL 查询参数 `?main=<imgID>`** 传递 | 零 JS 下无状态、可刷新、可后退、可加书签 |
|
||||
| 归属关系存储 | `is_detail` + **`parent_image_id`**(存所属主图的**行 id**,不存序号) | 序号会因重排/插入/删除而失效,存 id 稳定 |
|
||||
| 详情页渲染 | **按组折叠** | 用户选定 |
|
||||
| 公开 API | **保守扩展**:保留现有扁平 `images` 字段不变,**新增**分组字段 | 直接改结构会打断前端;新增字段让前端择期迁移 |
|
||||
| `image_count` 语义 | **不变**(仍计全部图) | 改语义会同时改动公开列表卡片的「N 张」,需要前端同步,另议 |
|
||||
| runway 侧 | 不受影响 | 已有自己的主/细节模型 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 数据模型
|
||||
|
||||
`street_snap_draft_images` 与 `street_snap_images` 各新增两列:
|
||||
|
||||
```sql
|
||||
is_detail smallint not null default 0 -- 0=主图 1=副图
|
||||
parent_image_id integer not null default 0 -- 副图指向所属主图的「行 id」;主图为 0
|
||||
```
|
||||
|
||||
**不变量**:`is_detail = 1` 的行,其 `parent_image_id` 必须指向**同一草稿 / 同一专辑内**一张 `is_detail = 0` 的行。
|
||||
|
||||
- **写入侧**保证该不变量(并入操作先校验主图存在且属于同一 owner)。
|
||||
- **读侧**容错:父行不存在(或已被软删)时,把该副图**按主图渲染**,不隐藏、不报错。
|
||||
|
||||
### 3.2 审核页交互(零 JS)
|
||||
|
||||
**当前主图**:由 URL 查询参数 `?main=<imgID>` 承载。页面顶部渲染一条常驻提示条「当前主图:#12 · 伦敦 Day 3 第 12 张」,直接在页面上可见,刷新后仍在。
|
||||
|
||||
**三个同步表单 POST 路由**(全部返回 302 回列表/详情,与后台现有风格一致):
|
||||
|
||||
| 路由 | 作用 |
|
||||
| --- | --- |
|
||||
| `POST /admin/reviews/:kind/:id/images/attach` | 表单含 `main=<主图行id>` + 多个 `img=<副图行id>`(checkbox)→ **批量并入**。这是主路径 |
|
||||
| `POST /admin/reviews/:kind/:id/images/:img/attach-prev` | **并入上一张**(相邻快捷,零勾选)。覆盖「连拍同一个人」这一最常见场景 |
|
||||
| `POST /admin/reviews/:kind/:id/images/:img/detach` | **拆出**:把副图恢复为主图(`is_detail=0, parent_image_id=0`) |
|
||||
|
||||
**不单设「设为主图」路由**:选定主图 = 在页面上点击某张图的「设为主图」链接,它只是把 URL 变成 `?main=<imgID>`(一个普通 GET 链接,无需服务端写入)。随后其他图的 checkbox 表单通过**隐藏字段**携带该 `main` 值。
|
||||
|
||||
这样「选定主图 → 滚到任意位置勾选 → 并入」全程**不需要同屏**,且天然支持批量。
|
||||
|
||||
### 3.3 详情页渲染(按组折叠)
|
||||
|
||||
- 主图区:逐个渲染主图卡片(大图 + 名称)
|
||||
- 每个主图卡片下方:一条**窄的副图缩略条**,副图带「拆出」按钮
|
||||
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**)
|
||||
- 主图卡片上显示「N 张副图」计数
|
||||
|
||||
### 3.4 晋升到正式表:必须重建父引用
|
||||
|
||||
`SaveStreetSnapFromDraft` 需要同步复制 `is_detail` / `parent_image_id`。
|
||||
|
||||
⚠️ **关键陷阱**:草稿晋升会**软删正式表旧图并整批重建**,新插入的正式图行拿到的是**全新的行 id**。因此**不能直接复制 `parent_image_id`**——旧 id 指向的是草稿表的行。必须先建立「草稿行 id → 新正式行 id」的映射,再把副图的 `parent_image_id` 改写为新 id。
|
||||
|
||||
漏掉这一步会导致:副图的父引用指向一个不存在(或属于别的图)的 id,详情页折叠结构错乱。
|
||||
|
||||
### 3.5 公开 API(保守扩展)
|
||||
|
||||
**保留** `PublicStreetSnapDetail.Images`(扁平、按 `sort_order` 升序)不变——现有前端不受影响。
|
||||
|
||||
**新增**分组字段:
|
||||
|
||||
```go
|
||||
type PublicStreetSnapGroup struct {
|
||||
Image PublicArticleImage `json:"image"` // 主图
|
||||
Details []PublicArticleImage `json:"details"` // 该主图下的副图(可为空)
|
||||
}
|
||||
|
||||
type PublicStreetSnapDetail struct {
|
||||
UID string
|
||||
Title string
|
||||
Cover string
|
||||
Images []PublicArticleImage // 保持原样(扁平全部)
|
||||
Groups []PublicStreetSnapGroup // 新增;未分组时为每张主图一个 group、details 为空
|
||||
Favorited bool
|
||||
}
|
||||
```
|
||||
|
||||
`Groups` 与 `Images` **同源同序**:`Groups` 只是 `Images` 按主副关系重排后的视图,不引入新的数据来源。
|
||||
|
||||
**列表**(`PublicStreetSnap`)不动。
|
||||
|
||||
### 3.6 明确不做
|
||||
|
||||
- **算法自动分组**(用户选择纯人工)。注:phash 只能判「画面像」,判不了「是不是同一个人」,做建议必然有误报;日后若要加,也只应作为提示。
|
||||
- **拖拽交互**(需引入 JS;且跨屏场景不可行)。
|
||||
- **修改 `image_count` 语义**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 影响面
|
||||
|
||||
```
|
||||
internal/model/street_snap_draft.go +is_detail / +parent_image_id
|
||||
internal/model/street_snap.go +is_detail / +parent_image_id
|
||||
internal/repository/review_repository.go attach / attach-prev / detach 三个写方法
|
||||
internal/repository/review_repository.go SaveStreetSnapFromDraft 重建父引用
|
||||
internal/service/review_service.go DraftImageRef 加分组信息;分组写入的校验
|
||||
internal/handler/backstage_handler.go 详情页折叠渲染 + 常驻主图条 + 三个表单
|
||||
internal/router/backstage.go 三个新路由
|
||||
internal/dto/street_snap.go +Groups(保守扩展)
|
||||
internal/service/street_snap_service.go 详情组装 Groups
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试
|
||||
|
||||
### 5.1 单元测试(不依赖数据库)
|
||||
|
||||
- 详情组装:主图 + 其副图归到同一 group;未分组的图各自成组。
|
||||
- 父引用失效的容错:`parent_image_id` 指向不存在/已软删的行时,该图按主图渲染,不丢图。
|
||||
|
||||
### 5.2 集成测试(需 PostgreSQL)
|
||||
|
||||
- 并入:`is_detail` / `parent_image_id` 正确写入;重复并入同一主图幂等。
|
||||
- 拆出:恢复为主图且 `parent_image_id` 归零。
|
||||
- 跨 owner 拦截:主图与副图不属于同一草稿时拒绝写入(不变量)。
|
||||
- **晋升重建父引用**:草稿有「1 主 + 2 副」时晋升,正式表中 2 张副图的 `parent_image_id` 必须指向**新的**主图行 id(而非草稿表的旧 id)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 待确认
|
||||
|
||||
1. 公开 API 采用「保留扁平 `images` + 新增 `groups`」的保守方案。若你希望**直接改成**只返回分组结构(更干净但会打断现有前端),需要另行确认。
|
||||
2. `image_count` 暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。
|
||||
3. 分组的「组序号」不落库,由读时按 `sort_order` 顺序推导(主图顺序即组序)。
|
||||
Reference in New Issue
Block a user