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:
@@ -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 disk,URL 可直接用於 `<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 volume,Nginx 透過 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 正確
|
||||
Reference in New Issue
Block a user