# 站台遷移 Runbook（新集中化系統）

> 把一個站台從「舊系統（CK4 + 分散上傳 + 本機資產）」遷移到新集中化系統。
> jygesg 是第一個完成的範例；以後每站照此表操作。
>
> **首次撰寫：2026-06-12** ｜ **2026-06-18 更新**（補上 5 項大改，見下面「現況更新」）

---

## ⚠️ 現況更新（2026-06-18）— 先讀這段

2026-06-16~18 做了 5 項大改，**下面第 1~5 節的部分內容已過時**（路徑與資料表變了）。以後加新站，以這段為準，下面舊節只當細節參考。

| # | 改動 | 對舊節的影響 |
|---|---|---|
| A | **砍掉 `hosts/` 目錄層** | `PHP71/`、`admin71/`、`stations/` 變同層。舊節寫的 `hosts/stations/...`、`dirname(SYSTEM)` 都要去掉。conf.weber.php 載入改成 `include_once(SYSTEM."/PHP71/pubs/index.php");` |
| B | **`files`/`file_usages` 取代 `uploaded_files`** | 集中化換成兩張表（files=本體+hash 去重+meta、file_usages=誰用了哪個檔）。舊節講 uploaded_files 的部分作廢，改用下列 SQL/端點 |
| C | **機密進 `.env`** | conf 不再寫死帳密。新增 `conf.env.php`/`conf.database.php`(自動探 `DB_<連線>_HOST`)/`conf.r2.php`；`.env` gitignore、`.env.example` committed |
| D | **每站一個 git repo** | `micro-station-<station>`，巢狀 repo。規範見 `~/.claude/guidelines/git.md` |
| E | **FTP 自動部署**（**未完成**）| 主機 SSH 被擋 → 改 FTP-based GitHub Actions。待設定 |

### 核心原則：所有檔案最終集中到 `files` 表

- **單一真相**：每個檔案在 `files` 一列（保留原 id，hash 去重）。`file_usages` 記「哪個 entity 的哪個 purpose 用了哪個 file」。寫入（上傳）一律走 `Uploader\Files` → 寫 `files` + hash 去重；R2 為遠端備份層。
- **DB 裡存的是 file_id，不是 URL**：
  - **裸 file_id 欄位（pathFields）**：`albums`/`banner`/`pictures` 等，值直接是 file_id（關聯子鍵當 purpose，如 `albums.cover→cover`）。
  - **HTML 欄位（htmlFields）**：`content`/`description`，內文圖存成 `<img data-file-id="X">`。

### 要更新哪些 table 的資料（遷移時）
1. `uploaded_files` → `files`：`file-storage-migrate.sql`（保留 id；hash 先留空待回填）。
2. 各模組內容欄位裡的圖改成 file_id 形式——涵蓋模組：`articles`/`products`/`services`/`bulletins`/`tags`/`videos`（各有 `{base}_bind` 的 albums+content、各語系的 content/description）+ `banners_files`（單表、`pictures` 裸 file_id、無語系）。
3. `file_usages` **不用手改**：跑 `/files/maintenance/scan` 由 Scanner 掃出上述欄位自動重建。

### 前端是否要變動？→ 幾乎不用
- 讀取一律經 `Media\Resolver` 即時還原：裸 file_id→相對路徑（給 `asset()`）、HTML 的 `<img data-file-id>`→注入 `src`（直接可用 URL）。
- **格式安全**：舊路徑、舊 `<img src>`、非 file_id 一律原樣放行 → 未遷移資料不破圖；批次查詢無 N+1。
- 所以前台模板**結構不用改**，只要確認「撈出的資料有經過 Resolver」即可（新模組接線時注意這點）。

### R2：什麼會上、怎麼上

**會上 R2 的兩種東西**：
1. **上傳的檔案** `assets/upload/<日期>/<id>.ext` — 上傳時 `Uploader\Files` 先算 sha256 去重（同內容已存在就回舊 id、不推）；新檔且 R2 啟用 → `R2->put` 成功才把該檔 `host` 寫成 R2 公開網域，失敗或未啟用則 host 留空、走本機相對路徑。
2. **pools 快取** `assets/pools/...` — `Lib\Files` write-through：寫本機同時推 R2；讀本機 miss 時自動從 R2 拉回暖快取。**只有 pools 路徑**走 R2，其他路徑不動。

**key 規則**：物件 key = 路徑去前導斜線（`assets/upload/<日期>/<id>.ext`、`assets/pools/<相對>`），對齊 DB 的 path。

**機制**：`Media\R2` 自寫 SigV4（不依賴 AWS SDK），方法 put/get/delete/putDir。設定全走 `.env`：`R2_ENDPOINT/BUCKET/REGION/KEY/SECRET/HOST`；**`R2_KEY` 留空＝停用、整站走本機**。

**每站要建什麼 + 命名規則**（在 Cloudflare R2，一站一個 bucket）：
| 項目 | 規則 | jygesg 實例 | 填到 .env |
|---|---|---|---|
| Bucket | `<station>-storage` | `jygesg-storage` | `R2_BUCKET` |
| 公開讀取網域（綁該 bucket 的自訂網域）| `<station>-i.microweb.cc` | `jygesg-i.microweb.cc`（前面加 `https://`）| `R2_HOST` |
| S3 Endpoint（**整個帳號共用同一個**）| `https://<accountid>.r2.cloudflarestorage.com` | 同帳號 | `R2_ENDPOINT` |
| Region | 固定 `auto` | auto | `R2_REGION` |
| API Token（R2 讀寫權限）| Cloudflare 後台建，拿 Access Key / Secret | — | `R2_KEY` / `R2_SECRET`（機密）|

> 建立順序：① R2 建 bucket `<station>-storage` ② 綁自訂網域 `<station>-i.microweb.cc` 並開公開讀取 ③ 建 API token 拿 key/secret ④ 填進該站 `.env` 的 `R2_*`。

**per-station 操作**：
- 啟用：`.env` 填 `R2_*` → 之後上傳自動推、pools 自動 write-through，不用額外動作。
- 既有 pools 一次性備份上去：後台開 `/files/maintenance/pools-to-r2`（內部 `putDir` → key=`assets/pools/<相對>`）。
- 既有 upload 舊檔：遷移資料 host 留空走本機讀取即可；若要整批上 R2，用 `R2->putDir` 逐日期目錄推（key 對齊 path），**先推檔成功再把 host 改 R2，否則破圖**。
- **中國站不要啟用 R2**（CF 全球網在中國慢/不穩）→ `R2_KEY` 留空，libs 留 `cdn.microweb.cc`。

⚠️ 既有雷（詳見下面舊節）：`.svg` 被 CF 擋；`asset()` 對 R2 絕對網址要原樣傳回（PHP71 已加守衛）；cf-cdn 有 4h 快取，改 CDN 檔後要 Purge。

### B 的 per-station 步驟（檔案集中化，現行）
共用程式（部署一次）：`PHP71/pubs/apps/Models/Files/`（Files/Usages/Scanner/Scan+DB/*）、Media\Resolver/R2/Upload、Uploader\Files、Lib\Files。
每站在該站 DB 上：
1. 建表 `file-storage-tables.sql`
2. 搬舊資料 `file-storage-migrate.sql`（uploaded_files→files）
3. 後台登入開 `/<base>/files/maintenance/scan` 全站重掃建 usages
4. 驗 `/files/maintenance/status`（totals+孤兒數）、前台抽查無破圖
5. 確認後退役 `file-storage-retire.sql`（uploaded_files→uploaded_files_bak），觀察期後再 DROP
- 維運端點（manager 登入）：`/files/maintenance/{status,scan,orphans,gc/mark,gc/purge,gc/pending,gc/run,pools-to-r2}`；purge/gc/run 需 `?confirm=1`。

> **編輯器臨時檔回收（2026-06-23 起）**：`files.status` 改具名常數 `App\Models\Files\Status`（正=保留/負=過渡）：`ACTIVE=1 / TRASHED=0 / GC=-1 / PENDING=-2`。編輯器未存檔上傳的圖標 `PENDING`，存檔轉正、放棄由偽 cron `Files\Cron::tick`（掛文章列表）跑 `gc()` 回收。**每站部署這版 PHP71 後，若該站曾跑過 `gc/mark` 而有 `status=2` 舊孤兒標記，務必補一次性遷移**（`status=3` 通常無）：
> ```sql
> UPDATE `files` SET `status` = -1 WHERE `status` = 2;
> UPDATE `files` SET `status` = -2 WHERE `status` = 3;
> ```
> 沒跑過 gc/mark（無 status=2 列）則為 no-op，跑了無害。詳見 `article-draft-binding.md` / `article-draft-migrate.sql`。前端：cf-cdn `editor.js?v=4` + adapter 上傳帶 `pending=1`（部署 cf-cdn 後 Purge Cache）。

### C 的 per-station 步驟（.env）
`cp .env.example .env`，填該站真值：每個 DB 連線各自 `DB_<連線>_HOST/USER/PASS/NAME` + `R2_*`。中國站 `R2_KEY` 留空＝停用走本機。

### E 待做
兩 repo 的 `.github/workflows/deploy.yml` 改 FTP-deploy；GitHub Secrets 填 FTP host/user/pass；目標 平台→`public_html/micro/`、站台→`public_html/micro/stations/<station>/`；exclude `.env`、`assets/{upload,pools}`。

---

## 0. 名詞與大原則

- **一站一站遷移**：靠 `dirname()` 與 `define()`，改資料夾/設定即可切換，不影響其他站。
- **共用 vs 一站一站**：
  - **共用（只需部署一次，全站共享）**：`PHP71/`、`hosts/admin71/`、cf-cdn 上的 `libs/neko/editor.js`、`libs/tinymce/langs/`
  - **一站一站（每站各自做）**：該站 `apps/conf/*`、DB 遷移、R2 金鑰、編輯頁接線（若該站 admin 有客製）
- **中國站台例外**：R2（Cloudflare 全球網）+ cf-cdn 在中國慢/不穩 → **中國站不要啟用 R2、libs 留原本 Apache cdn.microweb.cc**。
- **站台目錄非 git**：刪檔前先備份（見既有 `unused-assets-backup-*.tar.gz` 慣例）。

`<station>` 以下代表站台代碼（例 `jygesg`）。SITE/dbname 由資料夾名自動推導：
`define("SITE", strtolower(basename(WEBER)))`、dbname = `micro_<SITE>`。

---

## 1. 切到 PHP71 + admin71（隔離；測試者先用）

| 檔案 | 改動 |
|---|---|
| `hosts/stations/<station>/apps/conf/conf.weber.php` | `include_once(dirname(SYSTEM)."/PHP71/pubs/index.php");` |
| `hosts/stations/<station>/apps/resources/admin/index.php` | `define('ADMIN', SYSTEM.'/admin71');` |

> admin71 會透過 WEBER 載入「站台」的 conf.app.php，所以站台設定同時涵蓋前台＋後台。

---

## 2. 上傳集中化（DB 遷移）

把既有圖片/附件集中到 `uploaded_files`，內文改存 `<img data-file-id="X">`，讀取時由 Resolver 還原。

### 2.0 轉換規則（DB → file_id）— 遷移腳本做的事

**(a) 每張實體圖 → uploaded_files 一列**（去重：被多處引用也只一列）：

| 欄位 | 值 |
|---|---|
| `file_id` | 檔名**已是雪花號**（≥15 位純數字）→ 沿用；否則發新雪花號（`Lib\Snowflake`） |
| `name` / `path` | 原檔名 / `/assets/upload/<YYYYMMDD>/<file_id>.<ext>`（日期分桶取檔案 mtime） |
| `host` | 空字串（= 走本機相對路徑；R2 啟用後才由 r2-host-update.sql 改成 R2 網域） |
| `info`(json) | basename/type/size/local |

**(b) DB 既有引用 → 改寫成 file_id**：

| 來源欄位 | 原本 | 轉換後 |
|---|---|---|
| `*_bind` / `*_category_bind` 的 **albums**（JSON） | `{"cover":["/assets/upload/…/x.jpg"]}` 或 `{"cover":[{"filename":…,"src":…}]}` | `{"cover":["<file_id>"]}` |
| 內文 **content / description**（CKEditor HTML） | `<img src="https://…/x.jpg">` | `<img data-file-id="<file_id>">` |
| **pages_i18n.banner** | 橫幅圖路徑 | `<file_id>`（描述另由 2.2 併入 `uploaded_files.content`） |

> ⚠️ albums 兩種形式都要處理（純路徑字串 與 `{filename,src}` 物件）；早期版本只配對 `{cover:[path]}` 漏掉物件形式，務必用「JSON parse → 重建」而非單一 regex。

**(c) 讀取時還原（reverse，由 Resolver 即時做，不改 DB）**：
- `path` 模式（albums）→ file_id 換成相對路徑字串給 `asset()`
- `html` 模式（content/description）→ 把 `<img data-file-id>` 注入 `src`
- `object` 模式（banner）→ file_id 換成完整物件（含 content/描述）
- **務必在 DB fetch 迴圈「跑完之後」才 `resolve_list()`**：迴圈內查 uploaded_files 會覆寫共享 `$this->stmt`、截斷外層游標（會變成列表只剩第一筆）。

### 2.1 產生遷移 SQL（本機跑，不動線上）
```bash
python3 hosts/migrate-uploaded-files.py --build hosts/stations/<station>
# 產出 hosts/stations/<station>/micro_<station>_migrated.sql（含 uploaded_files + 改寫後內文/albums）
# 先 dry-run 檢視：python3 hosts/migrate-uploaded-files-dryrun.py hosts/stations/<station>
```

### 2.2 橫幅描述併入（選用）
```bash
python3 hosts/migrate-banner-desc.py hosts/stations/<station>
# 產出 migrate-banner-desc.sql：banner_desc → uploaded_files.content，並清空舊 banner_desc
```

### 2.3 Resolver 欄位設定
`hosts/stations/<station>/apps/conf/setting/conf.media.php`：
```php
$server->media->resolve = array(
    'path'   => array('albums'),              // 路徑模式 → 給 asset() 用
    'object' => array('banner'),              // 物件模式 → 帶 content/描述
    'html'   => array('content','description')// HTML 模式 → 注入 <img src>
);
```

### 2.4 匯入順序（線上 DB，**有先後**）
```
1. micro_<station>_migrated.sql   ← 先匯（建立 uploaded_files）
2. migrate-banner-desc.sql        ← 再匯（content 的 file_id 必須先存在）
3. r2-host-update.sql             ← R2 啟用後才需要（見第 3 步）
```
> ⚠️ #2/#3 的 UPDATE 都打 `WHERE file_id IN(...)`，目標列必須先由 #1 建立。

---

## 3. R2 儲存（per-station；中國站跳過）

### 3.0 R2 更新分兩種（都要）

| 種類 | 怎麼進 R2 | 何時 |
|---|---|---|
| **新上傳（自動）** | `Uploader/Files.php` 的 `upload()` 已掛 R2 hook：本機存好後 `if($r2->enabled() && $r2->put(ltrim($path,'/'),$file->path,$file->type)) $host=$r2->host();` → `uploaded_files.host` 直接寫 R2 網域 | 啟用 conf.r2.php 後**自動**，無需額外動作 |
| **既有檔（一次性回填）** | 把已在 DB（migrated）但還在本機、host 還空的舊檔推上 R2 + 改 host（見 3.2） | 遷移時**手動跑一次** |

> 重點：editor.js / TinyMCE 新插的圖也走 `/api/media/upload` → `Media\Upload` → `Uploader\Files->upload()` → **同一個 R2 hook**，所以新圖自動進 R2，不用另外處理。

### 3.1 金鑰設定
`hosts/stations/<station>/apps/conf/setting/conf.r2.php`（**含金鑰，repo git 化要 .gitignore**）：
```php
$server->media->r2 = (object)array(
    'endpoint' => 'https://<accountid>.r2.cloudflarestorage.com',
    'bucket'   => '<station>-storage',
    'region'   => 'auto',
    'key'      => '<access key>',
    'secret'   => '<secret>',
    'host'     => 'https://<station>-i.microweb.cc',   // R2 custom domain
);
```
`conf.app.php` 加：`include_once(dirname(__file__)."/conf.r2.php");`

### 3.2 把既有檔推上 R2 + 改 host（兩件事，缺一不可）
> ⚠️ **順序**：一定先「推檔」再「改 host」，否則 host 指向 R2 但檔案不在 → 全站破圖。

- **推檔**：兩種方式擇一
  - 線上打一次：登入 admin → `GET /api/media/r2-backfill`（由 `Media\Backfill` 處理，冪等）
  - 或本機批次：用 `PHP71/pubs/apps/Models/Media/R2.php` 的 `putDir()`（reflection 注入金鑰，逐筆讀 migrated SQL 的 path 推上去）
- **改 host**：跑 `r2-host-update.sql`
  ```sql
  UPDATE `uploaded_files` SET `host`='https://<station>-i.microweb.cc'
  WHERE `deleted_at`=0 AND `file_id` IN (<已成功推上的 file_id>);
  -- 反悔復原：SET host='' WHERE host='https://<station>-i.microweb.cc';
  ```

### 3.3 R2 連線自測（部署前驗證金鑰/簽章）
用 R2.php + reflection 注入金鑰 → `put()` 一個小檔 → curl custom domain 應回 200。

### 3.4 已知雷
- **`.svg` 被 Cloudflare 擋**：R2 custom domain 預設不服務 svg（回 404）→ CF 後台放行，或靜態 svg 留本機。
- **asset() 雙網址**：pathField（albums）的 src 變 R2 絕對網址後，`asset()` 會再加本機前綴 → 破圖。
  PHP71 的 `Models/Twig/Traits/Asset.php`（及 `Lib/URL.php`）已加守衛：**絕對網址（`http(s)://`）原樣傳回**。此為共用修正，已就位。

---

## 4. 切到 cf-cdn（Cloudflare CDN；中國站跳過）

`hosts/stations/<station>/apps/conf/setting/conf.inc.php`：
```php
$server->libs->rootURL   = "https://cf-cdn.microweb.cc";   // 原 cdn.microweb.cc（Apache）
$server->libs->assetsURL = "{$server->libs->rootURL}";
$server->layout->rootURL = "https://cf-cdn.microweb.cc";
$server->layout->assetsURL = "{$server->layout->rootURL}/layouts/Limitless_v1.3/01";
```
> cf-cdn 是 cdn-microweb-cc repo（GitHub → Cloudflare）的鏡像，內容一致。
> **cf-cdn 有 4h 快取**（`max-age=14400`）→ 更新 CDN 檔後到 Cloudflare 後台 **Purge Cache**。

---

## 5. 編輯器換 TinyMCE（取代 CK4）

### 5.1 共用層（cdn-microweb-cc repo，部署一次）
- `libs/neko/editor.js`：通用 `class Editor`（ES Module，底層 TinyMCE）。零參數：模板放 `<textarea data-editor name="...">` 即自動初始化；上傳走 `/admin/api/media/upload`；內容同步回 textarea（form.js 的 `toFormData` 直接讀，**不需 CKEDITOR、不需改 form.js**）。
- `libs/tinymce/langs/zh_TW.js`：TinyMCE 7 繁中包，`editor.js` 用 `language_url` 相對指向。
- 部署：`git push`（repo 屬 nextop-tw，需 nextop-tw 權限）→ **Purge Cache**。

### 5.2 TinyMCE 本體
仍自架在 `admin71/apps/resources/assets/plugins/tinymce/`（4.6M），editor.js 從 `/admin/.../tinymce/tinymce.min.js` 載。**只有語言包搬到 CDN**。

### 5.3 編輯頁接線（admin71；目前只接了試點）
把編輯頁的舊 CK include 換成：
```twig
<textarea data-editor name="{{欄位名}}">{{內容}}</textarea>
<script type="module" src="{{assetLibs('libs/neko/editor.js?base=admin')}}"></script>
```
- ✅ 已接：`com/design/description.html`、`com/design/content.html`（文章內文/描述）
- ⏳ 待接：其他入口 `com/com.ckeditor.html`、`com/com.editor.html`、`js/editor.js`（articles/bulletins/products 的 `editors.ckeditorART/BUL/PRO`）、faqs/forms/banners 等 `Neko.ckeditor.create(...)` 的頁
- 存檔框架：`cf-cdn/libs/neko/form.js`，`toFormData` 通用讀 `[name]`，**不用改**。

---

## 6. 部署清單（每站照打）

| 類別 | 檔案/動作 | 共用/per-station |
|---|---|---|
| 隔離 | conf.weber.php、admin/index.php | per-station |
| 上傳集中 | conf.media.php；匯入 3 支 SQL（順序） | per-station |
| R2 | conf.r2.php、conf.app.php；推檔 + r2-host-update.sql | per-station（中國站跳過） |
| cf-cdn | conf.inc.php（libs/layout） | per-station（中國站跳過） |
| 編輯器接線 | admin71 的 description/content.html…（陸續鋪） | 共用 admin71 |
| 共用平台 | PHP71、admin71、cf-cdn editor.js/langs | 共用，部署一次 |

---

## 7. 部署後驗證

1. 前台圖片：相簿封面/內文圖/橫幅都正常顯示（R2 站台 → 網址是 `<station>-i.microweb.cc`）。
2. 列表不只第一筆（驗 Resolver 的 `resolve_list` 在 fetch 迴圈外、未截斷游標）。
3. 後台編輯器：TinyMCE、**繁中**、工具列豐富。
4. 編輯器：打字 → 存檔 → 重進，內容還在（驗 form.js 讀 textarea）。
5. 編輯器插圖：上傳進 `/api/media/upload` → uploaded_files。
6. R2：抽一張圖 curl custom domain 回 200。

---

## 8. 相關檔案速查

- 遷移腳本：`hosts/migrate-uploaded-files.py`、`hosts/migrate-banner-desc.py`、`hosts/clean-orphan-uploaded-files.py`
- 集中上傳：`PHP71/pubs/apps/Models/Media/{Resolver,Upload,R2,Backfill}.php`、`Uploader/Files.php`
- asset 守衛：`PHP71/pubs/apps/Models/Twig/Traits/Asset.php`、`Models/Lib/URL.php`
- 編輯器：`cdn-microweb-cc/libs/neko/editor.js`、`libs/tinymce/langs/zh_TW.js`
- jygesg 範例 + 產出：`hosts/stations/jygesg/docs/ai-agents/project-init/`（deploy-checklist、r2-host-update.sql、migrate-banner-desc.sql…）

---

## 9. 待辦 / 已知問題（next session 接手，2026-06-12 收工點）

### 🔴 BUG：admin71 後台「封面圖片」不顯示既有圖
- **現象**：bulletins 分類編輯頁（`/admin/c/1/bulletins/cat/1/edit`）在 **admin71（jygesg.microweb.cc）** 封面圖空白；舊 admin（www.jygesg.com）正常顯示。
- **原因**：遷移後 `albums.cover` 變成 **file_id 陣列**，但 admin 上傳元件 `com/uploader` 需要「完整檔物件（含 src/name）」才能顯示既有圖。
  - 相關檔：`admin71/.../views/com/design/cover.html` → `{%set albums=json_decode(data.albums)%}` → `uploader.render(name, albums.cover, options)`，傳進去的是裸 file_id。
- **修法方向**：編輯時把 `albums.cover` 的 file_id **preload 成物件**再給 uploader。
  - 用 `\App\Models\Uploader\Files->preload($fileIds)`（回 `{file_id,name,src,...}`）。
  - 在「載入分類資料給編輯表單」那層做（找 bulletins 分類 edit 的 controller/model），或在 cover.html 用 Twig 函式 preload。
  - 注意：admin 需要的是「物件」（含 file_id 才能存回），跟前台 Resolver 的 path 模式不同——前台給路徑、後台給物件。
- **未查完**：尚未確認 bulletins 分類 edit 的資料載入點、uploader.render 期望的物件欄位。下次從 `com/uploader/index.html` 的 render macro 看它讀哪些欄位開始。

### 🔴 BUG：廣告橫幅有問題（待補細節）
- 2026-06-12 收工前用戶回報「廣告橫幅也有問題」，**症狀未確認**（後台編輯看不到既有圖 / 前台橫幅圖或描述沒出來 / 兩者）。
- banner 是 **objectField**（file_id → 完整物件含 content/描述，與封面 cover 的 path 模式不同）。
- 起手點：
  - 前台：`hosts/stations/<station>/.../views/web/com/banner.html` + `banners/layout_*.html`（已做容錯 `banner.src ? banner.src : asset(banner)`、`item.content ? content : banner_desc`），確認 Resolver 的 object 模式有把 banner file_id 換成物件。
  - 後台：admin71 banner 編輯頁的上傳元件，可能跟「封面圖不顯示」同類（拿到裸 file_id 沒 preload）。
  - 資料：`pages_i18n.banner`（file_id）+ `uploaded_files.content`（描述，由 migrate-banner-desc 併入）。

### 🟡 TinyMCE 新上傳的圖：內文格式與集中化不一致（data-file-id adapter）
- **現況**：插圖 → `/admin/api/media/upload` → 檔案進「本機 + R2(若啟用)」、寫 uploaded_files ✓；但 `Media\Upload->handle()` 回傳 `location=$info->path`（只有路徑、無 host），TinyMCE 插入 `<img src="/assets/upload/…/<file_id>.ext">`。
- **兩個 gap**：
  1. 內文存 `<img src="路徑">` 而非 `<img data-file-id>` → 未來換 host 無法靠 resolver 重寫。
  2. location 不含 host → 即使 R2 啟用，新圖內文 src 是相對路徑、走站台網域不走 R2。
- **修法方向**：檔名就是 file_id → 在「存檔寫入 或 取內容」時把 `<img src="/assets/upload/…/<file_id>.ext">` 轉成 `<img data-file-id="<file_id>">`，與舊內文一致、resolver 接得起來。（或 editor.js 上傳後改插 data-file-id；但 TinyMCE images_upload_handler 設計是回 URL 進 src，後處理較單純。）

### ⏳ 編輯器鋪到其他頁（試點已成功，要全面鋪開）
已接：`com/design/description.html`、`com/design/content.html`（文章內文/描述）。
待接（都改成 `<textarea data-editor name>` + 載 editor.js module，移除舊 CK）：
- `com/com.ckeditor.html`（載真 CK4 lib + `CKEDITOR.replace`）
- `com/com.editor.html`（append macro，目前是註解掉的 init）
- `js/editor.js`（`editors.ckeditorART/BUL/PRO` → articles/bulletins/products）
- `Neko.ckeditor.create(...)` 的頁：faqs、forms、properties、banners、slideshow…（grep `assetLibs('libs/neko/neko.ckeditor.js`）
- 全鋪完後可考慮：cf-cdn 的舊 `neko.ckeditor.js` 留著當 fallback；TinyMCE 本體是否也上 CDN（目前自架 admin71）。

### ⏳ 部署待辦
- `cdn-microweb-cc` 還有未 push 的 commit（`license_key:'gpl'` 消除 evaluation 警告；`promotion:false` 拿掉 Upgrade 鈕）→ `git push` + Cloudflare Purge Cache。
- admin71 的 `description.html`/`content.html`、jygesg `conf.inc.php`（cf-cdn）若尚未部署到正式 → 補部署。

---

## 10. 文章/分類資料模型重構（snowflake ID + 去 compID + match 葉子化 + append 集中）

> 決策日：2026-06-22。觸發點：新增文章（尤其「什麼都沒填存草稿」）建立成功卻不進列表。
> 根因是 create/綁定邏輯分散且有條件（只有 `if(!empty($i18n))` 才綁分類）。
>
> **⚠️ 結論（2026-06-22）：此重構延到「大改版」再做，現在別動。** 原因：
> 1. 修當下 bug 不需要它——正常（有填內容）新增已正常進列表（實測 postID 60 OK）；唯一不進的是「完全空白草稿」，那是 **opcache 沒清**（interim 修正 commit a31354bc 已部署但跑舊碼），清 opcache 即解，與 DB 無關。
> 2. 它本質是大改版層級（改 ID 機制 / 欄位型別 / 去 compID / 拆非正規化），與 `define→設定物件、media→file、app目錄` 同級。
> 3. 18 張表 × 逐站 live 協調式遷移，blast radius 大、現無回報。
> 4. snowflake 已定 workerId=0（單站唯一即可），跨站不撞 ID 的動機用不到，無急迫性。
>
> 下面設計**先存放**，大改版時直接取用（屆時仍是 per-station 跑 schema migration）。當下後端若要生效，先解 opcache。

### 10.1 決策內容（4 件事，套用到全 6 模組 articles/products/bulletins/services/videos/tags）
1. **新增 ID 改用 snowflake**：不再靠 DB AUTO_INCREMENT。產生器已存在 `\App\Models\Base\Model::id()`（內部 `\App\Models\Lib\Snowflake->id()`）。新列由程式明確帶入 `postID`/`catID` 再 insert。**既有列不動**（小 int 保留，URL/關聯不破），只有新列是 snowflake。
2. **相關欄位改 BIGINT**：snowflake 是 64-bit，所有承載 id 的欄位都要能放：`{m}_bind.postID`、`{m}_category_match.postID`、`{m}_category_match.catID`、`{m}_category.catID`、`{m}_category.parentID`、`{m}_category.boss_id`（及任何 seoID/relate_id 等指向這些的 FK）。型別 `BIGINT`（建議 `BIGINT UNSIGNED`，與 snowflake 正數一致）。
3. **移除 compID 欄位**：每個站台是單租戶（compID 恆為 1），snowflake 全域唯一後 compID 冗餘。從上述資料表移除 `compID` 欄，並把程式 query 內 `where("compID={compID}")` 一併拿掉。`{m}_category` 同樣處理。⚠️ 影響面大（光 Articles models 就 15+ 檔用到 compID），要連 routes `/c/{compID}/` 一起評估（可保留路由參數但 model 不再用，或一併簡化）。
4. **`{m}_category_match` 只留葉子 catID**：目前 `Match::update()` 會把**所有上層祖先**也寫進 match（`Match.php:31 get_upper_list()`），讓 `boss_id` 列表查詢能用 `catID IN (boss_id)` O(1) 命中。改成只存葉子後：
   - 列表 `boss_id`/root 範圍查詢要改成「先把 root 展開成所有子孫 catID，再 `catID IN(子孫清單)`」（子樹用 parentID 遞迴；注意本站 `root_id` 欄位未填，不能靠它）。
   - 既有 match 要做**資料遷移**：刪掉非葉子（祖先）列，只留葉子。
   - 要回歸測 6 模組的列表 / 計數 / 麵包屑（`get_post_list`/`count`/breadcrumb 是否依賴祖先列）。

### 10.2 邏輯集中到 `gate('main')->append()`
- 在 `PHP71/pubs/apps/Models/Articles/Main.php`（main gate，目前有 info/present/tree_results…無 append）新增 `append()`，作為「建立文章 + 綁定分類」的**唯一入口**：產生 snowflake id → 寫 bind（不帶 compID）→ 綁葉子 catID（Match 改為只寫葉子）→（視需要）建最小 i18n 列，讓空白草稿也能在列表顯示。
- `Article::create()` 改為呼叫 `append()`，移除「只有 i18n 才綁分類」的條件分支（這就是當前 bug 根因）。
- 6 模組共用（各模組 model 純 `extends Articles`，不必各寫一份）。

### 10.3 per-station 遷移步驟（草案，待定稿）
1. 產生並在站台 DB 跑 `article-datamodel-migrate.sql`（每站）：
   - `ALTER TABLE {m}_bind / {m}_category_match / {m}_category` 把 id 類欄位改 `BIGINT UNSIGNED`、`DROP COLUMN compID`。
   - 清 match 祖先列：`DELETE FROM {m}_category_match WHERE catID NOT IN (葉子集合)`（葉子 = 不是任何列 parentID 的 catID）。
2. 部署 PHP71/admin71 程式（append 集中、去 compID、boss_id 子樹展開、snowflake 寫入）。
3. 驗證：新增（含空白草稿）→ 回列表看得到；既有資料正常；分類樹/計數/麵包屑正常。

### 10.3b 分類 path 取代「文章祖先 match 列 / 查詢時子樹展開」（2026-06-22 定案）
觸發：postID 42「資料完整卻在列表消失」——因 match 只有葉子 `(42,10=SASB)`、缺祖先 `(42,1=ESG)`，而文章列表 boss 範圍是 `match.catID=ESG(1)` → 被擋。補祖先列(1)是多餘的。

**定案設計（屬大改版）：boss 範圍改由「分類的 path」判斷，不靠文章的祖先 match 列。**
- `articles_category_bind`（每個分類一列，6 模組各一張）新增 **`path`** 欄：
  - 型別 JSON（與既有 `*_bind.catID` 同風格）；內容 = root→self 的祖先鏈（**含自己**，字串元素），例 SASB(10) → `["1","10"]`、ESG(1) → `["1"]`。
  - 由分類 `parentID` 鏈一次算出；分類**新增/搬移**時（重）寫 path（含所有子孫要連動重算）。
- **`boss_id` 不是欄位**，是查詢值：文章列表「ESG 底下」=
  ```sql
  WHERE a.catID IN (SELECT catID FROM articles_category_bind WHERE JSON_CONTAINS(path,'"1"'))
  ```
  （`a.catID` = 文章的分類；只要葉子即可，「屬於哪個 root」看分類 path。）
- 命名定案：陣列那欄叫 **`path`**（不叫 boss_id——boss_id 語感是單一 root；它是拿來查 path 的值）。
- 效果：文章 `articles_category_match` 可只留葉子（呼應 10.1-4），祖先列變多餘；查詢也不必再展開子樹。
- **過渡（已上線，commit 8ef46929）**：在沒有 path 欄前，文章列表 boss_id 先改「查詢時展開子樹」（`Articles\Lists::to_list_inputs` 用 `Categories\Lists::descendants()` 展開 → `Articles\DB\Lists` 用 `match.catID IN(子樹)`）。path 上線後 `descendants()` 改優先用 path（`subtreeIds`→`JSON_CONTAINS(path,catID)`），查不到/出錯自動 fallback 回子樹展開。

#### per-station 遷移 SQL（path 回填）——**每張 `{module}_category_bind` 都要做**
newui 6 模組：`articles / products / bulletins / services / videos / tags`（menus 是導覽、不在此 boss 範圍，跳過）。空表也要 ALTER（之後有分類才會用）。把下面 `X` 換成各模組各跑一次：
```sql
-- ① 加欄（root 保持 NULL，故不預設值、也不要先填 []）
ALTER TABLE `X_category_bind`
  ADD COLUMN `path` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL
  COMMENT '上層(祖先)catID JSON,root→parent 不含自己,如 ["1"];第一層(root)為 NULL';

-- ② 第二層（父是 root）：path = ["父catID"]
UPDATE `X_category_bind` c JOIN `X_category_bind` p ON p.`catID`=c.`parentID`
  SET c.`path`=CONCAT('["',c.`parentID`,'"]')
  WHERE p.`parentID`=0 OR p.`parentID` IS NULL;

-- ③ 第三層以下（父已有 path）：path = 父path 去尾 ] 接 ,"父catID"]。重複此句直到「0 rows」。
UPDATE `X_category_bind` c JOIN `X_category_bind` p ON p.`catID`=c.`parentID`
  SET c.`path`=CONCAT(LEFT(p.`path`,CHAR_LENGTH(p.`path`)-1),',"',c.`parentID`,'"]')
  WHERE p.`path` IS NOT NULL AND c.`path` IS NULL;
```
⚠ **不要**做「`SET path=JSON_ARRAY() WHERE parentID=0`」那種把 root 填 `[]` 的步驟——root 要留 NULL。若已誤填：`UPDATE X_category_bind SET path=NULL WHERE path='[]';`。
驗證：`SASB→["1"]`、`CDP SC→["1","26"]`、`國際→["1","2"]`、root（ESG）→`NULL`。
程式端（全站一次性，已上線）：`Categories\Bind\Main`(calcPath/syncPath/rebuildAllPaths/subtreeIds)、`Bind\DB\Lists` path_contains、`Categories\Lists::descendants` 改用 path、`Category::updateBind` 掛 `rebuildAllPaths`（module-aware via `$this->key`）。分類新增/編輯/搬移後自動維護 path，不必再手跑回填。

### 10.3c (3) 完整：分類/文章 create 搬進新框架（含 i18n）— 執行計畫（未做）
觸發：path 維護目前掛在舊 `Category::create`/`updateBind`（transitional hook，module-aware via `$this->key`，已上線）。完整版要把 create 整包搬進新框架 `Categories\Main`。
**可行性：有現成範式** `Base\Datatable\I18n\`（App/Main/Lists/DB），base `Datatable\Main::to_update`:108 已用 `gate('i18n')->gate('main')->upsert($id,$i18n)` 寫多語內容。
步驟（每步在 jyg 測，分小步）：
1. **i18n 接新框架**：`Categories\App` schema 加 `i18n`（指內容表 `articles_category` + lng 欄 `lngID`）；建/設定 categories 的 i18n gate（extends `Base\Datatable\I18n`，table=`articles_category`）。驗證 `gate('i18n')->gate('main')->upsert` 能寫分類標題。
2. **`Categories\Main->append`** 編排：① 編碼唯一(is_unique→`編碼重複`，需 schema 設 `no`=code 或自查 bind) ② 算 levelID/root（從 parent `info()`）③ 寫 bind(`module('categories.bind')->gate('main')->append`) 取 catID ④ 寫 content i18n(`gate('i18n')->upsert`) ⑤ `syncPath` ⑥ 「建子分類搬父層文章」(`Match::get_post_list(parent)`→`Match::update(post,新catID)`)。
3. **route** `IndexsAPI::createCategory` → `gate('main')->append`（module-aware）；`update`/`destroy` 同款；拆掉對舊 `Category::create` 的依賴。
4. **文章本身**：`Article::create` 同樣搬進 `Articles\Main->append`（呼應 §10.2 append 集中化）；content 走 **`articles` 表**的 i18n gate。**舊 `articles_list` 表（舊 `Dal/Article` 用）已廢棄、不再維護，遷移完成後可 `DROP TABLE articles_list`（per-station）。**
5. 回歸測：建分類(bind/content/path 都對)、編輯、搬移、建子分類搬文章、6 模組、文章新增/編輯。
⚠ 這是動核心、6 模組、live 的大遷移——**建議在乾淨、專注的一輪 session 做**，分小步逐一測，勿一次盲推。

### 10.4 已定案（2026-06-25）
- **snowflake workerId/datacenterId**：維持預設 `workerId=0,datacenterId=0`。理由：**每個站台本來就是獨立 DB**，單站唯一即足夠，不會跨站撞號；未來若真要合併資料，屆時再處理（不為此預先複雜化）。
- **compID 移除範圍**：**只動文章/分類資料表 + 其 model query**，不動全域 `/c/{compID}/` 路由與其他模組（banner/page/form…）。已確認新框架（`Base/Datatable`、`Articles/Categories`、`Articles/Bind`、`Articles/Main`）**完全沒用到 compID**；只剩舊 DAL 檔（`IndexsWeb`/`Indexs`/`Base`/舊 `Match`）在用，那些正退場。故實體 DROP compID 對新框架讀取安全。
- **遷移執行方式**：照現行慣例＝產出 `.sql` 由你在站台 phpMyAdmin 手動跑（本機不直連線上 DB）。SQL = `docs/ai-agents/project-init/article-datamodel-migrate.sql`。

### 10.5 ⚠ 關鍵耦合 & 執行順序（產 SQL 後盤出，2026-06-25）
盤 jyg `micro_jygesg_migrated.sql` 後確認的硬約束：
1. **`{m}_category_bind.catID` / `{m}_bind.postID` 目前是 `int AUTO_INCREMENT`**(jyg：catID=30、postID=52）。改 bigint 並**拿掉 AUTO_INCREMENT** 後,新增就「只能」靠程式帶入 snowflake id。
   → 結論:**SQL(段 A/B)與 snowflake create 程式必須同一次部署**,不能只跑 SQL。
2. **compID 先不動**:舊建立路徑(`Dal::insBind/insCat` 會 merge `compID`)與**前台讀取**(`conf.twig.php → Article::getList/show` 用 `where compID`)仍在用 compID。physically DROP 會當場壞掉它們 → compID DROP 移到 SQL **段 D 全註解**,待舊 Dal create / 前台搬離 compID 後再跑。(管理端 admin71 已走新框架、不用 compID。)
3. **`{m}_category_design` PK 含 compID**:DROP 需先改 PK 且處理可能重複,風險高 → 段 D 內單獨標記、暫不做(只在段 A 把 design.catID 加寬 bigint)。
4. **match 只留葉子的 DELETE 具破壞性**,前提是 path 已回填且列表已走 path/descendants(已上線)→ SQL **段 C 預設註解**,確認後再手動跑。

**採用的做法(2026-06-25 定，最小改動版,非整包搬新框架):**
達成目標(catID/postID→bigint+snowflake)**不需要**把整包 create 重寫進 `Categories\Main->append`/`Articles\Main->append`(那會盲改一堆未測的上傳/tags/seo/i18n/match 邏輯、風險高)。只在**現有 create 路徑**改用 snowflake:
- `Articles\Category::createBind` + `Dal\Category::insBind` → 明確帶入 `catID`($this->id() snowflake)、回傳該 catID(已完成)
- `Articles\Article::createBind` + `Dal\Article::insBind` → 明確帶入 `postID`(snowflake)、回傳該 postID(已完成)
- 兩者 module-aware($this->key/tabKey),涵蓋全 6 模組;既有 path 維護(`rebuildAllPaths`)、Match 綁定、i18n、上傳全部不動。
- 「整包搬進新框架 append」改列為**日後純重構(不改行為)**,等 datamodel 穩了再做。

**執行順序(per-station,jyg 先):**
- ① 跑 `category-path-migrate.sql`(若尚未跑) → path 就緒。
- ② **同一次**:部署上述程式改動 + 跑 `article-datamodel-migrate.sql` 段 A + 段 B(bigint + 去 auto_increment;compID 不動)。
- ③ 大測(見 §10.6)。
- ④ 確認後再(各自獨立、擇時):段 C(match 葉子清理)、段 D(去 compID,需先搬前台讀取)、`DROP TABLE {m}_list`。

### 10.6 jyg 大測清單(段 A+B 部署後)
- **新增分類**(6 模組各一):成功、catID 是 snowflake(19 位)、bind/content(標題多語)/path 都對、出現在分類樹。
- **建子分類**:父層原本掛的文章有搬到新子分類(Match)。
- **新增文章**(6 模組):成功、postID 是 snowflake、綁到分類後出現在該分類列表;**空白草稿**(沒填內容)也綁得到分類、列表看得到。
- **編碼重複**:重複 code 會擋下並回「編碼重複」。
- **既有資料**:舊的小 int 分類/文章照常顯示、編輯、排序、麵包屑、計數正常。
- **前台**:文章列表/內頁/麵包屑正常(compID 還在,應不受影響)。
- ⚠ 若有「複製/匯入文章」等其他建立入口,確認也帶 snowflake(沒走 createBind 的話會因無 auto_increment 而失敗)。
