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:
@@ -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