feat(images): 課程圖片伺服器端壓縮——scaleDown 2048 + JPEG 85,上限放寬至 10MB
Run Tests / test (pull_request) Successful in 2m0s

實作 openspec change course-image-compression(O3.1 體感優化):
- 新增 App\Traits\CompressesImages:與聊天圖片管線參數一致的共用壓縮邏輯
- CourseImageController 封面/相簿套用壓縮,上傳上限 2MB→10MB(容納手機原圖)
- 前端課程卡片與相簿縮圖加 loading=lazy(詳情頁首屏封面刻意不加)
- CourseImageTest +4 案例(10MB 邊界、轉存 .jpg、>2048 縮小、小圖不放大)
- course-image-upload 主規格同步(新增伺服器端壓縮 requirement)

容器內全套件 155 passed / 400 assertions。
聊天端改用 trait 留待後續(該處無測試覆蓋,不在本 change 重構)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:43:24 +08:00
parent 87a4f70e65
commit 6414222a18
11 changed files with 255 additions and 11 deletions
+66 -1
View File
@@ -73,10 +73,75 @@ class CourseImageTest extends TestCase
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('big.png')->size(3000),
'image' => UploadedFile::fake()->image('big.png')->size(11000),
])->assertStatus(422);
}
public function test_upload_cover_accepts_phone_sized_original(): void
{
// 原上限 2MB 會擋掉手機原圖;放寬至 10MB 後 5MB 應通過(伺服器端會壓縮)
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('phone.jpg', 800, 600)->size(5000),
])->assertOk();
}
public function test_uploaded_cover_is_stored_as_jpeg(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('photo.png', 400, 300),
])->assertOk();
$path = $offer->fresh()->cover_image;
$this->assertStringEndsWith('.jpg', $path);
Storage::disk('public')->assertExists($path);
}
public function test_oversized_cover_is_scaled_down_to_2048(): void
{
// 2048px 上限保護的是課程列表頁的載入重量:原圖直存會讓手機照片
// (數 MB)直接進列表,壓縮管線是 O3.1 體感優化的核心
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('huge.jpg', 3000, 2500),
])->assertOk();
$stored = Storage::disk('public')->get($offer->fresh()->cover_image);
[$width, $height] = getimagesizefromstring($stored);
$this->assertLessThanOrEqual(2048, $width);
$this->assertLessThanOrEqual(2048, $height);
}
public function test_small_image_is_not_upscaled(): void
{
Storage::fake('public');
$provider = $this->makeProvider();
$offer = $this->makeOffer($provider);
$this->actingAs($provider)
->postJson("/api/provider/offers/{$offer->id}/cover", [
'image' => UploadedFile::fake()->image('small.jpg', 640, 480),
])->assertOk();
$stored = Storage::disk('public')->get($offer->fresh()->cover_image);
[$width, $height] = getimagesizefromstring($stored);
$this->assertSame(640, $width);
$this->assertSame(480, $height);
}
public function test_upload_cover_forbidden_for_other_provider(): void
{
Storage::fake('public');