Files
backend_v2/docs/superpowers/specs/2026-09-21-street-main-detail-design.md
toom1996 d1e42ea18a update
2026-09-21 15:56:29 +08:00

162 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 街拍主副图设计
- 日期: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` 顺序推导(主图顺序即组序)。