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