Files
backend_v2/docs/superpowers/specs/2026-09-22-single-table-publish-design.md
toom1996 bfe890bd56 update
2026-09-23 00:19:13 +08:00

16 KiB
Raw Blame History

单表发布模型(取消草稿表)设计

  • 日期:2026-09-22
  • 替代关系:本设计取代 2026-09-20-ingest-idempotency-design.md 中"草稿表持有 source/source_url 做来源幂等"的部分(该能力从未落到代码,列与索引为空转);并吸收 2026-09-21-street-main-detail-design.md 的主副图能力(从草稿图片迁移到正式图片表,父引用不再需要重映射)。

1. 背景与问题

现状是「草稿表 → 人工审核 → 晋升写入正式表」的两套表结构:

模块 草稿 正式
走秀 / article brand_runway_drafts + brand_runway_draft_images brand_runways + brand_runway_images
街拍 street_snap_drafts + street_snap_draft_images street_snaps + street_snap_images

由此产生的复杂度:

  1. 结构重复:内容字段(标题/描述/年份/季节/cover/image_count)在两张表各写一份;正式图片表与草稿图片表字段几乎相同(is_detail/look_index/phash/is_duplicate/dup_of 是双份)。
  2. 晋升即重建:SaveRunwayFromDraft / SaveStreetSnapFromDraft 会软删正式图片再整批重建,图片行 id 每次都变 —— 外层业已存在"重复发布打断单图收藏"的隐患(favorites.target_uid 存的是图片的 hashid)。
  3. 父引用重映射:街拍副图的 parent_image_id 存的是草稿表行 id,晋升时要两遍插入并改写为新正式行 id,父行缺失还要降级。
  4. 多来源聚合:sibling 图片聚合(按实体键把多个已通过草稿的图并成一条正式记录)只在晋升时发生,逻辑绕。
  5. 死物:brand_runway_drafts.source/source_url、street_snaps.source/source_url、image_duplicates、image_embeddings 全仓无代码读写。

核心判断:草稿表承担的两件事(审核态、来源追溯)都只是一个字段,不值得一整张表。

2. 目标 / 非目标

目标

  • 每个模块只剩一张记录表 + 一张图片表。
  • 审核态由 status 字段承载,审核阶段保留(不是取消审核,是取消"另一张表")。
  • 公开可见性从"每个公开查询各自记得过滤"升级为结构性保证(只读视图)。
  • 图片行 id 从入库起终身不变(收藏不再被打断)。
  • 删除晋升/聚合/父引用重映射的全部代码。

非目标(本次不做)

  • 不改公开 API 的响应结构(images / groups / 列表字段全部保持)。
  • 不改前台网站(独立前端仓库)。
  • 不合并"审核页"与"正式编辑页"两个后台入口(后续可再收敛)。
  • 不统一 image_count 口径(runway 计主图 / street 计全部图,现状保留,见 §12)。
  • 不修 favorites 的唯一索引问题(见 §12)。

3. 数据模型

3.1 保留 / 删除的表

保留:brand_runways、brand_runway_images、street_snaps、street_snap_images、brands(品牌本就没有草稿表,不参与本次改造)。

删除:brand_runway_drafts、brand_runway_draft_images、street_snap_drafts、street_snap_draft_images。

3.2 新增列(从草稿表搬到正式表)

ALTER TABLE brand_runways
  ADD COLUMN IF NOT EXISTS status        varchar(16)  NOT NULL DEFAULT 'pending',
  ADD COLUMN IF NOT EXISTS job_id        bigint       NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS reviewer      varchar(64)  NOT NULL DEFAULT '',
  ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';

ALTER TABLE street_snaps
  ADD COLUMN IF NOT EXISTS status        varchar(16)  NOT NULL DEFAULT 'pending',
  ADD COLUMN IF NOT EXISTS job_id        bigint       NOT NULL DEFAULT 0,
  ADD COLUMN IF NOT EXISTS reviewer      varchar(64)  NOT NULL DEFAULT '',
  ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';

默认值刻意用 pending(fail-closed):任何新增/遗漏赋值的行都会默认"不可见",而不是默认"已发布"。

图片表无需改动:

  • runway 的细节图归属用 look_index;
  • 街拍的副图归属用 is_detail + parent_image_id,单表之后它天然指向本表行 id,永远有效,不需要任何重映射。

3.3 状态取值

// internal/model/content_status.go(新增)
const (
    StatusPending   = "pending"   // 待审核(入库默认;公开不可见)
    StatusPublished = "published" // 已发布(公开可见)
    StatusRejected  = "rejected"  // 已驳回(公开不可见,留在库中备查)
)

审核列表的「已通过」tab 取值由 approved 改为 published(模板 review-list.html 的 tab 链接同步改)。

4. 状态机

        ingest 入库
             │
             ▼
        ┌─────────┐  通过(写 reviewer,应用字段编辑)  ┌───────────┐
        │ pending │ ────────────────────────────────► │ published │
        └─────────┘                                   └───────────┘
             │                                              │
             │ 驳回(写 reviewer + reject_reason)           │ 驳回(下架)
             ▼                                              │
        ┌──────────┐ ◄──────────────────────────────────────┘
        │ rejected │
        └──────────┘
  • 通过:UPDATE ... SET status='published', reviewer=? + 应用表单里的字段编辑。不再触碰图片行。
  • 驳回:UPDATE ... SET status='rejected', reviewer=?, reject_reason=?。行保留。
  • 每条记录只有一个实体键行(runway: brand_id+season_code+collection_type;street: city+year),不存在"同实体的多条 pending 并存"。

5. 写入链路(ingest 直写正式表)

internal/service/ingest_service.go 的 processRunway / processStreet:

  1. 实体键查重:沿用 RunwayIDByEntity / StreetSnapIDByEntity(WHERE 实体键 AND is_deleted=0)。单表之后,该条件会同时匹配 pending/published/rejected 的任意行 —— 命中即整任务 MarkDone 直接放弃(用户裁定),与现状对"已发布实体"的行为一致,且顺带把"重复爬取同一实体"也收敛掉。
  2. 建记录:CreateRunwayDraft → CreateRunway(写 brand_runways,status='pending'、job_id);CreateStreetSnapDraft → CreateStreetSnap(写 street_snaps)。
  3. 建图片:把图直接写 brand_runway_images / street_snap_images。
  4. 图片去重:FindNearDuplicateImage 的比对表从"草稿图 + 正式图"两张收敛为一张正式图表;phash / is_duplicate / dup_of 的写入位置不变。

失效的旧代码(删除):CreateRunwayDraft、CreateStreetSnapDraft、草稿相关的 RunwayIDByEntity 之外的草稿查询、MarkDraftDone 一类的草稿态更新。

6. 公开可见性:只读视图

DROP VIEW IF EXISTS public_brand_runway_images;
DROP VIEW IF EXISTS public_brand_runways;
DROP VIEW IF EXISTS public_street_snap_images;
DROP VIEW IF EXISTS public_street_snaps;

CREATE VIEW public_brand_runways AS
  SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;

CREATE VIEW public_brand_runway_images AS
  SELECT i.* FROM brand_runway_images i
  JOIN brand_runways r ON r.id = i.runway_id
  WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;

CREATE VIEW public_street_snaps AS
  SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;

CREATE VIEW public_street_snap_images AS
  SELECT i.* FROM street_snap_images i
  JOIN street_snaps s ON s.id = i.snap_id
  WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;

分层规则(硬约束)

层 读 写
公开(public API / SSG) 只读 public_* 视图 从不写
后台 读基表 读写基表

公开侧需要改读视图的方法(现状均为裸 is_deleted=0):

  • article_repository.go:List、FindByID、ListImages、ImagesByRunwayIDs
  • street_snap_repository.go:List、FindByID、ListImages、ImagesBySnapIDs、Popular
  • brand_repository.go:List 的 hasArticlesSubQuery("有档案品牌")、FeaturedIDs、PopularWithCover(这两个直接查 brand_runways,必须改读 public_brand_runways,否则待审走秀会让品牌提前出现在前台)
  • index_service.go / article_service.go / street_snap_service.go / brand_service.go 的公开映射方法本身不过滤,只跟着仓储走。

后台侧不改(ListAdmin、GetForEdit 等继续读基表)。

GORM 用法:视图无主键/无关联,统一以 .Table("public_brand_runways") + .Find(&[]model.BrandRunway{}) 形式查询;Preload 不可用(现有公开查询本就是显式 join/分步查询,不受影响)。

维护规则:视图用 SELECT * 冻结列集,因此任何给这 4 张基表加列的迁移,必须同批重建视图(DROP VIEW + CREATE VIEW,本文件 §3.2 的加列即需要)。

7. 后台

  • 审核列表:review_repository 的 ListDrafts(kind, status) 改为直接查基表 status;「全部」tab = 不过滤。
  • 审核详情:DraftDetailView 由记录行组装(逻辑不变,只是数据源从草稿表换成正式表)。街拍分组视图(Groups)继续用 is_detail/parent_image_id 组装,resolveRoot 的链式上浮与孤儿容错保持不变。
  • 通过 / 驳回:Approve 不再调用 SaveXxxFromDraft,改为「应用字段编辑 + 置 status」;Reject 置 status='rejected' + 理由。
  • 图片操作:AttachStreetDraftImages→AttachImages、AttachPrevStreetDraftImage、DetachStreetDraftImage、SoftDeleteXxxDraftImage 全部改为操作正式图片表(逻辑不变,事务与不变量保证不变)。
  • 正式列表页(/admin/runways、/admin/street-snaps):读基表,默认展示 published,加一列状态。
  • 命名收敛:Draft* 前缀统一改名(DraftDetailView→RecordDetailView、DraftImageRef→RecordImageRef、DraftImageGroup→ImageGroup、ListDrafts→ListRecords 等),模板数据键 .Draft → .Record(模板不受编译器保护,由 backstage_handler_test.go 的"逐页渲染"测试兜底:缺键会导致模板执行报错→500→测试红)。

8. 迁移

分三步,顺序不可颠倒。

第 1 步:DDL + 视图(db/migrations/2026-09-22-01-single-table-publish.sql)

  1. §3.2 加列。
  2. 存量正式行视为已发布:UPDATE brand_runways SET status='published' WHERE created_at < :迁移时刻;(street 同理)。带时间戳护栏,避免脚本被重复执行时把新入库的 pending 行误刷成 published。
  3. 建 §6 的 4 个视图。

本步必须在部署新代码之前应用(新代码开始写 status)。

第 2 步:数据搬迁(scripts/migrate_single_table/main.go,一次性命令,支持 -dry-run)

  1. 建 id 映射:map[draftID]newRecordID、map[draftImageID]newImageID。
  2. pending / rejected 草稿 → 插入正式表(保留 status/job_id/reviewer/reject_reason/is_deleted),草稿图片 → 插入正式图片表(保留 look_index/is_detail/phash/is_duplicate/dup_of/sort_order)。
  3. 街拍副图:第二遍把 parent_image_id 从"草稿图片 id"改写为"新正式图片 id"(用第 1 步的映射);映射缺失则降级 is_detail=0(与读侧容错一致)。
  4. 校验(不通过则中止,不删表):
    • 每个 approved 草稿都能在正式表找到对应实体行(否则说明有"只存在于草稿"的内容,需人工处理);
    • 待审/驳回草稿的记录数与图片数在迁移前后守恒;
    • street_snap_images 中不存在指向不存在行的 parent_image_id。
  5. -dry-run 打印上述统计与差异,不写库。

approved 草稿的内容已由当年的晋升写进正式表,不重复插入(只丢弃元数据)。

第 3 步:删表(db/migrations/2026-09-22-02-drop-draft-tables.sql,校验通过后再执行)

DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
                     street_snap_draft_images, street_snap_drafts;

9. 受影响文件(预估)

层 文件
model runway_draft.go(删)、street_snap_draft.go(删)、runway.go、street_snap.go(加列)、content_status.go(新)
repository ingest_repository.go(建记录/查重)、review_repository.go(最大改动:列表/详情/状态/图片操作/删除晋升)、article_repository.go、street_snap_repository.go、brand_repository.go(改读视图)
service ingest_service.go、review_service.go、article_service.go、street_snap_service.go、brand_service.go、index_service.go
handler backstage_handler.go(去掉 ?main= 之外基本不动)、article_handler.go/street_snap_handler.go/brand_handler.go/ssg_handler.go(应无需改动,公开过滤下沉到仓储)
templates review-list.html(status 值)、review-detail.html(.Draft→.Record)、runways.html、street-snaps.html(状态列)
DDL / 脚本 db/migrations/2026-09-22-01-*.sql、db/migrations/2026-09-22-02-*.sql、scripts/migrate_single_table/main.go
测试 street_main_detail_integration_test.go、dedup_integration_test.go、ingest_repository_test.go、backstage_handler_test.go、router/backstage_test.go、service 层单测

10. 测试策略

  1. 公开可见性(关键,新建):插入一条 status='pending' 的记录 + 图片,断言
    • 公开列表/详情/热门/SSG 全部查不到它;
    • 置为 published 后能查到;
    • 置回 rejected 后再次查不到。 这是"视图方案"真正要拿下的证据。
  2. 实体键唯一:同一实体键重复入库 → 只有一行,且第二次整任务被跳过。
  3. 图片 id 稳定(新):通过 → 编辑字段 → 再通过,断言图片行 id 不变(旧实现会变)。
  4. 街拍主副图不变量(现有集成测试改造):并入/并入上一张/拆出在正式图片表上仍满足"无副图的副图";父引用失效时按主图渲染。
  5. 模板逐页渲染:backstage_handler_test.go 现有"每个 page 渲染 200"继续兜住 .Draft→.Record 改名的遗漏。
  6. 迁移脚本:对拍迁移前后记录数/图片数;-dry-run 可重复执行。

11. 风险与回滚

风险 缓解
视图漏建/漏改,导致公开读到未审内容 视图是唯一公开入口 + §10.1 的专项测试;视图 DDL 与加列迁移同文件
加了列忘了重建视图(列集冻结) §6 维护规则 + 迁移文件里 DROP VIEW 后重建
迁移把内容搬丢 第 2 步先 -dry-run + 三项守恒校验,校验不过不执行第 3 步删表
存量 approved 草稿无对应正式行 校验会中止并列出,人工决定(补插或忽略)
审核期间已发布专辑"消失" 本设计下不会发生:重复入库直接放弃,不会把 published 行改回 pending

回滚:第 1 步可逆(删列删视图),第 2 步之前数据零损失;第 3 步删表后不可逆(需从备份恢复,db/backups/ 与 db_dump.sql 可用)。

12. 已知不一致(本次明确不处理,仅记录)

  1. image_count 口径:runway 只计主图,street 计全部图(含副图)。
  2. favorites 实建索引是 uniq_user_target(target_uid) 单列(代码注释写的是 (user_id, target_uid) 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
  3. image_duplicates、image_embeddings 两张表零引用;street_snaps.source/source_url 死列。可另起一个清理迁移删掉,与本次改造解耦。