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

5.5 KiB
Raw Blame History

Requirement: 伺服器端圖片壓縮

系統 SHALL 在儲存課程封面與相簿圖片前進行壓縮:長邊超過 2048px 時等比縮小至 2048px 內(僅縮不放大),一律轉存 JPEG(quality 85,副檔名 .jpguuid 檔名)。與聊天圖片管線(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 系統回傳 422,message:「相簿最多 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 回傳相簿圖片陣列,每筆含 idurlsort_order,依 sort_order ASC 排序;無相簿時回傳空陣列

Requirement: 圖片儲存持久化(Bind Mount

圖片 SHALL 儲存於 ./storage/app/public/host bind mount),appnginx 容器透過 ./:/var/www 共享同一目錄,跨容器重建後仍可存取。APP_URL 必須包含正確 port(如 http://localhost:8080)才能產生正確的圖片 URL。

Scenario: 容器重建後圖片保留

  • WHEN 執行 docker compose up --build 並重新啟動容器
  • THEN 先前上傳的圖片仍可正常存取(URL 不變),因圖片在 host 目錄不受容器重建影響