243 lines
16 KiB
Markdown
243 lines
16 KiB
Markdown
# 单表发布模型(取消草稿表)设计
|
||
|
||
- 日期: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=?`。行保留。
|
||
- 每条记录**只有一个实体键行**(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. 公开可见性:只读视图
|
||
|
||
```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. 存量正式行视为已发布:`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`,校验通过后再执行)
|
||
```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` 死列。可另起一个清理迁移删掉,与本次改造解耦。
|