Files
backend_v2/docs/superpowers/specs/2026-09-21-street-main-detail-design.md

169 lines
10 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` 的行。
> **落点已变更(2026-09-21)**:`AutoMigrate` / `EnsureDedupSchema` 已移除,结构改由 `cmd/dbtool` 的 dump 维护。
> 上述两列已落在 `db/migrations/2026-09-21-01-street-main-detail.sql`(已应用)。
- **写入侧**保证该不变量(并入操作先校验主图存在且属于同一 owner)。
- **读侧**容错:父行不存在(或已被软删)时,把该副图**按主图渲染**,不隐藏、不报错。
### 3.2 审核页交互(零 JS)
**两个同步表单 POST 路由**(全部返回 302 回详情页,与后台现有风格一致):
| 路由 | 作用 |
| --- | --- |
| `POST /admin/reviews/:kind/:id/images/attach` | 表单含单选 `main=<主图行id>` + 多个勾选 `img=<副图行id>` → **合并选中为一组** |
| `POST /admin/reviews/:kind/:id/images/:img/detach` | **拆出**:把副图恢复为主图(`is_detail=0, parent_image_id=0`) |
**审核页布局**:街拍草稿详情页渲染为**一个统一图片网格**(与列表页同款 `.grid` / `.cell` 卡片样式,不再有"分组大卡片 + 小网格"两套风格):
- 每张图片一张卡片:缩略图 + 角色徽标 + 文件名。
- **主图**卡片:徽标「主图 · N 副图」+ 勾选框「选入」+ 单选「主图」。
- **副图**卡片:徽标「副图 → #主图」(显示沿链归并后的**实际**所属主图,非原始父 id)+ 勾选框「选入」+ 按钮「拆出」。
- 每张卡片都带「删除」。
- 底部常驻(sticky)按钮条:`合并选中为一组`。
**操作只有一件事**:勾选属于同一个人的图片 → 在**其中一张主图**上点「主图」(单选)→ 点底部「合并选中为一组」(`main`=单选值,`img`=全部勾选值)。一次请求成组,无需刷新页面选主图。
> **历史(已废弃)**:早期版本用 URL 参数 `?main=` 承载"当前主图",并有一个按 `sort_order` 找"上一行"的 `attach-prev` 快捷。实践中发现**同一人的图往往不相邻**,该快捷用不上;且"先刷新选主图 → 再勾选 → 再提交"步骤过多。已改为本节的**单选主图 + 一次合并**,并删除 `attach-prev` 路由、`AttachPrevStreetDraftImage`、`?main=` 参数与 `DraftDetailView.MainID`。
### 3.3 详情页渲染(按组折叠)
**审核页**:见 §3.2——统一图片网格;主图卡片徽标显示「N 副图」,副图卡片显示所属主图。
**公开详情页**:`Images` 只含主图,副图挂在每张主图的 `Detail` 子数组下(与走秀详情同形,见 §3.5)。
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**)。
### 3.4 晋升到正式表:必须重建父引用
`SaveStreetSnapFromDraft` 需要同步复制 `is_detail` / `parent_image_id`。
⚠️ **关键陷阱**:草稿晋升会**软删正式表旧图并整批重建**,新插入的正式图行拿到的是**全新的行 id**。因此**不能直接复制 `parent_image_id`**——旧 id 指向的是草稿表的行。必须先建立「草稿行 id → 新正式行 id」的映射,再把副图的 `parent_image_id` 改写为新 id。
漏掉这一步会导致:副图的父引用指向一个不存在(或属于别的图)的 id,详情页折叠结构错乱。
### 3.5 公开 API(与走秀详情同形)
**决策(2026-09-21 修订)**:采用与走秀详情**完全一致**的嵌套结构(`images` 只含主图 + 每张主图自带 `detail`),**不采用**「保留扁平 `images` + 新增平行 `groups`」的保守方案。
```go
type PublicStreetSnapDetail struct {
UID string
Title string
Cover string // 封面:独立字段,与主/副图分组无关
Images []PublicArticleImage // 只含主图(is_detail=0);每张主图的 .Detail 挂其副图
Favorited bool
}
// 复用走秀的 PublicArticleImage:
// IsDetail 0=主图 / 1=副图
// LookIndex 该图归属的主图序号(街拍侧合成:主图按 sort_order 的 1-based 序;街拍不落库组序号)
// Detail []PublicArticleImage —— 该主图下的副图
```
- 与走秀详情(`PublicArticleService.Detail`)同形,前端可复用同一套渲染组件。
- `parent_image_id` 指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环 → 该图按主图渲染(**不丢图**)。这是与走秀读侧的刻意区别:走秀会丢弃「找不到主图的细节图」,街拍不丢。
- **列表**(`PublicStreetSnap`)不动;`image_count` 语义不变(仍计全部图)。
> ⚠️ **破坏性变更**:`Images` 含义从「全部图(扁平)」变为「只含主图」。前端若原样遍历 `Images` 只会看到主图,需改读每张主图的 `.Detail`(跨仓库,本次只保证后端结构)。
### 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 两个新路由(attach 合并 / detach 拆出)
internal/dto/street_snap.go 详情改为嵌套(images 只含主图,复用 PublicArticleImage)
internal/service/street_snap_service.go 详情组装嵌套 images(buildSnapImages)
```
---
## 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 保守方案~~ **已决(2026-09-21)**:改为与走秀同形的嵌套结构(`images` 只含主图、副图挂 `detail`),见 §3.5。
2. `image_count` 暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。
3. 分组的「组序号」不落库,由读时按 `sort_order` 顺序推导(主图顺序即组序)。