Files
a620906209 550e2fc97a feat:實作 Member Portal MVP 前端與後端整合
後端:
- 新增 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>
2026-05-10 01:41:28 +08:00

11 KiB
Raw Permalink Blame History

Context

後端(Laravel + Sanctum)已有會員認證、個人資料、Google OAuth 等 API,但 diving_offers 表雖已建立,尚無對應的公開 API。前端完全從零開始,需建立獨立 repo,透過 HTTP 呼叫後端 API。兩端分離部署,前端在開發期間以 http://localhost:5173 運行,後端以 http://localhost:80Laragon)運行。

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.jsLaravel + Vue 同 repo)→ 捨棄,因為部署彈性不足。


D2Auth 策略 — Sanctum TokenBearer

決定:前端登入後儲存 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 階段避免過早引入複雜的全域狀態。


D5Google 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 / spotLIKE 模糊匹配)
  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

# 前端 URLOAuth 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 模式不需要 cookiefalse 即可

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 並清除 URLhistory.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