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>
This commit is contained in:
2026-05-12 03:54:45 +08:00
parent 81a9f84b26
commit 4baa4cb52b
20 changed files with 1048 additions and 11 deletions
@@ -0,0 +1,147 @@
## Context
CFDivePlatform 課程目前無圖片欄位。Laravel 的 `public` disk`storage/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)` 產生:
```php
// 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 再存新檔。
### 決策四:封面覆蓋策略
```php
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
`app``nginx` 容器都已掛載 `./:/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,刪除整個課程目錄:
```php
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 設定:
```php
public $timestamps = false;
const CREATED_AT = 'created_at'; // 告知 Eloquent 此欄位存在(用於排序)
```
**理由**`useCurrent()``review_votes` 的做法一致,無需手動傳入 `created_at`
---
### 決策九:`FILESYSTEM_DISK` 設定
`.env``FILESYSTEM_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 正確