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 index 23d344f..ca39430 100644 --- a/docs/superpowers/specs/2026-04-18-post-media-assets-design.md +++ b/docs/superpowers/specs/2026-04-18-post-media-assets-design.md @@ -10,7 +10,7 @@ - 删改文章后易产生 **无人引用的孤儿文件**,磁盘只增不减。 - 缺少结构化元数据,难以做 **压缩、限宽、多格式** 等优化与后续 CDN 迁移。 -本 spec 定义 **站内托管图片** 的元数据表、**保存文章时** 的引用同步、**宽限期 + 定时清扫** 的孤儿回收,以及 **上传后优化** 的首版约定。 +本 spec 定义 **站内托管图片** 的元数据表、**保存文章时** 的引用同步、**孤儿文件的审查与清理**(默认 **不** 自动删除;**可配置** 自动清理;支持 **手动** 清理),以及 **上传后优化** 的首版约定。 ## 2. 与既有 spec 的关系 @@ -54,31 +54,60 @@ ## 6. 孤儿回收(A) +### 6.0 原则(已定) + +- **默认不自动删除**:系统 **不得** 在无人操作的情况下静默删除孤儿文件;须先满足「可删除」条件,且仅在 **自动清理已开启** 时由定时任务执行删除。 +- **可审查**:提供固定入口(见 6.5)列出 **候选孤儿**(含预览信息、体积、`createdAt`、成为孤儿的时间等),供用户确认。 +- **可配置自动清理**:通过配置项 **显式开启** 后,定时任务才对「已过宽限期的候选」执行与手动相同的删除逻辑。 +- **可手动清理**:在审查界面 **按条删除** 或 **批量删除当前筛选结果**(实现计划定义二次确认文案与权限)。 + ### 6.1 孤儿定义 `media_assets` 中某行 **不存在** 任何 `post_media_refs.assetId` 指向它,且满足用户隔离。 -### 6.2 宽限期 +### 6.2 宽限期(删除前置条件) + +以下时刻起满宽限期后的记录,才进入 **「可删除」** 集合(审查列表可展示「未过宽限期」与「可删」状态,避免与自动任务语义混淆)。 | 场景 | 规则 | |------|------| -| 从未被任何 post 引用 | 自 `createdAt` 起满 **T** 小时后才可删除;**T 默认 24**,实现为常量或环境变量 | -| 曾被引用、保存后引用被移除 | 自 **最后一次取消全部引用** 起满 **T2** 后才可删除;**T2 默认 24 小时**,可与 T 相同或独立配置 | +| 从未被任何 post 引用 | 自 `createdAt` 起满 **T** 小时后才 **可删**;**T 默认 24**,实现为常量或环境变量 | +| 曾被引用、保存后引用被移除 | 自 **最后一次取消全部引用** 起满 **T2** 后才 **可删**;**T2 默认 24 小时**,可与 T 相同或独立配置 | + +「最后一次取消全部引用」以实现为准:可在 ref 从非空变为空时更新 `assetOrphanSince`(需额外表字段),或在任务中根据 ref 不存在推断;**实现计划必须选定一种可测试策略**,避免误删编辑中未保存会话(数据库 ref 滞后是预期,由 T/T2 兜底)。 + +**未过宽限期的孤儿** 仍可在审查列表中展示(标注「冷却中」),但 **不可** 被手动或自动删除。 -「最后一次取消全部引用」以实现为准:可在 ref 从非空变为空时更新 `assetOrphanSince`(需额外表字段),或在清扫任务中根据 ref 不存在推断并用 `updatedAt`/审计字段近似;**实现计划必须选定一种可测试策略**,避免误删编辑中未保存会话(数据库 ref 滞后是预期,由 T/T2 兜底)。 +### 6.3 删除动作(手动与自动共用) -### 6.3 删除动作 +对 **可删除** 集合中的资产: -1. 确认无引用且过宽限期。 +1. 再次确认无 `post_media_refs` 引用(防竞态)。 2. 删除磁盘(或对象存储)上 `storageKey` 及 `variantsJson` 中列出的衍生文件。 3. 删除 `media_assets` 行(首版硬删;若需审计可后续加软删或日志表,非首版必做)。 **顺序与失败**:单机首版可采用「删文件 → 删行」;若删文件失败应记录日志且 **不** 删行,便于重试。禁止删除仍被某 `post_media_refs` 引用的行。 -### 6.4 定时任务 +### 6.4 自动清理配置与定时任务 + +| 配置项 | 约定 | +|--------|------| +| **自动清理总开关** | **默认关闭**。仅当开启时,定时任务才执行 **6.3** 的删除。配置载体首选用 **环境变量**(如 `MEDIA_ORPHAN_AUTO_SWEEP_ENABLED=false`),若项目已有全局配置表/管理后台,实现计划可合并为同一真相源。 | +| **执行周期** | 可配置(如每小时);默认仅在开关开启时注册调度。 | +| **范围** | 自动任务以 **系统内部 job** 身份运行,枚举 **全站** 满足「孤儿 + 已过宽限期」的 `media_assets`,对每一条调用与 **手动删除** 相同的 **6.3** 例程(含引用重检、用户隔离不得误删他人文件)。审查页仅展示 **当前登录用户** 自己的候选;**不得** 因「仅我的列表」而限制自动任务的全站职责(多租户下每行仍有 `userId`)。 | + +**关闭自动清理时**:定时任务 **不跑删除**,或空转仅写指标;审查与手动删除 **不受影响**。 + +### 6.5 审查入口(首版必做) + +- **位置**:实现计划从 ` /me/...` 中选具体路径(例如「文章」下子页或「设置」子区块),与现有 `me` 布局一致。 +- **能力**:分页列表候选孤儿(可按「全部 / 仅可删 / 冷却中」筛选);展示缩略图或 URL、`sizeBytes`、`createdAt`、孤儿时长、宽限期状态。 +- **手动操作**:单条删除、多选删除、(可选)「删除当前筛选下全部可删项」——均需 **二次确认**,且仅对 **可删除** 行生效。 +- **API**:`GET` 列表、`POST` 批量删除(body 含 `assetIds` 或「按筛选删除」的显式 flag),权限与 **第 10 节** 一致。 + +### 6.6 与「仅列表不删除」的观测 -- 周期性执行 `sweepOrphanAssets()`(例如每小时),批量限流,避免长时间锁表。 -- 首版 **不** 要求管理后台 UI;日志可观测即可。可选后续:「预览将删列表」接口。 +- 可选:定时任务在 **自动关闭** 时仍记录 **可删数量** 到日志或指标,便于运维;**非首版必做**。 ## 7. 图片优化(C) @@ -115,18 +144,20 @@ ## 11. 非目标(本 spec) -- 非图片附件(PDF、ZIP)与完整「媒体库」浏览 UI。 +- 非图片附件(PDF、ZIP)与完整「媒体库」浏览 UI(**审查页仅面向孤儿候选,不是通用资源管理器**)。 - 第三方图床配置、用户级 CDN 绑定。 - 跨实例分布式锁(单节点部署假设;多实例时需在实现计划中另加对象存储与锁,非本期必写)。 ## 12. 测试与验收(实现阶段) - 上传 → DB 有 `media_assets`;保存带图文章 → `post_media_refs` 正确;删图改文保存 → ref 更新。 -- 删除文章或去掉图中 URL 并保存 → 过宽限期后清扫任务删除磁盘与行。 -- 宽限期内 **不** 删除刚上传未引用文件。 +- **默认**:自动清理关闭时,即使存在可删孤儿,**定时任务不得删除** 磁盘或行。 +- **审查页**:列表与筛选正确;冷却中项不可删;可删项可单条/批量手动删除,且二次确认生效。 +- **自动清理开启**:仅对已过宽限期且无引用的记录执行删除,逻辑与手动删除一致。 +- 宽限期内 **不** 允许手动或自动删除刚上传未引用文件。 - 大图超宽被限宽;输出为 WebP;体积小于原图(典型 JPEG/PNG 样例)。 -- 外链图片 URL 出现在正文中 → 无 DB 行、不被 GC 误删。 +- 外链图片 URL 出现在正文中 → 无 DB 行、不被误删。 ## 13. 后续流程 -实现前另建 **`writing-plans` 实现计划**(迁移、API、清扫 cron、Sharp 依赖与错误处理、与现有编辑器契约)。 +实现前另建 **`writing-plans` 实现计划**(迁移、上传与优化、引用同步、`/me` 审查页与删除 API、自动清理配置与可选 cron、Sharp 依赖与错误处理、与现有编辑器契约)。