16 KiB
单表发布模型(取消草稿表)设计
- 日期: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 |
由此产生的复杂度:
- 结构重复:内容字段(标题/描述/年份/季节/cover/image_count)在两张表各写一份;正式图片表与草稿图片表字段几乎相同(
is_detail/look_index/phash/is_duplicate/dup_of是双份)。 - 晋升即重建:
SaveRunwayFromDraft/SaveStreetSnapFromDraft会软删正式图片再整批重建,图片行 id 每次都变 —— 外层业已存在"重复发布打断单图收藏"的隐患(favorites.target_uid存的是图片的 hashid)。 - 父引用重映射:街拍副图的
parent_image_id存的是草稿表行 id,晋升时要两遍插入并改写为新正式行 id,父行缺失还要降级。 - 多来源聚合:
sibling图片聚合(按实体键把多个已通过草稿的图并成一条正式记录)只在晋升时发生,逻辑绕。 - 死物:
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:
- 实体键查重:沿用
RunwayIDByEntity/StreetSnapIDByEntity(WHERE 实体键 AND is_deleted=0)。单表之后,该条件会同时匹配pending/published/rejected的任意行 —— 命中即整任务MarkDone直接放弃(用户裁定),与现状对"已发布实体"的行为一致,且顺带把"重复爬取同一实体"也收敛掉。 - 建记录:
CreateRunwayDraft→CreateRunway(写brand_runways,status='pending'、job_id);CreateStreetSnapDraft→CreateStreetSnap(写street_snaps)。 - 建图片:把图直接写
brand_runway_images/street_snap_images。 - 图片去重:
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、ImagesByRunwayIDsstreet_snap_repository.go:List、FindByID、ListImages、ImagesBySnapIDs、Popularbrand_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)
- §3.2 加列。
- 存量正式行视为已发布:
UPDATE brand_runways SET status='published' WHERE created_at < :迁移时刻;(street 同理)。带时间戳护栏,避免脚本被重复执行时把新入库的 pending 行误刷成 published。 - 建 §6 的 4 个视图。
本步必须在部署新代码之前应用(新代码开始写
status)。
第 2 步:数据搬迁(scripts/migrate_single_table/main.go,一次性命令,支持 -dry-run)
- 建 id 映射:
map[draftID]newRecordID、map[draftImageID]newImageID。 pending/rejected草稿 → 插入正式表(保留status/job_id/reviewer/reject_reason/is_deleted),草稿图片 → 插入正式图片表(保留look_index/is_detail/phash/is_duplicate/dup_of/sort_order)。- 街拍副图:第二遍把
parent_image_id从"草稿图片 id"改写为"新正式图片 id"(用第 1 步的映射);映射缺失则降级is_detail=0(与读侧容错一致)。 - 校验(不通过则中止,不删表):
- 每个
approved草稿都能在正式表找到对应实体行(否则说明有"只存在于草稿"的内容,需人工处理); - 待审/驳回草稿的记录数与图片数在迁移前后守恒;
street_snap_images中不存在指向不存在行的parent_image_id。
- 每个
-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. 测试策略
- 公开可见性(关键,新建):插入一条
status='pending'的记录 + 图片,断言- 公开列表/详情/热门/SSG 全部查不到它;
- 置为
published后能查到; - 置回
rejected后再次查不到。 这是"视图方案"真正要拿下的证据。
- 实体键唯一:同一实体键重复入库 → 只有一行,且第二次整任务被跳过。
- 图片 id 稳定(新):通过 → 编辑字段 → 再通过,断言图片行 id 不变(旧实现会变)。
- 街拍主副图不变量(现有集成测试改造):并入/并入上一张/拆出在正式图片表上仍满足"无副图的副图";父引用失效时按主图渲染。
- 模板逐页渲染:
backstage_handler_test.go现有"每个 page 渲染 200"继续兜住.Draft→.Record改名的遗漏。 - 迁移脚本:对拍迁移前后记录数/图片数;
-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. 已知不一致(本次明确不处理,仅记录)
image_count口径:runway 只计主图,street 计全部图(含副图)。favorites实建索引是uniq_user_target(target_uid)单列(代码注释写的是(user_id, target_uid)组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。image_duplicates、image_embeddings两张表零引用;street_snaps.source/source_url死列。可另起一个清理迁移删掉,与本次改造解耦。