From e794fddfb145e5342465b477cbd33ab73020b99b Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 3 Aug 2026 03:23:38 +0800 Subject: [PATCH 1/4] =?UTF-8?q?chore(openspec):=20=E6=96=B0=E5=A2=9E=20clo?= =?UTF-8?q?ud-ready-s3-storage=20change=20=E8=A6=8F=E5=8A=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 延續 P1 雲原生改造方向,規劃將上傳檔案(課程圖片、教練證照、聊天室圖片) 改存 S3 相容物件儲存,實現 container 無狀態化。核心設計:public disk driver 直接吃 FILESYSTEM_DISK env 切換,既有 6 個呼叫點零改動,本機開發 環境維持 local 預設不受影響。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ --- .../cloud-ready-s3-storage/.openspec.yaml | 2 + .../changes/cloud-ready-s3-storage/design.md | 117 ++++++++++++++++++ .../cloud-ready-s3-storage/proposal.md | 31 +++++ .../specs/env-cloud-annotations/spec.md | 24 ++++ .../specs/file-storage-s3/spec.md | 31 +++++ .../changes/cloud-ready-s3-storage/tasks.md | 46 +++++++ 6 files changed, 251 insertions(+) create mode 100644 openspec/changes/cloud-ready-s3-storage/.openspec.yaml create mode 100644 openspec/changes/cloud-ready-s3-storage/design.md create mode 100644 openspec/changes/cloud-ready-s3-storage/proposal.md create mode 100644 openspec/changes/cloud-ready-s3-storage/specs/env-cloud-annotations/spec.md create mode 100644 openspec/changes/cloud-ready-s3-storage/specs/file-storage-s3/spec.md create mode 100644 openspec/changes/cloud-ready-s3-storage/tasks.md diff --git a/openspec/changes/cloud-ready-s3-storage/.openspec.yaml b/openspec/changes/cloud-ready-s3-storage/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/cloud-ready-s3-storage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/cloud-ready-s3-storage/design.md b/openspec/changes/cloud-ready-s3-storage/design.md new file mode 100644 index 0000000..4cbe3dd --- /dev/null +++ b/openspec/changes/cloud-ready-s3-storage/design.md @@ -0,0 +1,117 @@ +## 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 改)。 + +### D3:S3 相容服務選 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 image,VPS 有短暫停機窗口** + → **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` 設定值,建議上線前確認。 diff --git a/openspec/changes/cloud-ready-s3-storage/proposal.md b/openspec/changes/cloud-ready-s3-storage/proposal.md new file mode 100644 index 0000000..85aed88 --- /dev/null +++ b/openspec/changes/cloud-ready-s3-storage/proposal.md @@ -0,0 +1,31 @@ +## Why + +作為實踐雲原生架構的一環,延續 P1 階段(Session/Log/Scheduler 雲端化)的方向,將使用者上傳檔案(課程圖片、教練證照、預約聊天室圖片)改為 S3 相容物件儲存,是 container 無狀態化的標準作法——容器可以隨時被重建、複製、水平擴容,而不必依賴本機磁碟保留使用者資料。`env-cloud-annotations` spec 已標注 `FILESYSTEM_DISK` 雲端應設為 `s3`,但目前只是註解提醒,尚未實際遷移——`.env.example` 仍預設 `local`,`public` disk 驅動也還是本機磁碟。這是 cloud-ready 路線圖 P0 剩餘兩項之一(另一項為移除 bind mount,不在本次範圍)。 + +## What Changes + +- **`composer.json`**:新增 `league/flysystem-aws-s3-v3`(Laravel 官方 S3 驅動套件,目前未安裝) +- **`config/filesystems.php`**:`public` disk 的 `driver` 從 `local` 改為 `s3` 相容設定(**disk key 名稱維持 `public` 不變**,因為 6 個既有呼叫點都寫死 `Storage::disk('public')`,此法零程式碼改動) +- **`.env.example`**:`FILESYSTEM_DISK` **預設維持 `local`**(本機開發環境沒有 S3 相容服務可用,維持 dev 友善),補上 `AWS_ENDPOINT`、`AWS_URL`、`AWS_USE_PATH_STYLE_ENDPOINT` 等 R2 相容欄位與「雲端環境請設為 s3」的說明註解 +- **新增一次性遷移 artisan command**:把 `storage/app/public/` 現有檔案(課程圖片、教練證照、聊天室圖片)上傳到 S3/R2,避免切換後舊圖 404 +- **VPS `.env`**:手動新增 AWS/R2 credentials(機敏值,不進版控) +- **驗證**:上傳、刪除、顯示三個流程在課程圖片、教練證照、預約聊天室圖片三個既有功能上皆需重新驗證 + +## Capabilities + +### New Capabilities +- `file-storage-s3`:使用者上傳檔案(課程圖片、教練證照、聊天室圖片)存放於 S3 相容物件儲存,而非 container 本地磁碟;container 重建/水平擴容時檔案不遺失 + +### Modified Capabilities +- `env-cloud-annotations`:`FILESYSTEM_DISK` 需求從「標注建議值」升級為「實際預設值」,比照 P1 階段 `SESSION_DRIVER`/`LOG_CHANNEL` 已完成的模式 + +## Impact + +- **`config/filesystems.php`**:`public` disk 驅動設定 +- **`composer.json` / `composer.lock`**:新增 S3 驅動依賴 +- **`.env.example`**:新增/調整 AWS_* 相關變數與說明 +- **新增檔案**:一次性遷移 command(`app/Console/Commands/`) +- **VPS `.env`**:需手動補齊 credentials(不進版控) +- **外部依賴(新增)**:需要一組 S3 相容物件儲存帳號(建議 Cloudflare R2,免費額度足夠現行用量) +- **不影響**:`CourseImageController`、`CompressesImages` trait、`CourseImage`/`ProviderCertification` model、`BookingMessageController` 這 6 個呼叫點程式碼本身(disk 名稱不變);資料庫 schema;前端 +- **部署影響**:需 rebuild app image(新 composer 依賴);遷移舊檔案時有短暫視窗新舊圖網址不一致,建議排低流量時段 diff --git a/openspec/changes/cloud-ready-s3-storage/specs/env-cloud-annotations/spec.md b/openspec/changes/cloud-ready-s3-storage/specs/env-cloud-annotations/spec.md new file mode 100644 index 0000000..4bbe5a1 --- /dev/null +++ b/openspec/changes/cloud-ready-s3-storage/specs/env-cloud-annotations/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: .env.example 標示雲端必要變數 +`.env.example` SHALL 以行內註解或正確預設值標示以下雲端部署時必須明確設定的變數: +- `FILESYSTEM_DISK`:**預設維持 `local`**(本機開發環境無 S3 相容服務可用);雲端部署時 SHALL 手動設為 `s3` 並補齊 `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_BUCKET`/`AWS_ENDPOINT`/`AWS_URL`/`AWS_USE_PATH_STYLE_ENDPOINT` 等變數,此時上傳行為 SHALL 實際切換至 S3 相容物件儲存(不再只是文件註解提醒,`public` disk 驅動確實依此變數切換) +- `LOG_CHANNEL`:預設改為 `stderr`(雲端 logging aggregator 標準;原 `stack/daily` 寫檔案不適合雲端) +- `QUEUE_CONNECTION`:預設為 `redis` +- `SESSION_DRIVER`:預設改為 `redis`(取代 `database`,擴容更高效) + +#### Scenario: 新環境照 .env.example 初始化 session driver +- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `SESSION_DRIVER` +- **THEN** Laravel session driver 使用 Redis + +#### Scenario: 新環境照 .env.example 初始化 log channel +- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `LOG_CHANNEL` +- **THEN** Laravel 日誌寫入 stderr,可透過 `docker compose logs app` 查看 + +#### Scenario: 新環境照 .env.example 初始化檔案儲存 +- **WHEN** 開發者以 `.env.example` 為基礎建立 `.env`,未手動修改 `FILESYSTEM_DISK` +- **THEN** 上傳檔案寫入本機磁碟,不需任何 S3 credentials 即可完整跑起來 + +#### Scenario: 操作者閱讀 .env.example 進行雲端部署 +- **WHEN** 操作者參照 `.env.example` 設定雲端環境的 `.env` +- **THEN** 每個雲端關鍵變數旁有說明,提示預設值在雲端環境的限制與建議替代值,且 `FILESYSTEM_DISK` 的說明明確指出改為 `s3` 後需一併設定哪些 `AWS_*` 變數 diff --git a/openspec/changes/cloud-ready-s3-storage/specs/file-storage-s3/spec.md b/openspec/changes/cloud-ready-s3-storage/specs/file-storage-s3/spec.md new file mode 100644 index 0000000..ba839d4 --- /dev/null +++ b/openspec/changes/cloud-ready-s3-storage/specs/file-storage-s3/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: 上傳檔案可切換為 S3 相容物件儲存 +`public` disk SHALL 支援透過 `FILESYSTEM_DISK` 與 `AWS_*` 環境變數切換底層儲存為 S3 相容物件儲存(如 Cloudflare R2),且不需修改任何呼叫 `Storage::disk('public')` 的既有程式碼。 + +#### Scenario: FILESYSTEM_DISK 設為 s3 時上傳走物件儲存 +- **WHEN** `.env` 設定 `FILESYSTEM_DISK=s3` 並提供有效的 `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_BUCKET`/`AWS_ENDPOINT` +- **THEN** 課程圖片上傳(`CourseImageController`)、教練證照上傳、預約聊天室圖片上傳皆寫入該 S3 相容物件儲存,本機 `storage/app/public/` 不再新增檔案 + +#### Scenario: FILESYSTEM_DISK 設為 local 時維持本機磁碟(開發環境預設) +- **WHEN** `.env` 未設定或設定 `FILESYSTEM_DISK=local` +- **THEN** 上傳行為與現行本機磁碟儲存完全一致,不需任何 S3 credentials + +#### Scenario: 顯示已上傳檔案的網址依 disk 驅動自動切換 +- **WHEN** 呼叫 `CourseImage`/`ProviderCertification` model 的 `Storage::disk('public')->url(...)` +- **THEN** 回傳的網址依當前 `FILESYSTEM_DISK` 設定,分別指向本機 `/storage/...` 路徑或 S3 相容物件儲存的公開網址 + +### Requirement: 既有本機檔案可一次性遷移至 S3 相容物件儲存 +系統 SHALL 提供一個 artisan command,將 `storage/app/public/` 下既有檔案上傳至當前設定的 S3 相容物件儲存,且不刪除本機原始檔案。 + +#### Scenario: 執行遷移 command 上傳既有檔案 +- **WHEN** 操作者執行 `php artisan storage:migrate-to-s3` +- **THEN** `storage/app/public/` 下所有檔案(不含 `storage:link` 符號連結本身)被上傳至 S3 相容物件儲存的對應相對路徑,且本機檔案保持不變 + +#### Scenario: 遷移 command 支援 dry-run 預覽 +- **WHEN** 操作者執行 `php artisan storage:migrate-to-s3 --dry-run` +- **THEN** command 僅列出將被上傳的檔案清單與總數,不實際執行任何上傳 + +#### Scenario: 部分檔案上傳失敗不中斷整體流程 +- **WHEN** 遷移過程中某個檔案上傳失敗(如網路中斷) +- **THEN** command 記錄該檔案為失敗、繼續處理其餘檔案,結束後印出失敗清單並以非 0 exit code 結束 diff --git a/openspec/changes/cloud-ready-s3-storage/tasks.md b/openspec/changes/cloud-ready-s3-storage/tasks.md new file mode 100644 index 0000000..f5d1b7e --- /dev/null +++ b/openspec/changes/cloud-ready-s3-storage/tasks.md @@ -0,0 +1,46 @@ +## 1. 依賴與設定 [後端] + +- [ ] 1.1 [後端] `composer require league/flysystem-aws-s3-v3` +- [ ] 1.2 [後端] `config/filesystems.php`:`public` disk 改為 D1 設計(`driver` 吃 `env('FILESYSTEM_DISK', 'local')`,補上 `key`/`secret`/`region`/`bucket`/`endpoint`/`use_path_style_endpoint`),並加註解說明此專案對 `FILESYSTEM_DISK` 的特殊用法 +- [ ] 1.3 [後端] `.env.example`:`FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT`、`AWS_URL`、`AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明 + +## 2. 遷移 Command [後端] + +- [ ] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php`(`storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結 +- [ ] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳 +- [ ] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗) +- [ ] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗) + +## 3. 測試 [後端] + +- [ ] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷 +- [ ] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest`/`ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸 +- [ ] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作) + +## 4. R2 環境準備 [整合測試] + +- [ ] 4.1 [整合測試] 於 Cloudflare R2 建立 bucket,設定 public access(或綁自訂網域) +- [ ] 4.2 [整合測試] 取得 `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_BUCKET`/`AWS_ENDPOINT` 等 R2 credentials +- [ ] 4.3 [整合測試] 用 `curl` 直接打 R2 網址驗證至少一個手動上傳的測試檔案可公開存取(403 排查必須在此步驟完成,不帶進生產切換) + +## 5. VPS 部署與遷移 [整合測試] + +- [ ] 5.1 [整合測試] SSH 進 VPS,`.env` 補齊 R2 credentials,`FILESYSTEM_DISK` 暫時保留 `local` +- [ ] 5.2 [整合測試] `docker compose up -d --build`(帶入新 composer 依賴) +- [ ] 5.3 [整合測試] 執行 `docker compose exec app php artisan storage:migrate-to-s3 --dry-run`,確認檔案數量與清單合理 +- [ ] 5.4 [整合測試] 執行 `docker compose exec app php artisan storage:migrate-to-s3`,記錄失敗清單(若有)並重跑補上傳 +- [ ] 5.5 [整合測試] 抽查數個已遷移檔案的 R2 直連網址,確認可正常開啟 + +## 6. 切換與驗證 [整合測試] + +- [ ] 6.1 [整合測試] VPS `.env` 將 `FILESYSTEM_DISK` 改為 `s3`,`docker compose exec app php artisan config:clear` +- [ ] 6.2 [整合測試] 手動驗證課程圖片上傳/刪除/顯示三個流程 +- [ ] 6.3 [整合測試] 手動驗證教練證照上傳/顯示流程 +- [ ] 6.4 [整合測試] 手動驗證預約聊天室圖片上傳/顯示流程 +- [ ] 6.5 [整合測試] 確認 `storage/app/public/` 本機檔案仍完整保留(作為遷移後的備份,暫不刪除) + +## 7. OpenSpec 同步 + +- [ ] 7.1 更新 `openspec/specs/env-cloud-annotations/spec.md`(套用本次 delta) +- [ ] 7.2 新增 `openspec/specs/file-storage-s3/spec.md` +- [ ] 7.3 完成後歸檔本 change(`openspec archive cloud-ready-s3-storage`) From 54178f6d70b80b701c4f7f4bc33d9d35ffba8d3f Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 3 Aug 2026 03:35:24 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(storage):=20S3=20=E7=9B=B8=E5=AE=B9?= =?UTF-8?q?=E7=89=A9=E4=BB=B6=E5=84=B2=E5=AD=98=E6=94=AF=E6=8F=B4=20+=20?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=E9=81=B7=E7=A7=BB=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實作 cloud-ready-s3-storage 的程式碼部分(tasks 1-3): - config/filesystems.php:public disk 的 driver 改吃 FILESYSTEM_DISK env, 切換為 s3 時不需改動既有 6 個 Storage::disk('public') 呼叫點 - .env.example:FILESYSTEM_DISK 維持 local 預設(本機無 S3 服務),補齊 AWS_ENDPOINT/AWS_URL/AWS_USE_PATH_STYLE_ENDPOINT 供 R2 等服務使用 - 新增 storage:migrate-to-s3 指令:一次性把 storage/app/public 既有檔案 上傳到 S3,來源固定讀本機磁碟(不受 FILESYSTEM_DISK 影響),支援 --dry-run,失敗不中斷且不刪除本機檔案 - 新增測試:遷移指令(上傳/dry-run/部分失敗)+ public disk 在 local driver 下零 AWS credentials 也能正常運作 239 tests passed / 578 assertions,容器內驗證無回歸。 R2 環境準備、VPS 部署與實際切換(tasks 4-6)待 Hank 自行處理。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ --- .env.example | 6 +- app/Console/Commands/MigrateStorageToS3.php | 83 +++++ composer.json | 1 + composer.lock | 346 +++++++++++++++++- config/filesystems.php | 14 +- .../changes/cloud-ready-s3-storage/tasks.md | 20 +- tests/Feature/FilesystemPublicDiskTest.php | 43 +++ tests/Feature/MigrateStorageToS3Test.php | 82 +++++ 8 files changed, 579 insertions(+), 16 deletions(-) create mode 100644 app/Console/Commands/MigrateStorageToS3.php create mode 100644 tests/Feature/FilesystemPublicDiskTest.php create mode 100644 tests/Feature/MigrateStorageToS3Test.php diff --git a/.env.example b/.env.example index 88cb57f..b7bde77 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案) +FILESYSTEM_DISK=local # 本機開發維持 local;雲端部署改為 s3 時需一併設定下方 AWS_* 變數(含 AWS_ENDPOINT/AWS_URL) QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容 CACHE_STORE=redis @@ -60,7 +60,9 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +AWS_ENDPOINT= # S3 相容服務(如 Cloudflare R2)的 endpoint URL,AWS S3 本身留空即可 +AWS_URL= # 公開存取用的網址前綴(如 R2 公開網域),留空時 fallback 為 AWS SDK 預設組法 +AWS_USE_PATH_STYLE_ENDPOINT=false # Cloudflare R2 需設為 true VITE_APP_NAME="${APP_NAME}" diff --git a/app/Console/Commands/MigrateStorageToS3.php b/app/Console/Commands/MigrateStorageToS3.php new file mode 100644 index 0000000..1515561 --- /dev/null +++ b/app/Console/Commands/MigrateStorageToS3.php @@ -0,0 +1,83 @@ + 'local', + 'root' => storage_path('app/public'), + ]); + + $files = $source->allFiles(); + + if (empty($files)) { + $this->info('storage/app/public 底下沒有檔案,無需遷移。'); + + return self::SUCCESS; + } + + if ($this->option('dry-run')) { + $this->info(sprintf('[dry-run] 將上傳 %d 個檔案:', count($files))); + + foreach ($files as $file) { + $this->line(" - {$file}"); + } + + return self::SUCCESS; + } + + $destination = Storage::disk('s3'); + $failed = []; + + $this->info(sprintf('開始上傳 %d 個檔案至 S3...', count($files))); + $bar = $this->output->createProgressBar(count($files)); + + foreach ($files as $file) { + try { + $destination->put($file, $source->get($file)); + + if (! $destination->exists($file)) { + $failed[] = $file; + } + } catch (Throwable $e) { + $failed[] = $file; + $this->newLine(); + $this->warn("上傳失敗:{$file}({$e->getMessage()})"); + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $succeeded = count($files) - count($failed); + $this->info(sprintf('完成:%d/%d 個檔案上傳成功。', $succeeded, count($files))); + + if (! empty($failed)) { + $this->error('以下檔案上傳失敗,本機檔案未刪除,可重新執行本指令補上傳:'); + + foreach ($failed as $file) { + $this->line(" - {$file}"); + } + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/composer.json b/composer.json index 6cc8900..9c98590 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/sanctum": "^4.1", "laravel/socialite": "^5.20", "laravel/tinker": "^2.9", + "league/flysystem-aws-s3-v3": "^3.35", "predis/predis": "^3.4", "sentry/sentry-laravel": "^4.25" }, diff --git a/composer.lock b/composer.lock index df4b54e..0f40218 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d2ea97103ddb23bb4bc532f34d4a2297", + "content-hash": "2ecac4c7d6ab5ffb62eeb2a4c6c69cb1", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.390.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "a09e95bf062e15b7343f9c1362fd45c7a0dec39b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a09e95bf062e15b7343f9c1362fd45c7a0dec39b", + "reference": "a09e95bf062e15b7343f9c1362fd45c7a0dec39b", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "mtdowling/jmespath.php": "^2.9.1", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.390.2" + }, + "time": "2026-07-31T18:06:07+00:00" + }, { "name": "brick/math", "version": "0.12.3", @@ -2543,6 +2694,61 @@ }, "time": "2024-10-08T08:58:34+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.35.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.371.5", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2" + }, + "time": "2026-07-01T23:25:49+00:00" + }, { "name": "league/flysystem-local", "version": "3.29.0", @@ -3001,6 +3207,72 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.52" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.9-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.9.2" + }, + "time": "2026-07-06T18:56:19+00:00" + }, { "name": "nesbot/carbon", "version": "3.9.1", @@ -6021,6 +6293,76 @@ ], "time": "2024-09-25T14:20:29+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/ff16a16bf87fdf264638b8f6995b3515975e3c79", + "reference": "ff16a16bf87fdf264638b8f6995b3515975e3c79", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T07:36:05+00:00" + }, { "name": "symfony/finder", "version": "v7.2.2", @@ -10768,5 +11110,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/config/filesystems.php b/config/filesystems.php index 44fe9c8..39f4dbb 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -36,12 +36,22 @@ return [ 'throw' => false, ], + // 注意:本專案所有上傳程式碼都寫死 Storage::disk('public'),不吃 'default' disk。 + // 因此這裡讓 'public' disk 的 driver 直接讀 FILESYSTEM_DISK,而非新增一顆獨立的 + // 's3' disk 再改呼叫點——FILESYSTEM_DISK=local 時以下 S3 專屬欄位會被忽略。 + // 詳見 openspec/changes/cloud-ready-s3-storage/design.md 的 D1。 'public' => [ - 'driver' => 'local', + 'driver' => env('FILESYSTEM_DISK', 'local'), 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', + 'url' => env('AWS_URL') ?: env('APP_URL').'/storage', 'visibility' => 'public', 'throw' => false, + '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' => [ diff --git a/openspec/changes/cloud-ready-s3-storage/tasks.md b/openspec/changes/cloud-ready-s3-storage/tasks.md index f5d1b7e..b941f02 100644 --- a/openspec/changes/cloud-ready-s3-storage/tasks.md +++ b/openspec/changes/cloud-ready-s3-storage/tasks.md @@ -1,21 +1,21 @@ ## 1. 依賴與設定 [後端] -- [ ] 1.1 [後端] `composer require league/flysystem-aws-s3-v3` -- [ ] 1.2 [後端] `config/filesystems.php`:`public` disk 改為 D1 設計(`driver` 吃 `env('FILESYSTEM_DISK', 'local')`,補上 `key`/`secret`/`region`/`bucket`/`endpoint`/`use_path_style_endpoint`),並加註解說明此專案對 `FILESYSTEM_DISK` 的特殊用法 -- [ ] 1.3 [後端] `.env.example`:`FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT`、`AWS_URL`、`AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明 +- [x] 1.1 [後端] `composer require league/flysystem-aws-s3-v3` +- [x] 1.2 [後端] `config/filesystems.php`:`public` disk 改為 D1 設計(`driver` 吃 `env('FILESYSTEM_DISK', 'local')`,補上 `key`/`secret`/`region`/`bucket`/`endpoint`/`use_path_style_endpoint`),並加註解說明此專案對 `FILESYSTEM_DISK` 的特殊用法 +- [x] 1.3 [後端] `.env.example`:`FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT`、`AWS_URL`、`AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明 ## 2. 遷移 Command [後端] -- [ ] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php`(`storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結 -- [ ] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳 -- [ ] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗) -- [ ] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗) +- [x] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php`(`storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結 +- [x] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳 +- [x] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗) +- [x] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗) ## 3. 測試 [後端] -- [ ] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷 -- [ ] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest`/`ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸 -- [ ] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作) +- [x] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷 +- [x] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest`/`ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸 +- [x] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作) ## 4. R2 環境準備 [整合測試] diff --git a/tests/Feature/FilesystemPublicDiskTest.php b/tests/Feature/FilesystemPublicDiskTest.php new file mode 100644 index 0000000..be7c1a5 --- /dev/null +++ b/tests/Feature/FilesystemPublicDiskTest.php @@ -0,0 +1,43 @@ +put('filesystem-public-disk-test.txt', 'ok'); + + $this->assertTrue(Storage::disk('public')->exists('filesystem-public-disk-test.txt')); + + Storage::disk('public')->delete('filesystem-public-disk-test.txt'); + } + + public function test_public_disk_driver_follows_filesystem_disk_env(): void + { + $this->assertSame( + env('FILESYSTEM_DISK', 'local'), + config('filesystems.disks.public.driver'), + 'public disk 的 driver 應完全跟隨 FILESYSTEM_DISK env(未設定時預設 local)' + ); + } +} diff --git a/tests/Feature/MigrateStorageToS3Test.php b/tests/Feature/MigrateStorageToS3Test.php new file mode 100644 index 0000000..0bb3cb2 --- /dev/null +++ b/tests/Feature/MigrateStorageToS3Test.php @@ -0,0 +1,82 @@ +createdFiles as $relativePath) { + @unlink(storage_path('app/public/'.$relativePath)); + } + $this->createdFiles = []; + + parent::tearDown(); + } + + private function putLocalFile(string $relativePath, string $contents = 'test-content'): void + { + $fullPath = storage_path('app/public/'.$relativePath); + @mkdir(dirname($fullPath), 0777, true); + file_put_contents($fullPath, $contents); + $this->createdFiles[] = $relativePath; + } + + public function test_uploads_existing_local_files_to_s3(): void + { + Storage::fake('s3'); + $this->putLocalFile('migrate-test/one.txt', 'file-one'); + $this->putLocalFile('migrate-test/two.txt', 'file-two'); + + $this->artisan('storage:migrate-to-s3')->assertSuccessful(); + + Storage::disk('s3')->assertExists('migrate-test/one.txt'); + Storage::disk('s3')->assertExists('migrate-test/two.txt'); + } + + public function test_dry_run_lists_files_without_uploading(): void + { + Storage::fake('s3'); + $this->putLocalFile('migrate-test/dry.txt'); + + $this->artisan('storage:migrate-to-s3', ['--dry-run' => true])->assertSuccessful(); + + Storage::disk('s3')->assertMissing('migrate-test/dry.txt'); + } + + public function test_no_files_to_migrate_still_succeeds(): void + { + Storage::fake('s3'); + + $this->artisan('storage:migrate-to-s3')->assertSuccessful(); + } + + public function test_reports_failure_and_does_not_stop_on_partial_upload_error(): void + { + $this->putLocalFile('migrate-test/ok.txt', 'ok'); + $this->putLocalFile('migrate-test/bad.txt', 'bad'); + + // storage/app/public 是共用的本機目錄,可能已存在其他測試無關的檔案(如手動測試留下的 + // 上傳圖片),所以這裡不斷言呼叫次數,只鎖定我們自己建立的兩個檔案的行為,其餘路徑一律放行。 + $mockDisk = Mockery::mock(); + $mockDisk->shouldReceive('put') + ->with('migrate-test/bad.txt', Mockery::any()) + ->once() + ->andThrow(new RuntimeException('simulated network failure')); + $mockDisk->shouldReceive('put') + ->withArgs(fn (string $path) => $path !== 'migrate-test/bad.txt') + ->andReturn(true); + $mockDisk->shouldReceive('exists')->andReturn(true); + + Storage::partialMock()->shouldReceive('disk')->with('s3')->andReturn($mockDisk); + + $this->artisan('storage:migrate-to-s3')->assertFailed(); + } +} From 1c81b5fc10eb6225267bf2609ad96682afe0de47 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 3 Aug 2026 03:38:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(openspec):=20=E6=A8=99=E8=A8=98=20clou?= =?UTF-8?q?d-ready-s3-storage=20R2/VPS=20=E9=9A=8E=E6=AE=B5=E6=9A=AB?= =?UTF-8?q?=E7=B7=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hank 目前沒有 S3 相容服務帳號,tasks 4-6(R2 建置、VPS 遷移、切換驗證) 暫緩,等申請帳號後再繼續。程式碼部分(1-3 組)已完成,不受影響。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ --- openspec/changes/cloud-ready-s3-storage/tasks.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/cloud-ready-s3-storage/tasks.md b/openspec/changes/cloud-ready-s3-storage/tasks.md index b941f02..bad1890 100644 --- a/openspec/changes/cloud-ready-s3-storage/tasks.md +++ b/openspec/changes/cloud-ready-s3-storage/tasks.md @@ -19,6 +19,8 @@ ## 4. R2 環境準備 [整合測試] +> **暫緩(2026-08-03)**:Hank 目前沒有 R2(或其他 S3 相容服務)帳號,4-6 組任務全部擱置,等有帳號後再繼續。程式碼部分(1-3 組)已完成且測試通過,可以先合併,不影響現有功能。 + - [ ] 4.1 [整合測試] 於 Cloudflare R2 建立 bucket,設定 public access(或綁自訂網域) - [ ] 4.2 [整合測試] 取得 `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_BUCKET`/`AWS_ENDPOINT` 等 R2 credentials - [ ] 4.3 [整合測試] 用 `curl` 直接打 R2 網址驗證至少一個手動上傳的測試檔案可公開存取(403 排查必須在此步驟完成,不帶進生產切換) From a0fa339d6dbbdba8960c08295e768f11bcefdaaa Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 3 Aug 2026 03:40:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E6=A8=99=E8=A8=BB=20S3=20=E7=9B=B8?= =?UTF-8?q?=E5=AE=B9=E5=84=B2=E5=AD=98=E5=B0=9A=E6=9C=AA=E5=95=9F=E7=94=A8?= =?UTF-8?q?=EF=BC=88=E7=BC=BA=20R2=20=E5=B8=B3=E8=99=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 .env.example 的 AWS_* 區塊與 MigrateStorageToS3 command 加上 TODO(2026-08-03) 註解,說明目前功能已實作但因缺帳號未啟用,直接在 code 裡就看得到狀態,不用另外翻 openspec 文件。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ --- .env.example | 3 +++ app/Console/Commands/MigrateStorageToS3.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index b7bde77..798fb8a 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,9 @@ MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +# TODO(2026-08-03): 尚未申請 S3 相容服務帳號,以下 AWS_* 僅供未來啟用 FILESYSTEM_DISK=s3 +# 時填入,目前留空不影響任何功能(public disk 走 local)。詳見 +# openspec/changes/cloud-ready-s3-storage/tasks.md 第 4-6 組。 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/app/Console/Commands/MigrateStorageToS3.php b/app/Console/Commands/MigrateStorageToS3.php index 1515561..4ba6612 100644 --- a/app/Console/Commands/MigrateStorageToS3.php +++ b/app/Console/Commands/MigrateStorageToS3.php @@ -6,6 +6,9 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Storage; use Throwable; +// TODO(2026-08-03): 尚未申請 S3 相容服務帳號,此指令目前無法在正式環境執行 +// (會因缺少 AWS_* credentials 連線失敗)。待 R2 帳號建立後才會實際使用, +// 見 openspec/changes/cloud-ready-s3-storage/tasks.md 第 4-6 組。 class MigrateStorageToS3 extends Command { protected $signature = 'storage:migrate-to-s3