Merge pull request 'feat(storage): S3 相容物件儲存支援(cloud-ready-s3-storage)' (#55) from feat/cloud-ready-s3-storage into master
Deploy Production / deploy (push) Successful in 23s
Run Tests / test (push) Failing after 15s

Reviewed-on: #55
This commit was merged in pull request #55.
This commit is contained in:
2026-08-02 19:44:03 +00:00
13 changed files with 828 additions and 6 deletions
+7 -2
View File
@@ -34,7 +34,7 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案
FILESYSTEM_DISK=local # 本機開發維持 local;雲端部署改為 s3 時需一併設定下方 AWS_* 變數(含 AWS_ENDPOINTAWS_URL
QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
CACHE_STORE=redis
@@ -56,11 +56,16 @@ 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
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_ENDPOINT= # S3 相容服務(如 Cloudflare R2)的 endpoint URLAWS S3 本身留空即可
AWS_URL= # 公開存取用的網址前綴(如 R2 公開網域),留空時 fallback 為 AWS SDK 預設組法
AWS_USE_PATH_STYLE_ENDPOINT=false # Cloudflare R2 需設為 true
VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
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
{--dry-run : 只列出將上傳的檔案與總數,不實際執行}';
protected $description = '將本機 storage/app/public 下既有上傳檔案搬遷至 S3 相容物件儲存(public disk 切換前的一次性遷移,不刪除本機檔案)';
public function handle(): int
{
// 直接指定 local 磁碟為來源,不透過會依 FILESYSTEM_DISK 變動的 'public' disk
// 避免這支指令在 FILESYSTEM_DISK 已切成 s3 後誤把來源當成目的地。
$source = Storage::build([
'driver' => '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;
}
}
+1
View File
@@ -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"
},
Generated
+344 -2
View File
@@ -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"
}
+12 -2
View File
@@ -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' => [
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -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 改)。
### 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` 設定值,建議上線前確認。
@@ -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 依賴);遷移舊檔案時有短暫視窗新舊圖網址不一致,建議排低流量時段
@@ -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_*` 變數
@@ -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 結束
@@ -0,0 +1,48 @@
## 1. 依賴與設定 [後端]
- [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 [後端]
- [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. 測試 [後端]
- [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 環境準備 [整合測試]
> **暫緩(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 排查必須在此步驟完成,不帶進生產切換)
## 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`
@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FilesystemPublicDiskTest extends TestCase
{
protected function tearDown(): void
{
@unlink(storage_path('app/public/filesystem-public-disk-test.txt'));
parent::tearDown();
}
public function test_public_disk_works_on_local_driver_without_aws_credentials(): void
{
Config::set('filesystems.disks.public.driver', 'local');
Config::set('filesystems.disks.public.key', null);
Config::set('filesystems.disks.public.secret', null);
Config::set('filesystems.disks.public.region', null);
Config::set('filesystems.disks.public.bucket', null);
Config::set('filesystems.disks.public.endpoint', null);
Storage::forgetDisk('public');
Storage::disk('public')->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'
);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Tests\Feature;
use Illuminate\Support\Facades\Storage;
use Mockery;
use RuntimeException;
use Tests\TestCase;
class MigrateStorageToS3Test extends TestCase
{
private array $createdFiles = [];
protected function tearDown(): void
{
foreach ($this->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();
}
}