Files
CFDivePlatform/openspec/specs/course-image-upload/spec.md
T
a620906209 6414222a18
Run Tests / test (pull_request) Successful in 2m0s
feat(images): 課程圖片伺服器端壓縮——scaleDown 2048 + JPEG 85,上限放寬至 10MB
實作 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>
2026-06-11 23:43:24 +08:00

104 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### 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 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。上傳大小上限為 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: 10MB 以內的手機原圖可直接上傳
- **WHEN** 上傳 2MB~10MB 之間的圖片(原規格會拒絕)
- **THEN** 系統接受並壓縮儲存
#### Scenario: 覆蓋封面時刪除舊檔
- **WHEN** Provider 上傳新封面,且原本已有封面
- **THEN** 系統先刪除舊實體檔案,再儲存新檔案
#### Scenario: 檔案格式驗證
- **WHEN** 上傳的檔案不是 jpeg/jpg/png/webp
- **THEN** 系統回傳 422
#### Scenario: 檔案大小驗證
- **WHEN** 上傳檔案超過 10MB10240KB
- **THEN** 系統回傳 422
#### Scenario: 不可上傳他人課程的封面
- **WHEN** Provider 嘗試上傳不屬於自己課程的封面
- **THEN** 系統回傳 403
### Requirement: Provider 刪除課程封面
Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。
#### Scenario: 成功刪除封面
- **WHEN** Provider 送出 `DELETE /api/provider/offers/{id}/cover`
- **THEN** 系統刪除實體檔案,將 `diving_offers.cover_image` 設為 null,回傳 200
#### Scenario: 無封面時刪除不報錯
- **WHEN** Provider 刪除封面,但 `cover_image` 本來就是 null
- **THEN** 系統直接回傳 200,不報錯
### Requirement: Provider 上傳課程相簿圖片
Provider SHALL 能為課程上傳最多 3 張相簿圖片,sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。
#### Scenario: 成功上傳相簿圖片
- **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 再次上傳
- **THEN** 系統回傳 422message:「相簿最多 3 張圖片」
#### Scenario: 不可上傳他人課程的相簿
- **WHEN** Provider 嘗試上傳不屬於自己課程的相簿
- **THEN** 系統回傳 403
### Requirement: Provider 刪除相簿圖片
Provider SHALL 能刪除自己課程的特定相簿圖片,同步移除實體檔案。
#### Scenario: 成功刪除相簿圖片
- **WHEN** Provider 送出 `DELETE /api/provider/images/{image_id}`
- **THEN** 系統刪除實體檔案,刪除 `course_images` 紀錄,回傳 200
#### Scenario: 不可刪除他人相簿圖片
- **WHEN** Provider 嘗試刪除不屬於自己課程的相簿圖片
- **THEN** 系統回傳 403
### Requirement: 課程刪除時清理圖片目錄
DivingOffer 刪除時,系統 SHALL 自動清除 `offers/{offer_id}/` 整個目錄,防止孤兒檔案累積。
#### Scenario: 刪除課程時清除圖片
- **WHEN** DivingOffer 被刪除(`static::deleting()` observer 觸發)
- **THEN** `Storage::disk('public')->deleteDirectory("offers/{$offer->id}")` 刪除封面與相簿所有實體檔案
### Requirement: 圖片 URL 隨課程資料一同回傳
公開課程 API SHALL 在回傳課程資料時包含 `cover_image_url`(含 APP_URL 與 port 的完整 URL)與 `images`(相簿陣列)。
#### Scenario: 有封面時回傳完整 URL
- **WHEN** 任何人取得課程資料(`GET /api/diving-offers/{id}` 或列表)
- **THEN** `cover_image_url` 回傳可直接用於 `<img src>` 的完整 URL(如 `http://host:port/storage/offers/...`);無封面時回傳 null
#### Scenario: 相簿陣列回傳
- **WHEN** 取得課程詳情 `GET /api/diving-offers/{id}`
- **THEN** `images` 回傳相簿圖片陣列,每筆含 `id``url``sort_order`,依 `sort_order ASC` 排序;無相簿時回傳空陣列
### Requirement: 圖片儲存持久化(Bind Mount
圖片 SHALL 儲存於 `./storage/app/public/`host bind mount),`app``nginx` 容器透過 `./:/var/www` 共享同一目錄,跨容器重建後仍可存取。`APP_URL` 必須包含正確 port(如 `http://localhost:8080`)才能產生正確的圖片 URL。
#### Scenario: 容器重建後圖片保留
- **WHEN** 執行 `docker compose up --build` 並重新啟動容器
- **THEN** 先前上傳的圖片仍可正常存取(URL 不變),因圖片在 host 目錄不受容器重建影響