7.9 KiB
设计:文章图片元数据、孤儿回收与优化流水线
日期: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 中的编辑器交互(分屏、
插入、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 删除动作
- 确认无引用且过宽限期。
- 删除磁盘(或对象存储)上
storageKey及variantsJson中列出的衍生文件。 - 删除
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/<picture>。 - 异步处理队列与
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 依赖与错误处理、与现有编辑器契约)。