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,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CourseImage;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImageController extends Controller
|
||||
{
|
||||
private function validateImage(Request $request): void
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeOffer(Request $request, DivingOffer $offer): void
|
||||
{
|
||||
if ($offer->provider_id !== $request->user()->id) {
|
||||
abort(403, '無權操作此課程');
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadCover(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
$this->validateImage($request);
|
||||
|
||||
if ($offer->cover_image) {
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
|
||||
$offer->update(['cover_image' => $path]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '封面已上傳',
|
||||
'cover_image_url' => $offer->cover_image_url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteCover(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
|
||||
if ($offer->cover_image) {
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
$offer->update(['cover_image' => null]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '封面已刪除']);
|
||||
}
|
||||
|
||||
public function uploadImage(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
$this->validateImage($request);
|
||||
|
||||
if ($offer->courseImages()->count() >= 3) {
|
||||
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
|
||||
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
|
||||
|
||||
$image = CourseImage::create([
|
||||
'diving_offer_id' => $offerId,
|
||||
'image_path' => $path,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '圖片已上傳',
|
||||
'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function deleteImage(Request $request, int $imageId)
|
||||
{
|
||||
$image = CourseImage::with('divingOffer')->findOrFail($imageId);
|
||||
|
||||
if ($image->divingOffer->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403);
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($image->image_path);
|
||||
$image->delete();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '圖片已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,11 @@ class DivingOfferController extends Controller
|
||||
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
$items = collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false));
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
@@ -46,18 +48,29 @@ class DivingOfferController extends Controller
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
$offer = DivingOffer::with('courseImages')->find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '課程不存在',
|
||||
], 404);
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $offer,
|
||||
return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
|
||||
}
|
||||
|
||||
private function formatOffer(DivingOffer $offer, bool $withImages): array
|
||||
{
|
||||
$data = array_merge($offer->toArray(), [
|
||||
'cover_image_url' => $offer->cover_image_url,
|
||||
]);
|
||||
|
||||
if ($withImages) {
|
||||
$data['images'] = $offer->courseImages->map(fn($img) => [
|
||||
'id' => $img->id,
|
||||
'url' => $img->url,
|
||||
'sort_order' => $img->sort_order,
|
||||
])->values();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImage extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
const CREATED_AT = 'created_at';
|
||||
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return Storage::disk('public')->url($this->image_path);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DivingOffer extends Model
|
||||
{
|
||||
@@ -22,6 +23,7 @@ class DivingOffer extends Model
|
||||
'description',
|
||||
'tag',
|
||||
'region',
|
||||
'cover_image',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -31,11 +33,30 @@ class DivingOffer extends Model
|
||||
'reviews'=> 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function ($offer) {
|
||||
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
|
||||
});
|
||||
}
|
||||
|
||||
public function getCoverImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->cover_image
|
||||
? Storage::disk('public')->url($this->cover_image)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function schedules()
|
||||
{
|
||||
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
||||
}
|
||||
|
||||
public function courseImages()
|
||||
{
|
||||
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function reviews()
|
||||
{
|
||||
return $this->hasMany(Review::class);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('cover_image', 500)->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropColumn('cover_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_images', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->string('image_path', 500);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_images');
|
||||
}
|
||||
};
|
||||
@@ -110,6 +110,10 @@ fi
|
||||
|
||||
echo "✅ CFDivePlatform 初始化完成!"
|
||||
|
||||
# 建立 storage symlink
|
||||
echo "🔗 建立 storage symlink..."
|
||||
php artisan storage:link --force || true
|
||||
|
||||
# 啟動 cron daemon(Laravel Scheduler)
|
||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||
service cron start || cron || true
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
function toFormData(file) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return fd
|
||||
}
|
||||
|
||||
export function uploadCover(offerId, file) {
|
||||
return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCover(offerId) {
|
||||
return coachApi.delete(`/provider/offers/${offerId}/cover`)
|
||||
}
|
||||
|
||||
export function uploadImage(offerId, file) {
|
||||
return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteImage(imageId) {
|
||||
return coachApi.delete(`/provider/images/${imageId}`)
|
||||
}
|
||||
@@ -9,7 +9,17 @@ defineProps({
|
||||
:to="`/courses/${offer.id}`"
|
||||
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="bg-ocean-700 h-40 flex items-center justify-center text-white text-5xl">🤿</div>
|
||||
<div class="h-40 overflow-hidden">
|
||||
<img
|
||||
v-if="offer.cover_image_url"
|
||||
:src="offer.cover_image_url"
|
||||
:alt="offer.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
|
||||
🤿
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col gap-2 flex-1">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -125,7 +125,31 @@ async function submitBooking() {
|
||||
← 返回課程列表
|
||||
</RouterLink>
|
||||
|
||||
<div class="bg-ocean-700 rounded-2xl h-56 flex items-center justify-center text-white text-7xl mb-6">🤿</div>
|
||||
<!-- 封面大圖 -->
|
||||
<div class="rounded-2xl h-64 overflow-hidden mb-4">
|
||||
<img
|
||||
v-if="offer.cover_image_url"
|
||||
:src="offer.cover_image_url"
|
||||
:alt="offer.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-7xl">
|
||||
🤿
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相簿縮圖列 -->
|
||||
<div v-if="offer.images && offer.images.length > 0" class="flex gap-2 mb-6">
|
||||
<a
|
||||
v-for="img in offer.images"
|
||||
:key="img.id"
|
||||
:href="img.url"
|
||||
target="_blank"
|
||||
class="w-24 h-20 rounded-xl overflow-hidden shrink-0 border-2 border-transparent hover:border-ocean-400 transition"
|
||||
>
|
||||
<img :src="img.url" class="w-full h-full object-cover" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import coachApi from '../../api/coachAxios'
|
||||
import { uploadCover, deleteCover, uploadImage, deleteImage } from '../../api/courseImageApi'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -25,6 +26,12 @@ const form = ref({
|
||||
description: '',
|
||||
})
|
||||
|
||||
// 圖片管理
|
||||
const coverUrl = ref(null)
|
||||
const galleryImgs = ref([])
|
||||
const imgUploading = ref(false)
|
||||
const imgError = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return
|
||||
loading.value = true
|
||||
@@ -41,6 +48,8 @@ onMounted(async () => {
|
||||
badges: Array.isArray(o.badges) ? o.badges.join(', ') : (o.badges || ''),
|
||||
description: o.description || '',
|
||||
}
|
||||
coverUrl.value = o.cover_image_url || null
|
||||
galleryImgs.value = o.images || []
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || '無法載入課程資料'
|
||||
} finally {
|
||||
@@ -48,6 +57,49 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function onCoverChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
imgUploading.value = true
|
||||
imgError.value = ''
|
||||
try {
|
||||
const res = await uploadCover(route.params.id, file)
|
||||
coverUrl.value = res.data.cover_image_url
|
||||
} catch (e) {
|
||||
imgError.value = e.response?.data?.message || '封面上傳失敗'
|
||||
} finally {
|
||||
imgUploading.value = false
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteCover() {
|
||||
if (!confirm('確定刪除封面?')) return
|
||||
await deleteCover(route.params.id)
|
||||
coverUrl.value = null
|
||||
}
|
||||
|
||||
async function onGalleryChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
imgUploading.value = true
|
||||
imgError.value = ''
|
||||
try {
|
||||
const res = await uploadImage(route.params.id, file)
|
||||
galleryImgs.value.push(res.data.data)
|
||||
} catch (e) {
|
||||
imgError.value = e.response?.data?.message || '圖片上傳失敗'
|
||||
} finally {
|
||||
imgUploading.value = false
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteImage(img) {
|
||||
await deleteImage(img.id)
|
||||
galleryImgs.value = galleryImgs.value.filter(i => i.id !== img.id)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errors.value = {}
|
||||
error.value = ''
|
||||
@@ -161,6 +213,51 @@ async function submit() {
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none" />
|
||||
</div>
|
||||
|
||||
<!-- 圖片管理(僅編輯模式) -->
|
||||
<div v-if="isEdit" class="border border-gray-200 rounded-xl p-5 space-y-5">
|
||||
<h3 class="text-sm font-semibold text-gray-700">課程圖片</h3>
|
||||
<p v-if="imgError" class="text-red-500 text-xs">{{ imgError }}</p>
|
||||
|
||||
<!-- 封面 -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-2">封面圖片(1 張)</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-32 h-24 rounded-lg overflow-hidden bg-gray-100 shrink-0">
|
||||
<img v-if="coverUrl" :src="coverUrl" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400 text-2xl">🤿</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="cursor-pointer text-xs bg-gray-900 text-white px-3 py-1.5 rounded-lg hover:bg-gray-700 transition text-center">
|
||||
{{ coverUrl ? '更換封面' : '上傳封面' }}
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="onCoverChange" :disabled="imgUploading" />
|
||||
</label>
|
||||
<button v-if="coverUrl" @click="onDeleteCover" type="button"
|
||||
class="text-xs text-red-500 hover:text-red-700 underline">刪除封面</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相簿 -->
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-2">相簿圖片(最多 3 張)</label>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<div v-for="img in galleryImgs" :key="img.id" class="relative w-24 h-20 rounded-lg overflow-hidden">
|
||||
<img :src="img.url" class="w-full h-full object-cover" />
|
||||
<button @click="onDeleteImage(img)" type="button"
|
||||
class="absolute top-1 right-1 bg-black/60 text-white rounded-full w-5 h-5 text-xs flex items-center justify-center hover:bg-black/80">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="galleryImgs.length < 3"
|
||||
class="w-24 h-20 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer hover:border-ocean-400 transition text-gray-400 text-xl">
|
||||
+
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="onGalleryChange" :disabled="imgUploading" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="imgUploading" class="text-xs text-gray-400 mt-1">上傳中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<RouterLink to="/coach/dashboard"
|
||||
class="px-5 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-11
|
||||
@@ -0,0 +1,3 @@
|
||||
# course-images
|
||||
|
||||
課程圖片上傳:封面圖 + 相簿最多 3 張,本地 public disk,Docker volume 持久化
|
||||
@@ -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 正確
|
||||
@@ -0,0 +1,41 @@
|
||||
## Why
|
||||
|
||||
目前課程頁面以 🤿 emoji 作為佔位,缺乏視覺吸引力。課程圖片是潛水平台的核心體驗,直接影響會員瀏覽意願與教練品牌形象。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `diving_offers.cover_image` 欄位:課程封面,顯示於課程卡與詳情頁頂部
|
||||
- 新增 `course_images` 資料表:相簿最多 3 張,顯示於課程詳情頁
|
||||
- 新增 Provider API:上傳/刪除封面、上傳/刪除相簿圖片
|
||||
- 更新前端:CourseCard 顯示封面、CourseDetailView 顯示封面 + 相簿、OfferFormView 加入圖片管理 UI
|
||||
- Docker volume 掛載 `storage/app/public`,圖片跨 build 持久化
|
||||
- `php artisan storage:link` 整合至 entrypoint,確保 symlink 正確
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `course-image-upload`:Provider 上傳課程封面與相簿圖片(本地 public disk、max 2MB、支援 jpg/jpeg/png/webp)、相簿上限 3 張、刪除圖片同步移除實體檔案
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `coach-offers-api`:新增圖片上傳/刪除端點,`DivingOffer` 回應加入 `cover_image_url` 與 `images`
|
||||
|
||||
## Impact
|
||||
|
||||
**後端**
|
||||
- Migration:`diving_offers` 加 `cover_image`(nullable string)
|
||||
- Migration:新增 `course_images`(id、diving_offer_id、image_path、sort_order)
|
||||
- Model:`CourseImage`;`DivingOffer` 加 `hasMany CourseImage`、`cover_image_url` accessor
|
||||
- Controller:`CourseImageController`(上傳封面、刪除封面、上傳相簿、刪除相簿)
|
||||
- Route:`/provider/offers/{id}/cover`(POST/DELETE)、`/provider/offers/{id}/images`(POST)、`/provider/images/{id}`(DELETE)
|
||||
|
||||
**前端**
|
||||
- `frontend/src/api/courseImageApi.js`
|
||||
- `CourseCard.vue`:有封面顯示圖片,無封面顯示漸層佔位
|
||||
- `CourseDetailView.vue`:頂部大圖(封面)+ 相簿縮圖列
|
||||
- `OfferFormView.vue`(或新建 `OfferImageManager.vue`):封面上傳預覽、相簿管理
|
||||
|
||||
**Docker**
|
||||
- `docker-compose.yml` 新增 named volume `storage-data` 掛載 `/var/www/storage/app/public`
|
||||
- `docker-entrypoint.sh` 加入 `php artisan storage:link --force`
|
||||
@@ -0,0 +1,79 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Provider 上傳課程封面
|
||||
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。
|
||||
|
||||
#### Scenario: 成功上傳封面
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤2MB)
|
||||
- **THEN** 系統儲存檔案至 `public` disk 的 `offers/{offer_id}/cover/` 目錄,更新 `diving_offers.cover_image`,回傳 `cover_image_url`
|
||||
|
||||
#### Scenario: 覆蓋封面時刪除舊檔
|
||||
- **WHEN** Provider 上傳新封面,且原本已有封面
|
||||
- **THEN** 系統先刪除舊實體檔案,再儲存新檔案
|
||||
|
||||
#### Scenario: 檔案格式驗證
|
||||
- **WHEN** 上傳的檔案不是 jpeg/jpg/png/webp
|
||||
- **THEN** 系統回傳 422,message:「只支援 jpeg、png、webp 格式」
|
||||
|
||||
#### Scenario: 檔案大小驗證
|
||||
- **WHEN** 上傳檔案超過 2MB(2048KB)
|
||||
- **THEN** 系統回傳 422,message:「圖片大小不可超過 2MB」
|
||||
|
||||
#### Scenario: 不可上傳他人課程的封面
|
||||
- **WHEN** Provider 嘗試上傳不屬於自己課程的封面
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: Provider 刪除課程封面
|
||||
Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。
|
||||
|
||||
#### Scenario: 成功刪除封面
|
||||
- **WHEN** Provider 送出 `DELETE /api/provider/offers/{id}/cover`
|
||||
- **THEN** 系統刪除實體檔案,將 `diving_offers.cover_image` 設為 null,回傳 200
|
||||
|
||||
#### Scenario: 無封面時刪除不報錯
|
||||
- **WHEN** Provider 刪除封面,但 `cover_image` 本來就是 null
|
||||
- **THEN** 系統直接回傳 200,不報錯
|
||||
|
||||
### Requirement: Provider 上傳課程相簿圖片
|
||||
Provider SHALL 能為課程上傳最多 3 張相簿圖片。
|
||||
|
||||
#### Scenario: 成功上傳相簿圖片
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制),且目前相簿圖片數 < 3
|
||||
- **THEN** 系統儲存檔案至 `offers/{offer_id}/gallery/` 目錄,建立 `course_images` 紀錄,`sort_order` 為目前最大值 + 1,回傳新圖片資訊
|
||||
|
||||
#### Scenario: 超過 3 張上限
|
||||
- **WHEN** 課程已有 3 張相簿圖片,Provider 再次上傳
|
||||
- **THEN** 系統回傳 422,message:「相簿最多 3 張圖片」
|
||||
|
||||
#### Scenario: 不可上傳他人課程的相簿
|
||||
- **WHEN** Provider 嘗試上傳不屬於自己課程的相簿
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: Provider 刪除相簿圖片
|
||||
Provider SHALL 能刪除自己課程的特定相簿圖片,同步移除實體檔案。
|
||||
|
||||
#### Scenario: 成功刪除相簿圖片
|
||||
- **WHEN** Provider 送出 `DELETE /api/provider/images/{image_id}`
|
||||
- **THEN** 系統刪除實體檔案,刪除 `course_images` 紀錄,回傳 200
|
||||
|
||||
#### Scenario: 不可刪除他人相簿圖片
|
||||
- **WHEN** Provider 嘗試刪除不屬於自己課程的相簿圖片
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: 圖片 URL 隨課程資料一同回傳
|
||||
公開課程 API SHALL 在回傳課程資料時包含 `cover_image_url`(完整可存取 URL)與 `images`(相簿陣列)。
|
||||
|
||||
#### Scenario: 有封面時回傳完整 URL
|
||||
- **WHEN** 任何人取得課程資料(`GET /api/diving-offers/{id}` 或列表)
|
||||
- **THEN** `cover_image_url` 回傳可直接用於 `<img src>` 的完整 URL;無封面時回傳 null
|
||||
|
||||
#### Scenario: 相簿陣列回傳
|
||||
- **WHEN** 取得課程詳情 `GET /api/diving-offers/{id}`
|
||||
- **THEN** `images` 回傳相簿圖片陣列,每筆含 `id`、`url`、`sort_order`;無相簿時回傳空陣列
|
||||
|
||||
### Requirement: 圖片儲存持久化
|
||||
圖片 SHALL 儲存於 Docker named volume,跨容器重建後仍可存取。
|
||||
|
||||
#### Scenario: 容器重建後圖片保留
|
||||
- **WHEN** 執行 `docker compose build` 並重新啟動容器
|
||||
- **THEN** 先前上傳的圖片仍可正常存取(URL 不變)
|
||||
@@ -0,0 +1,63 @@
|
||||
## 1. 資料庫層
|
||||
|
||||
- [x] 1.1 [後端] 建立 Migration `add_cover_image_to_diving_offers_table`:新增 `cover_image varchar(500) nullable`
|
||||
- [x] 1.2 [後端] 建立 Migration `create_course_images_table`:欄位含 `diving_offer_id`(FK cascade)、`image_path`(varchar 500)、`sort_order`(unsignedSmallInt DEFAULT 0)、`$table->timestamp('created_at')->useCurrent()`(DB 自動填入,無 updated_at);加索引 `(diving_offer_id, sort_order)`
|
||||
- [x] 1.3 [後端] 執行 Migration,確認欄位與索引正確
|
||||
|
||||
## 2. Model 層
|
||||
|
||||
- [x] 2.1 [後端] 建立 `app/Models/CourseImage.php`:fillable(diving_offer_id、image_path、sort_order)、`public $timestamps = false`、`const CREATED_AT = 'created_at'`(讓 Eloquent 知道此欄存在)、belongsTo DivingOffer、`url` accessor(`Storage::disk('public')->url($this->image_path)`)
|
||||
- [x] 2.2 [後端] 更新 `app/Models/DivingOffer.php`:新增 `hasMany CourseImage` 關聯、`cover_image_url` accessor(`Storage::disk('public')->url($this->cover_image)` 或 null)、新增 `static::deleting()` observer:`Storage::disk('public')->deleteDirectory("offers/{$offer->id}")`(刪除整個課程目錄,防孤兒檔)
|
||||
- [x] 2.3 [後端] 更新 `DivingOfferController` 的 `show` 與 `index` 回傳:加入 `cover_image_url` 與 `images`(含 id、url、sort_order,依 sort_order ASC 排序)
|
||||
|
||||
## 3. 圖片上傳 API
|
||||
|
||||
- [x] 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` → 建立 CourseImage(`sort_order = (MAX(sort_order) ?? 0) + 1`,不連續為預期行為)→ 回傳圖片資訊
|
||||
- `deleteImage`:CourseImage 所有權驗證(`$image->divingOffer->provider_id !== auth()->id()` → 403)→ 刪除實體檔 → 刪除 DB 紀錄 → 回傳 200
|
||||
- [x] 3.2 [後端] 在 `routes/api.php` Provider 群組新增四個路由(POST/DELETE cover、POST images、DELETE images/{id})
|
||||
|
||||
## 4. Docker 設定
|
||||
|
||||
- [x] 4.1 [基礎設施] 在 `docker-compose.yml` 的 `app` service 新增 volume `storage-data:/var/www/storage/app/public`,並在底部 `volumes:` 區塊宣告 `storage-data:`
|
||||
- [x] 4.2 [基礎設施] 在 `docker/php/docker-entrypoint.sh` 的初始化段落加入 `php artisan storage:link --force || true`
|
||||
- [x] 4.3 [基礎設施] 重新 build 並啟動容器,確認 `/var/www/public/storage` symlink 存在且可存取
|
||||
|
||||
## 5. 前端 API 封裝
|
||||
|
||||
- [x] 5.1 [前端] 建立 `frontend/src/api/courseImageApi.js`:`uploadCover(offerId, file)`、`deleteCover(offerId)`、`uploadImage(offerId, file)`、`deleteImage(imageId)`(皆使用 coachAxios,Content-Type: multipart/form-data)
|
||||
|
||||
## 6. 前端:課程卡封面顯示
|
||||
|
||||
- [x] 6.1 [前端] 更新 `frontend/src/components/CourseCard.vue`:有 `cover_image_url` 時顯示 `<img>`,無時顯示漸層佔位(保留 🤿 emoji 或 ocean 漸層背景)
|
||||
|
||||
## 7. 前端:課程詳情頁圖片展示
|
||||
|
||||
- [x] 7.1 [前端] 更新 `frontend/src/views/CourseDetailView.vue`:頂部大圖改為封面(有封面顯示圖片,無封面顯示漸層佔位)
|
||||
- [x] 7.2 [前端] 相簿縮圖列:`images.length > 0` 時在封面下方顯示最多 3 張縮圖橫列,點擊放大(用 `<img>` 原始連結即可,不需 lightbox)
|
||||
|
||||
## 8. 前端:教練圖片管理 UI
|
||||
|
||||
- [x] 8.1 [前端] 更新 `frontend/src/views/coach/OfferFormView.vue`(或新建 `OfferImageManager.vue`):編輯模式下在表單下方加入圖片管理區塊
|
||||
- 封面區:顯示目前封面縮圖 + 「更換封面」按鈕(file input)+ 「刪除封面」按鈕
|
||||
- 相簿區:顯示目前 0–3 張縮圖 + 「新增圖片」按鈕(達 3 張時隱藏)+ 每張縮圖右上角「✕」刪除
|
||||
|
||||
## 9. 整合驗證(手動)
|
||||
|
||||
- [x] 9.1 [整合測試] 上傳封面:上傳後確認 `cover_image_url` 在 API 回傳,且 URL 可直接 GET 存取(HTTP 200)
|
||||
- [x] 9.2 [整合測試] 覆蓋封面:二次上傳後確認舊實體檔案已從 storage 刪除
|
||||
- [x] 9.3 [整合測試] 相簿上限:上傳第 4 張應回傳 422
|
||||
- [x] 9.4 [整合測試] Docker 持久化:`docker compose build app && docker compose up -d app` 後,先前上傳的圖片 URL 仍可存取
|
||||
- [x] 9.5 [整合測試] 所有權驗證:Provider A 不可上傳到 Provider B 的課程(應回傳 403)
|
||||
|
||||
## 10. Feature Test(自動化)
|
||||
|
||||
- [x] 10.1 [測試] 建立 `tests/Feature/CourseImageTest.php`:使用 `Storage::fake('public')` + `UploadedFile::fake()->image()`,不寫真實檔案
|
||||
- [x] 10.2 [測試] 測試 `uploadCover`:成功上傳(201)、格式錯誤(422)、超過 2MB(422)、他人課程(403)
|
||||
- [x] 10.3 [測試] 測試 `deleteCover`:成功刪除(200)、無封面時不報錯(200)、他人課程(403)、確認 Storage::fake 內舊檔已刪
|
||||
- [x] 10.4 [測試] 測試 `uploadImage`:成功上傳(201)、第 4 張回傳 422、他人課程(403)、確認 sort_order = MAX + 1
|
||||
- [x] 10.5 [測試] 測試 `deleteImage`:成功刪除(200)、他人圖片(403)、確認實體檔已從 Storage::fake 刪除
|
||||
- [x] 10.6 [測試] 測試課程刪除孤兒清理:刪除 DivingOffer 後確認 `offers/{id}/` 目錄從 Storage::fake 消失
|
||||
@@ -0,0 +1,84 @@
|
||||
### Requirement: Provider 上傳課程封面
|
||||
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。
|
||||
|
||||
#### Scenario: 成功上傳封面
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/cover`,包含 `image` 檔案(jpeg/png/webp,≤2MB)
|
||||
- **THEN** 系統儲存檔案至 `public` disk 的 `offers/{offer_id}/cover/` 目錄,更新 `diving_offers.cover_image`,回傳 `cover_image_url`
|
||||
|
||||
#### Scenario: 覆蓋封面時刪除舊檔
|
||||
- **WHEN** Provider 上傳新封面,且原本已有封面
|
||||
- **THEN** 系統先刪除舊實體檔案,再儲存新檔案
|
||||
|
||||
#### Scenario: 檔案格式驗證
|
||||
- **WHEN** 上傳的檔案不是 jpeg/jpg/png/webp
|
||||
- **THEN** 系統回傳 422
|
||||
|
||||
#### Scenario: 檔案大小驗證
|
||||
- **WHEN** 上傳檔案超過 2MB(2048KB)
|
||||
- **THEN** 系統回傳 422
|
||||
|
||||
#### Scenario: 不可上傳他人課程的封面
|
||||
- **WHEN** Provider 嘗試上傳不屬於自己課程的封面
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: Provider 刪除課程封面
|
||||
Provider SHALL 能刪除自己課程的封面,同步移除實體檔案。
|
||||
|
||||
#### Scenario: 成功刪除封面
|
||||
- **WHEN** Provider 送出 `DELETE /api/provider/offers/{id}/cover`
|
||||
- **THEN** 系統刪除實體檔案,將 `diving_offers.cover_image` 設為 null,回傳 200
|
||||
|
||||
#### Scenario: 無封面時刪除不報錯
|
||||
- **WHEN** Provider 刪除封面,但 `cover_image` 本來就是 null
|
||||
- **THEN** 系統直接回傳 200,不報錯
|
||||
|
||||
### Requirement: Provider 上傳課程相簿圖片
|
||||
Provider SHALL 能為課程上傳最多 3 張相簿圖片,sort_order 不連續為預期行為(刪除後重新上傳序號接續最大值)。
|
||||
|
||||
#### Scenario: 成功上傳相簿圖片
|
||||
- **WHEN** Provider 送出 `POST /api/provider/offers/{id}/images`,包含 `image` 檔案(格式與大小同封面限制),且目前相簿圖片數 < 3
|
||||
- **THEN** 系統儲存檔案至 `offers/{offer_id}/gallery/` 目錄,建立 `course_images` 紀錄,`sort_order = MAX(sort_order) + 1`,回傳新圖片資訊
|
||||
|
||||
#### Scenario: 超過 3 張上限
|
||||
- **WHEN** 課程已有 3 張相簿圖片,Provider 再次上傳
|
||||
- **THEN** 系統回傳 422,message:「相簿最多 3 張圖片」
|
||||
|
||||
#### Scenario: 不可上傳他人課程的相簿
|
||||
- **WHEN** Provider 嘗試上傳不屬於自己課程的相簿
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: Provider 刪除相簿圖片
|
||||
Provider SHALL 能刪除自己課程的特定相簿圖片,同步移除實體檔案。
|
||||
|
||||
#### Scenario: 成功刪除相簿圖片
|
||||
- **WHEN** Provider 送出 `DELETE /api/provider/images/{image_id}`
|
||||
- **THEN** 系統刪除實體檔案,刪除 `course_images` 紀錄,回傳 200
|
||||
|
||||
#### Scenario: 不可刪除他人相簿圖片
|
||||
- **WHEN** Provider 嘗試刪除不屬於自己課程的相簿圖片
|
||||
- **THEN** 系統回傳 403
|
||||
|
||||
### Requirement: 課程刪除時清理圖片目錄
|
||||
DivingOffer 刪除時,系統 SHALL 自動清除 `offers/{offer_id}/` 整個目錄,防止孤兒檔案累積。
|
||||
|
||||
#### Scenario: 刪除課程時清除圖片
|
||||
- **WHEN** DivingOffer 被刪除(`static::deleting()` observer 觸發)
|
||||
- **THEN** `Storage::disk('public')->deleteDirectory("offers/{$offer->id}")` 刪除封面與相簿所有實體檔案
|
||||
|
||||
### Requirement: 圖片 URL 隨課程資料一同回傳
|
||||
公開課程 API SHALL 在回傳課程資料時包含 `cover_image_url`(含 APP_URL 與 port 的完整 URL)與 `images`(相簿陣列)。
|
||||
|
||||
#### Scenario: 有封面時回傳完整 URL
|
||||
- **WHEN** 任何人取得課程資料(`GET /api/diving-offers/{id}` 或列表)
|
||||
- **THEN** `cover_image_url` 回傳可直接用於 `<img src>` 的完整 URL(如 `http://host:port/storage/offers/...`);無封面時回傳 null
|
||||
|
||||
#### Scenario: 相簿陣列回傳
|
||||
- **WHEN** 取得課程詳情 `GET /api/diving-offers/{id}`
|
||||
- **THEN** `images` 回傳相簿圖片陣列,每筆含 `id`、`url`、`sort_order`,依 `sort_order ASC` 排序;無相簿時回傳空陣列
|
||||
|
||||
### Requirement: 圖片儲存持久化(Bind Mount)
|
||||
圖片 SHALL 儲存於 `./storage/app/public/`(host bind mount),`app` 與 `nginx` 容器透過 `./:/var/www` 共享同一目錄,跨容器重建後仍可存取。`APP_URL` 必須包含正確 port(如 `http://localhost:8080`)才能產生正確的圖片 URL。
|
||||
|
||||
#### Scenario: 容器重建後圖片保留
|
||||
- **WHEN** 執行 `docker compose up --build` 並重新啟動容器
|
||||
- **THEN** 先前上傳的圖片仍可正常存取(URL 不變),因圖片在 host 目錄不受容器重建影響
|
||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\API\MemberBookingController;
|
||||
use App\Http\Controllers\API\ReviewController;
|
||||
use App\Http\Controllers\API\AdminReviewController;
|
||||
use App\Http\Controllers\API\AdminBookingController;
|
||||
use App\Http\Controllers\API\CourseImageController;
|
||||
use App\Http\Controllers\API\AdminStatsController;
|
||||
use App\Http\Controllers\API\AdminUserController;
|
||||
use App\Http\Controllers\API\AdminOfferController;
|
||||
@@ -85,6 +86,11 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () {
|
||||
Route::get('/offers/{id}', [ProviderOfferController::class, 'show']);
|
||||
Route::put('/offers/{id}', [ProviderOfferController::class, 'update']);
|
||||
Route::delete('/offers/{id}', [ProviderOfferController::class, 'destroy']);
|
||||
// 課程圖片
|
||||
Route::post('/offers/{id}/cover', [CourseImageController::class, 'uploadCover']);
|
||||
Route::delete('/offers/{id}/cover', [CourseImageController::class, 'deleteCover']);
|
||||
Route::post('/offers/{id}/images', [CourseImageController::class, 'uploadImage']);
|
||||
Route::delete('/images/{id}', [CourseImageController::class, 'deleteImage']);
|
||||
// 時段管理
|
||||
Route::get('/schedules', [ScheduleController::class, 'index']);
|
||||
Route::post('/schedules', [ScheduleController::class, 'store']);
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\CourseImage;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourseImageTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function makeProvider(): User
|
||||
{
|
||||
return User::factory()->create(['role' => 'provider']);
|
||||
}
|
||||
|
||||
private function makeOffer(User $provider): DivingOffer
|
||||
{
|
||||
return DivingOffer::create([
|
||||
'provider_id' => $provider->id,
|
||||
'title' => 'Test Course',
|
||||
'location' => 'Test',
|
||||
'spot' => 'Test Spot',
|
||||
'price' => 1000,
|
||||
'region' => '南部',
|
||||
'rating' => 0,
|
||||
'reviews' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
private function fakeImage(string $name = 'test.png'): UploadedFile
|
||||
{
|
||||
return UploadedFile::fake()->image($name, 100, 100)->size(500);
|
||||
}
|
||||
|
||||
public function test_upload_cover_success(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()])
|
||||
->assertOk()->assertJsonPath('status', true);
|
||||
|
||||
$offer->refresh();
|
||||
$this->assertNotNull($offer->cover_image);
|
||||
Storage::disk('public')->assertExists($offer->cover_image);
|
||||
}
|
||||
|
||||
public function test_upload_cover_wrong_mime(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/cover", [
|
||||
'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'),
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_upload_cover_too_large(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/cover", [
|
||||
'image' => UploadedFile::fake()->image('big.png')->size(3000),
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_upload_cover_forbidden_for_other_provider(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$offer = $this->makeOffer($this->makeProvider());
|
||||
|
||||
$this->actingAs($this->makeProvider())
|
||||
->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()])
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_delete_cover_removes_file(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]);
|
||||
$offer->refresh();
|
||||
$oldPath = $offer->cover_image;
|
||||
|
||||
$this->actingAs($provider)
|
||||
->deleteJson("/api/provider/offers/{$offer->id}/cover")
|
||||
->assertOk();
|
||||
|
||||
Storage::disk('public')->assertMissing($oldPath);
|
||||
$this->assertNull($offer->fresh()->cover_image);
|
||||
}
|
||||
|
||||
public function test_delete_cover_when_no_cover_is_ok(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->deleteJson("/api/provider/offers/{$offer->id}/cover")
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_delete_cover_forbidden_for_other_provider(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$offer = $this->makeOffer($this->makeProvider());
|
||||
|
||||
$this->actingAs($this->makeProvider())
|
||||
->deleteJson("/api/provider/offers/{$offer->id}/cover")
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_upload_gallery_image_success(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()])
|
||||
->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseCount('course_images', 1);
|
||||
}
|
||||
|
||||
public function test_gallery_max_3_images(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
|
||||
}
|
||||
|
||||
$this->actingAs($provider)
|
||||
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_gallery_sort_order_increments(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$r1 = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
|
||||
$r2 = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
|
||||
|
||||
$this->assertEquals(1, $r1->json('data.sort_order'));
|
||||
$this->assertEquals(2, $r2->json('data.sort_order'));
|
||||
}
|
||||
|
||||
public function test_upload_image_forbidden_for_other_provider(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$offer = $this->makeOffer($this->makeProvider());
|
||||
|
||||
$this->actingAs($this->makeProvider())
|
||||
->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()])
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_delete_gallery_image_removes_file(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$res = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
|
||||
$imgId = $res->json('data.id');
|
||||
$path = CourseImage::find($imgId)->image_path;
|
||||
|
||||
$this->actingAs($provider)->deleteJson("/api/provider/images/{$imgId}")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('course_images', ['id' => $imgId]);
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
}
|
||||
|
||||
public function test_delete_image_forbidden_for_other_provider(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$res = $this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
|
||||
$imgId = $res->json('data.id');
|
||||
|
||||
$this->actingAs($this->makeProvider())
|
||||
->deleteJson("/api/provider/images/{$imgId}")
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_deleting_offer_removes_storage_directory(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$provider = $this->makeProvider();
|
||||
$offer = $this->makeOffer($provider);
|
||||
|
||||
$this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/cover", ['image' => $this->fakeImage()]);
|
||||
$this->actingAs($provider)->postJson("/api/provider/offers/{$offer->id}/images", ['image' => $this->fakeImage()]);
|
||||
|
||||
$offerId = $offer->id;
|
||||
$offer->delete();
|
||||
|
||||
Storage::disk('public')->assertDirectoryEmpty("offers/{$offerId}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user