Files
backend_v2/docs/superpowers/specs/2026-09-22-single-table-publish-design.md
toom1996 42f6316125 update
2026-09-25 11:31:52 +08:00

261 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 单表发布模型(取消草稿表)设计
- 日期: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 新增列(从草稿表搬到正式表)
```sql
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 状态取值
```go
// 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=?`。行保留(备查)。
- **驳回后重爬**:`rejected → pending`(复用同一行,见 §5 第 1 条第 2 支)。驳回不是永久黑名单。
- 每条记录**只有一个实体键行**(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` → 整任务 `MarkDone`,**直接放弃**(用户裁定)。语义:该实体已收录或正在审核,不重复入库。
- 命中 `rejected` → **复用该行**:覆盖内容字段(标题/描述/年份/季节/collection_type/season_code/cover)、软删其旧图片、写入本次抓取的图片、置回 `status='pending'` 并清空 `reviewer`/`reject_reason`。语义:驳回不是永久黑名单,重爬即重新送审。
> ⚠️ 这一支是**必需的修正**:查重条件不带 status,若不特判 `rejected`,一条被驳回(甚至误驳)的记录会**永久挡住重爬**;而现状(草稿表)不会——查重只查正式表,驳回的草稿不挡路。
- 未命中 → 新建 `pending` 记录。
"同一实体只有一行"因此始终成立。
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. 公开可见性:只读视图
```sql
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. 建 §6 的 4 个视图(视图定义必须幂等:`DROP VIEW IF EXISTS` 后重建)。
**存量行置已发布拆成独立的一次性文件**(`db/migrations/2026-09-22-01b-publish-existing-rows.sql`):
```sql
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
```
为什么必须拆开:数据搬迁(第 2 步)会把 pending 草稿**连同旧的 `created_at`** 搬进正式表。若这条 UPDATE 留在可重复执行的迁移文件里(集成测试每次都会执行它),这些待审内容会被误刷成 `published` —— 恰好是本设计要防的泄漏。因此:结构 DDL 可重复执行,数据变更只执行一次,且**必须在第 2 步之前**执行。
> 本步必须在部署新代码**之前**应用(新代码开始写 `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-05-drop-draft-tables.sql`,校验通过后再执行)
```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-05-*.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` 死列。可另起一个清理迁移删掉,与本次改造解耦。
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题做**措辞**微调,入库查重会当作新实体,从而多出一行(内容重复)。
- ✅ 已处理(残留项 延后-4):纯**空白**差异(首尾空白、内部多空格 / 制表 / 换行)已通过 `repository.NormalizeStreetTitle`(空白折叠)在落库与查重两侧统一消除,不会再因空白微调产生重复行;措辞差异无法机械归一,仍按不同实体处理(符合预期)。
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
- 关联修复(残留项 延后-5):`cover` 是街拍 / 走秀主表上的图片 key 指针,删图 / 下架若删掉封面图会留下悬空指针。已在 `DeleteRunwayImage` / `DeleteSnapImage` / `SoftDelete*Images` 中,当被删图即封面时重指向存活图(无存活图则清空),消除这一唯一真数据丢失窗口。