後端: - 新增 DivingOffer Model / DivingOfferController(列表+詳情 API,支援搜尋/篩選/分頁) - 修正 Google OAuth callback 改為 redirect 至前端(SocialAuthController) - 新增 config/cors.php 允許前端 origin - .gitignore 新增 frontend/ 排除規則 前端(frontend/): - Vue 3 + Vite + Tailwind CSS + Pinia + Vue Router - 頁面:首頁、課程列表、課程詳情、登入、註冊、個人資料、OAuth callback - 整合至 Docker(multi-stage build,nginx 靜態服務於 port 5173) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
11 KiB
Context
後端(Laravel + Sanctum)已有會員認證、個人資料、Google OAuth 等 API,但 diving_offers 表雖已建立,尚無對應的公開 API。前端完全從零開始,需建立獨立 repo,透過 HTTP 呼叫後端 API。兩端分離部署,前端在開發期間以 http://localhost:5173 運行,後端以 http://localhost:80(Laragon)運行。
Goals / Non-Goals
Goals:
- 建立可運行的 Vue 3 前端 MVP,讓會員能瀏覽和搜尋潛水課程
- 後端補齊
diving-offers公開 API(列表 + 詳情) - 整合現有 Auth API(登入、註冊、Google OAuth)
- 會員個人資料頁可讀取與更新
Non-Goals:
- 預約/訂單系統(本次不做)
- 金流整合
- Provider 端介面
- Admin 後台
- SSR / SEO 優化
- 自動化測試
Decisions
D1:前端獨立 repo,不用 Inertia.js
決定:前端為獨立 Vue 3 SPA repo,透過 REST API 與後端溝通。
理由:前後端分離讓未來可獨立部署、擴展。Inertia.js 雖整合方便,但綁定 Laravel monolith,不符長期架構方向。
替代方案:Inertia.js(Laravel + Vue 同 repo)→ 捨棄,因為部署彈性不足。
D2:Auth 策略 — Sanctum Token(Bearer)
決定:前端登入後儲存 Bearer token 於 localStorage,每次請求附加 Authorization: Bearer <token> header。
理由:Sanctum 在 SPA 跨域場景下有兩種模式(cookie-based SPA 和 token-based API)。Token 模式對跨 origin 最簡單,不需設定 session cookie、CSRF。
替代方案:Sanctum SPA cookie 模式 → 需要同 domain 或複雜 CORS cookie 設定,開發期間繁瑣。
D3:樣式策略 — Tailwind CSS,無 UI 框架
決定:純 Tailwind CSS 配合自定義 Vue 組件。
理由:設計彈性最高,不被 Element Plus / Naive UI 的元件語言綁定,適合建立品牌識別感強的潛水平台。
D4:狀態管理 — Pinia(只管 Auth 狀態)
決定:僅用 Pinia 管理認證狀態(user、token、isLoggedIn)。課程列表等資料用組件本地 ref + Axios 取得,不過度使用全域 store。
理由:MVP 階段避免過早引入複雜的全域狀態。
D5:Google OAuth Callback 改為 Redirect
決定:SocialAuthController::handleGoogleCallback() 現行實作回傳 JSON response,但前後端分離時瀏覽器停在後端 origin(:80),前端無法取得 token。必須改為 redirect 至前端 callback 頁面。
OAuth 完整流程契約:
cf-dive-frontend (:5173) CFDivePlatform (:80) Google
| | |
| 點擊「Google 登入」 | |
| window.location = | |
| :80/api/auth/google/ | |
| redirect | |
|──────────────────────────▶| |
| | stateless()->redirect()|
| |──────────────────────▶|
| | 302 → Google 同意頁 |
|◀──────────────────────────| |
| (使用者在 Google 同意) | |
| |◀──────────────────────|
| | callback?code=xxx |
| | |
| | 1. 取得 Google user |
| | 2. 建立/查詢 User |
| | 3. 建立 Sanctum token |
| | |
| [成功] 302 redirect → | |
| :5173/auth/callback | |
| ?token=<sanctum_token> | |
|◀──────────────────────────| |
| | |
| [失敗] 302 redirect → | |
| :5173/login | |
| ?error=oauth_failed | |
|◀──────────────────────────| |
| | |
| /auth/callback 頁面: | |
| 讀取 ?token= | |
| 存入 Pinia + localStorage | |
| 導向 /courses | |
後端改動:handleGoogleCallback() 末段改為:
// 成功
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
// 失敗(catch 區塊)
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
前端新增:/auth/callback 路由對應 AuthCallbackView.vue,讀取 ?token= 後存入 store,再導向 /courses。
Contracts
Contract 1 — API Schema
所有 API response 遵循統一結構:
成功:{ "status": true, "message": "...", "data": {...} 或 [...] }
失敗:{ "status": false, "message": "錯誤說明" }
GET /api/diving-offers(公開,無需 auth)
Query Parameters:
q : string 搜尋 title / location / spot(LIKE 模糊匹配)
region : string 完全匹配 region 欄位
tag : string LIKE 匹配 tag 欄位
per_page : integer default=12, max=50
page : integer default=1
Response 200:
{
"status": true,
"data": [
{
"id": 1,
"title": "墾丁海底探險",
"location": "屏東縣",
"spot": "龍坑生態保護區",
"rating": 4.8,
"reviews": 32,
"price": 2500,
"badges": ["PADI認證", "含裝備"], ← JSON decode 後為陣列
"description": "課程描述文字...",
"tag": "初學者",
"region": "南部",
"created_at": "2025-05-01T00:00:00.000000Z"
}
],
"meta": {
"total": 48,
"per_page": 12,
"current_page": 1,
"last_page": 4
}
}
GET /api/diving-offers/{id}(公開,無需 auth)
Response 200:
{
"status": true,
"data": {
"id": 1,
"title": "墾丁海底探險",
"location": "屏東縣",
"spot": "龍坑生態保護區",
"rating": 4.8,
"reviews": 32,
"price": 2500,
"badges": ["PADI認證", "含裝備"],
"description": "課程描述文字...",
"tag": "初學者",
"region": "南部",
"created_at": "2025-05-01T00:00:00.000000Z"
}
}
Response 404:
{ "status": false, "message": "課程不存在" }
POST /api/member/login(現有 API,前端需遵守)
Request Body (application/json):
{ "email": "user@example.com", "password": "password123" }
Response 200:
{
"status": true,
"message": "登入成功",
"data": {
"user": {
"id": 1,
"name": "王小明",
"email": "user@example.com",
"role": "member"
},
"token": "1|abcdef...",
"token_type": "Bearer"
}
}
Response 401/422:
{ "status": false, "message": "帳號或密碼錯誤" }
POST /api/member/register(現有 API)
Request Body (application/json):
{ "name": "王小明", "email": "user@example.com", "password": "password123", "password_confirmation": "password123" }
Response 201:
{
"status": true,
"message": "註冊成功",
"data": { "user": { "id": 1, "name": "王小明", "email": "...", "role": "member" } }
}
Response 422:
{ "status": false, "message": "此 Email 已被使用" }
GET /api/member/profile(需 Bearer token)
Request Header: Authorization: Bearer <token>
Response 200:
{
"status": true,
"data": {
"id": 1,
"name": "王小明",
"email": "user@example.com",
"role": "member",
"profile": {
"birthday": "1990-01-01",
"gender": "male",
"address": "台北市信義區...",
"emergency_contact": "王大明",
"emergency_phone": "0987654321"
}
}
}
PUT /api/member/profile(需 Bearer token)
Request Header: Authorization: Bearer <token>
Request Body (application/json):
{
"name": "王小明",
"birthday": "1990-01-01",
"gender": "male",
"address": "台北市信義區...",
"emergency_contact": "王大明",
"emergency_phone": "0987654321"
}
Response 200:
{ "status": true, "message": "資料已更新", "data": { ...同 GET profile } }
POST /api/member/logout(需 Bearer token)
Request Header: Authorization: Bearer <token>
Response 200:
{ "status": true, "message": "已登出" }
Contract 2 — 環境變數
後端(CFDivePlatform/.env)需新增
# Google OAuth
GOOGLE_CLIENT_ID=<從 Google Cloud Console 取得>
GOOGLE_CLIENT_SECRET=<從 Google Cloud Console 取得>
GOOGLE_REDIRECT_URI=http://localhost:80/api/auth/google/callback
# 前端 URL(OAuth callback redirect 用)
FRONTEND_URL=http://localhost:5173
前端(cf-dive-frontend/.env)
VITE_API_URL=http://localhost:80
Contract 3 — CORS 設定
config/cors.php(Laravel 11 預設不存在,需執行 php artisan config:publish cors 建立後修改):
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
'allowed_origins_patterns'=> [],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false, // Token 模式不需要 cookie,false 即可
supports_credentials: false是刻意的決定。若改用 Sanctum cookie 模式才需設為 true,但會帶來 SameSite / CSRF 複雜度。Token 模式維持 false 最簡單。
Risks / Trade-offs
| 風險 | 緩解策略 |
|---|---|
localStorage 存 token 有 XSS 風險 |
MVP 階段接受,未來可改用 httpOnly cookie |
diving_offers 表無 provider_id,課程無法關聯教練 |
MVP 不處理,僅展示靜態課程資料 |
| Google OAuth callback redirect 帶 token 在 URL 中,有 Referer 洩漏風險 | token 在 query param 僅短暫存在,前端取得後立即存 localStorage 並清除 URL(history.replaceState) |
| 前端 repo 與 Laravel repo 分開,OpenSpec tasks 橫跨兩個位置 | Tasks 以 [後端] / [前端] 標記,分別在對應 repo 操作 |
Open Questions
- Google OAuth callback 邏輯:
SocialAuthController::handleGoogleCallback()現行回傳 JSON,確認需改為 redirect 至FRONTEND_URL/auth/callback?token=<token>。失敗時 redirect 至FRONTEND_URL/login?error=oauth_failed。 - 前端 repo 名稱:確認為
cf-dive-frontend。