Files
CFDivePlatform/openspec/changes/archive/2026-08-02-cloud-ready-s3-storage/design.md
T
a620906209 81411877f8
Run Tests / test (pull_request) Successful in 32s
chore(openspec): 歸檔 cloud-ready-s3-storage + 同步主規格
程式碼部分(tasks 1-3)已完成並測試通過,符合 spec 描述的行為(可測試
的程式邏輯,非部署狀態)。R2 帳號申請、VPS 部署與正式切換(tasks 4-6)
仍暫緩,待後續另開 change 處理,archive 後的 tasks.md 保留暫緩註記。

順便修正 env-cloud-annotations 主規格缺少 ## Purpose section 的問題
(openspec CLI 現版本嚴格驗證要求),這是既有規格檔案的既存缺口,本次
只修了本 change 有動到的這一份,其餘規格檔案的同類問題不在此次範圍。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ
2026-08-03 04:00:17 +08:00

118 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
現況:
- 上傳功能有 6 個呼叫點,全部寫死 `Storage::disk('public')``CourseImageController``CompressesImages` trait、`CourseImage``ProviderCertification` model 的 `Storage::disk('public')->url(...)``BookingMessageController`
- `config/filesystems.php``default` 設定(`FILESYSTEM_DISK` env)**沒有任何程式碼讀取**,因為所有呼叫點都直接指名 `'public'`,不吃 default disk
- `public` disk 目前寫死 `driver => local``root => storage_path('app/public')`
- `s3` disk 設定區塊已存在(Laravel 骨架預設),但未安裝 `league/flysystem-aws-s3-v3`,且應用程式從未使用這個 disk key
- 本機開發環境(docker-compose)沒有任何 S3 相容服務(無 MinIO container
## Goals / Non-Goals
**Goals:**
- 上傳檔案(課程圖片、教練證照、聊天室圖片)改存 S3 相容物件儲存,實現 container 無狀態化,容器可自由重建/複製/水平擴容而不遺失使用者資料
- 零改動 6 個既有呼叫點程式碼
- 本機開發環境不受影響,不需要開發者額外申請 S3 帳號才能跑起來
- 既有已上傳檔案完整遷移,切換後所有舊圖片網址仍可正常顯示
**Non-Goals:**
- 不處理 P0 另一項待辦「移除 bind mount / code 烘進 image」
- 不引入 MinIO 或任何本機 S3 模擬服務——開發環境維持 local disk
- 不重構 6 個呼叫點的程式碼結構(即使 `Storage::disk('public')` 這種寫死字串不是最佳實踐,此次不動,避免範圍擴大)
- 不處理 `storage/logs/` 或其他非使用者上傳內容的磁碟佔用(那是 log rotation 的範疇,非本次目標)
## Decisions
### D1:讓 `public` disk 的 `driver` 本身吃 env,而非新增第二顆 disk
`config/filesystems.php``public` disk 改成:
```php
'public' => [
'driver' => env('FILESYSTEM_DISK', 'local'),
'root' => storage_path('app/public'),
'url' => env('AWS_URL') ?: env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
// S3 專用欄位,driver=local 時被忽略,不影響本機開發
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
],
```
**為什麼不是新增一顆 `s3` disk 再改 6 個呼叫點指過去**:呼叫點越少改動風險越低;`FILESYSTEM_DISK` 這個 env 名稱原本就是為了「決定用什麼儲存」而存在(`env-cloud-annotations` spec 也是這樣描述的語意),只是它原本控制的是沒人用的 `default` key。把它直接接到 `public` disk 的 `driver`,是重新賦予這個既有 env 變數「做它原本應該做的事」,不是新造一個變數。
**取捨**:這是刻意偏離 Laravel 慣例(`FILESYSTEM_DISK` 官方語意是選預設 disk,不是切換單一 disk 的 driver)。因為程式碼現狀已經把 `public` 寫死,順著現狀改動 blast radius 最小;若未來重構掉 6 個寫死呼叫點,屆時可以再拆回標準的雙 disk 寫法。
### D2:本機開發維持 `local``.env.example` 預設不動
`.env.example``FILESYSTEM_DISK=local` 保留不變(只更新註解文字),開發者 clone 專案照舊直接動。VPS 的 `.env`(不進版控)手動改成 `FILESYSTEM_DISK=s3` + 補齊 `AWS_*` credentials,跟 `cloud-ready-p1` 階段 Session/Log 的做法一致(機敏值不走 CI 自動化,一次性手動 SSH 改)。
### D3S3 相容服務選 Cloudflare R2
理由:免費額度(10GB 儲存 + 無出口流量費)對目前用量綽綽有餘;API 相容 S3,`league/flysystem-aws-s3-v3` 原生支援(透過 `AWS_ENDPOINT` + `AWS_USE_PATH_STYLE_ENDPOINT=true` 指向 R2 endpoint)。非鎖定 R2——設計上任何 S3 相容服務(AWS S3、MinIO 等)都能用同一套 env 變數切換,不影響 code。
**取捨**:R2 bucket 預設不開放公開讀取,需要在 R2 後台額外設定 public access 或綁自訂網域,否則圖片網址會回 403。這是外部平台設定,不是 code 範疇,但屬於本次上線前必須確認的步驟。
### D4:既有檔案遷移用一次性 artisan command,不自動刪除本機檔案
新增 `app/Console/Commands/MigrateStorageToS3.php`
- 掃描 `storage/app/public/` 底下所有檔案(排除 `storage:link` 產生的符號連結本身)
- 逐一用 `Storage::disk('s3')->put()` 上傳到 S3/R2,保留原本相對路徑(DB 裡 `image_path` 欄位值不用改,因為路徑結構不變)
- 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗的路徑列表印出來,不中斷整個流程(方便重跑補上傳失敗的部分)
- 支援 `--dry-run` 只列出會上傳的檔案數量與清單,不實際執行
- **不刪除本機檔案**——遷移完成、人工確認 S3 上圖片都能正常顯示後,本機檔案清理是後續手動動作,不在這個 command 的職責內(避免遷移腳本本身變成一個刪檔風險點)
## Risks / Trade-offs
- **[Risk] R2 bucket 未開放公開讀取,切換後圖片全部 403/404**
**Mitigation**:上線前先用 `curl` 直接打 R2 網址驗證至少一張測試圖可公開存取,再切 `FILESYSTEM_DISK=s3`
- **[Risk] 遷移腳本執行中網路中斷,部分檔案未上傳成功,但沒人發現**
**Mitigation**:command 明確印出失敗清單並回傳非 0 exit code;上線前先跑 `--dry-run` 確認檔案總數,遷移後跑一次全量 `exists()` 檢查
- **[Risk] `env('FILESYSTEM_DISK')` 語意被重新定義,未來新開發者看 Laravel 官方文件會誤解**
**Mitigation**:在 `config/filesystems.php` 加註解說明此專案的特殊用法(此為文件層級緩解,非 code 邏輯風險)
- **[Risk] Composer 新依賴需要 rebuild imageVPS 有短暫停機窗口**
**Mitigation**:沿用 `cloud-ready-p1` 已驗證過的 `docker compose up -d --build` 流程,排低流量時段
## Migration Plan
**程式碼(PR 合併前):**
1. `composer require league/flysystem-aws-s3-v3`
2. `config/filesystems.php``public` disk 改為 D1 的設定
3. `.env.example`:補齊 `AWS_ENDPOINT``AWS_URL``AWS_USE_PATH_STYLE_ENDPOINT` 說明
4. 新增 `MigrateStorageToS3` artisan command + 測試
**VPS 維護窗口(PR merge + CI/CD 完成後):**
```bash
# 1. R2 後台先建好 bucket,設定 public access,取得 endpoint/bucket 資訊
# 2. SSH 進 VPS,更新 .env(不進版控)
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION=auto
# AWS_BUCKET / AWS_ENDPOINT / AWS_URL / AWS_USE_PATH_STYLE_ENDPOINT=true
# FILESYSTEM_DISK 暫時保留 local(先不切)
# 3. Rebuild(帶入新 composer 依賴)
docker compose up -d --build
# 4. 用 s3 disk 跑遷移(此時 FILESYSTEM_DISK 仍是 local,遷移用另一個 artisan 參數指定目標 disk
docker compose exec app php artisan storage:migrate-to-s3 --dry-run
docker compose exec app php artisan storage:migrate-to-s3
# 5. 驗證幾張圖片的 R2 直連網址可正常開啟後,才切換
sed -i 's/FILESYSTEM_DISK=local/FILESYSTEM_DISK=s3/' .env
docker compose exec app php artisan config:clear
# 6. 實際跑一次上傳/刪除/顯示(三個功能各測一次)
```
## Open Questions
- R2 帳號是否已申請?需要 Hank 先建好 bucket 才能進入 VPS 維護窗口步驟。
- 是否綁自訂網域到 R2(例如 `cdn.hank-space.com`)取代 R2 預設網域?影響 `AWS_URL` 設定值,建議上線前確認。