repo 內 35/38 份既有 spec 都是舊版歸檔流程留下的原始 delta 內容(`## ADDED Requirements`,缺標題與 Purpose),openspec CLI 現版本嚴格驗證 (--strict)會報錯或警告。逐一補上: - 缺標題/Purpose 的 30 份:加上 `# <name> Specification` 標題 + 依內容 撰寫的 Purpose 段落,`## ADDED Requirements` 改回 `## Requirements` - compose-cloud-baseline、scheduler-container:已有標題與 Requirements,只補 Purpose 包裝 - provider-verification:已有標題與 Purpose,只需把 `## ADDED Requirements` 改回 `## Requirements` - admin-user-management、notification-email:各有一條 requirement 缺 SHALL/MUST 關鍵字(純敘述句或表格),補上規範用語,內容不變 `openspec validate --specs --strict`:38/38 全過(原本 4/38)。純文件 補齊,未變更任何 requirement 的實質內容或行為描述。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ
5.7 KiB
course-image-upload Specification
Purpose
定義課程封面與相簿圖片的伺服器端壓縮規則、上傳/刪除端點行為、課程刪除時的圖片清理,以及圖片透過 host bind mount 持久化的機制。
Requirements
Requirement: 伺服器端圖片壓縮
系統 SHALL 在儲存課程封面與相簿圖片前進行壓縮:長邊超過 2048px 時等比縮小至 2048px 內(僅縮不放大),一律轉存 JPEG(quality 85,副檔名 .jpg,uuid 檔名)。與聊天圖片管線(scaleDown(2048) + toJpeg(85))參數一致,共用 App\Traits\CompressesImages。
Scenario: 大圖縮小
- WHEN Provider 上傳長邊 > 2048px 的圖片
- THEN 儲存的檔案長邊 ≤ 2048px,格式為 JPEG
Scenario: 小圖不放大
- WHEN Provider 上傳長邊 ≤ 2048px 的圖片
- THEN 儲存的檔案維持原尺寸,格式轉為 JPEG
Scenario: PNG/WebP 轉存 JPEG
- WHEN Provider 上傳 png 或 webp 格式圖片
- THEN 儲存的檔案為
.jpg,回傳的 URL 指向轉存後檔案
Requirement: Provider 上傳課程封面
Provider SHALL 能為自己的課程上傳一張封面圖片,新上傳會覆蓋舊封面並刪除舊實體檔案。上傳大小上限為 10MB(原 2MB;伺服器端會壓縮,放寬以容納手機原圖)。
Scenario: 成功上傳封面
- WHEN Provider 送出
POST /api/provider/offers/{id}/cover,包含image檔案(jpeg/png/webp,≤10MB) - THEN 系統壓縮後儲存至
publicdisk 的offers/{offer_id}/cover/目錄(.jpg),更新diving_offers.cover_image,回傳cover_image_url
Scenario: 10MB 以內的手機原圖可直接上傳
- WHEN 上傳 2MB~10MB 之間的圖片(原規格會拒絕)
- THEN 系統接受並壓縮儲存
Scenario: 覆蓋封面時刪除舊檔
- WHEN Provider 上傳新封面,且原本已有封面
- THEN 系統先刪除舊實體檔案,再儲存新檔案
Scenario: 檔案格式驗證
- WHEN 上傳的檔案不是 jpeg/jpg/png/webp
- THEN 系統回傳 422
Scenario: 檔案大小驗證
- WHEN 上傳檔案超過 10MB(10240KB)
- 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檔案(格式與大小同封面限制:jpeg/png/webp、≤10MB),且目前相簿圖片數 < 3 - THEN 系統壓縮後儲存至
offers/{offer_id}/gallery/目錄(.jpg),建立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 目錄不受容器重建影響