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
@@ -5,15 +5,19 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\CourseImage;
use App\Models\DivingOffer;
use App\Traits\CompressesImages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CourseImageController extends Controller
{
use CompressesImages;
private function validateImage(Request $request): void
{
$request->validate([
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
// 10MB:伺服器端會壓縮(compressToJpeg),放寬以容納手機原圖
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:10240',
]);
}
@@ -34,7 +38,7 @@ class CourseImageController extends Controller
Storage::disk('public')->delete($offer->cover_image);
}
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/cover");
$offer->update(['cover_image' => $path]);
return response()->json([
@@ -67,7 +71,7 @@ class CourseImageController extends Controller
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
}
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
$path = $this->compressToJpeg($request->file('image'), "offers/{$offerId}/gallery");
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
$image = CourseImage::create([
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Traits;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
trait CompressesImages
{
/**
* 壓縮上傳圖片並存入 public disk,回傳相對路徑。
* 參數與聊天圖片管線一致(scaleDown 2048 + JPEG quality 85),
* 確保平台內影像處理行為單一來源。
*/
private function compressToJpeg(UploadedFile $file, string $directory): string
{
$manager = new ImageManager(new Driver());
$image = $manager->read($file);
if ($image->width() > 2048 || $image->height() > 2048) {
$image->scaleDown(width: 2048, height: 2048);
}
$path = trim($directory, '/') . '/' . Str::uuid() . '.jpg';
Storage::disk('public')->put($path, $image->toJpeg(quality: 85));
return $path;
}
}
+1
View File
@@ -14,6 +14,7 @@ defineProps({
v-if="offer.cover_image_url"
:src="offer.cover_image_url"
:alt="offer.title"
loading="lazy"
class="w-full h-full object-cover"
/>
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
+1 -1
View File
@@ -147,7 +147,7 @@ async function submitBooking() {
target="_blank"
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
>
<img :src="img.url" class="w-full h-full object-cover" />
<img :src="img.url" loading="lazy" class="w-full h-full object-cover" />
</a>
</div>
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-11
@@ -0,0 +1,31 @@
# Design — course-image-compression
## D1:壓縮參數與聊天管線完全一致
`scaleDown(2048×2048,僅縮不放大) + toJpeg(quality: 85)`。理由:聊天圖片已用此參數運行無客訴,平台內影像品質一致;2048px 足夠 Retina 全螢幕顯示,quality 85 是檔案大小與畫質的常見甜蜜點(5MB 手機照通常壓到 300~600KB)。
不做多尺寸縮圖(列表 600px 版本):Simplicity First——先用單一尺寸 + 前端 lazy load 解決主要痛點,若日後列表頁仍慢再開 change 加 responsive 尺寸。
## D2:共用邏輯抽 trait、聊天端暫不重構
壓縮邏輯在聊天與課程兩處重複,抽 `App\Traits\CompressesImages::compressToJpeg(UploadedFile, string $directory): string`(回傳儲存路徑)。本 change 只讓 `CourseImageController` 使用;`BookingMessageController` 改用 trait 屬行為等價重構,但該處**無測試覆蓋**,依 Rule 3surgical)不在本 change 動,列為後續清理項。
選 trait 而非 Service class:專案已有 `app/Traits/` 慣例、無 `app/Services/`;單一方法的無狀態工具用 trait 侵入最小。
## D3:一律轉存 .jpg 的取捨
- PNG 透明 → GD toJpeg 轉為實色背景。課程圖片皆為實拍照片,透明場景不存在,可接受
- 副檔名統一 `.jpg`:儲存路徑由 `store()` 隨機檔名改為 `Storage::put` + uuid 檔名(與聊天一致),舊圖路徑不受影響(DB 存相對路徑,URL accessor 不變)
- 既有舊圖不回溯處理:數量少且會隨教練更新自然汰換;回溯腳本是過度工程
## D4:驗證上限 10MB 的位置
維持 Laravel validation `max:10240`(與聊天一致)。壓縮在 validation 之後、儲存之前,記憶體峰值由 `memory_limit=512M`local.ini)涵蓋——10MB JPEG 解碼後約 2048×2048×4bytes ≈ 16MB 級別,安全。
## D5:測試策略
既有 `CourseImageTest` 14 案例的調整面:
- 「upload cover too large」:5MB → 改為 >10MB 才 422;新增 5MB 通過案例
- 路徑斷言:`offers/{id}/cover/` 目錄不變,副檔名改 `.jpg`
- 新增:>2048px 的 fake 圖上傳後實際尺寸 ≤2048(用 Intervention 讀回驗證)
- 測試必須在容器內跑(本機 laragon PHP 缺 GD
@@ -0,0 +1,26 @@
## Why
課程封面與圖庫圖片目前**原檔直存**(`CourseImageController` 只驗證格式與 2MB 上限,無任何處理),而聊天圖片已有成熟的壓縮管線(`scaleDown(2048) + toJpeg(85)``BookingMessageController.php:115`)。後果:
1. 課程列表/詳情頁直接載入未壓縮原圖,頁面重量大、行動網路下體感慢(效能優化計畫 O3.1,`docs/analysis/2026-06-11-performance-optimization-plan.md` §Phase 3
2. 2MB 上限對手機拍攝原圖(5~10MB)太嚴格,教練必須自行縮圖才能上傳——體驗差且實務上常失敗
## What Changes
- `CourseImageController``uploadCover``uploadImage` 套用與聊天圖片一致的壓縮管線:超過 2048px 等比縮小、轉存 JPEG quality 85
- 上傳大小上限 2MB → **10MB**(與聊天圖片一致;反正入庫前會壓縮)
- 壓縮邏輯抽共用 trait `App\Traits\CompressesImages`(聊天與課程兩處重複,依維護性規則抽共用;聊天端改用 trait 留待後續——該處無測試覆蓋,不在本 change 冒險重構)
- 前端課程列表/詳情的 `<img>``loading="lazy"`
- 更新 `CourseImageTest` 既有案例(儲存副檔名、大小上限)並新增壓縮行為測試
## Capabilities
### Modified Capabilities
- `course-image-upload`:上傳大小上限 2MB→10MB;新增「伺服器端壓縮」requirement(縮圖、JPEG 轉存、尺寸上限 2048px
## Impact
- 影響檔案:`CourseImageController`、新增 `app/Traits/CompressesImages.php``tests/Feature/CourseImageTest.php`、前端 `CoursesView.vue` / `CourseDetailView.vue`
- 行為變更:儲存檔案一律為 `.jpg`(PNG 透明區域轉為實色背景——課程照片皆為實拍照,可接受,見 design);既有已上傳的舊圖不處理(數量少,自然汰換)
- 依賴:Intervention Image v3 + GD 已安裝(聊天功能在用);測試需在容器內跑(本機 PHP 缺 GD)
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: 伺服器端圖片壓縮
系統 SHALL 在儲存課程封面與相簿圖片前進行壓縮:長邊超過 2048px 時等比縮小至 2048px 內(僅縮不放大),一律轉存 JPEG(quality 85,副檔名 `.jpg`uuid 檔名)。與聊天圖片管線(`scaleDown(2048) + toJpeg(85)`)參數一致。
#### Scenario: 大圖縮小
- **WHEN** Provider 上傳長邊 > 2048px 的圖片
- **THEN** 儲存的檔案長邊 ≤ 2048px,格式為 JPEG
#### Scenario: 小圖不放大
- **WHEN** Provider 上傳長邊 ≤ 2048px 的圖片
- **THEN** 儲存的檔案維持原尺寸,格式轉為 JPEG
#### Scenario: PNG/WebP 轉存 JPEG
- **WHEN** Provider 上傳 png 或 webp 格式圖片
- **THEN** 儲存的檔案為 `.jpg`,回傳的 URL 指向轉存後檔案
## MODIFIED Requirements
### Requirement: Provider 上傳課程封面
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。上傳大小上限為 10MB(原 2MB;伺服器端會壓縮,放寬以容納手機原圖)。
#### Scenario: 成功上傳封面
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤10MB
- **THEN** 系統壓縮後儲存至 `public` disk 的 `offers/{offer_id}/cover/` 目錄(`.jpg`),更新 `diving_offers.cover_image`,回傳 `cover_image_url`
#### Scenario: 檔案大小驗證
- **WHEN** 上傳檔案超過 10MB10240KB
- **THEN** 系統回傳 422
#### Scenario: 10MB 以內的手機原圖可直接上傳
- **WHEN** 上傳 2MB~10MB 之間的圖片(原規格會拒絕)
- **THEN** 系統接受並壓縮儲存
### Requirement: Provider 上傳課程相簿圖片
Provider SHALL 能為課程上傳最多 3 張相簿圖片(格式與大小限制同封面:jpeg/png/webp、≤10MB,壓縮後存為 `.jpg`),sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。
#### Scenario: 成功上傳相簿圖片
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案,且目前相簿圖片數 < 3
- **THEN** 系統壓縮後儲存至 `offers/{offer_id}/gallery/` 目錄(`.jpg`),建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
@@ -0,0 +1,23 @@
## 1. 共用壓縮 trait
- [x] 1.1 新增 `app/Traits/CompressesImages.php``compressToJpeg(UploadedFile $file, string $directory): string`——Intervention v3 讀取 → 長邊 >2048 時 `scaleDown(2048, 2048)``toJpeg(quality: 85)``Storage::disk('public')->put("{$directory}/{uuid}.jpg", ...)`,回傳相對路徑
## 2. CourseImageController 套用
- [x] 2.1 `validateImage()``max:2048``max:10240`
- [x] 2.2 `uploadCover()``store()` 改用 `compressToJpeg($file, "offers/{$offerId}/cover")`
- [x] 2.3 `uploadImage()`:同上,目錄 `offers/{$offerId}/gallery`
## 3. 前端 lazy loading
- [x] 3.1 `frontend/src/views/CoursesView.vue``CourseDetailView.vue` 的課程圖 `<img>``loading="lazy"`(其他列表元件若有課程圖一併處理)
## 4. 測試
- [x] 4.1 更新 `CourseImageTest`:大小上限案例改 >10MB 422、新增 5MB 通過案例、儲存路徑斷言改 `.jpg`
- [x] 4.2 新增壓縮行為案例:上傳 3000×3000 fake 圖 → 讀回實際尺寸 ≤2048;上傳 PNG → 儲存為 `.jpg`
- [x] 4.3 容器內 `php artisan test` 全綠(基準:151 passed;本機缺 GD 不可作準)
## 5. 規格同步
- [x] 5.1 將本 change 的 specs 增量套用至主規格 `openspec/specs/course-image-upload/spec.md`
+25 -6
View File
@@ -1,9 +1,28 @@
### Requirement: 伺服器端圖片壓縮
系統 SHALL 在儲存課程封面與相簿圖片前進行壓縮:長邊超過 2048px 時等比縮小至 2048px 內(僅縮不放大),一律轉存 JPEG(quality 85,副檔名 `.jpg`uuid 檔名)。與聊天圖片管線(`scaleDown(2048) + toJpeg(85)`)參數一致,共用 `App\Traits\CompressesImages`
#### Scenario: 大圖縮小
- **WHEN** Provider 上傳長邊 > 2048px 的圖片
- **THEN** 儲存的檔案長邊 ≤ 2048px,格式為 JPEG
#### Scenario: 小圖不放大
- **WHEN** Provider 上傳長邊 ≤ 2048px 的圖片
- **THEN** 儲存的檔案維持原尺寸,格式轉為 JPEG
#### Scenario: PNG/WebP 轉存 JPEG
- **WHEN** Provider 上傳 png 或 webp 格式圖片
- **THEN** 儲存的檔案為 `.jpg`,回傳的 URL 指向轉存後檔案
### Requirement: Provider 上傳課程封面
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。上傳大小上限為 10MB(原 2MB;伺服器端會壓縮,放寬以容納手機原圖)。
#### Scenario: 成功上傳封面
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤2MB
- **THEN** 系統儲存檔案`public` disk 的 `offers/{offer_id}/cover/` 目錄,更新 `diving_offers.cover_image`,回傳 `cover_image_url`
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤10MB
- **THEN** 系統壓縮後儲存至 `public` disk 的 `offers/{offer_id}/cover/` 目錄`.jpg`,更新 `diving_offers.cover_image`,回傳 `cover_image_url`
#### Scenario: 10MB 以內的手機原圖可直接上傳
- **WHEN** 上傳 2MB~10MB 之間的圖片(原規格會拒絕)
- **THEN** 系統接受並壓縮儲存
#### Scenario: 覆蓋封面時刪除舊檔
- **WHEN** Provider 上傳新封面,且原本已有封面
@@ -14,7 +33,7 @@ Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆
- **THEN** 系統回傳 422
#### Scenario: 檔案大小驗證
- **WHEN** 上傳檔案超過 2MB2048KB
- **WHEN** 上傳檔案超過 10MB10240KB
- **THEN** 系統回傳 422
#### Scenario: 不可上傳他人課程的封面
@@ -36,8 +55,8 @@ Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。
Provider SHALL 能為課程上傳最多 3 張相簿圖片,sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。
#### Scenario: 成功上傳相簿圖片
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制),且目前相簿圖片數 < 3
- **THEN** 系統儲存檔案`offers/{offer_id}/gallery/` 目錄,建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制jpeg/png/webp、≤10MB),且目前相簿圖片數 < 3
- **THEN** 系統壓縮後儲存至 `offers/{offer_id}/gallery/` 目錄`.jpg`,建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
#### Scenario: 超過 3 張上限
- **WHEN** 課程已有 3 張相簿圖片,Provider 再次上傳
+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');