diff --git a/docs/superpowers/specs/2026-04-18-post-media-assets-design.md b/docs/superpowers/specs/2026-04-18-post-media-assets-design.md new file mode 100644 index 0000000..23d344f --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-post-media-assets-design.md @@ -0,0 +1,132 @@ +# 设计:文章图片元数据、孤儿回收与优化流水线 + +**日期**:2026-04-18 +**状态**:已定稿(与产品对话一致:优先 **A 孤儿回收** + **C 图片优化**) + +## 1. 背景与目标 + +当前 `POST /api/file/upload` 将图片写入 `public/assets/`,返回 `/public/assets/...` URL;`posts.bodyMarkdown` 与 `coverUrl` 仅保存字符串,**磁盘文件与数据库无引用关系**。导致: + +- 删改文章后易产生 **无人引用的孤儿文件**,磁盘只增不减。 +- 缺少结构化元数据,难以做 **压缩、限宽、多格式** 等优化与后续 CDN 迁移。 + +本 spec 定义 **站内托管图片** 的元数据表、**保存文章时** 的引用同步、**宽限期 + 定时清扫** 的孤儿回收,以及 **上传后优化** 的首版约定。 + +## 2. 与既有 spec 的关系 + +- `2026-04-18-article-edit-markdown-design.md` 曾将「服务端图片压缩流水线」列为 **非目标**;**本 spec 取代该条**:在保持「Markdown 内为 URL、复用上传接口形态」的前提下,增加元数据与优化流水线。 +- 该 spec 中的编辑器交互(分屏、`![](url)` 插入、MIME/大小限制)仍然有效;**大小与 MIME 上限** 若与本 spec 冲突,以 **较严者** 为准,或在实现计划中统一改为一处常量。 + +## 3. 架构取向(已定) + +采用 **元数据表 + 保存文章时建立/刷新引用**(非「全库扫描目录差集」作为主真相)。外链图片 **不** 入 `media_assets`,**不参与** 孤儿删除。 + +## 4. 数据模型 + +### 4.1 `media_assets`(名称实现时可微调,语义为「用户上传的站内媒体文件」) + +| 字段 | 说明 | +|------|------| +| `id` | 主键 | +| `userId` | 所有者;所有查询与删除必须 **按用户隔离** | +| `storageKey` | 相对存储根的路径或稳定键,与对外 URL 可逆映射(与现 `public/assets` 约定对齐) | +| `mime` | 存储的主资源 MIME(优化后多为 `image/webp`) | +| `sizeBytes` | 主资源字节数 | +| `sha256` | 可选;用于去重或完整性 | +| `variantsJson` | 可选;记录衍生路径,如 `{ "original": "...", "webp": "..." }`,具体键名实现计划锁定 | +| `status` | 可选首版:`ready`;若上异步优化则 `processing \| ready \| failed` | +| `createdAt` | 创建时间(用于宽限期) | + +**首版**:`post_media_refs` 解析与 GC 以 **canonical 对外 URL** 与 `media_assets` 主行一致为准;若存在 `variantsJson`,在实现计划中明确 **哪一条为 Markdown 插入的默认 URL**,避免同一逻辑资产被拆成多条无法追踪的记录。 + +### 4.2 `post_media_refs` + +- 多对多:`(postId, assetId)`,带唯一约束。 +- **主路径**:每次 **post 创建/更新成功** 后,根据当前 `bodyMarkdown` + `coverUrl` 解析出 **本站前缀** 下的图片 URL,解析为 `assetId`,**替换** 该 post 的整行关联集合(实现可采用先删后插或 diff,以事务保证一致)。 + +**真相来源**:关联表为引用真相;**不** 以 post 上冗余「URL 缓存列」作为主真相。 + +## 5. 引用解析规则 + +- **范围**:`coverUrl` + `bodyMarkdown` 中出现的图片 URL。 +- **识别本站资源**:与当前静态资源约定一致的前缀(例如 `/public/assets/`);实现计划锁定正则或解析器,避免误把外链当站内。 +- **外链**:忽略,不创建、不删除 `media_assets`。 + +## 6. 孤儿回收(A) + +### 6.1 孤儿定义 + +`media_assets` 中某行 **不存在** 任何 `post_media_refs.assetId` 指向它,且满足用户隔离。 + +### 6.2 宽限期 + +| 场景 | 规则 | +|------|------| +| 从未被任何 post 引用 | 自 `createdAt` 起满 **T** 小时后才可删除;**T 默认 24**,实现为常量或环境变量 | +| 曾被引用、保存后引用被移除 | 自 **最后一次取消全部引用** 起满 **T2** 后才可删除;**T2 默认 24 小时**,可与 T 相同或独立配置 | + +「最后一次取消全部引用」以实现为准:可在 ref 从非空变为空时更新 `assetOrphanSince`(需额外表字段),或在清扫任务中根据 ref 不存在推断并用 `updatedAt`/审计字段近似;**实现计划必须选定一种可测试策略**,避免误删编辑中未保存会话(数据库 ref 滞后是预期,由 T/T2 兜底)。 + +### 6.3 删除动作 + +1. 确认无引用且过宽限期。 +2. 删除磁盘(或对象存储)上 `storageKey` 及 `variantsJson` 中列出的衍生文件。 +3. 删除 `media_assets` 行(首版硬删;若需审计可后续加软删或日志表,非首版必做)。 + +**顺序与失败**:单机首版可采用「删文件 → 删行」;若删文件失败应记录日志且 **不** 删行,便于重试。禁止删除仍被某 `post_media_refs` 引用的行。 + +### 6.4 定时任务 + +- 周期性执行 `sweepOrphanAssets()`(例如每小时),批量限流,避免长时间锁表。 +- 首版 **不** 要求管理后台 UI;日志可观测即可。可选后续:「预览将删列表」接口。 + +## 7. 图片优化(C) + +### 7.1 首版能力 + +- 上传后:**解码** → **限最大宽度**(默认 **1920px**,实现计划可配置)→ 输出 **WebP**(质量默认 **82**,可配置)。 +- Markdown 与上传 API 返回的 `url` **指向优化后的主资源**(路径规则与现 `public/assets` 命名兼容或可迁移)。 +- **是否保留原图**:首版 **可不保留**(仅 WebP),以减少复杂度与存储;若产品要求可回滚原图,则在 `variantsJson` 中保留 `original` 键并在实现计划中写清磁盘布局。 + +### 7.2 二期(非首版验收) + +- 多宽度衍生(`w800` / `w1200`)与渲染侧 `srcset` / ``。 +- 异步处理队列与 `processing` 状态(首版若同步优化在 10MB 内可接受则不必上队列)。 + +### 7.3 CDN + +- 首版无 CDN 前缀;DB 存相对 path 或稳定 `storageKey`,便于日后只改对外域名。 + +## 8. 上传接口演进 + +- `POST /api/file/upload`:在写磁盘 **同时** 插入 `media_assets`(`userId` 从会话取,与现站认证一致)。 +- 响应仍兼容现有 `{ files: [{ name, url, path, mimeType, size }, ...] }`;`url` 为插入 Markdown 的 **canonical** 地址;`size`/`mimeType` 反映 **主存储资源**(优化后)。 +- 实现计划需处理:优化失败时的回退(拒绝上传并提示 vs 保留原图),首版推荐 **失败则整笔上传失败并 Toast**,不留下半条记录。 + +## 9. Post 服务与事务 + +- `createPost` / `updatePost` 成功提交正文后,**在同一请求内或紧随其后的可靠步骤** 调用「根据 markdown + cover 刷新 `post_media_refs`」。 +- 若刷新失败:实现计划定义 **是否回滚整次保存** 或 **重试队列**;首版推荐 **与 post 保存同事务或先 post 后 ref 且可重试修复**,避免长期 ref 与正文不一致。 + +## 10. 安全与隔离 + +- 所有 `media_assets` 与 `post_media_refs` 的读删改必须 **校验 post 属于当前 user 且 asset 属于当前 user**。 +- GC 仅处理本应用创建的 `storageKey` 模式,**禁止** 删除不匹配前缀的路径。 + +## 11. 非目标(本 spec) + +- 非图片附件(PDF、ZIP)与完整「媒体库」浏览 UI。 +- 第三方图床配置、用户级 CDN 绑定。 +- 跨实例分布式锁(单节点部署假设;多实例时需在实现计划中另加对象存储与锁,非本期必写)。 + +## 12. 测试与验收(实现阶段) + +- 上传 → DB 有 `media_assets`;保存带图文章 → `post_media_refs` 正确;删图改文保存 → ref 更新。 +- 删除文章或去掉图中 URL 并保存 → 过宽限期后清扫任务删除磁盘与行。 +- 宽限期内 **不** 删除刚上传未引用文件。 +- 大图超宽被限宽;输出为 WebP;体积小于原图(典型 JPEG/PNG 样例)。 +- 外链图片 URL 出现在正文中 → 无 DB 行、不被 GC 误删。 + +## 13. 后续流程 + +实现前另建 **`writing-plans` 实现计划**(迁移、API、清扫 cron、Sharp 依赖与错误处理、与现有编辑器契约)。