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

10 KiB
Raw Permalink Blame History

街拍主副图设计

  • 日期: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 的行。

落点已变更(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」的保守方案。

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 顺序推导(主图顺序即组序)。