feat(images): 課程圖片伺服器端壓縮——scaleDown 2048 + JPEG 85,上限放寬至 10MB
Run Tests / test (pull_request) Successful in 2m0s
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:
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 3(surgical)不在本 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** 上傳檔案超過 10MB(10240KB)
|
||||
- **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`
|
||||
@@ -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** 上傳檔案超過 2MB(2048KB)
|
||||
- **WHEN** 上傳檔案超過 10MB(10240KB)
|
||||
- **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 再次上傳
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user