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:
@@ -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 再次上傳
|
||||
|
||||
Reference in New Issue
Block a user