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:
2026-08-03 03:35:24 +08:00
parent e794fddfb1
commit 54178f6d70
8 changed files with 579 additions and 16 deletions
@@ -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;
}
}