Files
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.4 KiB
Raw Permalink Blame History

Context

CFDivePlatform 課程目前無圖片欄位。Laravel 的 public diskstorage/app/public)透過 storage:link symlink 至 public/storage,讓外部可直接存取。Docker 部署需要 named volume 掛載 storage 目錄,確保圖片跨 build 持久化。

Goals / Non-Goals

Goals:

  • Provider 可上傳課程封面(1 張)與相簿(最多 3 張)
  • 圖片存於本地 public diskURL 可直接用於 <img src>
  • Docker volume 持久化,重建容器不丟圖
  • FILESYSTEM_DISK=public 從 env 控制,未來切換 S3 只改設定

Non-Goals:

  • 圖片後端處理(resize、WebP 轉換)
  • S3 / CDN 整合(留給未來)
  • 管理員上傳課程圖片(僅 Provider 可上傳自己的課程)
  • 圖片排序拖拉(sort_order 按上傳順序自動遞增)

Decisions

決策一:資料表設計

diving_offers 新增欄位:

cover_image  varchar(500) nullable   ← 儲存相對路徑,如 offers/7/cover/abc.jpg

新增 course_images 資料表:

id                bigint PK
diving_offer_id   bigint FK → diving_offers (cascade)
image_path        varchar(500)         ← 相對路徑
sort_order        unsignedSmallInt DEFAULT 0
created_at        timestamp

索引:(diving_offer_id, sort_order)

決策二:URL 產生方式

cover_image_url accessor 與相簿 url 統一透過 Storage::url($path) 產生:

// DivingOffer Model accessor
public function getCoverImageUrlAttribute(): ?string
{
    return $this->cover_image
        ? Storage::disk('public')->url($this->cover_image)
        : null;
}

理由Storage::url() 自動對應 APP_URL,未來切換 S3 disk 後 URL 自動改為 S3 endpoint,零修改。

決策三:檔案儲存路徑

storage/app/public/
  offers/
    {offer_id}/
      cover/
        {uuid}.jpg        ← 封面(只會有一個)
      gallery/
        {uuid}.jpg        ← 相簿圖片(最多 3 個)

UUID 檔名防止猜測,覆蓋封面時先刪舊 UUID 再存新檔。

決策四:封面覆蓋策略

if ($offer->cover_image) {
    Storage::disk('public')->delete($offer->cover_image);
}
$path = $request->file('image')->store("offers/{$offer->id}/cover", 'public');
$offer->update(['cover_image' => $path]);

不保留歷史封面,節省磁碟空間。

決策五:Docker 持久化(Bind Mount

appnginx 容器都已掛載 ./:/var/www bind mount,圖片存至 ./storage/app/public/,兩個容器透過同一份 host 目錄共享。

不使用 named volume 的原因:nginx 容器需要能直接讀取圖片(Laravel 只寫檔,Nginx 才是實際提供靜態檔的服務)。若只在 app 掛 named volumeNginx 透過 bind mount 找不到該 volume 的檔案,導致圖片 404。Bind mount 對兩個容器一致,是最簡單的解法。

docker-entrypoint.sh 加入 php artisan storage:link --force,每次啟動確保 symlink 正確指向 ./storage/app/public/

決策六:課程刪除時的孤兒檔案清理

選擇:在 DivingOffer Model 加 static::deleting() observer,刪除整個課程目錄:

static::deleting(function ($offer) {
    Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
});

理由course_images cascade 只清 DB 紀錄,實體檔案殘留。若課程反覆建刪,磁碟累積孤兒目錄。一行程式碼解決,不算 over-engineering。deleteDirectory 目錄不存在時不報錯,安全。

放棄:排程清理孤兒檔 → 有時間差,且需額外邏輯比對 DB 與 storage。


決策七:sort_order 不連續為預期行為

刪除中間相簿圖片後再新增,sort_order = MAX(sort_order) + 1,序號會不連續(如:1, 3 而非 1, 2)。

決定MVP 接受不連續。前端依 sort_order ASC 排序顯示即可,不需重新排號。

理由:重新排號需要 UPDATE 多筆紀錄,複雜度不值得(相簿最多 3 張,視覺影響極小)。


決策八:course_images 的 created_at 自動填入

Migration 使用 $table->timestamp('created_at')->useCurrent(),讓 DB 自動填入,不依賴 PHP 層。

Model 設定:

public $timestamps = false;
const CREATED_AT = 'created_at';  // 告知 Eloquent 此欄位存在(用於排序)

理由useCurrent()review_votes 的做法一致,無需手動傳入 created_at


決策九:FILESYSTEM_DISK 設定

.envFILESYSTEM_DISK=local 改為 public,確保 Storage::disk() 預設指向公開路徑。CourseImageController 明確指定 disk('public'),不依賴預設值,避免混淆。

API 路由總覽

Provider (auth:sanctum)
  POST   /api/provider/offers/{id}/cover    ← 上傳/覆蓋封面
  DELETE /api/provider/offers/{id}/cover    ← 刪除封面
  POST   /api/provider/offers/{id}/images   ← 上傳相簿圖片(最多 3 張)
  DELETE /api/provider/images/{imageId}     ← 刪除特定相簿圖片

Risks / Trade-offs

  • 本地 disk 無法多機部署:目前單機 Docker 可接受;未來水平擴展必須改 S3,設計已預留切換彈性(Storage::disk('public')
  • 檔案孤兒:若刪除課程(DivingOffer)時,course_images cascade 刪除 DB 紀錄,但實體檔案不會自動清除。MVP 可接受(磁碟空間有限時手動清理)
  • storage:link 在 Docker 重啟時:每次 entrypoint 執行 storage:link --force 覆蓋重建,確保 symlink 正確