# 編輯器上傳檔回收（臨時檔 status + 偽 cron GC）設計

> 狀態：一期實作完成，待 jyg 實測
> 範本站：jyg（admin71 + PHP71）
> 相關：`upload-centralization-strategy.md`、`file-storage-tables.sql`、記憶 [[upload-centralization-plan]]、[[file-storage-schema-design]]

> ⚠ 設計幾經迭代，最終定案見本檔。早期版本（articles_bind 加 `draft` 欄位 / lazy 建草稿端點 /
> edit.html 主導 redirect / 上傳帶 entity_id）**皆已捨棄並回退**，勿參考 git 歷史中的中間版本。

---

## 1. 問題

新建文章時還沒 `postID`，編輯器上傳的圖寫進中央 `files` 表後**沒有東西可歸屬**（`file_usages` 需要 entity_id）。
若使用者上傳了圖卻沒存檔（關頁），這些圖就是永久孤兒。痛點＝**這些「上傳了沒存」的圖要能自動回收**。

> 既有基建：`files`/`file_usages` 兩表、write-on-save 同步 usages、scanner、孤兒 GC（`gcMark→gcPurge`）、
> 後台 `/files/maintenance/*` route 都已存在。本案只補「上傳未存的臨時檔回收」+「無 cronjob 的 GC 觸發」。

---

## 2. 核心觀念：用 `files.status` 區分「臨時 / 正式」

從**檔案**角度只有兩種狀態，不需動 entity_type、不需新表、不需碰主表：

值定義在 `App\Models\Files\Status`（常數，非魔術數字）；採 **signed 語意：正=保留 / 負=過渡**。

| 常數 = 值 | 意義 | 回收 |
|---|---|---|
| `ACTIVE` = **1** | 正式（被真文章引用，**不論已發布或草稿，都是 1**） | 不時間回收；失去所有引用 → 既有孤兒 GC |
| `TRASHED` = **0** | 軟刪（migration / 人工停用） | GC 不碰 |
| `GC` = **-1** | 孤兒待回收（gcMark 設） | gcPurge 過 7 天且仍無引用 → 刪 |
| `PENDING` = **-2** | 編輯器「未存檔」時上傳、尚未被引用 | gcPending 過 grace 未轉正 → 刪 |

> `status > 0` = 保留、`< 0` = 過渡待清（目前查詢都用等值比較，留此不變式備日後範圍查詢用）。
> 原 GC=2 改 -1：一次性 `UPDATE files SET status=-1 WHERE status=2`（見 migrate.sql）。

關鍵分辨：
- **臨時 ≠ 草稿**。臨時是「還沒存任何東西的暫存上傳」，靠時間回收。
- **草稿是主表的真文章**（文章層 `draft` 狀態，二期 UX），檔案被它引用即 `status=1`，**GC 永不碰**，只能使用者手動刪文章。
- 因此「正式檔」與「草稿文章的檔」**同一個 status=1**，不需再分。

無 DDL：`files.status` 已是 tinyint，3 是合法值；既有列皆 1，不受影響。

---

## 3. 流程

1. **新建頁編輯器上傳圖** → `/api/media/upload` 帶 `pending=1` → `files` 寫入 `status=3`（臨時）。圖照常回 `{file_id,location}`，編輯器插入 `<img data-file-id>`。
2. **跳出沒上傳** → 什麼都沒建，**零污染**（連 id 都不進任何表）。
3. **存檔**（正常 create/update）→ write-on-save `scanOne` 掃內文 `data-file-id` + 結構化欄位 → `Usages::sync` 建 `file_usages` 並對引用到的檔**轉正 `status 3→1`**。
4. **放棄**（上傳了沒存）→ 該檔留在 `status=3`、過 grace → `gcPending` 刪 R2 物件 + `files` 列。
5. **既有孤兒**（status=1 但失去所有引用，例如文章刪了某圖）→ 既有 `gcMark→gcPurge` 照舊。

> 為什麼上傳一律 `pending=1`：編輯器圖在「存檔」前都還沒落地到任何文章（連既有文章也是存檔才寫內文），
> 故一律先當臨時、存檔才轉正。其他上傳路徑不帶 `pending` → 照舊 `status=1`，不受影響。

---

## 4. GC 觸發：偽 cron（無 cronjob）

系統無系統排程。用 WP-cron 式**請求觸發 + 節流**：

- `Files\Cron::tick()`：讀站台本地時間戳檔 `{media->root}/.gc_last_<host8>`，距上次 ≥ 1 天才跑；
  `flock` 搶鎖防併發；先更新時間戳再跑 GC（出錯也不狂跑）；全程 try/catch，永不影響呼叫端。
- 掛在**文章列表 load**（`Articles\Indexs::showArticleList`）。絕大多數請求只讀一下時間戳就跳過。
- 跑的內容 `Files\Scan::gc(1,7)`：`gcPending(1天)` → `gcMark` → `gcPurge(7天)`。
- 手動備援 route：`/files/maintenance/gc/run?confirm=1`（全流程）、`/files/maintenance/gc/pending`（只清臨時）。

---

## 5. 實作清單（一期，已完成）

| 項目 | 位置 |
|---|---|
| `files.status=3` 語意 | 無 DDL；`article-draft-migrate.sql` 只記說明 |
| 上傳標臨時 | `Uploader\Files::upload` 收 `pending` → `ingest(status=3)`；`Files\Files::ingest` 接受 status |
| 存檔轉正 | `Files\Usages::sync` 末尾 `Files\Files::confirm(file_ids)`（`Files\DB\Files::confirm` UPDATE status 3→1） |
| 臨時檔 GC | `Files\Scan::gcPending` + `Files\DB\Scan::pendingPurgeable`（status=3 且 created_at 過 grace） |
| GC 全流程 | `Files\Scan::gc(pendingGrace,fileGrace)` = gcPending→gcMark→gcPurge |
| 偽 cron | `Files\Cron::tick`（時間戳檔+flock），掛 `Articles\Indexs::showArticleList` |
| 維運 route | `admin71/.../conf.route.main.php`：`gc/run`、`gc/pending` |
| 前端 | cf-cdn `libs/neko/editors/tinymce.js` upload handler 加 `pending=1`；adapter 版號 `editor.js` `?v=4`，micro 端 `editor.js?...&v=4` 全數同步 |

**沒動**：主表 articles_bind（無 draft 欄位）、文章 create/update 流程、列表查詢、entity_type。

---

## 6. 待辦 / 待測（jyg）

1. **實測**：新建文章編輯器上傳 → `files.status=3`；存檔後該圖 `status=1`、有 `file_usages`；上傳沒存 → 隔天 GC 刪掉。
2. **cover/album/attached** 等結構化上傳目前**沒帶 pending**（仍 status=1）；放棄的封面圖靠既有 7 天孤兒 GC 回收。如要比照即時臨時化，再讓那些上傳端點也帶 `pending`（phase 1.x）。
3. **偽 cron 部署**：jyg 跑一次 `/files/maintenance/gc/run?confirm=1` 驗證；確認 `.gc_last_*` 寫得進 `media->root`。
4. **草稿（二期）**：文章層「存草稿/發布」狀態 + 後台草稿清單——與本案檔案回收**正交**，不影響。
