diff --git a/app/Http/Controllers/API/CourseImageController.php b/app/Http/Controllers/API/CourseImageController.php index 6cfcfdb..8e699bb 100644 --- a/app/Http/Controllers/API/CourseImageController.php +++ b/app/Http/Controllers/API/CourseImageController.php @@ -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([ diff --git a/app/Traits/CompressesImages.php b/app/Traits/CompressesImages.php new file mode 100644 index 0000000..4c2ed5b --- /dev/null +++ b/app/Traits/CompressesImages.php @@ -0,0 +1,33 @@ +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; + } +} diff --git a/frontend/src/components/CourseCard.vue b/frontend/src/components/CourseCard.vue index 917f4ec..81bc8a8 100644 --- a/frontend/src/components/CourseCard.vue +++ b/frontend/src/components/CourseCard.vue @@ -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" />
diff --git a/frontend/src/views/CourseDetailView.vue b/frontend/src/views/CourseDetailView.vue index bb8fba8..69c782d 100644 --- a/frontend/src/views/CourseDetailView.vue +++ b/frontend/src/views/CourseDetailView.vue @@ -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" > - +
diff --git a/openspec/changes/course-image-compression/.openspec.yaml b/openspec/changes/course-image-compression/.openspec.yaml new file mode 100644 index 0000000..e0c0898 --- /dev/null +++ b/openspec/changes/course-image-compression/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-11 diff --git a/openspec/changes/course-image-compression/design.md b/openspec/changes/course-image-compression/design.md new file mode 100644 index 0000000..d70903a --- /dev/null +++ b/openspec/changes/course-image-compression/design.md @@ -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) diff --git a/openspec/changes/course-image-compression/proposal.md b/openspec/changes/course-image-compression/proposal.md new file mode 100644 index 0000000..7482026 --- /dev/null +++ b/openspec/changes/course-image-compression/proposal.md @@ -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 冒險重構) +- 前端課程列表/詳情的 `` 加 `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) diff --git a/openspec/changes/course-image-compression/specs/course-image-upload/spec.md b/openspec/changes/course-image-compression/specs/course-image-upload/spec.md new file mode 100644 index 0000000..529405e --- /dev/null +++ b/openspec/changes/course-image-compression/specs/course-image-upload/spec.md @@ -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`,回傳新圖片資訊 diff --git a/openspec/changes/course-image-compression/tasks.md b/openspec/changes/course-image-compression/tasks.md new file mode 100644 index 0000000..e13a9d5 --- /dev/null +++ b/openspec/changes/course-image-compression/tasks.md @@ -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` 的課程圖 `` 加 `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` diff --git a/openspec/specs/course-image-upload/spec.md b/openspec/specs/course-image-upload/spec.md index a577627..4f0c18e 100644 --- a/openspec/specs/course-image-upload/spec.md +++ b/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 再次上傳 diff --git a/tests/Feature/CourseImageTest.php b/tests/Feature/CourseImageTest.php index 291ed4e..f5255b7 100644 --- a/tests/Feature/CourseImageTest.php +++ b/tests/Feature/CourseImageTest.php @@ -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');