feat(storage): S3 相容物件儲存支援 + 一次性遷移指令
實作 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ
This commit is contained in:
+4
-2
@@ -34,7 +34,7 @@ SESSION_PATH=/
|
|||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local # 雲端應改為 s3(local 在容器重啟後會遺失上傳檔案)
|
FILESYSTEM_DISK=local # 本機開發維持 local;雲端部署改為 s3 時需一併設定下方 AWS_* 變數(含 AWS_ENDPOINT/AWS_URL)
|
||||||
QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
|
QUEUE_CONNECTION=redis # 雲端建議值;database 效能較差且不利擴容
|
||||||
|
|
||||||
CACHE_STORE=redis
|
CACHE_STORE=redis
|
||||||
@@ -60,7 +60,9 @@ AWS_ACCESS_KEY_ID=
|
|||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
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}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"laravel/sanctum": "^4.1",
|
"laravel/sanctum": "^4.1",
|
||||||
"laravel/socialite": "^5.20",
|
"laravel/socialite": "^5.20",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
|
"league/flysystem-aws-s3-v3": "^3.35",
|
||||||
"predis/predis": "^3.4",
|
"predis/predis": "^3.4",
|
||||||
"sentry/sentry-laravel": "^4.25"
|
"sentry/sentry-laravel": "^4.25"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+344
-2
@@ -4,8 +4,159 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d2ea97103ddb23bb4bc532f34d4a2297",
|
"content-hash": "2ecac4c7d6ab5ffb62eeb2a4c6c69cb1",
|
||||||
"packages": [
|
"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",
|
"name": "brick/math",
|
||||||
"version": "0.12.3",
|
"version": "0.12.3",
|
||||||
@@ -2543,6 +2694,61 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-10-08T08:58:34+00:00"
|
"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",
|
"name": "league/flysystem-local",
|
||||||
"version": "3.29.0",
|
"version": "3.29.0",
|
||||||
@@ -3001,6 +3207,72 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-03-24T10:02:05+00:00"
|
"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",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.9.1",
|
"version": "3.9.1",
|
||||||
@@ -6021,6 +6293,76 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-09-25T14:20:29+00:00"
|
"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",
|
"name": "symfony/finder",
|
||||||
"version": "v7.2.2",
|
"version": "v7.2.2",
|
||||||
@@ -10768,5 +11110,5 @@
|
|||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -36,12 +36,22 @@ return [
|
|||||||
'throw' => false,
|
'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' => [
|
'public' => [
|
||||||
'driver' => 'local',
|
'driver' => env('FILESYSTEM_DISK', 'local'),
|
||||||
'root' => storage_path('app/public'),
|
'root' => storage_path('app/public'),
|
||||||
'url' => env('APP_URL').'/storage',
|
'url' => env('AWS_URL') ?: env('APP_URL').'/storage',
|
||||||
'visibility' => 'public',
|
'visibility' => 'public',
|
||||||
'throw' => false,
|
'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' => [
|
's3' => [
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
## 1. 依賴與設定 [後端]
|
## 1. 依賴與設定 [後端]
|
||||||
|
|
||||||
- [ ] 1.1 [後端] `composer require league/flysystem-aws-s3-v3`
|
- [x] 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` 的特殊用法
|
- [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` 的特殊用法
|
||||||
- [ ] 1.3 [後端] `.env.example`:`FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT`、`AWS_URL`、`AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明
|
- [x] 1.3 [後端] `.env.example`:`FILESYSTEM_DISK` 保留 `local` 預設,更新行內註解;補齊 `AWS_ENDPOINT`、`AWS_URL`、`AWS_USE_PATH_STYLE_ENDPOINT` 三個變數與說明
|
||||||
|
|
||||||
## 2. 遷移 Command [後端]
|
## 2. 遷移 Command [後端]
|
||||||
|
|
||||||
- [ ] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php`(`storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結
|
- [x] 2.1 [後端] 新增 `app/Console/Commands/MigrateStorageToS3.php`(`storage:migrate-to-s3` 指令),掃描 `storage/app/public/` 並上傳至 `Storage::disk('s3')`,保留相對路徑,排除 `storage:link` 符號連結
|
||||||
- [ ] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳
|
- [x] 2.2 [後端] 實作 `--dry-run` 參數:只列出檔案清單與總數,不執行上傳
|
||||||
- [ ] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗)
|
- [x] 2.3 [後端] 上傳後用 `Storage::disk('s3')->exists()` 驗證,失敗檔案收集成清單並於結尾印出,command 以非 0 exit code 結束(若有失敗)
|
||||||
- [ ] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗)
|
- [x] 2.4 [後端] 確認 command 不刪除任何本機檔案(無論成功或失敗)
|
||||||
|
|
||||||
## 3. 測試 [後端]
|
## 3. 測試 [後端]
|
||||||
|
|
||||||
- [ ] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷
|
- [x] 3.1 [後端] `MigrateStorageToS3` command 測試:正常上傳、`--dry-run` 不執行上傳、部分失敗時正確回報且不中斷
|
||||||
- [ ] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest`/`ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸
|
- [x] 3.2 [後端] `config/filesystems.php` 改動後,既有 `CourseImageTest`/`ProviderCertification` 相關測試在 `FILESYSTEM_DISK=local`(預設)下全數通過,確認零回歸
|
||||||
- [ ] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作)
|
- [x] 3.3 [後端] 新增測試驗證 `FILESYSTEM_DISK=local` 與未設定時行為一致(呼叫 `Storage::disk('public')` 不需任何 AWS credentials 即可運作)
|
||||||
|
|
||||||
## 4. R2 環境準備 [整合測試]
|
## 4. R2 環境準備 [整合測試]
|
||||||
|
|
||||||
|
|||||||
@@ -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)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user