Files
CFDivePlatform/openspec/changes/archive/2026-05-12-course-images/tasks.md
T
a620906209 4baa4cb52b feat:實作課程圖片上傳 — 封面 + 相簿管理
後端:
- Migration:diving_offers 新增 cover_image 欄位、新增 course_images 表(含索引)
- CourseImage Model(CREATED_AT、url accessor)
- DivingOffer:cover_image_url accessor、hasMany courseImages、static::deleting() 孤兒清理
- CourseImageController:封面上傳/刪除、相簿上傳(max 3)/刪除,統一 mimes+size 驗證
- DivingOfferController:index/show 回傳加入 cover_image_url 與 images 陣列
- 修正 APP_URL 加入 port(:8080),Storage::url() 才能產生正確圖片連結

前端:
- courseImageApi.js:uploadCover/deleteCover/uploadImage/deleteImage
- CourseCard:有封面顯示 <img>,無封面顯示漸層佔位
- CourseDetailView:封面大圖 + 相簿縮圖橫列(點擊開新分頁)
- OfferFormView(編輯模式):封面預覽/更換/刪除、相簿縮圖管理(達 3 張隱藏上傳按鈕)

基礎設施:
- docker-entrypoint.sh:加入 storage:link --force
- docker-compose.yml:移除 storage-data named volume(改用 bind mount,避免 Nginx 讀不到圖片)

測試:
- CourseImageTest.php:14 個 Feature Test 全部 PASS(Storage::fake)
  涵蓋:上傳成功/格式驗證/大小驗證/所有權、刪除/無封面不報錯、
        相簿上限/sort_order 遞增、孤兒清理

OpenSpec:
- course-images change 歸檔至 archive/2026-05-12-course-images
- 新增 specs/course-image-upload 主規格(含 bind mount 持久化說明)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 03:54:45 +08:00

5.6 KiB
Raw Blame History

1. 資料庫層

  • 1.1 [後端] 建立 Migration add_cover_image_to_diving_offers_table:新增 cover_image varchar(500) nullable
  • 1.2 [後端] 建立 Migration create_course_images_table:欄位含 diving_offer_idFK cascade)、image_pathvarchar 500)、sort_orderunsignedSmallInt DEFAULT 0)、$table->timestamp('created_at')->useCurrent()DB 自動填入,無 updated_at);加索引 (diving_offer_id, sort_order)
  • 1.3 [後端] 執行 Migration,確認欄位與索引正確

2. Model 層

  • 2.1 [後端] 建立 app/Models/CourseImage.phpfillablediving_offer_id、image_path、sort_order)、public $timestamps = falseconst CREATED_AT = 'created_at'(讓 Eloquent 知道此欄存在)、belongsTo DivingOffer、url accessorStorage::disk('public')->url($this->image_path)
  • 2.2 [後端] 更新 app/Models/DivingOffer.php:新增 hasMany CourseImage 關聯、cover_image_url accessorStorage::disk('public')->url($this->cover_image) 或 null)、新增 static::deleting() observerStorage::disk('public')->deleteDirectory("offers/{$offer->id}")(刪除整個課程目錄,防孤兒檔)
  • 2.3 [後端] 更新 DivingOfferControllershowindex 回傳:加入 cover_image_urlimages(含 id、url、sort_order,依 sort_order ASC 排序)

3. 圖片上傳 API

  • 3.1 [後端] 建立 app/Http/Controllers/API/CourseImageController.php
    • 所有上傳方法使用統一 validate:'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048'
    • uploadCover:所有權驗證 → validate → 刪除舊封面實體檔(若有)→ store("offers/{id}/cover", 'public') → 更新 cover_image → 回傳 cover_image_url
    • deleteCover:所有權驗證 → 刪除實體檔 → cover_image = null → 回傳 200(無封面時不報錯)
    • uploadImage:所有權驗證 → validate → 相簿數量檢查(COUNT < 3,否則 422)→ store 至 offers/{id}/gallery → 建立 CourseImagesort_order = (MAX(sort_order) ?? 0) + 1,不連續為預期行為)→ 回傳圖片資訊
    • deleteImageCourseImage 所有權驗證($image->divingOffer->provider_id !== auth()->id() → 403)→ 刪除實體檔 → 刪除 DB 紀錄 → 回傳 200
  • 3.2 [後端] 在 routes/api.php Provider 群組新增四個路由(POST/DELETE cover、POST images、DELETE images/{id}

4. Docker 設定

  • 4.1 [基礎設施] 在 docker-compose.ymlapp service 新增 volume storage-data:/var/www/storage/app/public,並在底部 volumes: 區塊宣告 storage-data:
  • 4.2 [基礎設施] 在 docker/php/docker-entrypoint.sh 的初始化段落加入 php artisan storage:link --force || true
  • 4.3 [基礎設施] 重新 build 並啟動容器,確認 /var/www/public/storage symlink 存在且可存取

5. 前端 API 封裝

  • 5.1 [前端] 建立 frontend/src/api/courseImageApi.jsuploadCover(offerId, file)deleteCover(offerId)uploadImage(offerId, file)deleteImage(imageId)(皆使用 coachAxiosContent-Type: multipart/form-data

6. 前端:課程卡封面顯示

  • 6.1 [前端] 更新 frontend/src/components/CourseCard.vue:有 cover_image_url 時顯示 <img>,無時顯示漸層佔位(保留 🤿 emoji 或 ocean 漸層背景)

7. 前端:課程詳情頁圖片展示

  • 7.1 [前端] 更新 frontend/src/views/CourseDetailView.vue:頂部大圖改為封面(有封面顯示圖片,無封面顯示漸層佔位)
  • 7.2 [前端] 相簿縮圖列:images.length > 0 時在封面下方顯示最多 3 張縮圖橫列,點擊放大(用 <img> 原始連結即可,不需 lightbox)

8. 前端:教練圖片管理 UI

  • 8.1 [前端] 更新 frontend/src/views/coach/OfferFormView.vue(或新建 OfferImageManager.vue):編輯模式下在表單下方加入圖片管理區塊
    • 封面區:顯示目前封面縮圖 + 「更換封面」按鈕(file input)+ 「刪除封面」按鈕
    • 相簿區:顯示目前 0–3 張縮圖 + 「新增圖片」按鈕(達 3 張時隱藏)+ 每張縮圖右上角「✕」刪除

9. 整合驗證(手動)

  • 9.1 [整合測試] 上傳封面:上傳後確認 cover_image_url 在 API 回傳,且 URL 可直接 GET 存取(HTTP 200
  • 9.2 [整合測試] 覆蓋封面:二次上傳後確認舊實體檔案已從 storage 刪除
  • 9.3 [整合測試] 相簿上限:上傳第 4 張應回傳 422
  • 9.4 [整合測試] Docker 持久化:docker compose build app && docker compose up -d app 後,先前上傳的圖片 URL 仍可存取
  • 9.5 [整合測試] 所有權驗證:Provider A 不可上傳到 Provider B 的課程(應回傳 403

10. Feature Test(自動化)

  • 10.1 [測試] 建立 tests/Feature/CourseImageTest.php:使用 Storage::fake('public') + UploadedFile::fake()->image(),不寫真實檔案
  • 10.2 [測試] 測試 uploadCover:成功上傳(201)、格式錯誤(422)、超過 2MB(422)、他人課程(403)
  • 10.3 [測試] 測試 deleteCover:成功刪除(200)、無封面時不報錯(200)、他人課程(403)、確認 Storage::fake 內舊檔已刪
  • 10.4 [測試] 測試 uploadImage:成功上傳(201)、第 4 張回傳 422、他人課程(403)、確認 sort_order = MAX + 1
  • 10.5 [測試] 測試 deleteImage:成功刪除(200)、他人圖片(403)、確認實體檔已從 Storage::fake 刪除
  • 10.6 [測試] 測試課程刪除孤兒清理:刪除 DivingOffer 後確認 offers/{id}/ 目錄從 Storage::fake 消失