Files
CFDivePlatform/openspec/changes/archive/2026-05-12-course-images/design.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

148 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 正確