8.5 KiB
街拍主副图设计
- 日期: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 各新增两列:
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 升序)不变——现有前端不受影响。
新增分组字段:
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. 待确认
- 公开 API 采用「保留扁平
images+ 新增groups」的保守方案。若你希望直接改成只返回分组结构(更干净但会打断现有前端),需要另行确认。 image_count暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。- 分组的「组序号」不落库,由读时按
sort_order顺序推导(主图顺序即组序)。